Compare commits
186
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffa1fa89ab | ||
|
|
3ad2605c9e | ||
|
|
1f705ce512 | ||
|
|
cf76e00f12 | ||
|
|
0861f1fdbf | ||
|
|
72fcef2ba9 | ||
|
|
5a537f5dc3 | ||
|
|
8ab6569974 | ||
|
|
69a0aceba6 | ||
|
|
d76d7943fc | ||
|
|
d125eb399e | ||
|
|
e6c1f7dbb3 | ||
|
|
465853ac59 | ||
|
|
1706ad1ee9 | ||
|
|
d074689b18 | ||
|
|
9562f30366 | ||
|
|
6dfd6d718e | ||
|
|
401d0b17ca | ||
|
|
5951c707eb | ||
|
|
f2f2f3209e | ||
|
|
f2e5684e4e | ||
|
|
b56b181c3e | ||
|
|
2fceed0da2 | ||
|
|
436898d86d | ||
|
|
e0237e576f | ||
|
|
189ed5bbca | ||
|
|
ce0d561ade | ||
|
|
95bc861cdb | ||
|
|
ea19d1d050 | ||
|
|
7f998faf7b | ||
|
|
0b251649e5 | ||
|
|
13d5f8625a | ||
|
|
d707a6850a | ||
|
|
f1ce34a8a6 | ||
|
|
b5207216f1 | ||
|
|
62620ef884 | ||
|
|
b0cb3a3923 | ||
|
|
e6afa004d0 | ||
|
|
bf3d1ac0cc | ||
|
|
f9da57c9e0 | ||
|
|
88e4ae1db8 | ||
|
|
a0db4d6e65 | ||
|
|
fb14c7e709 | ||
|
|
0d475c3d2d | ||
|
|
98af112ba1 | ||
|
|
7096707b3e | ||
|
|
9ab47d8113 | ||
|
|
41c1d315d3 | ||
|
|
4038e9bdad | ||
|
|
334a937507 | ||
|
|
c7d4fd901f | ||
|
|
7794a03934 | ||
|
|
9dfc38706c | ||
|
|
d4adf89db5 | ||
|
|
055956a351 | ||
|
|
3b3e95e23a | ||
|
|
37337a5de7 | ||
|
|
e8797afa05 | ||
|
|
3b00cbc371 | ||
|
|
d986959927 | ||
|
|
714642363b | ||
|
|
df78badd0c | ||
|
|
9836c85e24 | ||
|
|
61a33a7891 | ||
|
|
e707becbf8 | ||
|
|
da3e09c215 | ||
|
|
19e8ba0e4a | ||
|
|
7a6c66a609 | ||
|
|
3bb17eb984 | ||
|
|
bdc15c7328 | ||
|
|
c435e277f2 | ||
|
|
d7e0ace2b7 | ||
|
|
292f3b5851 | ||
|
|
d1d8c6fd2a | ||
|
|
d50fed52ce | ||
|
|
d477c1e058 | ||
|
|
ba363eaee9 | ||
|
|
881c3e3e0a | ||
|
|
c36082be6a | ||
|
|
fbc8d657e7 | ||
|
|
cef01dffaf | ||
|
|
da1fccb38f | ||
|
|
f1dd9d6181 | ||
|
|
6fe5d2f723 | ||
|
|
5c350ff8f6 | ||
|
|
e9b6244ef0 | ||
|
|
f09de98406 | ||
|
|
3b27c57230 | ||
|
|
91b4e8d0e5 | ||
|
|
511ad942cc | ||
|
|
d40fdcafe3 | ||
|
|
15cc2d6f21 | ||
|
|
0112a5b5d8 | ||
|
|
b3c90efa73 | ||
|
|
f84ff201ea | ||
|
|
bb12763a41 | ||
|
|
3913f9f129 | ||
|
|
f8df0e13b5 | ||
|
|
32c81f156d | ||
|
|
8b0a0aea3a | ||
|
|
52d7a0d006 | ||
|
|
bd582866d1 | ||
|
|
b15289b4bc | ||
|
|
6356cd5703 | ||
|
|
3956f1ffa5 | ||
|
|
d96fb4171e | ||
|
|
63a41ade48 | ||
|
|
43aca1a1f7 | ||
|
|
d6bb424021 | ||
|
|
4dc06eb203 | ||
|
|
e4b196a528 | ||
|
|
0c271d5972 | ||
|
|
65559e5069 | ||
|
|
e4d17eb96c | ||
|
|
2391de418d | ||
|
|
917a04d543 | ||
|
|
113f880863 | ||
|
|
571d78115b | ||
|
|
27c9eb98db | ||
|
|
3025a7a44e | ||
|
|
75b7f29826 | ||
|
|
0b63ed17bc | ||
|
|
b594458b6e | ||
|
|
c322f30801 | ||
|
|
bd109c273a | ||
|
|
f57f66a93a | ||
|
|
9ba3480d4e | ||
|
|
34368a4946 | ||
|
|
1c11b9d351 | ||
|
|
3cb9edc1f3 | ||
|
|
e752e46583 | ||
|
|
9eedfa67ca | ||
|
|
56fa5dcd61 | ||
|
|
667eb6534d | ||
|
|
b60cc8a7c6 | ||
|
|
23df10732b | ||
|
|
c809a864e6 | ||
|
|
6e30589133 | ||
|
|
fa97f07ec8 | ||
|
|
5390a6ceab | ||
|
|
aa2db0103d | ||
|
|
2bdf5e4166 | ||
|
|
f7f5bae336 | ||
|
|
814dbe8c2e | ||
|
|
d12681720d | ||
|
|
e64a60c42b | ||
|
|
151ae4a3aa | ||
|
|
4939eb5a12 | ||
|
|
dcd7932a58 | ||
|
|
7c922088c8 | ||
|
|
6ebfc245f1 | ||
|
|
59fc7b7975 | ||
|
|
049861828d | ||
|
|
c586513b03 | ||
|
|
a7289db621 | ||
|
|
9c4de01fd2 | ||
|
|
461492eede | ||
|
|
ea81e85848 | ||
|
|
50273fcb1a | ||
|
|
24a760c5eb | ||
|
|
f7d87e6555 | ||
|
|
1b3aed19ff | ||
|
|
c7c6e52deb | ||
|
|
4bceb7ce91 | ||
|
|
cd6dac9ea6 | ||
|
|
afe670bdd3 | ||
|
|
e32abfbfa6 | ||
|
|
514b1da9a7 | ||
|
|
d19668e60f | ||
|
|
99fee56089 | ||
|
|
d13f13785b | ||
|
|
348793ba13 | ||
|
|
cbba7c506a | ||
|
|
38a188e6e2 | ||
|
|
50aed9e357 | ||
|
|
31db48a40d | ||
|
|
45eefa0c11 | ||
|
|
c4ff0a41e0 | ||
|
|
b76aaad5ee | ||
|
|
c4d654676c | ||
|
|
fbd191c85b | ||
|
|
288f99d81e | ||
|
|
13e0bfb28a | ||
|
|
16abeb9def | ||
|
|
44bf469c53 | ||
|
|
3c5eac3429 |
@@ -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/
|
||||
|
||||
@@ -9,6 +9,13 @@ GITEA_WEBHOOK_SECRET=
|
||||
GITHUB_OAUTH_CLIENT_ID=
|
||||
GITHUB_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# ── OAuth2 ──
|
||||
# Laisser VIDE = redirect URI dynamique (dérivée du Host/X-Forwarded-* de la requête).
|
||||
# Ne définir QUE si on veut forcer une URI exacte — elle DOIT être enregistrée
|
||||
# dans l'application OAuth2 côté Gitea/GitHub (Settings → Applications).
|
||||
# Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback
|
||||
OAUTH_REDIRECT_URI=
|
||||
|
||||
# ── App ──
|
||||
APP_SECRET_KEY=change-me-to-random
|
||||
APP_HOST=0.0.0.0
|
||||
@@ -24,3 +31,54 @@ DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
# ── Sync ──
|
||||
SYNC_INTERVAL=60
|
||||
GITEA_CACHE_TTL=30
|
||||
|
||||
# ── Backups (v5.2.0) ──
|
||||
# Sauvegarde automatique quotidienne du fichier SQLite (fichiers datés).
|
||||
BACKUP_ENABLED=true
|
||||
BACKUP_DIR=/data/backups
|
||||
BACKUP_INTERVAL_HOURS=24
|
||||
BACKUP_KEEP=30
|
||||
|
||||
# ── Forge projects sync (v5.2.0) ──
|
||||
# Rafraîchissement périodique de la table `projects` depuis les forges connectées.
|
||||
PROJECT_SYNC_ENABLED=true
|
||||
PROJECT_SYNC_INTERVAL_HOURS=1
|
||||
|
||||
# ── Public API v2 (v6.3.0) ──
|
||||
# PUBLIC_API_INSECURE_OK=true autorise le token de dev fd-public-key (jamais en prod).
|
||||
PUBLIC_API_INSECURE_OK=false
|
||||
API_V2_RATE_LIMIT_PER_TOKEN=300
|
||||
|
||||
# ── Email notifications (v4.9.0) ──
|
||||
# Laisser SMTP_HOST vide = pas d'envoi d'email (seulement les notifications in-app).
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
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
|
||||
|
||||
+39
-10
@@ -1,28 +1,57 @@
|
||||
name: FlowDeck CI
|
||||
|
||||
on:
|
||||
# Run on every pushed branch so feature branches are validated before the PR.
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
container: python:3.12-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt pytest pytest-cov
|
||||
- name: Run tests with coverage
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install lint tools
|
||||
run: pip install -r requirements-dev.txt
|
||||
- name: Ruff (Python)
|
||||
run: ruff check app tests
|
||||
- name: ESLint (JavaScript)
|
||||
run: npx --yes eslint static/js
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
# NOTE: no `container:` here. A `python:*-slim` image ships no Node.js, so the
|
||||
# JavaScript `actions/checkout` action could not run and every job failed at
|
||||
# the first step. The runner's default image already provides Node; we install
|
||||
# the Python toolchain explicitly with actions/setup-python.
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install system dependencies (WeasyPrint / emoji fonts)
|
||||
run: |-
|
||||
SUDO=""
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libffi-dev \
|
||||
libjpeg-dev libopenjp2-7 libcairo2 fonts-noto-color-emoji
|
||||
- name: Install Python dependencies
|
||||
run: pip install -r requirements-dev.txt pytest-cov
|
||||
- name: Run tests (parallel) with coverage
|
||||
env:
|
||||
GITEA_URL: https://git.dracodev.net
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
APP_SECRET_KEY: ci-test-key
|
||||
run: python -m pytest tests/ -v --tb=short --cov=app --cov-report=term
|
||||
# `-n auto` needs pytest-xdist, provided by requirements-dev.txt.
|
||||
run: python -m pytest tests/ -v --tb=short -n auto --cov=app --cov-report=term
|
||||
- name: Coverage summary
|
||||
run: |
|
||||
python -m pytest tests/ --cov=app --cov-report=term 2>&1 | tail -20
|
||||
if: always()
|
||||
run: coverage report -m || true
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+11
@@ -4,6 +4,7 @@ __pycache__/
|
||||
/data/
|
||||
.venv/
|
||||
venv/
|
||||
.venv*/
|
||||
*.egg-info/
|
||||
dist/
|
||||
.pytest_cache/
|
||||
@@ -15,3 +16,13 @@ dist/
|
||||
.ua/tmp/
|
||||
.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/
|
||||
|
||||
+8
-3
@@ -109,8 +109,11 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
|
||||
│ │ ├─ pages.py — /pages/... Pages CRUD │ │
|
||||
│ │ ├─ collections.py — /db/... Collections │ │
|
||||
│ │ ├─ editor.py — /api/editor/... Block editor │ │
|
||||
│ │ ├─ private.py — /api/private/* Section privée │ │
|
||||
│ │ ├─ public_api.py — /api/public/* Public API │ │
|
||||
│ │ ├─ public_api.py — /api/v1 Public API v1 │ │
|
||||
│ │ ├─ api_v2.py — /api/v2 Public API v2 │ │
|
||||
│ │ │ — Bearer + scopes, CRUD complet │ │
|
||||
│ │ ├─ web_clipper.py — /api/v2/web-clipper Web Clipper │ │
|
||||
│ │ ├─ permissions.py — /api/v2 (ACL) Permissions │ │
|
||||
│ │ ├─ workspace.py — Workspaces API + Gitea projets │ │
|
||||
│ │ ├─ webhooks.py — /webhooks/... Gitea hooks │ │
|
||||
│ │ └─ admin.py — /api/admin/* Admin users │ │
|
||||
@@ -1705,6 +1708,8 @@ docker compose restart flowdeck
|
||||
- **Automatisations** — Règles déclenchées sur événements (Notion-style)
|
||||
- **Base de données avancée** — Relations inter-collections, rollups
|
||||
- **Kanban flexible** — Colonnes custom, WIP limits
|
||||
- **API publique REST** — Tokens d'accès pour intégrations tierces
|
||||
- **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
|
||||
|
||||
+2213
-1
File diff suppressed because it is too large
Load Diff
+31
-6
@@ -1,19 +1,44 @@
|
||||
FROM python:3.12-slim
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# FlowDeck — multi-stage Docker build (v5.2.0)
|
||||
# Stage 1 "builder": build Python wheels once.
|
||||
# Stage 2 "runtime": minimal image with WeasyPrint system libs.
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
|
||||
|
||||
# ── runtime stage ───────────────────────────────────────────
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# WeasyPrint PDF: text layout (pango/harfbuzz), image decoding, fonts,
|
||||
# colour emoji support. curl = healthcheck.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
libpango-1.0-0 \
|
||||
libpangoft2-1.0-0 \
|
||||
libharfbuzz0b \
|
||||
libffi-dev \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
shared-mime-info \
|
||||
fonts-dejavu-core \
|
||||
fonts-noto-color-emoji \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /wheels /wheels
|
||||
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /data
|
||||
RUN mkdir -p /data /data/backups
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/api/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
|
||||
|
||||
> **v2.1.0** — API publique, Webhooks sortants, PWA
|
||||
> **v6.7.0** — SSO / SAML + OIDC entreprise (auth fédérée IdP, auto-provisioning, group mapping, mode SSO only) · avant : v6.6.x agent API + marketplace, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -49,10 +49,14 @@ docker compose up -d
|
||||
- **CSV Import/Export**
|
||||
- **Public Sharing**: lien de partage lecture seule
|
||||
|
||||
### API & Intégrations (v2.1)
|
||||
- **API publique REST**: `/api/v1` avec token auth
|
||||
- **Webhooks sortants**: gestion + dispatcher d'événements
|
||||
- **PWA**: manifest.json, prêt pour installation mobile
|
||||
### 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)
|
||||
- **PWA**: manifest.json + service worker, offline support
|
||||
|
||||
### UI Notion-Style (v1.1–v1.2)
|
||||
- Sidebar gauche avec sections hiérarchiques
|
||||
@@ -85,7 +89,7 @@ DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/ -v # 73/73 passent
|
||||
python3 -m pytest tests/ -v # 764/764 passent (0 skip)
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
+853
-95
File diff suppressed because it is too large
Load Diff
+19
-4
@@ -1,7 +1,7 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v2.2.0 | **Statut**: EN COURS 🔄
|
||||
> **Cible v3.0**: Multi-User, Multi-Forge (Gitea/GitHub), Standalone
|
||||
> **Début**: 2026-07-08 | **Version**: v7.3.8 (audit sécurité — A25 + A21 partiel) | **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
|
||||
|
||||
@@ -21,7 +21,22 @@
|
||||
| v2.0 | Multi-User + Editor Complete | ✅ | 67/67 |
|
||||
| v2.1 | Public API, Webhooks, PWA | ✅ | 73/73 |
|
||||
| v2.2 | Share/Publish, Favorites, Library | ✅ | 73/73 |
|
||||
| v3.0 | **Auth locale, Multi-Forge, Standalone** | 🔲 | — |
|
||||
| v3.0 | Auth locale, Multi-Forge, Standalone | ✅ | — |
|
||||
| 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
|
||||
|
||||
@@ -58,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, 73 tests, TestClient avec SQLite temporaire
|
||||
- **Tests**: pytest, 764+ tests, TestClient avec SQLite temporaire
|
||||
- **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""FlowDeck — Auth module: session, OAuth2, dependencies."""
|
||||
from app.auth.oauth import GiteaOAuth
|
||||
from app.auth.session import SessionManager, get_current_user
|
||||
|
||||
__all__ = ["GiteaOAuth", "SessionManager", "get_current_user"]
|
||||
|
||||
@@ -165,7 +165,7 @@ class GitHubProvider(OAuthProvider):
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"redirect_uri": redirect_uri or self.redirect_uri,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""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(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
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()
|
||||
+123
-8
@@ -1,26 +1,43 @@
|
||||
"""FlowDeck — Session management with signed cookies."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_serializer = URLSafeTimedSerializer(settings.app_secret_key)
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Manages user sessions via signed cookies."""
|
||||
"""Manages user sessions via signed cookies (v5.2.0: revocable).
|
||||
|
||||
Each cookie embeds a ``sid`` referencing a row in ``user_sessions``.
|
||||
Revoking that row instantly invalidates the cookie (checked in
|
||||
``decode_session``). Legacy cookies without a ``sid`` stay valid.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_session(user_data: dict) -> str:
|
||||
"""Create a signed session cookie value."""
|
||||
def create_session(user_data: dict, request=None) -> str:
|
||||
"""Create a signed session cookie value.
|
||||
|
||||
``request`` is optional — when provided the session is recorded in the
|
||||
``user_sessions`` table (ip + user agent) and becomes revocable.
|
||||
"""
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
if user_id:
|
||||
sid = str(uuid4())
|
||||
payload["sid"] = sid
|
||||
_record_session(sid, user_id, request)
|
||||
return _serializer.dumps(payload)
|
||||
|
||||
@staticmethod
|
||||
@@ -28,10 +45,65 @@ class SessionManager:
|
||||
"""Decode and validate a session cookie. Returns user data or None."""
|
||||
try:
|
||||
payload = _serializer.loads(cookie, max_age=86400 * 7) # 7 days
|
||||
return payload.get("user")
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
|
||||
sid = payload.get("sid") or ""
|
||||
if sid and not _session_active(sid):
|
||||
# Revoked or deleted session → treat as logged out.
|
||||
return None
|
||||
if sid:
|
||||
_touch_session(sid)
|
||||
return payload.get("user")
|
||||
|
||||
@staticmethod
|
||||
def session_id(cookie: str) -> str | None:
|
||||
"""Return the session id embedded in a cookie (or None)."""
|
||||
try:
|
||||
payload = _serializer.loads(cookie, max_age=86400 * 7)
|
||||
return payload.get("sid")
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def list_sessions(user_id: int) -> list[dict]:
|
||||
"""All recorded sessions for a user (for the Settings UI)."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, ip_address, user_agent, created_at, last_seen_at, revoked "
|
||||
"FROM user_sessions WHERE user_id=? ORDER BY last_seen_at DESC",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@staticmethod
|
||||
def revoke_session(sid: str) -> bool:
|
||||
"""Revoke a session row. Returns True if a row was updated."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE user_sessions SET revoked=1 WHERE id=? AND revoked=0", (sid,)
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
def refresh_session(cookie: str, user_data: dict, request=None) -> str:
|
||||
"""Re-sign a cookie keeping its session id (used after profile edits)."""
|
||||
sid = SessionManager.session_id(cookie) if cookie else None
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
if user_id:
|
||||
if sid is None:
|
||||
sid = str(uuid4())
|
||||
_record_session(sid, user_id, request)
|
||||
payload["sid"] = sid
|
||||
return _serializer.dumps(payload)
|
||||
|
||||
@staticmethod
|
||||
def store_token(user_id: int, gitea_token: str) -> None:
|
||||
"""Store a user's Gitea OAuth token in SQLite."""
|
||||
@@ -58,10 +130,53 @@ class SessionManager:
|
||||
return row["gitea_token"] if row else None
|
||||
|
||||
|
||||
def _record_session(sid: str, user_id: int, request) -> None:
|
||||
ip = ""
|
||||
ua = ""
|
||||
if request is not None:
|
||||
ip = request.client.host if getattr(request, "client", None) else ""
|
||||
ua = (request.headers.get("user-agent", "") or "")[:500]
|
||||
try:
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO user_sessions (id, user_id, ip_address, user_agent) VALUES (?, ?, ?, ?)",
|
||||
(sid, user_id, ip, ua),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc: # table may not exist in very old installs
|
||||
logger.debug("session record skipped: %s", exc)
|
||||
|
||||
|
||||
def _session_active(sid: str) -> bool:
|
||||
try:
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT revoked FROM user_sessions WHERE id=?", (sid,)
|
||||
).fetchone()
|
||||
return bool(row and not row["revoked"])
|
||||
except Exception:
|
||||
# No table / DB unavailable → keep the cookie valid (fail-open-safe).
|
||||
return True
|
||||
|
||||
|
||||
def _touch_session(sid: str) -> None:
|
||||
try:
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET last_seen_at=CURRENT_TIMESTAMP WHERE id=? AND revoked=0",
|
||||
(sid,),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("_touch_session")
|
||||
|
||||
|
||||
# FastAPI dependency
|
||||
async def get_current_user(request) -> dict | None:
|
||||
"""FastAPI dependency: extract current user from session cookie."""
|
||||
from fastapi import Request
|
||||
session = request.cookies.get("flowdeck_session")
|
||||
if session:
|
||||
return SessionManager.decode_session(session)
|
||||
|
||||
+73
-2
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -24,8 +25,9 @@ class Settings(BaseSettings):
|
||||
# Standalone mode
|
||||
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
|
||||
|
||||
# OAuth2
|
||||
oauth_redirect_uri: str = "http://localhost:8080/auth/callback"
|
||||
# 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 = ""
|
||||
|
||||
# Webhook
|
||||
webhook_base_url: str = "http://localhost:8080"
|
||||
@@ -41,6 +43,10 @@ class Settings(BaseSettings):
|
||||
rate_limit_enabled: bool = True
|
||||
rate_limit_requests: int = 60 # per minute
|
||||
|
||||
# Public API v2 (v6.3.0)
|
||||
public_api_insecure_ok: bool = False # if True, fd-public-key is accepted (dev only)
|
||||
api_v2_rate_limit_per_token: int = 300 # req/min per token for /api/v2
|
||||
|
||||
# Database
|
||||
database_url: str = "sqlite:////data/flowdeck.db"
|
||||
|
||||
@@ -48,6 +54,71 @@ class Settings(BaseSettings):
|
||||
sync_interval: int = 60
|
||||
gitea_cache_ttl: int = 30
|
||||
|
||||
# Backup (v5.2.0) — scheduled daily snapshot of the SQLite file
|
||||
backup_enabled: bool = True
|
||||
backup_dir: str = "/data/backups"
|
||||
backup_interval_hours: int = 24
|
||||
backup_keep: int = 30
|
||||
|
||||
# Forge projects sync (v5.2.0) — periodic refresh of `projects` table
|
||||
project_sync_enabled: bool = True
|
||||
project_sync_interval_hours: int = 1
|
||||
|
||||
# Reminders (v5.8.0) — background scan for due date reminders
|
||||
reminders_enabled: bool = True
|
||||
reminder_scan_interval_seconds: int = 60
|
||||
|
||||
# Webhooks outbound (v6.4.0) — retry of failed deliveries
|
||||
webhook_retry_enabled: bool = True
|
||||
webhook_retry_interval_seconds: int = 60
|
||||
|
||||
# Email / SMTP notifications (v4.9.0) — optional. If smtp_host is empty,
|
||||
# email notifications are skipped (only in-app notifications are delivered).
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from: str = "FlowDeck <[email protected]>"
|
||||
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
|
||||
llm_provider: str = "offline" # any id from llm_client.PROVIDERS
|
||||
# (openai, anthropic, mistral, cohere,
|
||||
# google, groq, deepseek, openrouter,
|
||||
# nvidia, together, perplexity, xai,
|
||||
# qwencloud, minimax, morph, fireworks,
|
||||
# cerebras, sambanova, chutes, xiaomi,
|
||||
# sealion, sensenova, ollama, offline)
|
||||
llm_model: str = "gpt-4o"
|
||||
llm_api_key: str = ""
|
||||
llm_api_base: str = "" # custom base URL (Ollama, OpenRouter, ...)
|
||||
agent_max_iterations: int = 12
|
||||
agent_max_tokens_budget: int = 500000
|
||||
agent_run_timeout_seconds: int = 300
|
||||
|
||||
@property
|
||||
def db_path(self) -> Path:
|
||||
if self.database_url == "sqlite:///:memory:":
|
||||
|
||||
@@ -442,12 +442,18 @@ def init_db():
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
shared_with_user_id INTEGER REFERENCES users(id),
|
||||
shared_with_group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
shared_with_email TEXT DEFAULT '',
|
||||
permission TEXT NOT NULL DEFAULT 'view',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id)
|
||||
)
|
||||
""")
|
||||
# v5.x: migration — partage par groupes (colonne manquante sur DB existantes)
|
||||
try:
|
||||
conn.execute("ALTER TABLE page_shares ADD COLUMN shared_with_group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# 4) recents table
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS recents (
|
||||
@@ -487,6 +493,12 @@ def init_db():
|
||||
pass
|
||||
conn.commit()
|
||||
|
||||
# v4.6.0: Add collection_id to pages (page ↔ collection link for full-page DBs)
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN collection_id INTEGER REFERENCES collections(id)")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
# v4.2.0: Collection Templates (enhanced) + Dashboards
|
||||
# Add description, is_recurring, recurrence_rule to page_templates
|
||||
try:
|
||||
@@ -536,6 +548,286 @@ def init_db():
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# v4.5.0: Sprints
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sprints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
goal TEXT DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'planning',
|
||||
auto_complete BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sprint_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sprint_id INTEGER NOT NULL REFERENCES sprints(id) ON DELETE CASCADE,
|
||||
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
status_at_start TEXT DEFAULT '',
|
||||
velocity_points INTEGER DEFAULT 1,
|
||||
UNIQUE(sprint_id, page_id)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# v4.6.0: Sidebar customization config per user
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN sidebar_config TEXT DEFAULT '{}'")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.commit()
|
||||
|
||||
# ═══════════ v4.9.0: Collaboration — notifications, inline comments, prefs ═══════════
|
||||
# Notifications table (mentions, comments, page changes)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
actor_id INTEGER REFERENCES users(id),
|
||||
ntype TEXT NOT NULL DEFAULT 'mention', -- 'mention' | 'comment' | 'page'
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
resource_type TEXT NOT NULL DEFAULT 'page',
|
||||
resource_id INTEGER NOT NULL DEFAULT 0,
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
is_read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_notif_user_read ON notifications(user_id, is_read)"
|
||||
)
|
||||
|
||||
# Notification email preferences (JSON: {"comments": true, "mentions": true})
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN notification_prefs TEXT DEFAULT '{}'")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
# Inline comments on pages: the v2.0.0 `comments` table had a NOT NULL FK to
|
||||
# collection_pages, which prevents using page-editor (pages) ids. Rebuild it so
|
||||
# it can hold page comments with optional inline anchors, while preserving data.
|
||||
# target_type='collection_page' (legacy) or 'page' (editor); target_id = resource id.
|
||||
# anchor_block_id = block id; anchor_start/anchor_end = text selection offsets.
|
||||
_cols = [r[1] for r in conn.execute("PRAGMA table_info(comments)").fetchall()]
|
||||
if "target_type" not in _cols:
|
||||
try:
|
||||
conn.execute("""
|
||||
CREATE TABLE comments_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
parent_id INTEGER,
|
||||
resolved BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
target_type TEXT NOT NULL DEFAULT 'page',
|
||||
target_id INTEGER NOT NULL DEFAULT 0,
|
||||
anchor_block_id TEXT,
|
||||
anchor_start INTEGER,
|
||||
anchor_end INTEGER
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"""INSERT INTO comments_new
|
||||
(id, page_id, user_id, body, parent_id, resolved, created_at, updated_at, target_type, target_id)
|
||||
SELECT id, page_id, user_id, body, parent_id, resolved, created_at, updated_at,
|
||||
'collection_page', COALESCE(page_id, 0)
|
||||
FROM comments"""
|
||||
)
|
||||
conn.execute("DROP TABLE comments")
|
||||
conn.execute("ALTER TABLE comments_new RENAME TO comments")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.commit()
|
||||
|
||||
# ═══════════ v4.10.0: FlowDeck Agent — agents, conversations, audit ═══════════
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id),
|
||||
name TEXT NOT NULL DEFAULT 'FlowDeck Agent',
|
||||
icon TEXT DEFAULT '🤖',
|
||||
agent_type TEXT NOT NULL DEFAULT 'personal',
|
||||
description TEXT DEFAULT '',
|
||||
system_instructions TEXT DEFAULT '',
|
||||
model TEXT DEFAULT 'gpt-4o',
|
||||
scope_json TEXT NOT NULL DEFAULT '{}',
|
||||
trigger_json TEXT NOT NULL DEFAULT '{}',
|
||||
approval_mode TEXT NOT NULL DEFAULT 'auto',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
UNIQUE(workspace_id, name)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
title TEXT DEFAULT 'New conversation',
|
||||
status TEXT NOT NULL DEFAULT 'idle',
|
||||
context_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
tool_calls_json TEXT DEFAULT '[]',
|
||||
model TEXT,
|
||||
tokens_used INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_actions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
|
||||
tool_name TEXT NOT NULL,
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
result_json TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
undo_snapshot_json TEXT DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
executed_by INTEGER REFERENCES users(id)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_skills (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
prompt_template TEXT NOT NULL,
|
||||
allowed_tools_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
UNIQUE(workspace_id, name)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_triggers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
trigger_type TEXT NOT NULL DEFAULT 'manual',
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
last_fired_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
# ─── v4.15.0: feedback des réponses de l'agent (👍 / 👎) ───────────────
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER REFERENCES agent_conversations(id) ON DELETE SET NULL,
|
||||
message_id INTEGER REFERENCES agent_messages(id) ON DELETE SET NULL,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
rating TEXT NOT NULL CHECK (rating IN ('up', 'down')),
|
||||
snippet TEXT DEFAULT '',
|
||||
comment TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_feedback_conv ON agent_feedback(conversation_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_feedback_user ON agent_feedback(user_id)")
|
||||
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_user ON agent_conversations(user_id, updated_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_msg_conv ON agent_messages(conversation_id, created_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_action_conv ON agent_actions(conversation_id)")
|
||||
|
||||
# ─── v4.10.1: LLM runtime config (single row id=1) ─────────────────────
|
||||
# Created lazily (no seed) so .env stays the default until an admin saves
|
||||
# the LLM settings from the UI. Precedence: DB row > settings.llm_*.
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS llm_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
provider TEXT NOT NULL DEFAULT 'offline',
|
||||
model TEXT DEFAULT '',
|
||||
api_key TEXT DEFAULT '',
|
||||
api_base TEXT DEFAULT '',
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
verified_model TEXT DEFAULT '',
|
||||
verified_at TIMESTAMP,
|
||||
last_error TEXT DEFAULT '',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
# ─── v4.10.2: per-user provider API keys ──────────────────────────────
|
||||
# Each user can save several providers with their own key/base + the
|
||||
# live model list fetched from the provider (models_json cache).
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_llm_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
api_base TEXT NOT NULL DEFAULT '',
|
||||
default_model TEXT DEFAULT '',
|
||||
models_json TEXT NOT NULL DEFAULT '[]',
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
verified_model TEXT DEFAULT '',
|
||||
verified_at TIMESTAMP,
|
||||
last_error TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, provider)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_user_llm_keys_user ON user_llm_keys(user_id)")
|
||||
# v4.12: provider activation/verification — a provider is only offered in
|
||||
# the Agent UI once it is configured AND its connection test succeeded.
|
||||
for col, ddl in (
|
||||
("verified", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("verified_model", "TEXT DEFAULT ''"),
|
||||
("verified_at", "TIMESTAMP"),
|
||||
("last_error", "TEXT DEFAULT ''"),
|
||||
):
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE user_llm_keys ADD COLUMN {col} {ddl}")
|
||||
except sqlite3.OperationalError:
|
||||
pass # column already exists
|
||||
for col, ddl in (
|
||||
("verified", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("verified_model", "TEXT DEFAULT ''"),
|
||||
("verified_at", "TIMESTAMP"),
|
||||
("last_error", "TEXT DEFAULT ''"),
|
||||
):
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE llm_config ADD COLUMN {col} {ddl}")
|
||||
except sqlite3.OperationalError:
|
||||
pass # column already exists
|
||||
# Migration: per-conversation provider/model override columns
|
||||
for col in ("provider", "model"):
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE agent_conversations ADD COLUMN {col} TEXT DEFAULT ''")
|
||||
except sqlite3.OperationalError:
|
||||
pass # column already exists
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_llm ON agent_conversations(provider, model)")
|
||||
conn.commit()
|
||||
|
||||
# ── v5.2.0: apply any pending VERSIONED migrations (schema_version) ──
|
||||
from app.migrations import apply_migrations
|
||||
apply_migrations(conn)
|
||||
|
||||
# Webhook subscriptions (v2.1.0) — created here (idempotent) so the full
|
||||
# schema exists without depending on the FastAPI lifespan startup.
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
init_webhook_tables()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_conn():
|
||||
@@ -545,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:
|
||||
|
||||
+205
-33
@@ -1,22 +1,67 @@
|
||||
"""FlowDeck — Kanban léger intégré à Gitea."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as _StarHTTPException
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.db import init_db
|
||||
from app.middleware.csrf import CSRFMiddleware
|
||||
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
|
||||
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing
|
||||
from app.routers import (
|
||||
admin,
|
||||
agent,
|
||||
api,
|
||||
auth,
|
||||
board,
|
||||
collections,
|
||||
dashboard,
|
||||
export,
|
||||
library,
|
||||
my_tasks,
|
||||
notes,
|
||||
onboarding,
|
||||
projects,
|
||||
public_api,
|
||||
search,
|
||||
security,
|
||||
sharing,
|
||||
sidebar_config,
|
||||
sync,
|
||||
webhooks,
|
||||
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.services.gitea_client import gitea
|
||||
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(
|
||||
@@ -30,24 +75,87 @@ logger = logging.getLogger(__name__)
|
||||
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!")
|
||||
|
||||
# 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:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)",
|
||||
(admin_hash,)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("FlowDeck v4.0.0 started on port %d", settings.app_port)
|
||||
yield
|
||||
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())
|
||||
|
||||
# ── Automations (v5.1.0): cron trigger scheduler ──
|
||||
from app.services.automations import automation_scheduler
|
||||
automation_task = asyncio.create_task(automation_scheduler())
|
||||
|
||||
# ── Backups (v5.2.0): automatic daily SQLite snapshot ──
|
||||
from app.services.backup import backup_scheduler
|
||||
backup_task = asyncio.create_task(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())
|
||||
|
||||
# ── 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())
|
||||
|
||||
# ── Reminders (v5.8.0): due-reminder scan for database rows ──
|
||||
from app.services.reminders import reminder_scheduler
|
||||
reminder_task = asyncio.create_task(reminder_scheduler())
|
||||
|
||||
# ── Semantic search (v6.9.0): incremental vector indexing ──
|
||||
from app.services.semantic_search import semantic_index_scheduler
|
||||
semantic_task = asyncio.create_task(semantic_index_scheduler())
|
||||
|
||||
# ── Calendar sync (v7.1.0): external calendars every 15 min ──
|
||||
from app.services.calendar_sync import calendar_sync_scheduler
|
||||
calendar_task = asyncio.create_task(calendar_sync_scheduler())
|
||||
|
||||
# ── Webhooks outbound (v6.4.0): retry failed deliveries ──
|
||||
from app.services.webhook_outbound import webhook_retry_scheduler
|
||||
webhook_task = None
|
||||
if settings.webhook_retry_enabled:
|
||||
webhook_task = asyncio.create_task(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, semantic_task, calendar_task)
|
||||
if webhook_task is not None:
|
||||
_tasks = _tasks + (webhook_task,)
|
||||
for task in _tasks:
|
||||
task.cancel()
|
||||
for task in _tasks:
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="4.0.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
version="7.3.8",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -58,9 +166,12 @@ app.add_middleware(RateLimitMiddleware)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
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)
|
||||
app.include_router(projects.router)
|
||||
app.include_router(projects.backups_router)
|
||||
app.include_router(api.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(collections.router)
|
||||
@@ -72,21 +183,52 @@ app.include_router(gitea_router)
|
||||
app.include_router(github_router)
|
||||
app.include_router(public_api.router)
|
||||
app.include_router(sharing.router)
|
||||
app.include_router(sidebar_config.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(notifications_router)
|
||||
app.include_router(automations_router)
|
||||
app.include_router(collaboration_router)
|
||||
app.include_router(emoji_router)
|
||||
app.include_router(realtime_router)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(security.router)
|
||||
app.include_router(onboarding.router)
|
||||
app.include_router(sync.router)
|
||||
app.include_router(imports_router)
|
||||
app.include_router(import_page_router)
|
||||
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():
|
||||
return {
|
||||
"name": "FlowDeck",
|
||||
"short_name": "FlowDeck",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#191919",
|
||||
"theme_color": "#191919",
|
||||
"icons": [{"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"}],
|
||||
}
|
||||
"""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():
|
||||
"""Serve the PWA service worker at top-level scope (/)."""
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse("static/sw.js", media_type="application/javascript")
|
||||
|
||||
|
||||
# ═══════════ API aliases (v4.0.1) ═══════════
|
||||
@@ -95,8 +237,9 @@ async def pwa_manifest():
|
||||
@app.get("/api/csrf-token")
|
||||
async def csrf_token_endpoint(request: Request):
|
||||
"""Return a fresh CSRF token. Used by the frontend to auto-recover from 403."""
|
||||
from fastapi.responses import JSONResponse
|
||||
import secrets
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
token = secrets.token_hex(32)
|
||||
response = JSONResponse({"csrf_token": token})
|
||||
response.set_cookie(
|
||||
@@ -150,12 +293,41 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
|
||||
</html>"""
|
||||
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def not_found_handler(request: Request, exc):
|
||||
"""Redirect 404 HTML pages to /workspaces. API routes still get JSON."""
|
||||
# Preserve JSON 404 for all API-like paths (including /db/xxx/api)
|
||||
if "/api" in request.url.path:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Not found"}, status_code=404)
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
@app.exception_handler(_StarHTTPException)
|
||||
async 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
|
||||
raised exceptions and route-miss 404s.
|
||||
"""
|
||||
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 is_api_v2:
|
||||
from app.services.api_v2_helpers import problem_response
|
||||
return problem_response(request, exc)
|
||||
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 is_api_v2:
|
||||
from app.services.api_v2_helpers import problem_response
|
||||
return problem_response(request, exc)
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": detail if isinstance(detail, str) else str(detail)}, status_code=status)
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
"""FlowDeck — Custom middleware."""
|
||||
from app.middleware.csrf import CSRFMiddleware
|
||||
|
||||
__all__ = ["CSRFMiddleware"]
|
||||
|
||||
+18
-2
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
import secrets
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
@@ -16,7 +16,23 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/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"}
|
||||
# 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
|
||||
|
||||
@@ -68,7 +68,7 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||
"img-src 'self' data: blob: https:; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com; "
|
||||
"connect-src 'self' https: wss:; "
|
||||
"connect-src 'self' https: wss: ws:; "
|
||||
"media-src 'self' blob:; "
|
||||
"frame-src 'self'; "
|
||||
"object-src 'none'; "
|
||||
@@ -115,6 +115,11 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
|
||||
# Respect the global rate-limit toggle (disabled in tests/local).
|
||||
from app.config import settings
|
||||
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):
|
||||
return await call_next(request)
|
||||
|
||||
+1522
File diff suppressed because it is too large
Load Diff
+9
-12
@@ -1,14 +1,11 @@
|
||||
"""FlowDeck — Pydantic request models for API validation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import UploadFile
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.middleware.security import ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, _ext
|
||||
|
||||
|
||||
# ── File Save ────────────────────────────────────────────────
|
||||
|
||||
class FileSaveRequest(BaseModel):
|
||||
@@ -16,7 +13,7 @@ class FileSaveRequest(BaseModel):
|
||||
path: str = Field(..., min_length=1, description="File path in the repository")
|
||||
content: str = Field(..., description="File content (UTF-8 encoded)")
|
||||
message: str = Field(default="Update via FlowDeck", description="Commit message")
|
||||
sha: Optional[str] = Field(default=None, description="SHA of the file being updated (required for updates)")
|
||||
sha: str | None = Field(default=None, description="SHA of the file being updated (required for updates)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_path_extension(self):
|
||||
@@ -34,10 +31,10 @@ class UploadValidationResult(BaseModel):
|
||||
size: int
|
||||
extension: str
|
||||
valid: bool
|
||||
error: Optional[str] = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def validate_upload_request(file: UploadFile) -> Optional[str]:
|
||||
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
|
||||
@@ -66,12 +63,12 @@ class IssueCreateRequest(BaseModel):
|
||||
|
||||
class IssueUpdateRequest(BaseModel):
|
||||
"""Request model for updating a Gitea issue (partial update)."""
|
||||
title: Optional[str] = Field(default=None, max_length=500)
|
||||
body: Optional[str] = Field(default=None)
|
||||
state: Optional[str] = Field(default=None, pattern=r"^(open|closed)$")
|
||||
labels: Optional[str] = Field(default=None, description="Comma-separated label IDs")
|
||||
milestone: Optional[str] = Field(default=None)
|
||||
assignee: Optional[str] = Field(default=None)
|
||||
title: str | None = Field(default=None, max_length=500)
|
||||
body: str | None = Field(default=None)
|
||||
state: str | None = Field(default=None, pattern=r"^(open|closed)$")
|
||||
labels: str | None = Field(default=None, description="Comma-separated label IDs")
|
||||
milestone: str | None = Field(default=None)
|
||||
assignee: str | None = Field(default=None)
|
||||
|
||||
|
||||
# ── Card Move ────────────────────────────────────────────────
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""FlowDeck — Standardized response models."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -13,7 +13,7 @@ class ErrorResponse(BaseModel):
|
||||
ErrorResponse(error="Rate limit exceeded", detail="Max 100 req/min per IP")
|
||||
"""
|
||||
error: str
|
||||
detail: Optional[str] = None
|
||||
detail: str | None = None
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
@@ -29,7 +29,7 @@ class SuccessResponse(BaseModel):
|
||||
SuccessResponse(status="ok", data={"issue_id": 42})
|
||||
"""
|
||||
status: str = "ok"
|
||||
data: Optional[dict[str, Any]] = None
|
||||
data: dict[str, Any] | None = None
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""FlowDeck — Admin API: users, roles, stats, audit."""
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["admin"], prefix="/api/admin")
|
||||
@@ -48,9 +48,9 @@ async def list_users(_admin=Depends(admin_required)):
|
||||
@router.post("/users")
|
||||
async def create_user(request: Request, _admin=Depends(admin_required)):
|
||||
"""Create a new user (admin only)."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
import json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -80,9 +80,9 @@ 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)):
|
||||
"""Update a user: name, email, password, admin status, active status."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
import json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+32
-29
@@ -4,19 +4,38 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
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
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.routers.board import _issue_column, _map_issue_to_card, STATUS_COLORS, STATUS_LABELS
|
||||
from app.routers.board import STATUS_COLORS, STATUS_LABELS, _issue_column, _map_issue_to_card
|
||||
from app.services.gitea_client import gitea
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
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]] = {}
|
||||
@@ -48,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",
|
||||
@@ -80,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,
|
||||
@@ -150,7 +153,7 @@ async def move_card(
|
||||
issue = await gitea.get_issue(owner, repo, issue_id)
|
||||
current_labels = [lbl["name"] for lbl in issue.get("labels", [])]
|
||||
status_labels = await _get_status_labels(owner, repo, board_id)
|
||||
filtered_names = [l for l in current_labels if l not in status_labels]
|
||||
filtered_names = [name for name in current_labels if name not in status_labels]
|
||||
filtered_names.append(mapping["gitea_label"])
|
||||
|
||||
# Resolve label names to IDs
|
||||
@@ -294,7 +297,7 @@ async def create_issue(
|
||||
if not _check_rate_limit(request):
|
||||
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
||||
|
||||
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()] if labels else None
|
||||
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()] if labels else None
|
||||
milestone_id = int(milestone) if milestone.strip().isdigit() else None
|
||||
|
||||
issue = await gitea.create_issue(
|
||||
@@ -346,7 +349,7 @@ async def update_issue_api(
|
||||
if state:
|
||||
kwargs["state"] = state
|
||||
if labels:
|
||||
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()]
|
||||
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()]
|
||||
if milestone and milestone.strip().isdigit():
|
||||
kwargs["milestone"] = int(milestone)
|
||||
if assignee:
|
||||
@@ -389,7 +392,7 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
|
||||
comments = await gitea.get_issue_comments(owner, repo, issue_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch issue %s/%s #%d: %s", owner, repo, issue_id, e)
|
||||
raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found")
|
||||
raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found") from e
|
||||
|
||||
# Get checklists from local DB
|
||||
with get_conn() as conn:
|
||||
@@ -441,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))
|
||||
|
||||
|
||||
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")
|
||||
async 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}")
|
||||
async 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}")
|
||||
async 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")
|
||||
async 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}")
|
||||
async 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}")
|
||||
async 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")
|
||||
async 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")
|
||||
async 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")
|
||||
async 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")
|
||||
async 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}")
|
||||
async 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")
|
||||
async 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}")
|
||||
async 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")
|
||||
async def audit_logs(request: Request):
|
||||
_admin_user(request)
|
||||
qp = request.query_params
|
||||
source = (qp.get("source") or "all").lower()
|
||||
if source not in ("all", "api", "permissions", "sso"):
|
||||
raise HTTPException(400, "source must be all|api|permissions|sso")
|
||||
limit, offset = parse_pagination(request, default_limit=50, max_limit=500)
|
||||
rows = _query(source, qp.get("actor") or "", qp.get("action") or "", limit, offset)
|
||||
rows = [dict(r) if not isinstance(r, dict) else r for r in rows]
|
||||
if qp.get("format") == "csv":
|
||||
import csv
|
||||
import io
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(buf, fieldnames=["at", "source", "actor", "action",
|
||||
"resource", "ip", "detail"])
|
||||
writer.writeheader()
|
||||
for r in rows[:10000]:
|
||||
writer.writerow({k: r.get(k, "") for k in writer.fieldnames})
|
||||
return PlainTextResponse(buf.getvalue(), media_type="text/csv",
|
||||
headers={"Content-Disposition":
|
||||
"attachment; filename=audit.csv"})
|
||||
return JSONResponse(content={"logs": rows, "source": source,
|
||||
"limit": limit, "offset": offset})
|
||||
+214
-23
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
@@ -13,6 +13,24 @@ from app.config import settings
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["auth"], prefix="/auth")
|
||||
|
||||
|
||||
def get_redirect_uri(request: Request) -> str:
|
||||
"""OAuth redirect URI for this request.
|
||||
|
||||
Explicit `OAUTH_REDIRECT_URI` env override wins (must be registered in the
|
||||
provider's OAuth app). Otherwise it is derived from the request so it always
|
||||
matches the URL the user actually used: scheme from `X-Forwarded-Proto`
|
||||
(reverse proxies) falling back to the request scheme, host from
|
||||
`X-Forwarded-Host` falling back to the `Host` header.
|
||||
"""
|
||||
if settings.oauth_redirect_uri:
|
||||
return settings.oauth_redirect_uri
|
||||
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}/auth/callback"
|
||||
|
||||
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -45,6 +63,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>
|
||||
@@ -70,11 +89,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
|
||||
@@ -83,6 +108,38 @@ 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>"""
|
||||
@@ -144,19 +201,20 @@ async def login(request: Request, provider: str = Query("gitea")):
|
||||
# Encode auth mode in state to survive session loss during OAuth redirect
|
||||
signed_state = f"{state}:{mode}" if mode else state
|
||||
request.session["oauth_mode"] = mode
|
||||
# Dynamic redirect URI based on incoming Host header
|
||||
host = request.headers.get("host", "localhost:8080")
|
||||
dynamic_redirect_uri = f"http://{host}/auth/callback"
|
||||
auth_url = oauth_provider.get_authorize_url(signed_state, redirect_uri=dynamic_redirect_uri, force_login=(mode == "link"))
|
||||
# Redirect URI derived from the incoming request (scheme-aware); stored in
|
||||
# session so the callback reuses the EXACT same URI for token exchange
|
||||
redirect_uri = get_redirect_uri(request)
|
||||
request.session["oauth_redirect_uri"] = redirect_uri
|
||||
auth_url = oauth_provider.get_authorize_url(signed_state, redirect_uri=redirect_uri, force_login=(mode == "link"))
|
||||
return RedirectResponse(url=auth_url, status_code=302)
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(request: Request):
|
||||
"""Register a new local account."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
import json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -172,6 +230,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:
|
||||
@@ -191,7 +259,7 @@ async def register(request: Request):
|
||||
user_data = dict(user)
|
||||
# Log login
|
||||
_log_login(user_data["id"], request)
|
||||
session = SessionManager.create_session(user_data)
|
||||
session = SessionManager.create_session(user_data, request)
|
||||
from fastapi.responses import JSONResponse
|
||||
response = JSONResponse({"status": "ok", "user": {"login": email, "name": name}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
@@ -201,10 +269,12 @@ async def register(request: Request):
|
||||
@router.post("/local-login")
|
||||
async def local_login(request: Request):
|
||||
"""Login with email + password."""
|
||||
from app.db import get_conn
|
||||
from app.password_utils import verify_password, is_locked
|
||||
import time
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
import json, time
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import is_locked, verify_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -239,14 +309,42 @@ 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()
|
||||
session = SessionManager.create_session(ud)
|
||||
|
||||
# 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"]}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
@@ -282,10 +380,10 @@ async def callback(
|
||||
if not oauth_provider:
|
||||
return HTMLResponse(f"<h1>Unknown provider: {provider_name}</h1>", status_code=400)
|
||||
|
||||
# Exchange code for token — use dynamic redirect URI matching the authorize step
|
||||
host = request.headers.get("host", "localhost:8080")
|
||||
dynamic_redirect_uri = f"http://{host}/auth/callback"
|
||||
token_data = await oauth_provider.exchange_code(code, redirect_uri=dynamic_redirect_uri)
|
||||
# Exchange code for token — reuse the redirect URI from the authorize step
|
||||
# (stored in session), falling back to deriving it from this request
|
||||
redirect_uri = request.session.get("oauth_redirect_uri") or get_redirect_uri(request)
|
||||
token_data = await oauth_provider.exchange_code(code, redirect_uri=redirect_uri)
|
||||
if not token_data:
|
||||
return HTMLResponse("<h1>Token exchange failed</h1>", status_code=400)
|
||||
|
||||
@@ -344,7 +442,7 @@ async def callback(
|
||||
user_data = dict(user) if user else oauth_user
|
||||
|
||||
# Create session
|
||||
session = SessionManager.create_session(user_data)
|
||||
session = SessionManager.create_session(user_data, request)
|
||||
_log_login(user_data["id"], request)
|
||||
response = RedirectResponse(url="/workspaces", status_code=302)
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
@@ -352,9 +450,21 @@ 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)
|
||||
async 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
|
||||
|
||||
@@ -368,6 +478,87 @@ async def current_user(request: Request):
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": True, "user": user}
|
||||
|
||||
|
||||
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/local-verify")
|
||||
async def local_verify(request: Request):
|
||||
"""Exchange a 2FA ``pending`` token + TOTP/backup code for a session."""
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import two_factor as _2fa
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
user_id = _2fa.redeem_pending(body.get("pending", ""))
|
||||
if not user_id:
|
||||
return JSONResponse({"error": "Challenge expired — log in again"}, status_code=401)
|
||||
if not _2fa.verify_code(user_id, body.get("code", "")):
|
||||
return JSONResponse({"error": "Invalid code"}, status_code=401)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row or not row["is_active"]:
|
||||
return JSONResponse({"error": "Account disabled"}, status_code=403)
|
||||
ud = dict(row)
|
||||
session = SessionManager.create_session(ud, request)
|
||||
_log_login(ud["id"], request)
|
||||
response = JSONResponse({"status": "ok", "user": {"login": ud["login"]}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True,
|
||||
max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
def _session_user_or_401(request: Request) -> dict:
|
||||
from fastapi import HTTPException
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/2fa/status")
|
||||
async def twofa_status(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
return {"enabled": _2fa.is_enabled(user["id"]),
|
||||
"backup_remaining": _2fa.remaining_backup_codes(user["id"])}
|
||||
|
||||
|
||||
@router.post("/2fa/setup")
|
||||
async def twofa_setup(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
return _2fa.setup_secret(user["id"])
|
||||
|
||||
|
||||
@router.post("/2fa/activate")
|
||||
async def twofa_activate(request: Request):
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
try:
|
||||
codes = _2fa.activate_secret(user["id"], body.get("secret", ""),
|
||||
body.get("code", ""))
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Invalid code — secret not activated"},
|
||||
status_code=400)
|
||||
return {"status": "enabled", "backup_codes": codes}
|
||||
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
async def twofa_disable(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
_2fa.disable(user["id"])
|
||||
return {"status": "disabled"}
|
||||
|
||||
# ── Helpers ──
|
||||
def _log_login(user_id: int, request: Request):
|
||||
"""Record login in history."""
|
||||
@@ -382,4 +573,4 @@ def _log_login(user_id: int, request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_log_login")
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
"""FlowDeck — Automations API (v5.1.0): rules CRUD, manual/button run, history."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import (
|
||||
get_page_context,
|
||||
get_steps,
|
||||
press_button,
|
||||
run_automation,
|
||||
validate_step,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def _json_or_dumps(val, default="[]"):
|
||||
"""Store JSON string columns without double-encoding."""
|
||||
if val is None:
|
||||
return default
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
json.loads(val)
|
||||
return val
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return json.dumps(val)
|
||||
return json.dumps(val)
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
return user if user and user.get("id") else {}
|
||||
|
||||
|
||||
def _validate_payload(body: dict) -> None:
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name required")
|
||||
trigger_type = body.get("trigger_type", "event")
|
||||
if trigger_type not in TRIGGER_TYPES:
|
||||
raise HTTPException(status_code=400, detail="invalid trigger_type")
|
||||
if trigger_type == "event" and not body.get("event"):
|
||||
raise HTTPException(status_code=400, detail="event required for event trigger")
|
||||
if trigger_type == "cron" and not (body.get("cron_expression") or "").strip():
|
||||
raise HTTPException(status_code=400, detail="cron_expression required for cron trigger")
|
||||
for key in ("condition_json", "actions_json"):
|
||||
val = body.get(key, "[]")
|
||||
try:
|
||||
if isinstance(val, str):
|
||||
json.loads(val)
|
||||
else:
|
||||
json.dumps(val)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
raise HTTPException(status_code=400, detail=f"{key} must be valid JSON") from None
|
||||
|
||||
|
||||
@router.get("/workspace/automations")
|
||||
async 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]
|
||||
return {"automations": items}
|
||||
|
||||
|
||||
@router.post("/workspace/automations")
|
||||
async def create_automation(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
_validate_payload(body)
|
||||
user = _current_user(request)
|
||||
by = user["id"]
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO automations
|
||||
(workspace, name, trigger_type, event, cron_expression, collection_id,
|
||||
condition_json, actions_json, enabled, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
body.get("workspace", "") or "",
|
||||
(body.get("name") or "").strip(),
|
||||
body.get("trigger_type", "event"),
|
||||
body.get("event", "page.created"),
|
||||
body.get("cron_expression", "") or "",
|
||||
body.get("collection_id") or None,
|
||||
_json_or_dumps(body.get("condition", body.get("condition_json", []))),
|
||||
_json_or_dumps(body.get("actions", body.get("actions_json", []))),
|
||||
int(body.get("enabled", True)),
|
||||
by,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
return {"id": new_id, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}")
|
||||
async 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:
|
||||
raise HTTPException(status_code=404, detail="Automation not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@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 {}
|
||||
_validate_payload(body)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Automation not found")
|
||||
conn.execute(
|
||||
"""UPDATE automations SET
|
||||
name=?, trigger_type=?, event=?, cron_expression=?, collection_id=?,
|
||||
condition_json=?, actions_json=?, enabled=?, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?""",
|
||||
(
|
||||
(body.get("name") or "").strip(),
|
||||
body.get("trigger_type", "event"),
|
||||
body.get("event", "page.created"),
|
||||
body.get("cron_expression", "") or "",
|
||||
body.get("collection_id") or None,
|
||||
_json_or_dumps(body.get("condition", body.get("condition_json", []))),
|
||||
_json_or_dumps(body.get("actions", body.get("actions_json", []))),
|
||||
int(body.get("enabled", True)),
|
||||
auto_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": auto_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/workspace/automations/{auto_id}")
|
||||
async def delete_automation(request: Request, auto_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM automations WHERE id=?", (auto_id,))
|
||||
conn.commit()
|
||||
return {"id": auto_id, "status": "deleted"}
|
||||
|
||||
|
||||
async def _execute(automation_id: int, trigger_source: str, body: dict) -> dict:
|
||||
page_id = body.get("page_id") if isinstance(body, dict) else None
|
||||
collection_id = body.get("collection_id") if isinstance(body, dict) else None
|
||||
context = {"collection_id": collection_id, "page_id": page_id}
|
||||
if page_id:
|
||||
context.update(get_page_context(int(page_id), collection_id or 0))
|
||||
result = await run_automation(automation_id, trigger_source, context)
|
||||
result["automation_id"] = automation_id
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/workspace/automations/{auto_id}/run")
|
||||
async def run_automation_endpoint(request: Request, auto_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
return await _execute(auto_id, "manual", body)
|
||||
|
||||
|
||||
@router.post("/api/automations/{auto_id}/run")
|
||||
async def run_automation_button(request: Request, auto_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
return await _execute(auto_id, "button", body)
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}/runs")
|
||||
async 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=?
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?""",
|
||||
(auto_id, limit),
|
||||
).fetchall()
|
||||
return {"runs": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ── v7.0.0 — chained steps (trigger/condition/delay/action) ───────────────
|
||||
|
||||
STEP_SECRET_FIELDS = {"webhook_url"}
|
||||
|
||||
|
||||
def _require_session(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
def _get_auto(auto_id: int) -> dict | None:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _auto_404():
|
||||
# NOTE: return (not raise) — the global 404 handler redirects non-/api
|
||||
# paths to /workspaces, which TestClient follows into a 200.
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Automation not found"}, status_code=404)
|
||||
|
||||
|
||||
def _encrypt_step_config(config: dict) -> dict:
|
||||
"""Encrypt secret fields at rest (empty = keep existing, like sso_config)."""
|
||||
from app.services.sso_provisioning import encrypt_secret
|
||||
cfg = dict(config or {})
|
||||
for field in STEP_SECRET_FIELDS:
|
||||
if field in cfg and cfg[field]:
|
||||
val = str(cfg[field])
|
||||
if not val.startswith("gAAAAA"):
|
||||
cfg[field] = encrypt_secret(val)
|
||||
return cfg
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}/steps")
|
||||
async def list_steps(request: Request, auto_id: int):
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
return {"automation_id": auto_id, "steps": get_steps(auto_id)}
|
||||
|
||||
|
||||
@router.post("/workspace/automations/{auto_id}/steps")
|
||||
async def create_step(request: Request, auto_id: int):
|
||||
_require_session(request)
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
kind = body.get("kind", "")
|
||||
config = body.get("config", {}) or {}
|
||||
validate_step(kind, config)
|
||||
with get_conn() as conn:
|
||||
pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1)+1 FROM automation_steps WHERE automation_id=?",
|
||||
(auto_id,)).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO automation_steps (automation_id, kind, position, config_json)"
|
||||
" VALUES (?,?,?,?)",
|
||||
(auto_id, kind, int(body.get("position", pos)),
|
||||
json.dumps(_encrypt_step_config(config))))
|
||||
conn.commit()
|
||||
step_id = cur.lastrowid
|
||||
return {"id": step_id, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/workspace/automations/steps/{step_id}")
|
||||
async def update_step(request: Request, step_id: int):
|
||||
_require_session(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automation_steps WHERE id=?", (step_id,)).fetchone()
|
||||
if not row:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Step not found"}, status_code=404)
|
||||
kind = body.get("kind", row["kind"])
|
||||
try:
|
||||
config = body.get("config", json.loads(row["config_json"] or "{}"))
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
config = {}
|
||||
validate_step(kind, config if isinstance(config, dict) else {})
|
||||
conn.execute(
|
||||
"UPDATE automation_steps SET kind=?, position=?, config_json=? WHERE id=?",
|
||||
(kind, int(body.get("position", row["position"])),
|
||||
json.dumps(_encrypt_step_config(config)), step_id))
|
||||
conn.commit()
|
||||
return {"id": step_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/workspace/automations/steps/{step_id}")
|
||||
async def delete_step(request: Request, step_id: int):
|
||||
_require_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM automation_steps WHERE id=?", (step_id,))
|
||||
conn.commit()
|
||||
return {"id": step_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.put("/workspace/automations/{auto_id}/mode")
|
||||
async def set_trigger_mode(request: Request, auto_id: int):
|
||||
"""Set multi-trigger mode: any (default) or all (5-minute window)."""
|
||||
_require_session(request)
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
mode = (body.get("mode") or "any").lower()
|
||||
if mode not in ("any", "all"):
|
||||
raise HTTPException(status_code=400, detail="mode must be any or all")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE automations SET trigger_mode=? WHERE id=?", (mode, auto_id))
|
||||
conn.commit()
|
||||
return {"id": auto_id, "trigger_mode": mode}
|
||||
|
||||
|
||||
@router.post("/api/automations/press-button")
|
||||
async def press_button_endpoint(request: Request):
|
||||
"""Run the automation linked to a native DB button cell (CSRF-exempt)."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
try:
|
||||
collection_id = int(body.get("collection_id", 0))
|
||||
row_id = int(body.get("row_id", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="collection_id + row_id required") from None
|
||||
prop_ref = body.get("property", body.get("property_id", ""))
|
||||
if not prop_ref:
|
||||
raise HTTPException(status_code=400, detail="property required")
|
||||
user = _current_user(request)
|
||||
try:
|
||||
result = await press_button(collection_id, row_id, prop_ref, user.get("id") or 1)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||
return result
|
||||
+1212
-87
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
||||
"""FlowDeck — Collaboration API (v4.9.0): inline comments on pages + mentions.
|
||||
|
||||
Comments live in the existing `comments` table, extended with a target_type /
|
||||
target_id pair and inline anchors (anchor_block_id + text offsets). Mentions
|
||||
written in a comment body automatically notify the mentioned users.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, 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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collaboration"], prefix="/api")
|
||||
|
||||
|
||||
def _current_user(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 _page_url(page_id: int) -> str:
|
||||
from app.config import settings
|
||||
return f"{settings.app_base_url}/pages/{page_id}"
|
||||
|
||||
|
||||
def _serialize(rows):
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["author"] = {
|
||||
"id": r["author_id"],
|
||||
"login": r["author_login"],
|
||||
"full_name": r["author_name"],
|
||||
"avatar_url": r["author_avatar"],
|
||||
"avatar_color": r["author_color"],
|
||||
}
|
||||
for k in ("author_id", "author_login", "author_name", "author_avatar", "author_color"):
|
||||
d.pop(k, None)
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/comments")
|
||||
async 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:
|
||||
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
rows = conn.execute(
|
||||
"""SELECT c.*, c.user_id AS author_id, u.login AS author_login,
|
||||
u.full_name AS author_name, u.avatar_url AS author_avatar,
|
||||
u.avatar_color AS author_color
|
||||
FROM comments c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.target_type='page' AND c.target_id=?
|
||||
ORDER BY c.created_at ASC, c.id ASC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return {"page_id": page_id, "comments": _serialize(rows)}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
async def add_comment(request: Request, page_id: int):
|
||||
"""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")
|
||||
|
||||
anchor_block = body.get("anchor_block_id")
|
||||
anchor_start = body.get("anchor_start")
|
||||
anchor_end = body.get("anchor_end")
|
||||
# normalize empty anchor → page-level comment
|
||||
if not anchor_block or anchor_start is None or anchor_end is None:
|
||||
anchor_block, anchor_start, anchor_end = None, None, None
|
||||
elif int(anchor_start) == int(anchor_end):
|
||||
anchor_block, anchor_start, anchor_end = None, None, None
|
||||
|
||||
parent_id = body.get("parent_id")
|
||||
uid = user["id"]
|
||||
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")
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||
(uid, user.get("login", "admin"), user.get("full_name", "Admin")),
|
||||
)
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO comments
|
||||
(page_id, user_id, body, parent_id, target_type, target_id,
|
||||
anchor_block_id, anchor_start, anchor_end)
|
||||
VALUES (?,?,?,?, 'page', ?, ?, ?, ?)""",
|
||||
(page_id, uid, text, parent_id, page_id, anchor_block, anchor_start, anchor_end),
|
||||
)
|
||||
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']}”"
|
||||
message = f"{user.get('full_name') or user.get('login')} commented: {text[:300]}"
|
||||
notif.process_mentions(
|
||||
text, uid, "mention", title, message,
|
||||
"page", page_id, url, conn=conn,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _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)})
|
||||
except Exception:
|
||||
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):
|
||||
"""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()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
url = _page_url(page_id)
|
||||
mentioned = notif.process_mentions(
|
||||
text, user["id"], "mention", f"You were mentioned in “{page['title']}”",
|
||||
f"{user.get('full_name') or user.get('login')} mentioned you on a page.",
|
||||
"page", page_id, url, conn=conn,
|
||||
)
|
||||
conn.commit()
|
||||
if mentioned:
|
||||
try:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
|
||||
except Exception:
|
||||
logger.exception("notify_page_mentions")
|
||||
return {"mentioned": mentioned}
|
||||
|
||||
|
||||
@router.put("/comments/{comment_id}")
|
||||
async def update_comment(request: Request, comment_id: int):
|
||||
"""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,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Comment not found")
|
||||
if row["user_id"] != user["id"]:
|
||||
raise HTTPException(403, "Not allowed to edit this comment")
|
||||
if "body" in body and body.get("body") is not None:
|
||||
conn.execute(
|
||||
"UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(body["body"].strip(), comment_id),
|
||||
)
|
||||
was_resolved = int(row["resolved"] or 0)
|
||||
if "resolved" in body and body.get("resolved") is not None:
|
||||
conn.execute("UPDATE comments SET resolved=? WHERE id=?",
|
||||
(1 if body["resolved"] else 0, comment_id))
|
||||
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"]})
|
||||
except Exception:
|
||||
logger.exception("update_comment")
|
||||
return {"id": comment_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/comments/{comment_id}")
|
||||
async def delete_comment(request: Request, comment_id: int):
|
||||
"""Delete a comment and its replies."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Comment not found")
|
||||
if row["user_id"] != user["id"]:
|
||||
# allow page "owners" — fall back to a simple ownership rule for now
|
||||
raise HTTPException(403, "Not allowed to delete this comment")
|
||||
conn.execute("DELETE FROM comments WHERE id=? OR parent_id=?", (comment_id, comment_id))
|
||||
conn.commit()
|
||||
return {"id": comment_id, "status": "deleted"}
|
||||
+988
-63
File diff suppressed because it is too large
Load Diff
+761
-140
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
"""FlowDeck — custom workspace emojis (v5.6.0).
|
||||
|
||||
Uploaded emoji images stored per-workspace and usable as page icons. Kept on a
|
||||
prefix-less router so the paths stay ``/api/custom-emojis`` (the board router's
|
||||
``/{owner}/{repo}`` HTML catch-all would otherwise shadow them).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
router = APIRouter(tags=["emojis"])
|
||||
|
||||
_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"))
|
||||
|
||||
|
||||
def _active_ws(request: Request) -> int:
|
||||
"""Workspace id from the active-workspace cookie, fallback 1."""
|
||||
try:
|
||||
ws_id = int(request.cookies.get("flowdeck_workspace", "") or 0)
|
||||
if ws_id > 0:
|
||||
return ws_id
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return 1
|
||||
|
||||
|
||||
@router.get("/api/custom-emojis")
|
||||
async def list_custom_emojis(request: Request):
|
||||
"""List the current workspace's custom emojis."""
|
||||
ws_id = _active_ws(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, url FROM custom_emojis WHERE workspace_id=? ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
return {"status": "ok", "emojis": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/custom-emojis")
|
||||
async def create_custom_emoji(request: Request):
|
||||
"""Upload a custom emoji image (multipart: ``name`` + ``file``)."""
|
||||
form = await request.form()
|
||||
name = (form.get("name") or "").strip()[:40] or "emoji"
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
original = (upload.filename or "emoji.png").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]", "_", original)[:80]
|
||||
ext = safe.rsplit(".", 1)[-1].lower() if "." in safe else "png"
|
||||
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")
|
||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
final = f"emoji_{stamp}_{safe}"
|
||||
(folder / final).write_bytes(await upload.read())
|
||||
url = f"/api/files/{ws_id}/{final}"
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO custom_emojis (workspace_id, name, url) VALUES (?, ?, ?)",
|
||||
(ws_id, name, url),
|
||||
)
|
||||
conn.commit()
|
||||
emoji_id = cur.lastrowid
|
||||
return {"status": "ok", "emoji": {"id": emoji_id, "name": name, "url": url}}
|
||||
|
||||
|
||||
@router.delete("/api/custom-emojis/{emoji_id}")
|
||||
async 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:
|
||||
row = conn.execute(
|
||||
"SELECT url FROM custom_emojis WHERE id=? AND workspace_id=?",
|
||||
(emoji_id, ws_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "emoji not found")
|
||||
conn.execute("DELETE FROM custom_emojis WHERE id=?", (emoji_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
fname = (row["url"] or "").rsplit("/", 1)[-1]
|
||||
if fname:
|
||||
(_upload_root() / f"uploads/workspace_{ws_id}" / fname).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return {"status": "ok", "id": emoji_id}
|
||||
@@ -0,0 +1,103 @@
|
||||
"""FlowDeck — Export endpoints (v4.7.0).
|
||||
|
||||
Routes /api/export/* — generate Markdown, HTML, PDF and static-site (zip)
|
||||
exports server-side for a given page.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.export import (
|
||||
build_static_site_bytes,
|
||||
page_to_markdown,
|
||||
page_to_pdf_bytes,
|
||||
page_to_standalone_html,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["export"], prefix="/api/export")
|
||||
|
||||
|
||||
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",
|
||||
(page_id,),
|
||||
).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)
|
||||
|
||||
|
||||
def _download_header(filename: str, media_type: str) -> dict:
|
||||
ascii_name = re.sub(r"[^\x00-\x7F]", "_", filename)
|
||||
quoted = filename.replace('"', '')
|
||||
return {
|
||||
"Content-Disposition": f'attachment; filename="{ascii_name}"; filename*=UTF-8\'\'{quoted}',
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": media_type,
|
||||
}
|
||||
|
||||
|
||||
def _safe_filename(page: dict, ext: str) -> str:
|
||||
title = (page.get("title") or "Untitled").strip() or "Untitled"
|
||||
title = re.sub(r'[\\/:*?"<>|]+', "_", title)
|
||||
return f"{title}.{ext}"
|
||||
|
||||
|
||||
@router.get("/markdown/{page_id}")
|
||||
async 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")
|
||||
return Response(content=md.encode("utf-8"), status_code=200, headers=headers)
|
||||
|
||||
|
||||
@router.get("/html/{page_id}")
|
||||
async 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")
|
||||
return Response(content=html.encode("utf-8"), status_code=200, headers=headers)
|
||||
|
||||
|
||||
@router.get("/pdf/{page_id}")
|
||||
async 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:
|
||||
raise HTTPException(status_code=501, detail="PDF export requires 'weasyprint' or 'xhtml2pdf'") from None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("PDF export failed for page %s: %s", page_id, exc)
|
||||
raise HTTPException(status_code=500, detail="PDF generation failed") from exc
|
||||
filename = _safe_filename(page, "pdf")
|
||||
headers = _download_header(filename, "application/pdf")
|
||||
return Response(content=pdf_bytes, status_code=200, headers=headers)
|
||||
|
||||
|
||||
@router.get("/site/{page_id}")
|
||||
async 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"
|
||||
headers = _download_header(filename, "application/zip")
|
||||
return Response(content=site_bytes, status_code=200, headers=headers)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""FlowDeck — Gitea integration API routes."""
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["gitea"], prefix="/api/gitea")
|
||||
@@ -7,7 +7,7 @@ router = APIRouter(tags=["gitea"], prefix="/api/gitea")
|
||||
|
||||
def _require_gitea(request: Request):
|
||||
"""Return a per-user GiteaClient or raise 401.
|
||||
|
||||
|
||||
Only returns a client if the user has personally connected their Gitea
|
||||
account (OAuth token). No fallback to admin token — each user must link
|
||||
their own Gitea account to see Gitea projects.
|
||||
@@ -92,7 +92,6 @@ async def get_file(request: Request, owner: str, repo: str, path: str):
|
||||
@router.put("/projects/{owner}/{repo}/file")
|
||||
async def save_file(request: Request, owner: str, repo: str):
|
||||
"""Create or update a file in the repo."""
|
||||
import json
|
||||
gitea = _require_gitea(request) # admin token can write too
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -140,7 +139,6 @@ async def upload_file(request: Request, owner: str, repo: str):
|
||||
@router.delete("/projects/{owner}/{repo}/file")
|
||||
async def delete_file(request: Request, owner: str, repo: str):
|
||||
"""Delete a file from the repo."""
|
||||
import json
|
||||
gitea = _require_gitea(request) # admin token can write too
|
||||
path = request.query_params.get("path", "")
|
||||
sha = request.query_params.get("sha", "")
|
||||
@@ -162,8 +160,8 @@ async def get_labels(request: Request, owner: str, repo: str):
|
||||
try:
|
||||
labels = await gitea.get_labels(owner, repo)
|
||||
return {"labels": [
|
||||
{"id": l["id"], "name": l["name"], "color": l.get("color", "#787774")}
|
||||
for l in labels
|
||||
{"id": lbl["id"], "name": lbl["name"], "color": lbl.get("color", "#787774")}
|
||||
for lbl in labels
|
||||
]}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
@@ -214,9 +212,9 @@ 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):
|
||||
"""Create a new private page for this Gitea project."""
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
import json
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
@@ -255,9 +253,9 @@ 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):
|
||||
"""Update a private page."""
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
import json
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
@@ -329,7 +327,8 @@ async def sync_labels(request: Request, owner: str, repo: str):
|
||||
for label in labels:
|
||||
name = label.get("name", "")
|
||||
color = label.get("color", "#787774")
|
||||
if not name: continue
|
||||
if not name:
|
||||
continue
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM tags WHERE name=? AND user_id=?", (name, user["id"])
|
||||
).fetchone()
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""FlowDeck — agent governance API (v7.2.0): policies + approval queue."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import agent_policies as policies
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
router = APIRouter(tags=["governance"])
|
||||
|
||||
|
||||
def _owner_or_admin(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
is_admin = bool(row and row["is_admin"])
|
||||
if not is_admin and request.query_params.get("workspace_id"):
|
||||
member = conn.execute(
|
||||
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(request.query_params.get("workspace_id"), user["id"])).fetchone()
|
||||
owner = conn.execute("SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(request.query_params.get("workspace_id"),
|
||||
user["id"])).fetchone()
|
||||
if not member and not owner:
|
||||
raise HTTPException(403, "Workspace access required")
|
||||
if member and member["role"] not in ("admin", "editor", "owner"):
|
||||
raise HTTPException(403, "Editor role required")
|
||||
user["is_admin"] = is_admin
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/api/v2/agent-policies")
|
||||
async def list_policies(request: Request):
|
||||
_owner_or_admin(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM agent_policies ORDER BY workspace_id").fetchall()
|
||||
return {"policies": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/v2/agent-policies")
|
||||
async def upsert_policy(request: Request):
|
||||
user = _owner_or_admin(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
wid = body.get("workspace_id")
|
||||
tools = body.get("allowed_tools")
|
||||
if tools is not None and not isinstance(tools, list):
|
||||
raise HTTPException(400, "allowed_tools must be a list or null")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO agent_policies (workspace_id, allowed_tools_json, max_steps,
|
||||
require_approval)
|
||||
VALUES (?,?,?,?)
|
||||
ON CONFLICT(workspace_id) DO UPDATE SET
|
||||
allowed_tools_json=excluded.allowed_tools_json,
|
||||
max_steps=excluded.max_steps, require_approval=excluded.require_approval""",
|
||||
(wid, json.dumps(tools) if tools is not None else None,
|
||||
max(1, min(int(body.get("max_steps") or 12), 50)),
|
||||
1 if body.get("require_approval") else 0))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM agent_policies WHERE workspace_id IS ?",
|
||||
(wid,)).fetchone()
|
||||
audit_log(user, "agent.policy.upsert", "workspace", wid or 0, "", request)
|
||||
return JSONResponse(status_code=201, content=dict(row))
|
||||
|
||||
|
||||
@router.get("/api/v2/agent-approvals")
|
||||
async def list_approvals(request: Request):
|
||||
_owner_or_admin(request)
|
||||
status = request.query_params.get("status", "pending")
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM agent_approvals WHERE status=? ORDER BY id DESC"
|
||||
" LIMIT 100", (status,)).fetchall()
|
||||
return {"approvals": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/v2/agent-approvals/{approval_id}/decide")
|
||||
async def decide_approval(approval_id: int, request: Request):
|
||||
user = _owner_or_admin(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
out = policies.decide_approval(approval_id, user["id"], bool(body.get("approve")))
|
||||
if out is None:
|
||||
raise HTTPException(404, "Pending approval not found")
|
||||
audit_log(user, "agent.approval.decide", "agent_approval", approval_id,
|
||||
out["status"], request)
|
||||
return out
|
||||
@@ -0,0 +1,378 @@
|
||||
"""FlowDeck — unified import API (v5.6.0, Phase 0/1/2).
|
||||
|
||||
Exposes the importer registry, a dry-run preview, a synchronous run and an
|
||||
optional background job with polling. Works with the existing workspace cookie
|
||||
(``flowdeck_workspace``) and session.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
from app.services.importers import (
|
||||
get_job,
|
||||
list_jobs,
|
||||
list_sources,
|
||||
parse_upload,
|
||||
preview_result,
|
||||
resolve_relations,
|
||||
run_import,
|
||||
start_import_job,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/import", tags=["import"])
|
||||
page_router = APIRouter(tags=["import"])
|
||||
|
||||
MAX_UPLOAD_BYTES = 200 * 1024 * 1024
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
return SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {}
|
||||
|
||||
|
||||
@page_router.get("/import", response_class=HTMLResponse)
|
||||
async 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 app.templating import ENV
|
||||
|
||||
env = ENV
|
||||
return HTMLResponse(content=env.get_template("import.html").render(user=user))
|
||||
|
||||
|
||||
def _workspace(request: Request) -> tuple[int | None, str]:
|
||||
"""Resolve (workspace_id, login) from the workspace cookie + session."""
|
||||
ws_id: int | None = None
|
||||
cookie = request.cookies.get("flowdeck_workspace", "")
|
||||
try:
|
||||
value = int(cookie)
|
||||
if value > 0:
|
||||
ws_id = value
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
user = _current_user(request)
|
||||
login = user.get("login", "") if user else ""
|
||||
return ws_id, login
|
||||
|
||||
|
||||
async def _read_upload(request: Request) -> tuple[str, bytes, str | None]:
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
filename = (upload.filename or "import").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
data = await upload.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, "File too large (max 200 MB)")
|
||||
source_id = form.get("source") or None
|
||||
return filename, data, source_id
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
async def import_sources(request: Request):
|
||||
"""List every available importer for the UI source picker."""
|
||||
return {"sources": list_sources()}
|
||||
|
||||
|
||||
@router.post("/preview")
|
||||
async def import_preview(request: Request):
|
||||
"""Dry-run: parse the upload and describe what would be created."""
|
||||
filename, data, source_id = await _read_upload(request)
|
||||
imp, result = parse_upload(filename, data, source_id)
|
||||
if imp is None:
|
||||
raise HTTPException(400, "Format non reconnu — choisissez une source")
|
||||
out = preview_result(result)
|
||||
out["detected_source"] = imp.source_id
|
||||
out["source_label"] = imp.label
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def import_run(request: Request):
|
||||
"""Import an upload (synchronously, or as a background job when async=true)."""
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
filename = (upload.filename or "import").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
data = await upload.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, "File too large (max 200 MB)")
|
||||
|
||||
source_id = form.get("source") or None
|
||||
parent_id = _int_or_none(form.get("parent_id"))
|
||||
target = _int_or_none(form.get("target_collection_id"))
|
||||
dedup = str(form.get("dedup", "true")).lower() not in ("false", "0", "no")
|
||||
async_mode = str(form.get("async", "false")).lower() in ("true", "1", "yes")
|
||||
mapping = _parse_mapping(form.get("mapping"))
|
||||
mode = _parse_mode(form.get("mode"))
|
||||
ws_id, login = _workspace(request)
|
||||
|
||||
if async_mode:
|
||||
job = start_import_job(
|
||||
filename=filename, data=data, source_id=source_id,
|
||||
workspace_id=ws_id, workspace_name=login, user_login=login,
|
||||
parent_page_id=parent_id, target_collection_id=target, dedup=dedup,
|
||||
mapping=mapping, mode=mode,
|
||||
)
|
||||
return {"status": "queued", "job_id": job["id"]}
|
||||
|
||||
imp, result = parse_upload(filename, data, source_id)
|
||||
if imp is None:
|
||||
raise HTTPException(400, "Format non reconnu — choisissez une source")
|
||||
report = run_import(
|
||||
result, workspace_id=ws_id, workspace_name=login, user_login=login,
|
||||
parent_page_id=parent_id, target_collection_id=target, dedup=dedup,
|
||||
mapping=mapping, mode=mode,
|
||||
)
|
||||
report["detected_source"] = imp.source_id
|
||||
if imp.source_id == "notion":
|
||||
with get_conn() as conn:
|
||||
report["relations"] = resolve_relations(conn, ws_id)
|
||||
for page_id in report.get("page_ids", [])[:100]:
|
||||
try:
|
||||
await fire_event("page.created", {"page_id": page_id, "title": "", "workspace": login})
|
||||
except Exception: # noqa: BLE001 - events are best-effort
|
||||
pass
|
||||
return report
|
||||
|
||||
|
||||
@router.post("/forge")
|
||||
async def import_forge(request: Request):
|
||||
"""Import a forge repo's issues (+ labels/milestones) into collections."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
provider = str(body.get("provider") or "gitea").lower()
|
||||
owner = str(body.get("owner") or "").strip()
|
||||
repo = str(body.get("repo") or "").strip()
|
||||
if not owner or not repo:
|
||||
raise HTTPException(400, "owner and repo are required")
|
||||
state = str(body.get("state") or "all")
|
||||
include_labels = bool(body.get("include_labels", True))
|
||||
include_milestones = bool(body.get("include_milestones", True))
|
||||
ws_id, login = _workspace(request)
|
||||
|
||||
if provider == "gitea":
|
||||
from app.services.gitea_client import get_user_gitea_client
|
||||
from app.services.importers.forge import GiteaForgeAdapter
|
||||
client = get_user_gitea_client(request)
|
||||
if client is None:
|
||||
raise HTTPException(400, "Gitea non connecté")
|
||||
adapter = GiteaForgeAdapter(client)
|
||||
elif provider == "github":
|
||||
from app.services.github_adapter import GitHubAdapter
|
||||
token = _user_oauth_token(request, "github")
|
||||
if not token:
|
||||
raise HTTPException(400, "GitHub non connecté")
|
||||
adapter = GitHubAdapter(token)
|
||||
else:
|
||||
raise HTTPException(400, "provider must be 'gitea' or 'github'")
|
||||
|
||||
from app.services.importers.forge import fetch_forge_issues
|
||||
result = await fetch_forge_issues(
|
||||
adapter, owner, repo, provider=provider, state=state,
|
||||
include_labels=include_labels, include_milestones=include_milestones,
|
||||
)
|
||||
report = run_import(
|
||||
result, workspace_id=ws_id, workspace_name=login, user_login=login,
|
||||
)
|
||||
report["detected_source"] = f"forge:{provider}"
|
||||
return report
|
||||
|
||||
|
||||
@router.post("/forge-repo")
|
||||
async def import_forge_repo(request: Request):
|
||||
"""Import a forge repo's text files as pages (folder hierarchy preserved)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
provider = str(body.get("provider") or "gitea").lower()
|
||||
owner = str(body.get("owner") or "").strip()
|
||||
repo = str(body.get("repo") or "").strip()
|
||||
if not owner or not repo:
|
||||
raise HTTPException(400, "owner and repo are required")
|
||||
path = str(body.get("path") or "")
|
||||
max_files = min(int(body.get("max_files") or 200), 1000)
|
||||
ws_id, login = _workspace(request)
|
||||
|
||||
adapter = _forge_adapter(request, provider)
|
||||
|
||||
from app.services.importers.forge_repo import fetch_forge_repo
|
||||
result = await fetch_forge_repo(
|
||||
adapter, owner, repo, provider=provider, path=path, max_files=max_files,
|
||||
)
|
||||
report = run_import(result, workspace_id=ws_id, workspace_name=login, user_login=login)
|
||||
report["detected_source"] = f"forge-repo:{provider}"
|
||||
return report
|
||||
|
||||
|
||||
@router.post("/url")
|
||||
async def import_url(request: Request):
|
||||
"""Web clipper: fetch a URL and create a page (bookmark card + content)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
url = str(body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url is required")
|
||||
ws_id, login = _workspace(request)
|
||||
|
||||
from app.services.importers.url_fetch import fetch_url_result
|
||||
try:
|
||||
result = await fetch_url_result(url)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
if not result.pages:
|
||||
raise HTTPException(422, "; ".join(result.warnings) or "Page introuvable")
|
||||
report = run_import(result, workspace_id=ws_id, workspace_name=login, user_login=login)
|
||||
report["detected_source"] = "url"
|
||||
return report
|
||||
|
||||
|
||||
@router.post("/run-batch")
|
||||
async def import_run_batch(request: Request):
|
||||
"""Import several uploaded files sequentially, returning one report each."""
|
||||
form = await request.form()
|
||||
uploads = form.getlist("file")
|
||||
if not uploads:
|
||||
raise HTTPException(400, "file field required")
|
||||
source_id = form.get("source") or None
|
||||
parent_id = _int_or_none(form.get("parent_id"))
|
||||
target = _int_or_none(form.get("target_collection_id"))
|
||||
dedup = str(form.get("dedup", "true")).lower() not in ("false", "0", "no")
|
||||
mode = _parse_mode(form.get("mode"))
|
||||
mapping = _parse_mapping(form.get("mapping"))
|
||||
ws_id, login = _workspace(request)
|
||||
|
||||
results: list[dict] = []
|
||||
summary = {"files": 0, "pages_created": 0, "rows_created": 0, "errors": 0}
|
||||
for upload in uploads:
|
||||
filename = (getattr(upload, "filename", "") or "import").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
data = await upload.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
results.append({"filename": filename, "report": {"status": "error",
|
||||
"errors": [{"title": filename, "error": "File too large"}]}})
|
||||
summary["errors"] += 1
|
||||
continue
|
||||
imp, result = parse_upload(filename, data, source_id)
|
||||
if imp is None:
|
||||
results.append({"filename": filename, "report": {"status": "error",
|
||||
"errors": [{"title": filename, "error": "Format non reconnu"}]}})
|
||||
summary["errors"] += 1
|
||||
continue
|
||||
report = run_import(
|
||||
result, workspace_id=ws_id, workspace_name=login, user_login=login,
|
||||
parent_page_id=parent_id, target_collection_id=target, dedup=dedup,
|
||||
mapping=mapping, mode=mode,
|
||||
)
|
||||
report["detected_source"] = imp.source_id
|
||||
results.append({"filename": filename, "report": report})
|
||||
summary["files"] += 1
|
||||
summary["pages_created"] += report.get("pages_created", 0)
|
||||
summary["rows_created"] += report.get("rows_created", 0)
|
||||
summary["errors"] += len(report.get("errors", []))
|
||||
return {"status": "ok", "summary": summary, "results": results}
|
||||
|
||||
|
||||
@router.post("/relations/resolve")
|
||||
async def import_resolve_relations(request: Request):
|
||||
"""Convert text columns referencing another collection into relation props."""
|
||||
ws_id, _ = _workspace(request)
|
||||
with get_conn() as conn:
|
||||
return resolve_relations(conn, ws_id)
|
||||
|
||||
|
||||
@router.get("/jobs")
|
||||
async def import_jobs(request: Request):
|
||||
return {"jobs": list_jobs()}
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}")
|
||||
async def import_job(job_id: str):
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/report")
|
||||
async def import_job_report(job_id: str):
|
||||
"""Download a job's import report as JSON."""
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
payload = json.dumps(job.get("report") or {}, ensure_ascii=False, indent=2)
|
||||
return Response(
|
||||
content=payload,
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="import-{job_id}.json"'},
|
||||
)
|
||||
|
||||
|
||||
def _forge_adapter(request: Request, provider: str):
|
||||
if provider == "gitea":
|
||||
from app.services.gitea_client import get_user_gitea_client
|
||||
from app.services.importers.forge import GiteaForgeAdapter
|
||||
client = get_user_gitea_client(request)
|
||||
if client is None:
|
||||
raise HTTPException(400, "Gitea non connecté")
|
||||
return GiteaForgeAdapter(client)
|
||||
if provider == "github":
|
||||
from app.services.github_adapter import GitHubAdapter
|
||||
token = _user_oauth_token(request, "github")
|
||||
if not token:
|
||||
raise HTTPException(400, "GitHub non connecté")
|
||||
return GitHubAdapter(token)
|
||||
raise HTTPException(400, "provider must be 'gitea' or 'github'")
|
||||
|
||||
|
||||
def _int_or_none(value) -> int | None:
|
||||
try:
|
||||
ivalue = int(value)
|
||||
return ivalue if ivalue > 0 else None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_mapping(value) -> dict[str, str] | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if isinstance(parsed, dict):
|
||||
return {str(k): str(v) for k, v in parsed.items() if v}
|
||||
return None
|
||||
|
||||
|
||||
def _parse_mode(value) -> str | None:
|
||||
mode = str(value or "").strip().lower()
|
||||
return mode if mode in ("skip", "update", "duplicate") else None
|
||||
|
||||
|
||||
def _user_oauth_token(request: Request, provider: str) -> str:
|
||||
user = _current_user(request)
|
||||
if not user or not user.get("id"):
|
||||
return ""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider=? "
|
||||
"ORDER BY updated_at DESC LIMIT 1",
|
||||
(user["id"], provider),
|
||||
).fetchone()
|
||||
return row["access_token"] if row else ""
|
||||
+112
-24
@@ -3,11 +3,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import APIRouter, Query, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["library"], prefix="/api/library")
|
||||
@@ -60,9 +59,12 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
|
||||
elif content_format == "file":
|
||||
icon = "📄"
|
||||
fn = title.lower()
|
||||
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 = "📜"
|
||||
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 = "📜"
|
||||
else:
|
||||
icon = "📝"
|
||||
|
||||
@@ -70,6 +72,7 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
|
||||
"id": page_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"page_icon": db_row.get("page_icon") or "",
|
||||
"is_folder": bool(db_row.get("is_folder", 0)),
|
||||
"source_type": source_type,
|
||||
"source_label": _source_label(source_type, workspace),
|
||||
@@ -84,6 +87,8 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
|
||||
"url": url,
|
||||
"content_format": content_format,
|
||||
"favorited": bool(db_row.get("favorited", 0)),
|
||||
"share_mode": db_row.get("share_mode", "private"),
|
||||
"tags": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +103,30 @@ def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[st
|
||||
|
||||
|
||||
def _rows_to_items(rows, uid: int = 1) -> list:
|
||||
return [_build_item(dict(r), uid) for r in rows]
|
||||
items = [_build_item(dict(r), uid) for r in rows]
|
||||
return _attach_tags(items)
|
||||
|
||||
|
||||
def _attach_tags(items: list) -> list:
|
||||
"""Batch-load page tags for library items."""
|
||||
if not items:
|
||||
return items
|
||||
ids = [it["id"] for it in items]
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt "
|
||||
f"JOIN tags t ON t.id = pt.tag_id WHERE pt.page_id IN ({placeholders})",
|
||||
ids,
|
||||
).fetchall()
|
||||
tag_map: dict = {}
|
||||
for r in rows:
|
||||
tag_map.setdefault(r["page_id"], []).append({
|
||||
"id": r["id"], "name": r["name"], "color": r["color"],
|
||||
})
|
||||
for it in items:
|
||||
it["tags"] = tag_map.get(it["id"], [])
|
||||
return items
|
||||
|
||||
|
||||
def _enrich_children(items: list) -> list:
|
||||
@@ -124,7 +152,7 @@ def _enrich_children(items: list) -> list:
|
||||
|
||||
BASE_SELECT = (
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
|
||||
"p.parent_id, p.share_mode, p.parent_section"
|
||||
"p.parent_id, p.share_mode, p.parent_section, p.page_icon"
|
||||
)
|
||||
|
||||
|
||||
@@ -173,10 +201,10 @@ async def library_favorites(
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = (
|
||||
f"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
|
||||
f"p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
|
||||
f"FROM favorites f JOIN pages p ON p.id = f.page_id "
|
||||
f"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
|
||||
"p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
|
||||
"FROM favorites f JOIN pages p ON p.id = f.page_id "
|
||||
"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
)
|
||||
params: list = [uid]
|
||||
if tree:
|
||||
@@ -197,13 +225,54 @@ async def library_shared(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
dir: str = Query(default="all"),
|
||||
):
|
||||
if source_type not in SOURCE_TYPES:
|
||||
source_type = "all"
|
||||
if dir not in ("made", "received", "all"):
|
||||
dir = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.share_mode != 'private' AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
|
||||
# Page ids the user shares toward others (nominal page_shares) or receives
|
||||
# (direct shares + group shares via group_members)
|
||||
with get_conn() as conn:
|
||||
made_rows = conn.execute(
|
||||
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.created_by=?",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
try:
|
||||
recv_rows = conn.execute(
|
||||
"""SELECT DISTINCT s.page_id FROM page_shares s
|
||||
LEFT JOIN group_members gm ON gm.group_id = s.shared_with_group_id AND gm.user_id=?
|
||||
WHERE s.shared_with_user_id=? OR gm.user_id=?""",
|
||||
(uid, uid, uid),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
recv_rows = conn.execute(
|
||||
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.shared_with_user_id=?",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
made_ids = {r[0] for r in made_rows}
|
||||
recv_ids = {r[0] for r in recv_rows}
|
||||
|
||||
conds: list[str] = []
|
||||
params: list = []
|
||||
if dir in ("all", "made"):
|
||||
conds.append("p.share_mode != 'private'")
|
||||
if dir in ("all", "made") and made_ids:
|
||||
conds.append(f"p.id IN ({','.join('?' for _ in made_ids)})")
|
||||
params.extend(made_ids)
|
||||
if dir in ("all", "received") and recv_ids:
|
||||
conds.append(f"p.id IN ({','.join('?' for _ in recv_ids)})")
|
||||
params.extend(recv_ids)
|
||||
if dir == "received" and not recv_ids:
|
||||
return {"items": []}
|
||||
|
||||
query = (
|
||||
f"{BASE_SELECT} FROM pages p "
|
||||
f"WHERE ({' OR '.join(conds)}) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
)
|
||||
if tree:
|
||||
query += " AND p.parent_id IS NULL"
|
||||
query, params = _apply_source_filter(query, params, source_type)
|
||||
@@ -213,6 +282,11 @@ async def library_shared(
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = _rows_to_items(rows, uid)
|
||||
for it in items:
|
||||
sid = it["id"]
|
||||
is_made = sid in made_ids or it.get("share_mode", "private") != "private"
|
||||
is_recv = sid in recv_ids
|
||||
it["share_dir"] = "both" if (is_made and is_recv) else ("made" if is_made else ("received" if is_recv else ""))
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
@@ -270,7 +344,7 @@ async def library_private(
|
||||
@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."""
|
||||
uid = _get_user_id(request)
|
||||
_get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
# Get the item to find its workspace
|
||||
item = conn.execute(
|
||||
@@ -296,9 +370,12 @@ async def library_local_workspace_children(item_id: int, request: Request):
|
||||
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 = "📜"
|
||||
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(
|
||||
@@ -324,6 +401,8 @@ async def library_local_workspace_children(item_id: int, request: Request):
|
||||
"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 "",
|
||||
})
|
||||
|
||||
@@ -404,9 +483,12 @@ async def library_local_workspace(
|
||||
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 = "📜"
|
||||
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(
|
||||
@@ -432,6 +514,8 @@ async def library_local_workspace(
|
||||
"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 "",
|
||||
})
|
||||
|
||||
@@ -439,10 +523,14 @@ async def library_local_workspace(
|
||||
|
||||
|
||||
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"
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""FlowDeck — Calendar links + Meetings API (v7.1.0).
|
||||
|
||||
``/api/v2/calendar-links*`` — Google/CalDAV link CRUD (session or Bearer
|
||||
``write``), manual sync trigger. ``GET /db/{id}/calendar/freebusy`` —
|
||||
weekday availability. ``/api/v2/meetings/*`` — audio upload, manual
|
||||
transcript, AI summary (fires ``meeting.summarized``).
|
||||
|
||||
See ``docs/V71_Calendar_Meetings.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import calendar_sync as cal
|
||||
from app.services import meetings as meet
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
has_scope,
|
||||
resolve_bearer_token,
|
||||
row_to_dict,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["calendar-meetings"])
|
||||
|
||||
|
||||
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if sess:
|
||||
return sess
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired API token")
|
||||
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
return user
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
# ── calendar links ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/calendar-links")
|
||||
async def create_link(request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
provider = (body.get("provider") or "").lower()
|
||||
if provider not in cal.PROVIDERS:
|
||||
raise HTTPException(400, "provider must be google|caldav")
|
||||
try:
|
||||
collection_id = int(body.get("collection_id", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "collection_id required") from None
|
||||
creds = body.get("credentials") or {}
|
||||
if provider == "google" and not creds.get("access_token"):
|
||||
raise HTTPException(400, "google needs credentials.access_token")
|
||||
if provider == "caldav" and not creds.get("url"):
|
||||
raise HTTPException(400, "caldav needs credentials.url")
|
||||
try:
|
||||
out = cal.save_link(user["id"], provider, collection_id, creds,
|
||||
body.get("calendar_id") or "primary",
|
||||
body.get("date_property") or "")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
audit_log(user, "calendar.link", "collection", collection_id, provider, request)
|
||||
return JSONResponse(status_code=201, content=out)
|
||||
|
||||
|
||||
@router.get("/api/v2/calendar-links")
|
||||
async def get_links(request: Request):
|
||||
user = _auth_user(request)
|
||||
return {"links": cal.list_links(user["id"])}
|
||||
|
||||
|
||||
@router.delete("/api/v2/calendar-links/{link_id}")
|
||||
async def remove_link(link_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
if not cal.delete_link(user["id"], link_id):
|
||||
raise HTTPException(404, "Link not found")
|
||||
audit_log(user, "calendar.unlink", "calendar_link", link_id, "", request)
|
||||
return {"status": "deleted", "id": link_id}
|
||||
|
||||
|
||||
@router.post("/api/v2/calendar-links/{link_id}/sync")
|
||||
async def sync_now(link_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM calendar_links WHERE id=?", (link_id,)).fetchone()
|
||||
if not row or (row["user_id"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Link not found")
|
||||
try:
|
||||
stats = await cal.sync_link(link_id)
|
||||
except (cal.SyncError, ValueError) as exc:
|
||||
raise HTTPException(502 if isinstance(exc, cal.SyncError) else 400,
|
||||
str(exc)) from None
|
||||
audit_log(user, "calendar.sync", "calendar_link", link_id, str(stats), request)
|
||||
return {"link_id": link_id, **stats}
|
||||
|
||||
|
||||
# ── free/busy ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/db/{collection_id}/calendar/freebusy")
|
||||
async def freebusy(collection_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
qp = request.query_params
|
||||
try:
|
||||
out = cal.freebusy(collection_id, qp.get("from", ""), qp.get("to", ""),
|
||||
qp.get("date_property", ""))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
return out
|
||||
|
||||
|
||||
# ── meetings ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/meetings/transcribe")
|
||||
async def upload_and_transcribe(request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
form = await request.form()
|
||||
except Exception:
|
||||
raise HTTPException(400, "multipart upload required") from None
|
||||
upload = form.get("audio")
|
||||
try:
|
||||
page_id = int(form.get("page_id", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "page_id required") from None
|
||||
language = (form.get("language") or "fr")[:10]
|
||||
manual = (form.get("transcript") or "").strip()
|
||||
if upload is None and not manual:
|
||||
raise HTTPException(400, "audio file or transcript required")
|
||||
audio_path = ""
|
||||
if upload is not None:
|
||||
filename = (upload.filename or "").lower()
|
||||
ext = filename.rsplit(".", 1)[-1] if "." in filename else ""
|
||||
if ext not in meet.AUDIO_EXTENSIONS:
|
||||
raise HTTPException(400, f"audio must be one of {sorted(meet.AUDIO_EXTENSIONS)}")
|
||||
data = await upload.read()
|
||||
if len(data) > meet.MAX_AUDIO_BYTES:
|
||||
raise HTTPException(413, "audio exceeds 100 MB")
|
||||
if not data:
|
||||
raise HTTPException(400, "empty audio file")
|
||||
audio_path = str(meet.meetings_dir()
|
||||
/ f"{page_id}_{secrets.token_hex(8)}.{ext}")
|
||||
with open(audio_path, "wb") as fh:
|
||||
fh.write(data)
|
||||
transcript = manual
|
||||
if not transcript and audio_path:
|
||||
try:
|
||||
transcript = meet.transcribe_audio(audio_path, language)
|
||||
except meet.TranscriptionUnavailable as exc:
|
||||
transcript = "" # stored; client transcribes or posts manual text later
|
||||
_ = exc
|
||||
try:
|
||||
tid = meet.save_transcript(page_id, transcript, language, audio_path)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from None
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (tid,)).fetchone()
|
||||
audit_log(user, "meeting.transcribe", "page", page_id, f"transcript={tid}", request)
|
||||
return JSONResponse(status_code=201, content={
|
||||
**row_to_dict(row), "transcribed": bool(transcript)})
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/text")
|
||||
async def set_transcript_text(transcript_id: int, request: Request):
|
||||
"""Store a client-side (manual) transcript on an existing row."""
|
||||
_auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
text = (body.get("transcript") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "transcript required")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
|
||||
(transcript_id,)).fetchone():
|
||||
raise HTTPException(404, "Transcript not found")
|
||||
conn.execute("UPDATE meeting_transcripts SET transcript=? WHERE id=?",
|
||||
(text, transcript_id))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?",
|
||||
(transcript_id,)).fetchone()
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/summarize")
|
||||
async def summarize(transcript_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
out = await meet.summarize_transcript(transcript_id, user.get("id"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(502, str(exc)) from None
|
||||
audit_log(user, "meeting.summarize", "transcript", transcript_id, "", request)
|
||||
return out
|
||||
@@ -4,12 +4,12 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.db import get_conn
|
||||
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")
|
||||
@@ -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 %}'
|
||||
@@ -121,7 +121,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
|
||||
async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
||||
"""API: return my tasks as JSON."""
|
||||
user = _get_current_user(request)
|
||||
user_login = user.get("login", "admin") if user else "admin"
|
||||
user.get("login", "admin") if user else "admin"
|
||||
|
||||
with get_conn() as conn:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
|
||||
@@ -21,9 +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)
|
||||
@@ -44,9 +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)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""FlowDeck — Notifications API (v4.9.0 collaboration)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["notifications"], prefix="/api/notifications")
|
||||
|
||||
|
||||
def _current_user(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
|
||||
|
||||
|
||||
@router.get("")
|
||||
async 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:
|
||||
rows = conn.execute(
|
||||
"""SELECT n.*, a.login AS actor_login, a.full_name AS actor_name,
|
||||
a.avatar_url AS actor_avatar, a.avatar_color AS actor_color
|
||||
FROM notifications n
|
||||
LEFT JOIN users a ON n.actor_id = a.id
|
||||
WHERE n.user_id=?
|
||||
ORDER BY n.created_at DESC, n.id DESC LIMIT ?""",
|
||||
(user["id"], limit),
|
||||
).fetchall()
|
||||
unread = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM notifications WHERE user_id=? AND is_read=0",
|
||||
(user["id"],),
|
||||
).fetchone()["c"]
|
||||
return {
|
||||
"notifications": [dict(r) for r in rows],
|
||||
"unread": unread,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
async def unread_count(request: Request):
|
||||
"""Unread count for the topbar badge."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
c = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM notifications WHERE user_id=? AND is_read=0",
|
||||
(user["id"],),
|
||||
).fetchone()["c"]
|
||||
return {"unread": c}
|
||||
|
||||
|
||||
@router.post("/read")
|
||||
async def mark_read(request: Request):
|
||||
"""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:
|
||||
conn.execute(
|
||||
"UPDATE notifications SET is_read=1 WHERE id=? AND user_id=?",
|
||||
(nid, user["id"]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE notifications SET is_read=1 WHERE user_id=?",
|
||||
(user["id"],),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/read-all")
|
||||
async def mark_all_read(request: Request):
|
||||
"""Mark all notifications as read."""
|
||||
return await mark_read(request)
|
||||
|
||||
|
||||
@router.get("/prefs")
|
||||
async def get_prefs(request: Request):
|
||||
"""Return the current user's notification email preferences."""
|
||||
user = _current_user(request)
|
||||
from app.services import notifications as notif
|
||||
return {"prefs": notif.get_user_prefs(user["id"])}
|
||||
|
||||
|
||||
@router.post("/prefs")
|
||||
async def set_prefs(request: Request):
|
||||
"""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:
|
||||
prefs[key] = bool(body[key])
|
||||
notif.set_user_prefs(user["id"], prefs)
|
||||
return {"status": "ok", "prefs": prefs}
|
||||
|
||||
|
||||
@router.get("/timezone")
|
||||
async def get_timezone(request: Request):
|
||||
"""Return the current user's IANA timezone ('' = UTC)."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT timezone FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
tz = (row["timezone"] if row and "timezone" in row.keys() else "") or ""
|
||||
from app.services.recurrence import common_timezones
|
||||
return {"timezone": tz, "zones": common_timezones()}
|
||||
|
||||
|
||||
@router.post("/timezone")
|
||||
async def set_timezone(request: Request):
|
||||
"""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):
|
||||
raise HTTPException(status_code=400, detail=f"Unknown timezone '{tz}'")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET timezone=? WHERE id=?", (tz, user["id"]))
|
||||
conn.commit()
|
||||
return {"status": "ok", "timezone": tz}
|
||||
|
||||
|
||||
@router.get("/users/search")
|
||||
async def search_users(request: Request, q: str = ""):
|
||||
"""User autocomplete for @mentions."""
|
||||
_current_user(request)
|
||||
q = (q or "").strip()
|
||||
with get_conn() as conn:
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
rows = conn.execute(
|
||||
"""SELECT id, login, full_name, avatar_url, avatar_color
|
||||
FROM users WHERE login LIKE ? OR full_name LIKE ?
|
||||
ORDER BY (login=? OR full_name=?) DESC, login LIMIT 20""",
|
||||
(like, like, q, q),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, login, full_name, avatar_url, avatar_color
|
||||
FROM users ORDER BY login LIMIT 20"""
|
||||
).fetchall()
|
||||
return {"users": [dict(r) for r in rows]}
|
||||
@@ -0,0 +1,135 @@
|
||||
"""FlowDeck — v5.2.0 Onboarding: /welcome wizard + its API.
|
||||
|
||||
First-launch experience: create workspace → connect a forge (optional) →
|
||||
create the first project (welcome page), then land in the app.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["onboarding"])
|
||||
|
||||
WORKSPACE_COOKIE = "flowdeck_workspace"
|
||||
|
||||
|
||||
def _require_user(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="Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/welcome", response_class=HTMLResponse)
|
||||
async 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", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
with get_conn() as conn:
|
||||
ws_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user["id"],)
|
||||
).fetchone()[0]
|
||||
if ws_count > 0:
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("welcome.html")
|
||||
return HTMLResponse(content=template.render(
|
||||
user=user,
|
||||
gitea_url_configured=_forge_configured("gitea"),
|
||||
github_url_configured=_forge_configured("github"),
|
||||
))
|
||||
|
||||
|
||||
def _forge_configured(provider: str) -> bool:
|
||||
try:
|
||||
from app.auth.providers import get_provider
|
||||
return get_provider(provider) is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ═══════════ Onboarding API ═══════════
|
||||
|
||||
|
||||
@router.post("/api/onboarding/workspace")
|
||||
async def onboarding_create_workspace(request: Request):
|
||||
"""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:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, '{}')",
|
||||
(name, user["id"]),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')",
|
||||
(cur.lastrowid, user["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
ws_id = cur.lastrowid
|
||||
|
||||
response = JSONResponse({"status": "ok", "id": ws_id, "name": name})
|
||||
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/api/onboarding/project")
|
||||
async def onboarding_create_project(request: Request):
|
||||
"""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")
|
||||
|
||||
with get_conn() as conn:
|
||||
ws = None
|
||||
if workspace_id:
|
||||
ws = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(workspace_id, user["id"]),
|
||||
).fetchone()
|
||||
if not ws:
|
||||
ws = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(status_code=400, detail="Create a workspace first")
|
||||
|
||||
blocks = [
|
||||
{"id": "1", "type": "heading_1", "content": title},
|
||||
{"id": "2", "type": "paragraph",
|
||||
"content": "Welcome to FlowDeck 🎉 — your workspace is ready."},
|
||||
{"id": "3", "type": "paragraph",
|
||||
"content": "Use the slash command « / » in any page to add blocks, databases, to-dos and more."},
|
||||
{"id": "4", "type": "paragraph",
|
||||
"content": "Connect Gitea or GitHub in Settings → Integrations to sync your repositories."},
|
||||
]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section) "
|
||||
"VALUES (?, ?, ?, ?, 'blocks', 'Private')",
|
||||
(user.get("login", "local"), ws["id"], title, json.dumps(blocks)),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
|
||||
return {"status": "ok", "id": page_id, "title": title, "workspace_id": ws["id"]}
|
||||
@@ -0,0 +1,525 @@
|
||||
"""FlowDeck — v6.0.0 Granular permissions API (page/collection/property ACL).
|
||||
|
||||
Backend for the page-editor "Permissions" panel, database property visibility
|
||||
and user-group management. Grants are stored in ``page_permissions`` /
|
||||
``collection_permissions`` / ``property_permissions``; every mutation is logged
|
||||
into ``permission_audit_log`` for the admin audit view.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["permissions"], prefix="/api/v2")
|
||||
|
||||
|
||||
PAGE_ROLES = ("viewer", "commenter", "editor", "owner")
|
||||
COLLECTION_ROLES = ("viewer", "commenter", "editor", "owner")
|
||||
PROPERTY_ROLES = ("viewer", "editor")
|
||||
PERMISSION_TYPES = ("inherit", "restricted", "private")
|
||||
|
||||
|
||||
def _require_user(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="Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
def _pm(request: Request) -> PermissionManager:
|
||||
return PermissionManager(_require_user(request)["id"])
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
try:
|
||||
return request.client.host if request.client else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _perm_list(conn, table: str, fk: str, resource_id: int) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
f"""SELECT p.*,
|
||||
u.login AS user_login, u.full_name AS user_name,
|
||||
g.name AS group_name
|
||||
FROM {table} p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
LEFT JOIN user_groups g ON g.id = p.group_id
|
||||
WHERE p.{fk}=? ORDER BY p.id""",
|
||||
(resource_id,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
if d.get("user_id"):
|
||||
d["name"] = d["user_name"] or d["user_login"] or f"User #{d['user_id']}"
|
||||
d["kind"] = "user"
|
||||
else:
|
||||
d["name"] = d["group_name"] or f"Group #{d['group_id']}"
|
||||
d["kind"] = "group"
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _grant_common(request: Request, pm: PermissionManager, resource_type: str,
|
||||
resource_id: int, body: dict, table: str, fk: str,
|
||||
allowed_roles: tuple[str, ...],
|
||||
extra_cols: dict | None = None) -> dict:
|
||||
user_id = body.get("user_id")
|
||||
group_id = body.get("group_id")
|
||||
role = (body.get("role") or "").strip()
|
||||
if role not in allowed_roles:
|
||||
raise HTTPException(400, f"role must be one of {', '.join(allowed_roles)}")
|
||||
if not user_id and not group_id:
|
||||
raise HTTPException(400, "Provide either user_id or group_id")
|
||||
if user_id and not isinstance(user_id, int):
|
||||
raise HTTPException(400, "user_id must be an integer")
|
||||
if group_id and not isinstance(group_id, int):
|
||||
raise HTTPException(400, "group_id must be an integer")
|
||||
actor = _require_user(request)["id"]
|
||||
with get_conn() as conn:
|
||||
if user_id:
|
||||
exists = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not exists:
|
||||
raise HTTPException(404, "User not found")
|
||||
if group_id:
|
||||
exists = conn.execute("SELECT id FROM user_groups WHERE id=?", (group_id,)).fetchone()
|
||||
if not exists:
|
||||
raise HTTPException(404, "Group not found")
|
||||
existing = conn.execute(
|
||||
f"SELECT id, role FROM {table} WHERE {fk}=? AND user_id IS ? AND group_id IS ?",
|
||||
(resource_id, user_id, group_id),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(f"UPDATE {table} SET role=? WHERE id=?",
|
||||
(role, existing["id"]))
|
||||
old_role = existing["role"]
|
||||
perm_id = existing["id"]
|
||||
else:
|
||||
cols = [fk, "user_id", "group_id", "role", "granted_by"]
|
||||
vals: list = [resource_id, user_id, group_id, role, actor]
|
||||
for col, val in (extra_cols or {}).items():
|
||||
cols.append(col)
|
||||
vals.append(val)
|
||||
placeholders = ", ".join("?" for _ in cols)
|
||||
cur = conn.execute(
|
||||
f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders})",
|
||||
tuple(vals),
|
||||
)
|
||||
perm_id = cur.lastrowid
|
||||
old_role = None
|
||||
conn.commit()
|
||||
pm.invalidate()
|
||||
pm.log_permission_change(resource_type, resource_id, "grant",
|
||||
target_user_id=user_id, target_group_id=group_id,
|
||||
old_role=old_role, new_role=role, ip_address=_client_ip(request))
|
||||
return {"status": "ok", "id": perm_id, "role": role, "user_id": user_id, "group_id": group_id}
|
||||
|
||||
|
||||
def _revoke_common(request: Request, pm: PermissionManager, resource_type: str,
|
||||
resource_id: int, table: str, fk: str, perm_id: int) -> dict:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
f"SELECT user_id, group_id, role FROM {table} WHERE id=? AND {fk}=?",
|
||||
(perm_id, resource_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Permission not found")
|
||||
conn.execute(f"DELETE FROM {table} WHERE id=?", (perm_id,))
|
||||
conn.commit()
|
||||
pm.invalidate()
|
||||
pm.log_permission_change(resource_type, resource_id, "revoke",
|
||||
target_user_id=row["user_id"], target_group_id=row["group_id"],
|
||||
old_role=row["role"], new_role=None, ip_address=_client_ip(request))
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
def _set_permission_type(request: Request, pm: PermissionManager, resource_type: str,
|
||||
resource_id: int, table: str, body: dict) -> dict:
|
||||
ptype = (body.get("permission_type") or "").strip()
|
||||
if ptype not in PERMISSION_TYPES:
|
||||
raise HTTPException(400, f"permission_type must be one of {', '.join(PERMISSION_TYPES)}")
|
||||
with get_conn() as conn:
|
||||
conn.execute(f"UPDATE {table} SET permission_type=? WHERE id=?", (ptype, resource_id))
|
||||
conn.commit()
|
||||
pm.invalidate()
|
||||
pm.log_permission_change(resource_type, resource_id, "type_change",
|
||||
new_role=ptype, ip_address=_client_ip(request))
|
||||
return {"status": "ok", "permission_type": ptype}
|
||||
|
||||
|
||||
# ═══════════════ Page permissions ═══════════════
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/permissions")
|
||||
async 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):
|
||||
raise HTTPException(404, "Page not found")
|
||||
with get_conn() as conn:
|
||||
grants = _perm_list(conn, "page_permissions", "page_id", page_id)
|
||||
return {
|
||||
"permissions": grants,
|
||||
"mine": pm.get_page_permission(page_id),
|
||||
"permission_type": _page_type(page_id),
|
||||
"can_manage": pm.can_manage_page_permissions(page_id),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/permissions/mine")
|
||||
async 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):
|
||||
raise HTTPException(404, "Page not found")
|
||||
return {
|
||||
"role": pm.get_page_permission(page_id),
|
||||
"can_edit": pm.can_edit_page(page_id),
|
||||
"can_comment": pm.can_comment_page(page_id),
|
||||
"can_manage": pm.can_manage_page_permissions(page_id),
|
||||
"permission_type": _page_type(page_id),
|
||||
}
|
||||
|
||||
|
||||
def _page_type(page_id: int) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT permission_type FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
return (row["permission_type"] if row else "inherit") or "inherit"
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permissions")
|
||||
async def grant_page_permission(page_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")
|
||||
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):
|
||||
"""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")
|
||||
results = []
|
||||
for g in grants:
|
||||
results.append(_grant_common(request, pm, "page", page_id, g,
|
||||
"page_permissions", "page_id", PAGE_ROLES))
|
||||
return {"status": "ok", "granted": results}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/permissions/{perm_id}")
|
||||
async 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")
|
||||
return _revoke_common(request, pm, "page", page_id, "page_permissions", "page_id", perm_id)
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permission-type")
|
||||
async def set_page_permission_type(page_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")
|
||||
return _set_permission_type(request, pm, "page", page_id, "pages", await request.json())
|
||||
|
||||
|
||||
# ═══════════════ Collection permissions ═══════════════
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/permissions")
|
||||
async 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")
|
||||
with get_conn() as conn:
|
||||
grants = _perm_list(conn, "collection_permissions", "collection_id", collection_id)
|
||||
return {
|
||||
"permissions": grants,
|
||||
"mine": pm.get_collection_permission(collection_id),
|
||||
"permission_type": _collection_type(collection_id),
|
||||
"can_manage": pm.can_manage_collection_permissions(collection_id),
|
||||
}
|
||||
|
||||
|
||||
def _collection_type(collection_id: int) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT permission_type FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
return (row["permission_type"] if row else "inherit") or "inherit"
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/permissions")
|
||||
async def grant_collection_permission(collection_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")
|
||||
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):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
return _revoke_common(request, pm, "collection", collection_id,
|
||||
"collection_permissions", "collection_id", perm_id)
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/permission-type")
|
||||
async def set_collection_permission_type(collection_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")
|
||||
return _set_permission_type(request, pm, "collection", collection_id,
|
||||
"collections", await request.json())
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties/visible")
|
||||
async 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):
|
||||
raise HTTPException(404, "Collection not found")
|
||||
visible = pm.get_visible_properties(collection_id)
|
||||
with get_conn() as conn:
|
||||
all_ids = [r["id"] for r in conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()]
|
||||
return {
|
||||
"visible": visible,
|
||||
"hidden": [pid for pid in all_ids if pid not in visible],
|
||||
"can_edit": pm.can_edit_collection(collection_id),
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════ Property permissions ═══════════════
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties/{property_id}/permissions")
|
||||
async 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")
|
||||
with get_conn() as conn:
|
||||
grants = _perm_list(conn, "property_permissions", "property_id", property_id)
|
||||
return {
|
||||
"permissions": grants,
|
||||
"mine_view": pm.can_view_property(collection_id, property_id),
|
||||
"mine_edit": pm.can_edit_property(collection_id, property_id),
|
||||
"can_manage": pm.can_manage_collection_permissions(collection_id),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/properties/{property_id}/permissions")
|
||||
async def grant_property_permission(collection_id: int, property_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 property permissions")
|
||||
body = await request.json()
|
||||
with get_conn() as conn:
|
||||
prop = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE id=? AND collection_id=?",
|
||||
(property_id, collection_id),
|
||||
).fetchone()
|
||||
if not prop:
|
||||
raise HTTPException(404, "Property not found")
|
||||
return _grant_common(request, pm, "property", property_id, body,
|
||||
"property_permissions", "property_id", PROPERTY_ROLES,
|
||||
extra_cols={"collection_id": collection_id})
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/properties/{property_id}/permissions/{perm_id}")
|
||||
async 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):
|
||||
raise HTTPException(403, "Only a collection owner can manage property permissions")
|
||||
return _revoke_common(request, pm, "property", property_id,
|
||||
"property_permissions", "property_id", perm_id)
|
||||
|
||||
|
||||
# ═══════════════ Groups ═══════════════
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
async 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):
|
||||
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)
|
||||
pm.log_permission_change("group", gid, "group_create",
|
||||
target_group_id=gid, new_role="",
|
||||
ip_address=_client_ip(request))
|
||||
return {"status": "ok", "id": gid}
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}")
|
||||
async def update_group(group_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Group not found")
|
||||
if not pm.is_workspace_admin(row["workspace_id"]):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can edit groups")
|
||||
conn.execute(
|
||||
"UPDATE user_groups SET name=?, description=? WHERE id=?",
|
||||
(name, body.get("description") or "", group_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}")
|
||||
async def delete_group(group_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Group not found")
|
||||
if not pm.is_workspace_admin(row["workspace_id"]):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can delete groups")
|
||||
pm.delete_group(group_id)
|
||||
pm.log_permission_change("group", group_id, "group_delete",
|
||||
target_group_id=group_id, ip_address=_client_ip(request))
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/members")
|
||||
async 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):
|
||||
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")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Group not found")
|
||||
if not pm.is_workspace_admin(row["workspace_id"]):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can manage groups")
|
||||
pm.add_user_to_group(group_id, user_id)
|
||||
pm.invalidate()
|
||||
pm.log_permission_change("group", group_id, "member_add",
|
||||
target_user_id=user_id, target_group_id=group_id,
|
||||
ip_address=_client_ip(request))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}/members/{user_id}")
|
||||
async def remove_group_member(group_id: int, user_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Group not found")
|
||||
if not pm.is_workspace_admin(row["workspace_id"]):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can manage groups")
|
||||
pm.remove_user_from_group(group_id, user_id)
|
||||
pm.invalidate()
|
||||
pm.log_permission_change("group", group_id, "member_remove",
|
||||
target_user_id=user_id, target_group_id=group_id,
|
||||
ip_address=_client_ip(request))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ═══════════════ Users (access pickers) + audit ═══════════════
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async 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()
|
||||
with get_conn() as conn:
|
||||
if workspace_id:
|
||||
rows = conn.execute(
|
||||
"""SELECT DISTINCT u.id, u.login, u.full_name, u.email, u.avatar_color
|
||||
FROM users u
|
||||
LEFT JOIN workspace_members wm ON wm.user_id=u.id AND wm.workspace_id=?
|
||||
WHERE u.is_admin=1 OR wm.id IS NOT NULL
|
||||
ORDER BY u.login""",
|
||||
(workspace_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, login, full_name, email, avatar_color FROM users ORDER BY login"
|
||||
).fetchall()
|
||||
users = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
if q and q not in (d["login"].lower(), d["full_name"].lower(),
|
||||
d["email"].lower()):
|
||||
continue
|
||||
users.append({"id": d["id"], "login": d["login"], "name": d["full_name"] or d["login"],
|
||||
"email": d["email"], "avatar_color": d["avatar_color"]})
|
||||
return {"users": users}
|
||||
|
||||
|
||||
@router.get("/audit/permissions")
|
||||
async def permission_audit(request: Request, limit: int = 100):
|
||||
"""Full permission change history — workspace owner/admin only."""
|
||||
user = _require_user(request)
|
||||
uid = user["id"]
|
||||
is_admin = bool(user.get("is_admin"))
|
||||
limit = max(1, min(int(limit), 500))
|
||||
with get_conn() as conn:
|
||||
if not is_admin:
|
||||
owned = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=?", (uid,)
|
||||
).fetchall()
|
||||
if not owned:
|
||||
raise HTTPException(403, "Only a workspace owner or admin can view the audit log")
|
||||
rows = conn.execute(
|
||||
"""SELECT a.*, u.login AS actor_login
|
||||
FROM permission_audit_log a LEFT JOIN users u ON u.id=a.performed_by
|
||||
ORDER BY a.created_at DESC, a.id DESC LIMIT ?""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return {"events": [dict(r) for r in rows]}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""FlowDeck — v5.2.0 Projects API: list, register, manual sync + backups admin."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services import projects as projects_svc
|
||||
from app.services.backup import backup_db, list_backups
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["projects"], prefix="/api/projects")
|
||||
backups_router = APIRouter(tags=["backups"])
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("is_admin"):
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("")
|
||||
async 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):
|
||||
"""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")
|
||||
project = projects_svc.create_builtin_project(name, body.get("owner", ""), body.get("description", ""))
|
||||
return {"status": "ok", "project": project}
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_projects(request: Request):
|
||||
"""Trigger an immediate forge sync for every connected account."""
|
||||
_require_admin(request)
|
||||
stats = await projects_svc.sync_all_projects()
|
||||
return {"status": "ok", "stats": stats}
|
||||
|
||||
|
||||
# ═══════════ Backups (admin) ═══════════
|
||||
|
||||
|
||||
@backups_router.post("/api/settings/backups/run")
|
||||
async def run_backup_now(request: Request):
|
||||
"""Admin: create a database backup immediately."""
|
||||
_require_admin(request)
|
||||
filename = backup_db()
|
||||
if not filename:
|
||||
raise HTTPException(status_code=400, detail="Backups disabled or no database file")
|
||||
return {"status": "ok", "filename": filename}
|
||||
|
||||
|
||||
@backups_router.get("/api/settings/backups")
|
||||
async def admin_list_backups(request: Request):
|
||||
"""Admin: list stored backups."""
|
||||
_require_admin(request)
|
||||
return {"backups": list_backups()}
|
||||
@@ -1,12 +1,13 @@
|
||||
"""FlowDeck — Public API router (v2.1.0)."""
|
||||
"""FlowDeck — Public API router (v2.1.0, v5.2.0 per-user tokens)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Header, Depends
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -14,27 +15,57 @@ router = APIRouter(tags=["public-api"], prefix="/api/v1")
|
||||
DEFAULT_TOKEN = "fd-public-key"
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _token_owner(token: str) -> dict | None:
|
||||
"""Resolve an api_tokens row by its sha256 hash (revoked → None)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, user_id, name FROM api_tokens WHERE token_hash=? AND revoked=0",
|
||||
(_hash_token(token),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],)
|
||||
)
|
||||
conn.commit()
|
||||
return dict(row)
|
||||
|
||||
|
||||
def verify_token(authorization: str | None = Header(None)):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(401, "API token required. Generate one via POST /api/v1/token.")
|
||||
raise HTTPException(401, "API token required. Generate one via Settings → API tokens.")
|
||||
token = authorization[7:] # strip "Bearer "
|
||||
if token == DEFAULT_TOKEN:
|
||||
from app.config import settings as _s
|
||||
if not _s.public_api_insecure_ok:
|
||||
raise HTTPException(401, "Default token disabled. Set PUBLIC_API_INSECURE_OK=true in dev or use a real Bearer token.")
|
||||
return token
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT 1 FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
|
||||
if not row:
|
||||
if row:
|
||||
return token
|
||||
owner = _token_owner(token)
|
||||
if not owner:
|
||||
raise HTTPException(403, "Invalid API token")
|
||||
return token
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
async def generate_token(request: Request):
|
||||
"""Generate a public API access token."""
|
||||
"""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:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
(0, token),
|
||||
"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>"}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""FlowDeck — v5.13.0 Realtime: WebSocket gateway /ws/pages/{page_id}.
|
||||
|
||||
Auth via cookie session (flowdeck_session). Rooms in-memory par page.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, WebSocket
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.realtime_server import manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["realtime"])
|
||||
|
||||
|
||||
@router.get("/api/realtime/stats")
|
||||
async 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).
|
||||
"""
|
||||
user = SessionManager.decode_session(
|
||||
request.cookies.get("flowdeck_session", "")
|
||||
)
|
||||
if not user or not user.get("id"):
|
||||
return {"error": "unauthorized"}
|
||||
return manager.stats()
|
||||
|
||||
|
||||
@router.websocket("/ws/pages/{page_id}")
|
||||
async def ws_page(websocket: WebSocket, page_id: int):
|
||||
await websocket.accept()
|
||||
user = SessionManager.decode_session(
|
||||
websocket.cookies.get("flowdeck_session", "")
|
||||
)
|
||||
if not user or not user.get("id"):
|
||||
try:
|
||||
await websocket.close(code=4401)
|
||||
except Exception:
|
||||
logger.exception("ws_page")
|
||||
return
|
||||
|
||||
conn = await manager.connect(websocket, page_id, user)
|
||||
if not conn:
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
await manager.handle(conn, msg)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("ws closed: %s", e)
|
||||
finally:
|
||||
await manager.disconnect(conn)
|
||||
@@ -0,0 +1,297 @@
|
||||
"""FlowDeck — SCIM 2.0 provisioning + domain claims (v7.2.0).
|
||||
|
||||
``/scim/v2/Users`` (Bearer ``scim_tokens``, admin) : IT systems provision and
|
||||
deprovision accounts. Suspend (``active=false``) flips ``users.is_active`` and
|
||||
revokes ``user_sessions``. Domain claims: ``/.well-known`` HTTP verification +
|
||||
optional local-login enforcement per email domain.
|
||||
|
||||
See ``docs/V72_Enterprise_SCIM_2FA.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
router = APIRouter(tags=["scim"])
|
||||
SCIM_SCHEMAS = ["urn:ietf:params:scim:schemas:core:2.0:User"]
|
||||
|
||||
|
||||
# ── auth ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _scim_guard(request: Request) -> dict:
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
digest = hashlib.sha256(auth[7:].strip().encode()).hexdigest()
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM scim_tokens WHERE token_hash=? AND revoked=0",
|
||||
(digest,)).fetchone()
|
||||
if row:
|
||||
return {"scim_token_id": row["id"], "name": row["name"]}
|
||||
raise HTTPException(401, "SCIM token required")
|
||||
|
||||
|
||||
def _admin_session(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(403, "Admin required")
|
||||
return user
|
||||
|
||||
|
||||
def _scim_user(row) -> dict:
|
||||
d = dict(row)
|
||||
return {"schemas": SCIM_SCHEMAS, "id": str(d["id"]), "userName": d["login"],
|
||||
"name": {"formatted": d.get("full_name") or d["login"]},
|
||||
"emails": [{"value": d.get("email") or "", "primary": True}],
|
||||
"active": bool(d.get("is_active", 1)),
|
||||
"meta": {"resourceType": "User"}}
|
||||
|
||||
|
||||
# ── SCIM resources ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/scim/v2/Users")
|
||||
async def scim_list(request: Request):
|
||||
_scim_guard(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM users ORDER BY id LIMIT 100").fetchall()
|
||||
items = [_scim_user(r) for r in rows]
|
||||
return {"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
"totalResults": len(items), "Resources": items}
|
||||
|
||||
|
||||
@router.post("/scim/v2/Users")
|
||||
async def scim_create(request: Request):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
username = (body.get("userName") or "").strip()
|
||||
if not username:
|
||||
raise HTTPException(400, "userName required")
|
||||
email = ""
|
||||
for em in body.get("emails") or []:
|
||||
if isinstance(em, dict) and em.get("value"):
|
||||
email = em["value"]
|
||||
break
|
||||
name = ((body.get("name") or {}).get("formatted") or username)[:200]
|
||||
active = body.get("active", True)
|
||||
with get_conn() as conn:
|
||||
if conn.execute("SELECT id FROM users WHERE login=?", (username,)).fetchone():
|
||||
raise HTTPException(409, "User already exists")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, is_active, auth_method)"
|
||||
" VALUES (?,?,?,?,'saml')",
|
||||
(username, name, email, 1 if active else 0))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (cur.lastrowid,)).fetchone()
|
||||
return JSONResponse(status_code=201, content=_scim_user(row))
|
||||
|
||||
|
||||
@router.get("/scim/v2/Users/{user_id}")
|
||||
async def scim_get(user_id: str, request: Request):
|
||||
_scim_guard(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "User not found")
|
||||
return _scim_user(row)
|
||||
|
||||
|
||||
def _apply_scim_update(conn, user_id: str, body: dict) -> None:
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "User not found")
|
||||
updates: dict = {}
|
||||
if "userName" in body and body["userName"]:
|
||||
updates["login"] = body["userName"].strip()
|
||||
if isinstance(body.get("name"), dict) and body["name"].get("formatted"):
|
||||
updates["full_name"] = body["name"]["formatted"][:200]
|
||||
if isinstance(body.get("emails"), list):
|
||||
for em in body["emails"]:
|
||||
if isinstance(em, dict) and em.get("value"):
|
||||
updates["email"] = em["value"][:200]
|
||||
break
|
||||
if "active" in body:
|
||||
updates["is_active"] = 1 if body["active"] else 0
|
||||
if updates:
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
conn.execute(f"UPDATE users SET {sets} WHERE id=?", (*updates.values(), user_id))
|
||||
if body.get("active") is False:
|
||||
conn.execute("UPDATE user_sessions SET revoked=1 WHERE user_id=?", (user_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
@router.put("/scim/v2/Users/{user_id}")
|
||||
async def scim_replace(user_id: str, request: Request):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
_apply_scim_update(conn, user_id, body)
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
return _scim_user(row)
|
||||
|
||||
|
||||
@router.patch("/scim/v2/Users/{user_id}")
|
||||
async def scim_patch(user_id: str, request: Request):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
flat: dict = {}
|
||||
for op in body.get("Operations") or []:
|
||||
path = (op.get("path") or "").lower()
|
||||
if path in ("username", "active"):
|
||||
flat["userName" if path == "username" else "active"] = op.get("value")
|
||||
with get_conn() as conn:
|
||||
_apply_scim_update(conn, user_id, {**body, **flat})
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
return _scim_user(row)
|
||||
|
||||
|
||||
@router.delete("/scim/v2/Users/{user_id}")
|
||||
async def scim_delete(user_id: str, request: Request):
|
||||
_scim_guard(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "User not found")
|
||||
# Deprovision = suspend (keeps content + audit trail).
|
||||
conn.execute("UPDATE users SET is_active=0 WHERE id=?", (user_id,))
|
||||
conn.execute("UPDATE user_sessions SET revoked=1 WHERE user_id=?", (user_id,))
|
||||
conn.commit()
|
||||
return JSONResponse(status_code=204, content=None)
|
||||
|
||||
|
||||
# ── SCIM token management (admin, session) ─────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/scim/tokens")
|
||||
async def create_scim_token(request: Request):
|
||||
admin = _admin_session(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
raw = f"scim_{secrets.token_urlsafe(32)}"
|
||||
digest = hashlib.sha256(raw.encode()).hexdigest()
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO scim_tokens (token_hash, name, created_by)"
|
||||
" VALUES (?,?,?)",
|
||||
(digest, str(body.get("name") or "SCIM")[:120], admin["id"]))
|
||||
conn.commit()
|
||||
audit_log(admin, "scim.token.create", "scim_token", cur.lastrowid, "", request)
|
||||
return JSONResponse(status_code=201,
|
||||
content={"id": cur.lastrowid, "token": raw,
|
||||
"warning": "shown once"})
|
||||
|
||||
|
||||
@router.get("/api/v2/scim/tokens")
|
||||
async def list_scim_tokens(request: Request):
|
||||
_admin_session(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, created_by, revoked, created_at"
|
||||
" FROM scim_tokens ORDER BY id DESC").fetchall()
|
||||
return {"tokens": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.delete("/api/v2/scim/tokens/{token_id}")
|
||||
async def revoke_scim_token(token_id: int, request: Request):
|
||||
admin = _admin_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE scim_tokens SET revoked=1 WHERE id=?", (token_id,))
|
||||
conn.commit()
|
||||
audit_log(admin, "scim.token.revoke", "scim_token", token_id, "", request)
|
||||
return {"status": "revoked", "id": token_id}
|
||||
|
||||
|
||||
# ── domain claims ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/domain-claims")
|
||||
async def list_domains(request: Request):
|
||||
_admin_session(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM domain_claims ORDER BY domain").fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d.pop("txt_token", None)
|
||||
out.append(d)
|
||||
return {"domains": out}
|
||||
|
||||
|
||||
@router.post("/api/v2/domain-claims")
|
||||
async def create_domain(request: Request):
|
||||
admin = _admin_session(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
domain = (body.get("domain") or "").strip().lower()
|
||||
if not domain or "." not in domain or "/" in domain:
|
||||
raise HTTPException(400, "valid domain required")
|
||||
token = f"flowdeck-verify={secrets.token_hex(16)}"
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO domain_claims
|
||||
(domain, txt_token, auto_join_role, enforce_sso, workspace_id)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(domain, token, body.get("auto_join_role") or "viewer",
|
||||
1 if body.get("enforce_sso") else 0, body.get("workspace_id")))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
raise HTTPException(409, "Domain already claimed") from None
|
||||
did = cur.lastrowid
|
||||
audit_log(admin, "domain.claim", "domain", did, domain, request)
|
||||
return JSONResponse(status_code=201, content={
|
||||
"id": did, "domain": domain,
|
||||
"verify_url": f"https://{domain}/.well-known/flowdeck-verify.txt",
|
||||
"expected_content": token})
|
||||
|
||||
|
||||
@router.post("/api/v2/domain-claims/{domain_id}/verify")
|
||||
async def verify_domain(domain_id: int, request: Request):
|
||||
admin = _admin_session(request)
|
||||
import httpx
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM domain_claims WHERE id=?", (domain_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Domain not found")
|
||||
claim = dict(row)
|
||||
url = f"https://{claim['domain']}/.well-known/flowdeck-verify.txt"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
||||
resp = await client.get(url)
|
||||
ok = resp.status_code == 200 and claim["txt_token"] in (resp.text or "")
|
||||
except Exception: # noqa: BLE001 — unreachable domain = not verified
|
||||
ok = False
|
||||
if ok:
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE domain_claims SET verified=1 WHERE id=?", (domain_id,))
|
||||
conn.commit()
|
||||
audit_log(admin, "domain.verify", "domain", domain_id, str(ok), request)
|
||||
return {"id": domain_id, "verified": ok}
|
||||
|
||||
|
||||
@router.delete("/api/v2/domain-claims/{domain_id}")
|
||||
async def delete_domain(domain_id: int, request: Request):
|
||||
admin = _admin_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM domain_claims WHERE id=?", (domain_id,))
|
||||
conn.commit()
|
||||
audit_log(admin, "domain.delete", "domain", domain_id, "", request)
|
||||
return {"status": "deleted", "id": domain_id}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""FlowDeck — unified search router (v5.0.0).
|
||||
|
||||
``GET /api/search?q=`` backs the Ctrl+K command palette. Returns matching
|
||||
editor pages and databases scoped to the current user's accessible workspaces.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.search import search as search_service
|
||||
|
||||
router = APIRouter(tags=["search"])
|
||||
|
||||
|
||||
@router.get("/api/search")
|
||||
async 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
|
||||
|
||||
data = search_service(q, user_id=user_id)
|
||||
return {
|
||||
"query": q,
|
||||
"pages": data["pages"],
|
||||
"collections": data["collections"],
|
||||
"total": len(data["pages"]) + len(data["collections"]),
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""FlowDeck — hybrid search + Ask AI API (v6.9.0).
|
||||
|
||||
``GET /api/v2/search/hybrid`` — lexical (FTS5/LIKE) fused with vector cosine
|
||||
(RRF), workspace-scoped, ACL-filtered, paginated with ``X-Total-Count``.
|
||||
``POST /api/v2/search/ask`` — RAG answer with ``[[fdpage:ID]]`` citations
|
||||
(LLM when configured, extractive offline fallback), cached 10 min.
|
||||
|
||||
Auth: session cookie first, Bearer fallback (``read`` scope suffices).
|
||||
See ``docs/V69_Search_Ask_AI.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import semantic_search as sem
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
resolve_bearer_token,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["search-ai"])
|
||||
|
||||
|
||||
def _auth_user(request: Request) -> dict:
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if sess:
|
||||
return sess
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired API token")
|
||||
if not has_scope(user.get("_token_scopes") or "read", "read"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: read")
|
||||
return user
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
@router.get("/api/v2/search/hybrid")
|
||||
async def hybrid(request: Request):
|
||||
user = _auth_user(request)
|
||||
q = (request.query_params.get("q") or request.query_params.get("query") or "").strip()
|
||||
if not q:
|
||||
raise HTTPException(400, "q is required")
|
||||
limit, offset = parse_pagination(request)
|
||||
ws_raw = request.query_params.get("workspace_id")
|
||||
workspace_id = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
|
||||
results, _total = sem.hybrid_search(q, user, limit=limit + offset,
|
||||
workspace_id=workspace_id)
|
||||
page = results[offset:offset + limit]
|
||||
# Index-on-read: a fresh page may not be indexed yet (scheduler runs every
|
||||
# 5 min). Best-effort is handled by tests calling index_resource directly.
|
||||
resp = JSONResponse({"query": q, "results": page,
|
||||
"total": len(results), "limit": limit, "offset": offset})
|
||||
for k, v in paginate_headers(len(results)).items():
|
||||
resp.headers[k] = v
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/api/v2/search/ask")
|
||||
async def ask_ai(request: Request):
|
||||
user = _auth_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
question = (body.get("question") or body.get("q") or "").strip()
|
||||
if not question:
|
||||
raise HTTPException(400, "question is required")
|
||||
ws = body.get("workspace_id")
|
||||
workspace_id = int(ws) if isinstance(ws, int) or (isinstance(ws, str) and ws.isdigit()) else None
|
||||
out = await sem.ask(question, user, workspace_id)
|
||||
audit_log(user, "search.ask", "search", "", question[:200], request)
|
||||
return {"question": question, "workspace_id": workspace_id, **out}
|
||||
|
||||
|
||||
@router.get("/api/v2/search/index-status")
|
||||
async def index_status(request: Request):
|
||||
"""How many resources are indexed vs pending (owner/admin visibility)."""
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
indexed = conn.execute("SELECT COUNT(*) FROM semantic_index_state").fetchone()[0]
|
||||
vectors = conn.execute("SELECT COUNT(*) FROM semantic_embeddings").fetchone()[0]
|
||||
pages_total = conn.execute(
|
||||
"SELECT COUNT(*) FROM pages WHERE (deleted_at IS NULL OR deleted_at='') "
|
||||
"AND COALESCE(search_excluded, 0)=0").fetchone()[0]
|
||||
return {"indexed_resources": indexed, "vectors": vectors,
|
||||
"indexable_pages": pages_total, "model": sem.MODEL, "dim": sem.DIM,
|
||||
"user_id": user.get("id")}
|
||||
@@ -0,0 +1,121 @@
|
||||
"""FlowDeck — v5.2.0 Security: per-user API tokens & active sessions.
|
||||
|
||||
Backend for the Settings → API tokens / Sessions UI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["security"], prefix="/api/settings")
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _current_user_id(request: Request) -> int:
|
||||
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")
|
||||
return user["id"]
|
||||
|
||||
|
||||
# ═══════════ API tokens ═══════════
|
||||
|
||||
|
||||
@router.get("/tokens")
|
||||
async 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:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, token_prefix, last_used_at, revoked, created_at "
|
||||
"FROM api_tokens WHERE user_id=? ORDER BY created_at DESC",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
return {"tokens": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/tokens")
|
||||
async def create_token(request: Request):
|
||||
"""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:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
|
||||
(uid, name[:80], _hash_token(token), token[:12]),
|
||||
)
|
||||
conn.commit()
|
||||
tid = cur.lastrowid
|
||||
return {"id": tid, "name": name, "token": token,
|
||||
"note": "Copy this token now — it won't be shown again."}
|
||||
|
||||
|
||||
@router.delete("/tokens/{token_id:int}")
|
||||
async def revoke_token(token_id: int, request: Request):
|
||||
"""Revoke an API token (soft delete)."""
|
||||
uid = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM api_tokens WHERE id=? AND user_id=?", (token_id, uid)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,))
|
||||
conn.commit()
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
# ═══════════ Active sessions ═══════════
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
async 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", ""))
|
||||
sessions = SessionManager.list_sessions(uid)
|
||||
now = __import__("datetime").datetime.now()
|
||||
for s in sessions:
|
||||
s["is_current"] = (s["id"] == current_sid)
|
||||
# A session older than 7 days is implicitly expired (cookie max-age).
|
||||
created = s.get("created_at") or ""
|
||||
try:
|
||||
from datetime import datetime
|
||||
created_dt = datetime.fromisoformat(str(created).replace("Z", ""))
|
||||
s["expired"] = (now - created_dt).days >= 7
|
||||
except Exception:
|
||||
s["expired"] = False
|
||||
return {"sessions": sessions}
|
||||
|
||||
|
||||
@router.post("/sessions/{sid}/revoke")
|
||||
async 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:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM user_sessions WHERE id=? AND user_id=?", (sid, uid)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
SessionManager.revoke_session(sid)
|
||||
# Also wipe the OAuth state cookie if the current session was revoked.
|
||||
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
|
||||
if current_sid == sid:
|
||||
try:
|
||||
request.session.clear()
|
||||
except Exception:
|
||||
logger.exception("revoke_session")
|
||||
return {"status": "revoked"}
|
||||
+187
-21
@@ -6,10 +6,11 @@ import re
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sharing"], prefix="/api")
|
||||
@@ -36,18 +37,23 @@ def _slugify(title: str) -> str:
|
||||
|
||||
@router.post("/pages/{page_id}/share")
|
||||
async def share_page(page_id: int, request: Request):
|
||||
"""Invite a user or email to a page."""
|
||||
"""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", "")
|
||||
permission = body.get("permission", "view")
|
||||
|
||||
if permission not in ("view", "comment", "edit"):
|
||||
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
|
||||
|
||||
if not target_user_id and not email:
|
||||
raise HTTPException(400, "Provide user_id or email to share with.")
|
||||
if not target_user_id and not target_group_id and not email:
|
||||
raise HTTPException(400, "Provide user_id, group_id or email to share with.")
|
||||
|
||||
# Bridge share permission (view/comment/edit) → granular role
|
||||
# (viewer/commenter/editor) so page_permissions grants stay in sync.
|
||||
_SHARE_TO_ROLE = {"view": "viewer", "comment": "commenter", "edit": "editor"}
|
||||
|
||||
with get_conn() as conn:
|
||||
# Verify page exists
|
||||
@@ -61,24 +67,151 @@ async def share_page(page_id: int, request: Request):
|
||||
if not target:
|
||||
raise HTTPException(404, "Target user not found")
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(page_id, target_user_id, email, permission, user["id"]),
|
||||
)
|
||||
# Verify target group exists if group_id given
|
||||
if target_group_id:
|
||||
gtarget = conn.execute("SELECT id FROM user_groups WHERE id=?", (target_group_id,)).fetchone()
|
||||
if not gtarget:
|
||||
raise HTTPException(404, "Target group not found")
|
||||
|
||||
# Upsert to avoid duplicates: update the existing permission if the same
|
||||
# target (user, group or email) is already shared on this page.
|
||||
target_row = None
|
||||
if target_user_id:
|
||||
target_row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE page_id=? AND shared_with_user_id=?",
|
||||
(page_id, target_user_id),
|
||||
).fetchone()
|
||||
elif target_group_id:
|
||||
target_row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE page_id=? AND shared_with_group_id=?",
|
||||
(page_id, target_group_id),
|
||||
).fetchone()
|
||||
elif email:
|
||||
target_row = conn.execute(
|
||||
"""SELECT id FROM page_shares
|
||||
WHERE page_id=? AND shared_with_email=? AND shared_with_user_id IS NULL AND shared_with_group_id IS NULL""",
|
||||
(page_id, email.strip()),
|
||||
).fetchone()
|
||||
|
||||
if target_row:
|
||||
conn.execute(
|
||||
"UPDATE page_shares SET permission=?, created_by=? WHERE id=?",
|
||||
(permission, user["id"], target_row["id"]),
|
||||
)
|
||||
share_id = target_row["id"]
|
||||
else:
|
||||
if not email:
|
||||
email = ""
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_group_id, shared_with_email, permission, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(page_id, target_user_id, target_group_id, email.strip(), permission, user["id"]),
|
||||
)
|
||||
except Exception:
|
||||
# Fallback for DBs where the migration has not run yet
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(page_id, target_user_id, email.strip(), permission, user["id"]),
|
||||
)
|
||||
share_id = cur.lastrowid
|
||||
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
|
||||
# ── Mirror group shares into page_permissions so the ACL used by
|
||||
# PermissionManager (can_view/edit/comment) grants real access to
|
||||
# every group member. Best-effort: never break legacy page_shares.
|
||||
if target_group_id:
|
||||
try:
|
||||
_mirror_share_grant(conn, page_id, target_group_id, _SHARE_TO_ROLE[permission], user["id"])
|
||||
except Exception:
|
||||
logger.warning("share→page_permissions mirror failed (page=%s group=%s)", page_id, target_group_id)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
|
||||
except Exception:
|
||||
logger.exception("share_page")
|
||||
|
||||
return {
|
||||
"id": cur.lastrowid,
|
||||
"id": share_id,
|
||||
"page_id": page_id,
|
||||
"shared_with_user_id": target_user_id,
|
||||
"shared_with_group_id": target_group_id,
|
||||
"shared_with_email": email,
|
||||
"permission": permission,
|
||||
"status": "shared",
|
||||
}
|
||||
|
||||
|
||||
def _mirror_share_grant(conn, page_id: int, group_id: int, role: str, granted_by: int) -> None:
|
||||
"""Upsert a ``page_permissions`` grant mirroring a group ``page_shares`` row.
|
||||
|
||||
Keeps the granular ACL (used by ``PermissionManager``) in sync with what
|
||||
the share dialog shows, so invited groups get effective view/edit rights.
|
||||
"""
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM page_permissions WHERE page_id=? AND user_id IS NULL AND group_id=?",
|
||||
(page_id, group_id),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute("UPDATE page_permissions SET role=?, granted_by=? WHERE id=?",
|
||||
(role, granted_by, existing["id"]))
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO page_permissions (page_id, user_id, group_id, role, granted_by) "
|
||||
"VALUES (?, NULL, ?, ?, ?)",
|
||||
(page_id, group_id, role, granted_by),
|
||||
)
|
||||
|
||||
|
||||
def _mirror_share_revoke(conn, page_id: int, group_id: int) -> None:
|
||||
"""Remove the mirrored grant when a group share is updated away or deleted."""
|
||||
conn.execute(
|
||||
"DELETE FROM page_permissions WHERE page_id=? AND user_id IS NULL AND group_id=?",
|
||||
(page_id, group_id),
|
||||
)
|
||||
|
||||
|
||||
@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):
|
||||
"""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"):
|
||||
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, shared_with_group_id FROM page_shares WHERE id=? AND page_id=?",
|
||||
(share_id, page_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Share entry not found")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE page_shares SET permission=? WHERE id=?",
|
||||
(permission, share_id),
|
||||
)
|
||||
# Keep the mirrored ACL grant in sync for group shares.
|
||||
try:
|
||||
gid = row["shared_with_group_id"] if "shared_with_group_id" in row.keys() else None
|
||||
except Exception:
|
||||
gid = None
|
||||
if gid:
|
||||
try:
|
||||
_mirror_share_grant(conn, page_id, gid,
|
||||
{"view": "viewer", "comment": "commenter", "edit": "editor"}[permission],
|
||||
user["id"])
|
||||
except Exception:
|
||||
logger.warning("share→page_permissions mirror failed (share=%s)", share_id)
|
||||
conn.commit()
|
||||
|
||||
return {"status": "updated", "share_id": share_id, "permission": permission}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/share/{share_id}")
|
||||
async def remove_share(page_id: int, share_id: int, request: Request):
|
||||
"""Remove a share invitation."""
|
||||
@@ -86,13 +219,22 @@ async def remove_share(page_id: int, share_id: int, request: Request):
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE id=? AND page_id=?",
|
||||
"SELECT id, shared_with_group_id FROM page_shares WHERE id=? AND page_id=?",
|
||||
(share_id, page_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Share entry not found")
|
||||
|
||||
conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,))
|
||||
try:
|
||||
gid = row["shared_with_group_id"] if "shared_with_group_id" in row.keys() else None
|
||||
except Exception:
|
||||
gid = None
|
||||
if gid:
|
||||
try:
|
||||
_mirror_share_revoke(conn, page_id, gid)
|
||||
except Exception:
|
||||
logger.warning("share→page_permissions revoke failed (share=%s)", share_id)
|
||||
# If no more shares, unset is_shared
|
||||
remaining = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM page_shares WHERE page_id=?", (page_id,)
|
||||
@@ -114,14 +256,25 @@ async def list_shares(page_id: int, request: Request):
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url
|
||||
FROM page_shares s
|
||||
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||
WHERE s.page_id=?
|
||||
ORDER BY s.created_at DESC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url, g.name AS group_name
|
||||
FROM page_shares s
|
||||
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||
LEFT JOIN user_groups g ON s.shared_with_group_id = g.id
|
||||
WHERE s.page_id=?
|
||||
ORDER BY s.created_at DESC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url
|
||||
FROM page_shares s
|
||||
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||
WHERE s.page_id=?
|
||||
ORDER BY s.created_at DESC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
@@ -129,6 +282,7 @@ async def list_shares(page_id: int, request: Request):
|
||||
{
|
||||
"id": r["id"],
|
||||
"shared_with_user_id": r["shared_with_user_id"],
|
||||
"shared_with_group_id": r["shared_with_group_id"] if "shared_with_group_id" in r.keys() else None,
|
||||
"shared_with_email": r["shared_with_email"],
|
||||
"permission": r["permission"],
|
||||
"created_at": r["created_at"],
|
||||
@@ -136,6 +290,8 @@ async def list_shares(page_id: int, request: Request):
|
||||
"user_login": r["login"],
|
||||
"user_full_name": r["full_name"],
|
||||
"user_avatar_url": r["avatar_url"],
|
||||
"group_name": r["group_name"] if "group_name" in r.keys() else None,
|
||||
"kind": "group" if (("shared_with_group_id" in r.keys() and r["shared_with_group_id"]) or ("group_name" in r.keys() and r["group_name"])) else "user",
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
@@ -148,7 +304,7 @@ async def list_shares(page_id: int, request: Request):
|
||||
@router.post("/pages/{page_id}/publish")
|
||||
async def publish_page(page_id: int, request: Request):
|
||||
"""Publish a page (is_published=1) with a URL slug."""
|
||||
user = _require_auth(request)
|
||||
_require_auth(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
@@ -173,6 +329,11 @@ async def publish_page(page_id: int, request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
logger.exception("publish_page")
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": True,
|
||||
@@ -199,6 +360,11 @@ async def unpublish_page(page_id: int, request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
logger.exception("unpublish_page")
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": False,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""FlowDeck — Sidebar customization API (v4.6.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sidebar"], prefix="/api/sidebar")
|
||||
|
||||
|
||||
def _get_user(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"workspace": {"visible": True, "order": 0, "show_count": None},
|
||||
"gitea": {"visible": True, "order": 1, "show_count": None},
|
||||
"meetings": {"visible": True, "order": 2, "show_count": 5},
|
||||
"recents": {"visible": True, "order": 3, "show_count": 10},
|
||||
"favorites": {"visible": True, "order": 4, "show_count": 10},
|
||||
"agents": {"visible": True, "order": 5, "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):
|
||||
"""Get the current user's sidebar customization config."""
|
||||
user = _get_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT sidebar_config FROM users WHERE id=?", (user["id"],)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {"config": DEFAULT_CONFIG}
|
||||
|
||||
raw = row["sidebar_config"]
|
||||
if not raw:
|
||||
return {"config": DEFAULT_CONFIG}
|
||||
|
||||
try:
|
||||
stored = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {"config": DEFAULT_CONFIG}
|
||||
|
||||
# Merge with defaults to ensure all keys exist
|
||||
merged = dict(DEFAULT_CONFIG)
|
||||
merged.update(stored)
|
||||
return {"config": merged}
|
||||
|
||||
|
||||
def get_sidebar_config_sync(user_id: int) -> dict:
|
||||
"""Synchronous helper to get sidebar config (used during template rendering)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT sidebar_config FROM users WHERE id=?", (user_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return dict(DEFAULT_CONFIG)
|
||||
raw = row["sidebar_config"]
|
||||
if not raw:
|
||||
return dict(DEFAULT_CONFIG)
|
||||
try:
|
||||
stored = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return dict(DEFAULT_CONFIG)
|
||||
merged = dict(DEFAULT_CONFIG)
|
||||
merged.update(stored)
|
||||
return merged
|
||||
|
||||
|
||||
@router.put("/config")
|
||||
async def save_sidebar_config(request: Request):
|
||||
"""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):
|
||||
raise HTTPException(status_code=400, detail="config object is required")
|
||||
|
||||
# Merge with defaults to ensure validity
|
||||
merged = dict(DEFAULT_CONFIG)
|
||||
for key, val in config.items():
|
||||
if key in DEFAULT_CONFIG and isinstance(val, dict):
|
||||
merged[key] = {
|
||||
"visible": val.get("visible", DEFAULT_CONFIG[key]["visible"]),
|
||||
"order": val.get("order", DEFAULT_CONFIG[key]["order"]),
|
||||
"show_count": val.get("show_count", DEFAULT_CONFIG[key]["show_count"]),
|
||||
}
|
||||
elif key not in DEFAULT_CONFIG:
|
||||
# Allow new custom sections
|
||||
merged[key] = val
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET sidebar_config=? WHERE id=?",
|
||||
(json.dumps(merged), user["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"status": "ok", "config": merged}
|
||||
@@ -0,0 +1,838 @@
|
||||
"""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, 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")
|
||||
async def create_site(request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
root_page_id = body.get("root_page_id")
|
||||
if not root_page_id:
|
||||
raise HTTPException(400, "root_page_id is required")
|
||||
slug = (body.get("slug") or "").strip().lower() or None
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (root_page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Root page not found")
|
||||
if not slug:
|
||||
slug = _slugify(page["title"])
|
||||
base, i = slug, 1
|
||||
while conn.execute("SELECT id FROM sites WHERE slug=?", (slug,)).fetchone():
|
||||
slug = f"{base}-{i}"
|
||||
i += 1
|
||||
else:
|
||||
_check_slug(slug)
|
||||
if conn.execute("SELECT id FROM sites WHERE slug=?", (slug,)).fetchone():
|
||||
raise HTTPException(409, "Slug already taken")
|
||||
theme = body.get("theme", "dark")
|
||||
if theme not in ("light", "dark"):
|
||||
raise HTTPException(400, "theme must be light or dark")
|
||||
custom_domain = (body.get("custom_domain") or "").strip() or None
|
||||
if custom_domain and conn.execute(
|
||||
"SELECT id FROM sites WHERE custom_domain=?", (custom_domain,)
|
||||
).fetchone():
|
||||
raise HTTPException(409, "Domain already linked to another site")
|
||||
expires_at = body.get("expires_at")
|
||||
if expires_at:
|
||||
try:
|
||||
datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid expires_at (use ISO-8601)") from None
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO sites (slug, root_page_id, title, theme, custom_domain,
|
||||
expires_at, noindex, analytics_id, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(slug, root_page_id, body.get("title") or page["title"],
|
||||
theme, custom_domain, expires_at,
|
||||
1 if body.get("noindex") else 0,
|
||||
(body.get("analytics_id") or "")[:120], user["id"]),
|
||||
)
|
||||
site_id = cur.lastrowid
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO site_pages (site_id, page_id, position) VALUES (?, ?, 0)",
|
||||
(site_id, root_page_id),
|
||||
)
|
||||
conn.commit()
|
||||
site = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
audit_log(user, "site.create", "site", site_id, f"slug={slug}", request)
|
||||
return JSONResponse(status_code=201, content=row_to_dict(site))
|
||||
|
||||
|
||||
@router.get("/api/v2/sites")
|
||||
async def list_sites(request: Request):
|
||||
user = _auth_user(request)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM sites WHERE created_by=?", (user["id"],)
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM sites WHERE created_by=? ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(user["id"], limit, offset),
|
||||
).fetchall()
|
||||
resp = JSONResponse([row_to_dict(r) for r in rows])
|
||||
for k, v in paginate_headers(total).items():
|
||||
resp.headers[k] = v
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}")
|
||||
async def get_site(site_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Site not found")
|
||||
site = dict(row)
|
||||
if site.get("created_by") != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(404, "Site not found")
|
||||
pages = _site_pages(conn, site_id)
|
||||
out = row_to_dict(row)
|
||||
out["pages"] = pages
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/api/v2/sites/{site_id}")
|
||||
async def update_site(site_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Site not found")
|
||||
site = dict(row)
|
||||
if site.get("created_by") != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(404, "Site not found")
|
||||
updates: dict = {}
|
||||
if "title" in body:
|
||||
updates["title"] = str(body["title"] or "")[:200]
|
||||
if "theme" in body:
|
||||
if body["theme"] not in ("light", "dark"):
|
||||
raise HTTPException(400, "theme must be light or dark")
|
||||
updates["theme"] = body["theme"]
|
||||
if "slug" in body and body["slug"] != site["slug"]:
|
||||
_check_slug(str(body["slug"]).lower())
|
||||
if conn.execute(
|
||||
"SELECT id FROM sites WHERE slug=? AND id!=?", (body["slug"].lower(), site_id)
|
||||
).fetchone():
|
||||
raise HTTPException(409, "Slug already taken")
|
||||
updates["slug"] = str(body["slug"]).lower()
|
||||
if "custom_domain" in body:
|
||||
dom = (body["custom_domain"] or "").strip() or None
|
||||
if dom and conn.execute(
|
||||
"SELECT id FROM sites WHERE custom_domain=? AND id!=?", (dom, site_id)
|
||||
).fetchone():
|
||||
raise HTTPException(409, "Domain already linked to another site")
|
||||
updates["custom_domain"] = dom
|
||||
if "expires_at" in body:
|
||||
if body["expires_at"]:
|
||||
try:
|
||||
datetime.fromisoformat(str(body["expires_at"]).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid expires_at (use ISO-8601)") from None
|
||||
updates["expires_at"] = body["expires_at"]
|
||||
if "noindex" in body:
|
||||
updates["noindex"] = 1 if body["noindex"] else 0
|
||||
if "analytics_id" in body:
|
||||
updates["analytics_id"] = str(body["analytics_id"] or "")[:120]
|
||||
if "password" in body:
|
||||
updates["password_hash"] = hash_password(str(body["password"])) if body["password"] else ""
|
||||
if updates:
|
||||
updates["updated_at"] = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
conn.execute(f"UPDATE sites SET {sets} WHERE id=?", (*updates.values(), site_id))
|
||||
conn.commit()
|
||||
site = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
audit_log(user, "site.update", "site", site_id, ",".join(updates), request)
|
||||
return row_to_dict(site)
|
||||
|
||||
|
||||
@router.delete("/api/v2/sites/{site_id}")
|
||||
async def delete_site(site_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Site not found")
|
||||
if row["created_by"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(404, "Site not found")
|
||||
conn.execute("DELETE FROM sites WHERE id=?", (site_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "site.delete", "site", site_id, "", request)
|
||||
return {"status": "deleted", "id": site_id}
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}/pages")
|
||||
async def list_site_pages(site_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Site not found")
|
||||
return {"site_id": site_id, "pages": _site_pages(conn, site_id)}
|
||||
|
||||
|
||||
@router.post("/api/v2/sites/{site_id}/pages")
|
||||
async def add_site_page(site_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
page_id = body.get("page_id")
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id is required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Site not found")
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1)+1 FROM site_pages WHERE site_id=?", (site_id,)
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO site_pages (site_id, page_id, position) VALUES (?, ?, ?)",
|
||||
(site_id, page_id, pos),
|
||||
)
|
||||
conn.commit()
|
||||
pages = _site_pages(conn, site_id)
|
||||
audit_log(user, "site.page.add", "site", site_id, f"page={page_id}", request)
|
||||
return {"site_id": site_id, "pages": pages}
|
||||
|
||||
|
||||
@router.delete("/api/v2/sites/{site_id}/pages/{page_id}")
|
||||
async def remove_site_page(site_id: int, page_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Site not found")
|
||||
if page_id == row["root_page_id"]:
|
||||
raise HTTPException(400, "Cannot remove the root page")
|
||||
conn.execute(
|
||||
"DELETE FROM site_pages WHERE site_id=? AND page_id=?", (site_id, page_id)
|
||||
)
|
||||
conn.commit()
|
||||
audit_log(user, "site.page.remove", "site", site_id, f"page={page_id}", request)
|
||||
return {"status": "removed", "site_id": site_id, "page_id": page_id}
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}/stats")
|
||||
async def site_stats(site_id: int, request: Request, days: int = 30):
|
||||
user = _auth_user(request)
|
||||
days = max(1, min(int(days or 30), 365))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Site not found")
|
||||
rows = conn.execute(
|
||||
"SELECT day, views FROM site_views WHERE site_id=? ORDER BY day DESC LIMIT ?",
|
||||
(site_id, days),
|
||||
).fetchall()
|
||||
total = conn.execute(
|
||||
"SELECT COALESCE(SUM(views), 0) FROM site_views WHERE site_id=?", (site_id,)
|
||||
).fetchone()[0]
|
||||
return {"site_id": site_id, "total_views": total,
|
||||
"days": [{"day": r["day"], "views": r["views"]} for r in rows]}
|
||||
|
||||
|
||||
# ── Public site rendering ──────────────────────────────────────────────────
|
||||
|
||||
def _public_guard(site: dict, request: Request):
|
||||
if _site_expired(site):
|
||||
return HTMLResponse("<h1>410 — Site expired.</h1>", status_code=410)
|
||||
if site.get("password_hash") and not _site_unlocked(request, site):
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html><body style="font-family:system-ui;background:#191919;color:#eee;
|
||||
display:flex;align-items:center;justify-content:center;height:100vh">
|
||||
<form method="post" action="/s/{site['slug']}/auth">
|
||||
<h2>🔒 {html.escape(site.get('title') or 'Protected site')}</h2>
|
||||
<input type="password" name="password" placeholder="Password"
|
||||
style="padding:8px;border-radius:6px;border:1px solid #444;background:#222;color:#fff">
|
||||
<button style="padding:8px 14px;border-radius:6px">Unlock</button></form></body></html>""",
|
||||
status_code=401,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/s/{slug}", response_class=HTMLResponse)
|
||||
async def public_site_home(request: Request, slug: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug,
|
||||
host=request.headers.get("host", ""))
|
||||
if not site:
|
||||
return HTMLResponse("<h1>404 — Site not found.</h1>", status_code=404)
|
||||
guard = _public_guard(site, request)
|
||||
if guard:
|
||||
return guard
|
||||
pages = _site_pages(conn, site["id"])
|
||||
page = conn.execute("SELECT * FROM pages WHERE id=?", (site["root_page_id"],)).fetchone()
|
||||
if not page:
|
||||
return HTMLResponse("<h1>404 — Root page removed.</h1>", status_code=404)
|
||||
page = dict(page)
|
||||
_track_view(site["id"])
|
||||
body = f"<h1>{html.escape(page.get('title') or 'Untitled')}</h1>" + _render_page_html(page)
|
||||
return _site_shell(site=site, pages=pages, current_id=page["id"],
|
||||
title=page.get("title") or "Untitled", body_html=body)
|
||||
|
||||
|
||||
@router.get("/s/{slug}/sitemap.xml", response_class=PlainTextResponse)
|
||||
async def site_sitemap(request: Request, slug: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug)
|
||||
if not site or _site_expired(site) or site.get("password_hash"):
|
||||
return PlainTextResponse("Not found", status_code=404)
|
||||
pages = _site_pages(conn, site["id"])
|
||||
base = str(request.base_url).rstrip("/")
|
||||
urls = [f"<url><loc>{base}/s/{slug}</loc></url>"] + [
|
||||
f"<url><loc>{base}/s/{slug}/{p['slug']}</loc></url>" for p in pages
|
||||
]
|
||||
return PlainTextResponse(
|
||||
"<?xml version='1.0' encoding='UTF-8'?>"
|
||||
"<urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'>"
|
||||
f"{''.join(urls)}</urlset>",
|
||||
media_type="application/xml",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse)
|
||||
async def public_site_page(request: Request, slug: str, page_ref: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug, host=request.headers.get("host", ""))
|
||||
if not site:
|
||||
return HTMLResponse("<h1>404 — Site not found.</h1>", status_code=404)
|
||||
guard = _public_guard(site, request)
|
||||
if guard:
|
||||
return guard
|
||||
pages = _site_pages(conn, site["id"])
|
||||
target = _find_site_page(pages, page_ref)
|
||||
if not target:
|
||||
return HTMLResponse("<h1>404 — Page not in this site.</h1>", status_code=404)
|
||||
page = conn.execute("SELECT * FROM pages WHERE id=?", (target["id"],)).fetchone()
|
||||
if not page:
|
||||
return HTMLResponse("<h1>404 — Page removed.</h1>", status_code=404)
|
||||
page = dict(page)
|
||||
_track_view(site["id"])
|
||||
body = f"<h1>{html.escape(page.get('title') or 'Untitled')}</h1>" + _render_page_html(page)
|
||||
return _site_shell(site=site, pages=pages, current_id=page["id"],
|
||||
title=page.get("title") or "Untitled", body_html=body)
|
||||
|
||||
|
||||
@router.post("/s/{slug}/auth")
|
||||
async def public_site_auth(request: Request, slug: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug)
|
||||
if not site:
|
||||
return JSONResponse({"detail": "Site not found"}, status_code=404)
|
||||
if not site.get("password_hash"):
|
||||
return {"status": "public"}
|
||||
ctype = request.headers.get("content-type", "")
|
||||
password = ""
|
||||
if "application/json" in ctype:
|
||||
try:
|
||||
password = (await request.json()).get("password", "")
|
||||
except Exception:
|
||||
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")
|
||||
async def get_form_config(collection_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
with get_conn() as conn:
|
||||
info = _form_config(conn, collection_id)
|
||||
return {"collection_id": collection_id, "name": info["name"], "form": info["config"]}
|
||||
|
||||
|
||||
@router.put("/api/v2/collections/{collection_id}/form")
|
||||
async def put_form_config(collection_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
info = _form_config(conn, collection_id)
|
||||
cfg = info["config"] if isinstance(info["config"], dict) else {}
|
||||
if "enabled" in body:
|
||||
cfg["enabled"] = bool(body["enabled"])
|
||||
for key in ("title", "success_message"):
|
||||
if key in body:
|
||||
cfg[key] = str(body[key] or "")[:300]
|
||||
for key in ("fields", "required", "notify_user_ids"):
|
||||
if key in body and isinstance(body[key], list):
|
||||
cfg[key] = body[key][:50]
|
||||
if "public_token" in body and body["public_token"]:
|
||||
tok = str(body["public_token"])
|
||||
if not _FORM_TOKEN_RE.match(tok):
|
||||
raise HTTPException(400, "Invalid public_token (f_ + 6-64 chars)")
|
||||
cfg["public_token"] = tok
|
||||
if cfg.get("enabled") and not cfg.get("public_token"):
|
||||
cfg["public_token"] = "f_" + secrets.token_urlsafe(9)
|
||||
conn.execute(
|
||||
"UPDATE collections SET form_config_json=? WHERE id=?",
|
||||
(json.dumps(cfg), collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
audit_log(user, "form.config", "collection", collection_id, "", request)
|
||||
return {"collection_id": collection_id, "form": cfg}
|
||||
|
||||
|
||||
def _collection_props(conn, collection_id: int) -> list[dict]:
|
||||
return [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,)).fetchall()]
|
||||
|
||||
|
||||
@router.get("/f/{token}", response_class=HTMLResponse)
|
||||
async def public_form(request: Request, token: str):
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections").fetchone()
|
||||
target = None
|
||||
if _FORM_TOKEN_RE.match(token or ""):
|
||||
for c in conn.execute("SELECT id, name, form_config_json FROM collections").fetchall():
|
||||
try:
|
||||
cfg = json.loads(c["form_config_json"] or "{}")
|
||||
except Exception:
|
||||
continue
|
||||
if cfg.get("enabled") and cfg.get("public_token") == token:
|
||||
target = (c, cfg)
|
||||
break
|
||||
_ = row
|
||||
if not target:
|
||||
return HTMLResponse("<h1>404 — Form not found.</h1>", status_code=404)
|
||||
coll, cfg = target
|
||||
props = _collection_props(conn, coll["id"])
|
||||
fields = cfg.get("fields") or [p["name"] for p in props if p["prop_type"] != "formula"][:10]
|
||||
required = set(cfg.get("required") or [])
|
||||
inputs = ""
|
||||
for name in fields:
|
||||
prop = next((p for p in props if p["name"] == name), None)
|
||||
ptype = (prop or {}).get("prop_type", "text")
|
||||
itype = {"number": "number", "email": "email", "url": "url",
|
||||
"date": "date", "phone": "tel"}.get(ptype, "text")
|
||||
req = "required" if name in required else ""
|
||||
if ptype in ("select", "status") and prop:
|
||||
try:
|
||||
opts = json.loads(prop.get("options_json") or "[]")
|
||||
except Exception:
|
||||
opts = []
|
||||
opts_html = "".join(
|
||||
f"<option>{html.escape(o.get('name', ''))}</option>" for o in opts)
|
||||
inputs += (f"<label>{html.escape(name)}{'*' if req else ''}</label>"
|
||||
f"<select name='{html.escape(name)}' {req}>{opts_html}</select>")
|
||||
elif ptype == "checkbox":
|
||||
inputs += (f"<label><input type='checkbox' name='{html.escape(name)}'> "
|
||||
f"{html.escape(name)}</label>")
|
||||
else:
|
||||
inputs += (f"<label>{html.escape(name)}{'*' if req else ''}</label>"
|
||||
f"<input type='{itype}' name='{html.escape(name)}' {req}>")
|
||||
chrome = "" if embed else f"<h1>{html.escape(cfg.get('title') or coll['name'])}</h1>"
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>{html.escape(cfg.get('title') or coll['name'])}</title>
|
||||
<style>body{{font-family:system-ui;background:#191919;color:#eee;margin:0;padding:24px}}
|
||||
form{{max-width:520px;margin:auto}}label{{display:block;margin:12px 0 4px}}
|
||||
input,select,textarea{{width:100%;padding:8px;border-radius:6px;border:1px solid #444;background:#222;color:#fff}}
|
||||
button{{margin-top:16px;padding:10px 18px;border-radius:6px;border:0;background:#2383E2;color:#fff}}</style>
|
||||
</head><body>{chrome}
|
||||
<form method="post" action="/f/{token}">
|
||||
<input type="text" name="__hp" style="display:none" tabindex="-1" autocomplete="off">
|
||||
{inputs}<button>Submit</button></form></body></html>"""
|
||||
)
|
||||
|
||||
|
||||
@router.post("/f/{token}")
|
||||
async def submit_form(request: Request, token: str):
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
_check_form_rate(ip or "unknown")
|
||||
ctype = request.headers.get("content-type", "")
|
||||
data: dict = {}
|
||||
if "application/json" in ctype:
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
else:
|
||||
try:
|
||||
form = await request.form()
|
||||
data = dict(form)
|
||||
except Exception:
|
||||
data = {}
|
||||
if data.get("__hp"):
|
||||
raise HTTPException(400, "Spam detected")
|
||||
with get_conn() as conn:
|
||||
target = None
|
||||
if _FORM_TOKEN_RE.match(token or ""):
|
||||
for c in conn.execute("SELECT id, name, form_config_json FROM collections").fetchall():
|
||||
try:
|
||||
cfg = json.loads(c["form_config_json"] or "{}")
|
||||
except Exception:
|
||||
continue
|
||||
if cfg.get("enabled") and cfg.get("public_token") == token:
|
||||
target = (c, cfg)
|
||||
break
|
||||
if not target:
|
||||
# NOTE: return (not raise) — the global 404 handler redirects
|
||||
# non-/api paths to /workspaces, which would turn this into a 200.
|
||||
return JSONResponse({"detail": "Form not found"}, status_code=404)
|
||||
coll, cfg = target
|
||||
props = _collection_props(conn, coll["id"])
|
||||
by_name = {p["name"]: p for p in props}
|
||||
fields = cfg.get("fields") or list(by_name)[:10]
|
||||
required = set(cfg.get("required") or [])
|
||||
values: dict = {}
|
||||
for name in fields:
|
||||
prop = by_name.get(name)
|
||||
if not prop:
|
||||
continue
|
||||
raw = data.get(name, "")
|
||||
if prop["prop_type"] == "checkbox":
|
||||
raw = True if raw in (True, "on", "true", "1", "checked") else False
|
||||
if name in required and (raw is None or raw == "" or raw is False):
|
||||
raise HTTPException(400, f"Field required: {name}")
|
||||
values[str(prop["id"])] = raw
|
||||
# Validate via property_types.validate_property_rule
|
||||
try:
|
||||
from app.services.property_types import validate_property_rule
|
||||
for name in fields:
|
||||
prop = by_name.get(name)
|
||||
if not prop:
|
||||
continue
|
||||
ok, _msg = validate_property_rule(
|
||||
prop.get("prop_type", "text"), values.get(str(prop["id"])),
|
||||
prop.get("validation_json") or prop.get("options_json") or "")
|
||||
if not ok:
|
||||
raise HTTPException(400, f"Invalid value for {name}: {_msg}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
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,656 @@
|
||||
"""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, 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")
|
||||
async 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")
|
||||
async 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 ══════════════════════════
|
||||
|
||||
|
||||
async 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")
|
||||
async 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")
|
||||
async def get_sso_config_api(request: Request):
|
||||
"""Read the current SSO configuration (secrets never returned)."""
|
||||
await _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")
|
||||
async def save_sso_config_api(request: Request):
|
||||
"""Create/replace the SSO configuration (admin, scope write)."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception as err:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body") from err
|
||||
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")
|
||||
async def delete_sso_config_api(request: Request):
|
||||
"""Disable SSO — local logins keep working (design §8 « SSO disable »)."""
|
||||
admin = await _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")
|
||||
async def sso_workspaces(request: Request):
|
||||
"""Workspaces available for default assignment / group mapping."""
|
||||
await _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")
|
||||
async def sso_sync(request: Request):
|
||||
"""Re-apply group → workspace role mapping for every SSO user."""
|
||||
admin = await _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")
|
||||
async def sso_history(request: Request, limit: int = 50):
|
||||
"""Audit trail of SSO login attempts (successes and rejections)."""
|
||||
await _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}
|
||||
@@ -0,0 +1,108 @@
|
||||
"""FlowDeck — /api/v2/sync endpoints (v6.0.0 PWA offline sync, Bearer v6.4.0).
|
||||
|
||||
Auth: ``Authorization: Bearer <token>`` (scopes ``read`` for delta/status,
|
||||
``write`` for batch). The legacy ``flowdeck_session`` cookie is still accepted
|
||||
as a fallback so the installed PWA/service worker keeps syncing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.api_v2_helpers import get_bearer_user, has_scope
|
||||
from app.services.sync_engine import SyncEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v2/sync", tags=["sync"])
|
||||
|
||||
_engine = SyncEngine()
|
||||
|
||||
|
||||
def _user(request: Request, authorization: str | None = None,
|
||||
*, required_scope: str = "read") -> dict:
|
||||
"""Bearer-first auth with session-cookie fallback (offline.js compat)."""
|
||||
auth = authorization or request.headers.get("authorization") or ""
|
||||
if auth and auth.lower().startswith("bearer "):
|
||||
try:
|
||||
user = get_bearer_user(request, authorization)
|
||||
except HTTPException:
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid or expired API token"
|
||||
) from None
|
||||
if not has_scope(user.get("_token_scopes"), required_scope):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Insufficient scope. Required: {required_scope}",
|
||||
)
|
||||
return user
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/delta")
|
||||
async def sync_delta(
|
||||
request: Request,
|
||||
since: float = Query(default=0, description="Epoch seconds (ou ms) du dernier sync"),
|
||||
workspace_id: int = Query(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
"""Pull server-side changes since `since` (for the given workspace)."""
|
||||
user = _user(request, authorization, required_scope="read")
|
||||
if workspace_id is None:
|
||||
raise HTTPException(status_code=400, detail="workspace_id is required")
|
||||
result = await _engine.get_delta(user["id"], since, workspace_id)
|
||||
if result.get("error") == "forbidden":
|
||||
return JSONResponse({"detail": "Forbidden"}, status_code=403)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
async def sync_batch(request: Request, authorization: str | None = Header(default=None)):
|
||||
"""Apply a batch of offline mutations and return per-mutation results."""
|
||||
user = _user(request, authorization, required_scope="write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body") from None
|
||||
|
||||
mutations = body.get("mutations") or []
|
||||
device_id = body.get("device_id") or "unknown"
|
||||
if not isinstance(mutations, list) or not mutations:
|
||||
return {"results": [], "conflicts": [], "server_time": SyncEngine._now_epoch()}
|
||||
|
||||
result = await _engine.apply_batch(user["id"], mutations, device_id)
|
||||
result["server_time"] = SyncEngine._now_epoch()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async 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")
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
if not SyncEngine._can_access(conn, user["id"], workspace_id):
|
||||
return JSONResponse({"detail": "Forbidden"}, status_code=403)
|
||||
pending = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM offline_sync_queue WHERE user_id=? AND status='pending'",
|
||||
(user["id"],),
|
||||
).fetchone()["n"]
|
||||
last = conn.execute(
|
||||
"SELECT MAX(created_at) AS last FROM offline_sync_queue "
|
||||
"WHERE user_id=? AND status='synced'",
|
||||
(user["id"],),
|
||||
).fetchone()["last"]
|
||||
return {
|
||||
"pending_count": pending,
|
||||
"last_sync": last,
|
||||
"is_syncing": False,
|
||||
"server_time": SyncEngine._now_epoch(),
|
||||
"workspace_id": workspace_id,
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"""FlowDeck — Web Clipper router (v6.0.0).
|
||||
|
||||
Endpoints:
|
||||
GET /api/v2/web-clipper/status
|
||||
POST /api/v2/web-clipper/auth/verify
|
||||
POST /api/v2/web-clipper/clip
|
||||
GET /api/v2/web-clipper/devices
|
||||
DELETE /api/v2/web-clipper/devices/{id}
|
||||
GET /extensions (HTML download page)
|
||||
|
||||
Auth: session cookie OR Bearer api_token OR Bearer extension device token.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.web_clipper import (
|
||||
MAX_CLIP_BYTES,
|
||||
_check_rate_limit,
|
||||
create_page_from_clip,
|
||||
list_devices,
|
||||
log_clip,
|
||||
register_device,
|
||||
revoke_device,
|
||||
sanitize_html,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["web-clipper"])
|
||||
api_router = APIRouter(prefix="/api/v2/web-clipper", tags=["web-clipper"])
|
||||
|
||||
|
||||
def _hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def _user_from_request(request: Request) -> dict | None:
|
||||
# 1) session cookie
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user:
|
||||
return user
|
||||
# 2) Authorization Bearer
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
token = auth[7:].strip()
|
||||
if not token:
|
||||
return None
|
||||
th = _hash(token)
|
||||
with get_conn() as conn:
|
||||
# api_tokens (Settings → API tokens)
|
||||
row = conn.execute(
|
||||
"SELECT user_id FROM api_tokens WHERE token_hash=? AND revoked=0", (th,)
|
||||
).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
return dict(u)
|
||||
# extension_devices
|
||||
row = conn.execute(
|
||||
"SELECT user_id FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)
|
||||
).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
return dict(u)
|
||||
# legacy user_tokens
|
||||
row = conn.execute("SELECT gitea_user_id FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["gitea_user_id"],)).fetchone()
|
||||
if u:
|
||||
return dict(u)
|
||||
return None
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict:
|
||||
user = _user_from_request(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
# ── API: status ──
|
||||
|
||||
@api_router.get("/status")
|
||||
async def clipper_status(request: Request):
|
||||
user = _user_from_request(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
with get_conn() as conn:
|
||||
dev_cnt = conn.execute("SELECT COUNT(*) FROM extension_devices WHERE user_id=? AND revoked=0", (user["id"],)).fetchone()[0]
|
||||
clip_cnt = conn.execute("SELECT COUNT(*) FROM extension_clips WHERE user_id=?", (user["id"],)).fetchone()[0]
|
||||
return {"authenticated": True, "user": {"id": user["id"], "login": user.get("login")}, "devices": dev_cnt, "clips": clip_cnt}
|
||||
|
||||
|
||||
# ── API: auth verify / device registration ──
|
||||
|
||||
@api_router.post("/auth/verify")
|
||||
async def auth_verify(request: Request):
|
||||
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()
|
||||
if not device_id:
|
||||
raise HTTPException(status_code=400, detail="device_id required")
|
||||
if len(device_id) > 128:
|
||||
raise HTTPException(status_code=400, detail="device_id too long")
|
||||
try:
|
||||
res = register_device(user["id"], device_id, device_name, extension_name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
if res["existing"]:
|
||||
return {"status": "ok", "device_id": device_id, "existing": True, "message": "Device already registered"}
|
||||
return {"status": "ok", "device_id": device_id, "token": res["token"], "note": "Copy token now — shown once. Use as Authorization: Bearer <token>"}
|
||||
|
||||
|
||||
@api_router.post("/clip")
|
||||
async def clip_page(request: Request):
|
||||
user = _require_user(request)
|
||||
# Enforce max body size early (10 MB)
|
||||
clen = request.headers.get("content-length")
|
||||
if clen:
|
||||
try:
|
||||
if int(clen) > MAX_CLIP_BYTES + 1024:
|
||||
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"
|
||||
# Rate limit 50/hour per device
|
||||
if not _check_rate_limit(f"{user['id']}:{device_id}"):
|
||||
raise HTTPException(status_code=429, detail="Rate limit: max 50 clips/hour per device")
|
||||
|
||||
url = (body.get("url") or body.get("source_url") or "").strip()
|
||||
title = (body.get("title") or "").strip()
|
||||
content = body.get("content") or body.get("html") or ""
|
||||
clip_type = (body.get("content_type") or body.get("clip_type") or "article").strip().lower()
|
||||
if clip_type not in ("article", "selection", "bookmark", "screenshot"):
|
||||
clip_type = "article"
|
||||
|
||||
if not url and not title and not content:
|
||||
raise HTTPException(status_code=400, detail="url, title or content required")
|
||||
|
||||
# Validate url if present
|
||||
if url:
|
||||
if not (url.startswith("http://") or url.startswith("https://")):
|
||||
# allow bare domain? reject javascript:
|
||||
if url.lower().startswith("javascript:") or url.lower().startswith("data:"):
|
||||
raise HTTPException(status_code=400, detail="Invalid URL")
|
||||
|
||||
# Cap content bytes
|
||||
if content and len(content.encode("utf-8")) > MAX_CLIP_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Content too large (max 10 MB)")
|
||||
|
||||
# Sanitize HTML content if present
|
||||
if content and "<" in content:
|
||||
# sanitize but keep structure for blocks converter
|
||||
content = sanitize_html(content)[: MAX_CLIP_BYTES]
|
||||
|
||||
# Prepare payload for service
|
||||
_img_b64 = body.get("image_base64") or body.get("screenshot") or ""
|
||||
if not _img_b64 and body.get("images"):
|
||||
try:
|
||||
_imgs = body.get("images")
|
||||
if isinstance(_imgs, list) and _imgs:
|
||||
_img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or ""
|
||||
except Exception:
|
||||
logger.exception("clip_page")
|
||||
clip_data = {
|
||||
"url": url,
|
||||
"title": title[:200],
|
||||
"content": content,
|
||||
"content_type": clip_type,
|
||||
"selection_html": body.get("selection_html") or body.get("selection") or "",
|
||||
"image_base64": _img_b64,
|
||||
"tags": body.get("tags") or [],
|
||||
"target_workspace_id": body.get("target_workspace_id") or body.get("workspace_id"),
|
||||
"target_page_id": body.get("target_page_id") or body.get("parent_page_id"),
|
||||
"metadata": body.get("metadata") or {},
|
||||
}
|
||||
|
||||
try:
|
||||
result = create_page_from_clip(clip_data, user["id"])
|
||||
except Exception as e:
|
||||
logger.exception("clip creation failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to create page") from None
|
||||
|
||||
# Log clip
|
||||
try:
|
||||
log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"])
|
||||
except Exception:
|
||||
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):
|
||||
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):
|
||||
user = _require_user(request)
|
||||
ok = revoke_device(user["id"], device_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
# ── HTML: /extensions download page ──
|
||||
|
||||
@router.get("/extensions", response_class=HTMLResponse)
|
||||
async def extensions_page(request: Request):
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
from app.templating import ENV
|
||||
|
||||
env = ENV
|
||||
try:
|
||||
sidebar = _sidebar_data(request, [])
|
||||
except Exception:
|
||||
sidebar = {}
|
||||
# Simple standalone page reusing base.html
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
)
|
||||
user = _user_from_request(request)
|
||||
# Count for auth user
|
||||
devices = []
|
||||
clips = 0
|
||||
if user:
|
||||
try:
|
||||
devices = list_devices(user["id"])
|
||||
clips = sum(d.get("clips_count", 0) for d in devices)
|
||||
except Exception:
|
||||
logger.exception("extensions_page")
|
||||
content_html = f"""
|
||||
<style>
|
||||
.ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}}
|
||||
.ext-hero{{text-align:center;padding:28px 0 8px;}}
|
||||
.ext-hero h1{{font-size:30px;font-weight:800;margin:0 0 6px;}}
|
||||
.ext-hero p{{color:var(--text-dim);font-size:14px;max-width:560px;margin:0 auto;line-height:1.6;}}
|
||||
.ext-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;margin:28px 0;}}
|
||||
.ext-card{{border:1px solid var(--border);border-radius:12px;padding:18px;background:var(--bg-card);}}
|
||||
.ext-card h3{{font-size:15px;margin:0 0 6px;display:flex;align-items:center;gap:8px;}}
|
||||
.ext-card p{{font-size:12.5px;color:var(--text-dim);line-height:1.5;margin:0 0 10px;}}
|
||||
.ext-card a{{font-size:13px;color:var(--accent);text-decoration:none;}}
|
||||
.ext-card a:hover{{text-decoration:underline;}}
|
||||
.ext-section{{margin:28px 0;}}
|
||||
.ext-section h2{{font-size:18px;font-weight:700;margin:0 0 10px;}}
|
||||
.ext-steps{{counter-reset:step;list-style:none;padding:0;margin:0;}}
|
||||
.ext-steps li{{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);font-size:13px;color:var(--text-dim);}}
|
||||
.ext-steps li::before{{counter-increment:step;content:counter(step);flex:0 0 26px;height:26px;display:flex;align-items:center;justify-content:center;background:var(--accent);color:#fff;border-radius:50%;font-size:12px;font-weight:600;}}
|
||||
.ext-dev-list{{margin-top:12px;}}
|
||||
.ext-dev-item{{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;background:var(--bg-tertiary);}}
|
||||
.ext-badge{{font-size:10px;padding:2px 8px;border-radius:99px;background:rgba(46,160,67,.14);color:#2ea043;font-weight:600;}}
|
||||
</style>
|
||||
<div class="ext-page">
|
||||
<div class="ext-hero">
|
||||
<h1>🧩 FlowDeck Web Clipper</h1>
|
||||
<p>Capture any web page — article, selection, bookmark or screenshot — directly into FlowDeck. Install the browser extension, connect it once, then clip in one click.</p>
|
||||
</div>
|
||||
<div class="ext-grid">
|
||||
<div class="ext-card">
|
||||
<h3>🟢 Chrome / Edge</h3>
|
||||
<p>Manifest V3 — Chrome 88+, Edge 88+.</p>
|
||||
<a href="/static/extension/flowdeck-clipper.zip" download>Download .zip</a> · <span style="font-size:11px;color:var(--text-dim);">load unpacked in chrome://extensions</span>
|
||||
</div>
|
||||
<div class="ext-card">
|
||||
<h3>🟠 Firefox</h3>
|
||||
<p>Firefox 109+ (Manifest V2 compat).</p>
|
||||
<a href="/static/extension/flowdeck-clipper.zip" download>Download .zip</a> · <span style="font-size:11px;color:var(--text-dim);">load temporary add-on</span>
|
||||
</div>
|
||||
<div class="ext-card">
|
||||
<h3>⌨️ Sans extension</h3>
|
||||
<p>API directe — <code>POST /api/v2/web-clipper/clip</code> avec Bearer token.</p>
|
||||
<a href="/help">Docs /help</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ext-section">
|
||||
<h2>How it works</h2>
|
||||
<ol class="ext-steps">
|
||||
<li>Install the extension (.zip) → enable in your browser.</li>
|
||||
<li>Open FlowDeck, go to <b>Settings → Extensions</b> and copy a Bearer token (or the clipper verifies via your session cookie).</li>
|
||||
<li>On any web page, click <b>📌 Clip to FlowDeck</b> (floating button, right-click selection, or extension popup).</li>
|
||||
<li>Choose type: Article (full), Selection, Bookmark or Screenshot — the page is created instantly in your workspace.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="ext-section">
|
||||
<h2>Captures on this account</h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);">{len(devices)} device(s) · {clips} clip(s) total</p>
|
||||
<div class="ext-dev-list">
|
||||
{"".join(f'<div class="ext-dev-item"><span><b>{d.get("device_name") or d.get("extension_name")}</b> <code style="font-size:11px;color:var(--text-dim);">{d.get("device_id")[:24]}</code></span><span><span class="ext-badge">{d.get("clips_count",0)} clips</span> <span style="font-size:11px;color:var(--text-dim);">{d.get("last_clip_at") or ""}</span></span></div>' for d in devices[:10]) or '<p style="font-size:13px;color:var(--text-dim);">No devices yet — clip your first page from the extension to appear here.</p>'}
|
||||
</div>
|
||||
<p style="margin-top:10px;"><a href="/accounts/settings" style="font-size:13px;color:var(--accent);">Manage in Settings → Extensions</a></p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
return HTMLResponse(block_tpl.render(**sidebar, request=request, page_title="Extensions", title_prefix="Extensions", page_icon="🧩", content_html=content_html))
|
||||
@@ -0,0 +1,226 @@
|
||||
"""FlowDeck — Passkeys / WebAuthn (v7.2.0).
|
||||
|
||||
Registration + passwordless login via the ``webauthn`` package (pinned in
|
||||
requirements). Challenges live in a short-lived in-memory store (5 min,
|
||||
single-process — same tradeoff as the SSE rooms). RP ID is derived from the
|
||||
request host. See ``docs/V72_Enterprise_SCIM_2FA.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
router = APIRouter(tags=["webauthn"], prefix="/auth/webauthn")
|
||||
|
||||
# key -> (challenge bytes, expires_at). key = f"reg:{user_id}" | f"login:{login}".
|
||||
_challenges: dict[str, tuple[bytes, float]] = {}
|
||||
_CHALLENGE_TTL = 300.0
|
||||
|
||||
|
||||
def _require_lib():
|
||||
try:
|
||||
import webauthn # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _store_challenge(key: str, challenge: bytes) -> None:
|
||||
_challenges[key] = (challenge, time.time() + _CHALLENGE_TTL)
|
||||
|
||||
|
||||
def _take_challenge(key: str) -> bytes | None:
|
||||
item = _challenges.pop(key, None)
|
||||
if not item:
|
||||
return None
|
||||
challenge, exp = item
|
||||
return challenge if exp > time.time() else None
|
||||
|
||||
|
||||
def _rp(request: Request) -> tuple[str, str]:
|
||||
host = (request.url.hostname or "localhost").split(":")[0]
|
||||
return host, f"{request.url.scheme}://{request.headers.get('host', host)}"
|
||||
|
||||
|
||||
def _session_user(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/register/begin")
|
||||
async def register_begin(request: Request):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import generate_registration_options, options_to_json
|
||||
user = _session_user(request)
|
||||
rp_id, _origin = _rp(request)
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT credential_id FROM webauthn_credentials WHERE user_id=?",
|
||||
(user["id"],)).fetchall()
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
exclude = [PublicKeyCredentialDescriptor(id=_b64url_to_bytes(r["credential_id"]))
|
||||
for r in existing]
|
||||
options = generate_registration_options(
|
||||
rp_id=rp_id, rp_name="FlowDeck", user_name=user.get("login", f"user{user['id']}"),
|
||||
user_id=str(user["id"]).encode(), exclude_credentials=exclude or None)
|
||||
_store_challenge(f"reg:{user['id']}", options.challenge)
|
||||
return JSONResponse(content=__import__("json").loads(options_to_json(options)))
|
||||
|
||||
|
||||
@router.post("/register/finish")
|
||||
async def register_finish(request: Request):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import verify_registration_response
|
||||
user = _session_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
challenge = _take_challenge(f"reg:{user['id']}")
|
||||
if not challenge:
|
||||
raise HTTPException(400, "Challenge expired — begin again")
|
||||
rp_id, origin = _rp(request)
|
||||
try:
|
||||
verified = verify_registration_response(
|
||||
credential=body.get("credential") or {},
|
||||
expected_challenge=challenge, expected_rp_id=rp_id, expected_origin=origin,
|
||||
require_user_verification=False)
|
||||
except Exception as exc: # noqa: BLE001 — invalid attestation → 400, never 500
|
||||
raise HTTPException(400, f"Registration rejected: {exc}") from None
|
||||
import base64
|
||||
cred_id = base64.urlsafe_b64encode(verified.credential_id).decode().rstrip("=")
|
||||
pubkey = base64.b64encode(bytes(verified.credential_public_key)).decode()
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO webauthn_credentials
|
||||
(user_id, credential_id, public_key, sign_count, name)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(user["id"], cred_id, pubkey, verified.sign_count,
|
||||
str(body.get("name") or "Passkey")[:80]))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
raise HTTPException(409, "Credential already registered") from None
|
||||
kid = cur.lastrowid
|
||||
return {"id": kid, "status": "registered"}
|
||||
|
||||
|
||||
@router.post("/login/begin")
|
||||
async def login_begin(request: Request):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import generate_authentication_options, options_to_json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = (body.get("login") or "").strip()
|
||||
if not login:
|
||||
raise HTTPException(400, "login required")
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
|
||||
if not user or not user["is_active"]:
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
creds = conn.execute("SELECT credential_id FROM webauthn_credentials WHERE user_id=?",
|
||||
(user["id"],)).fetchall()
|
||||
if not creds:
|
||||
raise HTTPException(400, "No passkeys for this account")
|
||||
rp_id, _origin = _rp(request)
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
allow = [PublicKeyCredentialDescriptor(id=_b64url_to_bytes(r["credential_id"]))
|
||||
for r in creds]
|
||||
options = generate_authentication_options(rp_id=rp_id, allow_credentials=allow)
|
||||
_store_challenge(f"login:{login}", options.challenge)
|
||||
return JSONResponse(content=__import__("json").loads(options_to_json(options)))
|
||||
|
||||
|
||||
@router.post("/login/finish")
|
||||
async def login_finish(request: Request):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import verify_authentication_response
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = (body.get("login") or "").strip()
|
||||
challenge = _take_challenge(f"login:{login}")
|
||||
if not login or not challenge:
|
||||
raise HTTPException(400, "Challenge expired — begin again")
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
|
||||
if not user or not user["is_active"]:
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
stored = conn.execute("SELECT * FROM webauthn_credentials WHERE user_id=?",
|
||||
(user["id"],)).fetchall()
|
||||
rp_id, origin = _rp(request)
|
||||
credential = body.get("credential") or {}
|
||||
cred_id = (credential.get("id") or "").rstrip("=")
|
||||
match = next((dict(r) for r in stored if r["credential_id"].rstrip("=") == cred_id), None)
|
||||
if not match:
|
||||
raise HTTPException(401, "Unknown credential")
|
||||
import base64
|
||||
try:
|
||||
verified = verify_authentication_response(
|
||||
credential=credential, expected_challenge=challenge,
|
||||
expected_origin=origin, expected_rp_id=rp_id,
|
||||
credential_public_key=base64.b64decode(match["public_key"]),
|
||||
credential_current_sign_count=match["sign_count"],
|
||||
require_user_verification=False)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(401, f"Authentication rejected: {exc}") from None
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE webauthn_credentials SET sign_count=? WHERE id=?",
|
||||
(verified.new_sign_count, match["id"]))
|
||||
conn.execute("UPDATE users SET last_login=? WHERE id=?",
|
||||
(str(time.time()), user["id"]))
|
||||
conn.commit()
|
||||
ud = dict(conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone())
|
||||
session = SessionManager.create_session(ud, request)
|
||||
response = JSONResponse({"status": "ok", "user": {"login": ud["login"]}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True,
|
||||
max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/keys")
|
||||
async def list_keys(request: Request):
|
||||
user = _session_user(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, sign_count, created_at FROM webauthn_credentials"
|
||||
" WHERE user_id=? ORDER BY id", (user["id"],)).fetchall()
|
||||
return {"keys": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.delete("/keys/{key_id}")
|
||||
async def delete_key(key_id: int, request: Request):
|
||||
user = _session_user(request)
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM webauthn_credentials WHERE id=? AND user_id=?",
|
||||
(key_id, user["id"]))
|
||||
conn.commit()
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "Key not found")
|
||||
return {"status": "deleted", "id": key_id}
|
||||
|
||||
|
||||
def _b64url_to_bytes(data: str) -> bytes:
|
||||
import base64
|
||||
padded = data + "=" * (-len(data) % 4)
|
||||
return base64.urlsafe_b64decode(padded)
|
||||
|
||||
|
||||
def reset_challenges() -> None:
|
||||
_challenges.clear()
|
||||
|
||||
|
||||
__all__ = ["router", "reset_challenges", "secrets"]
|
||||
@@ -1,12 +1,12 @@
|
||||
"""FlowDeck — Webhook receiver for real-time Gitea sync."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
@@ -162,7 +162,7 @@ async def register_webhook(owner: str, repo: str, request: Request):
|
||||
return {"status": "ok", "webhook": result}
|
||||
except Exception as e:
|
||||
logger.error("Failed to register webhook: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.get("/status/{owner}/{repo}")
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
"""FlowDeck — teamspaces, verified pages, wiki home, collab polish (v7.3.0).
|
||||
|
||||
Routes under ``/api/v2/wiki`` plus the guest entry point ``/g/{token}``.
|
||||
Design : ``docs/V73_Wiki_Teamspaces_Polish.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from 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")
|
||||
async def list_teamspaces(request: Request):
|
||||
user = _user(request)
|
||||
wid = request.query_params.get("workspace_id")
|
||||
if wid:
|
||||
try:
|
||||
wid = int(wid)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "invalid workspace_id") from None
|
||||
else:
|
||||
wid = None
|
||||
return {"teamspaces": wiki.list_teamspaces(user["id"], wid)}
|
||||
|
||||
|
||||
@router.get("/wiki/teamspaces/{teamspace_id}", response_class=HTMLResponse)
|
||||
async def teamspace_page(teamspace_id: int, request: Request):
|
||||
"""Teamspace detail HTML page — sidebar entry point."""
|
||||
user = _user(request)
|
||||
ts = _teamspace_or_404(teamspace_id, user["id"])
|
||||
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT name FROM workspaces WHERE id=?",
|
||||
(ts["workspace_id"],)).fetchone()
|
||||
pages = wiki.teamspace_pages(teamspace_id)
|
||||
collections = wiki.teamspace_collections(teamspace_id)
|
||||
page_rows = "\n".join(
|
||||
f'<a class="ts-row" href="/pages/{p["id"]}" style="display:flex;align-items:center;gap:8px;'
|
||||
f'padding:8px 10px;border-radius:6px;color:inherit;text-decoration:none;">'
|
||||
f'<span>📄</span><span>{_esc(p["title"] or "Untitled")}</span></a>'
|
||||
for p in pages)
|
||||
coll_rows = "\n".join(
|
||||
f'<a class="ts-row" href="/db/{c["id"]}" style="display:flex;align-items:center;gap:8px;'
|
||||
f'padding:8px 10px;border-radius:6px;color:inherit;text-decoration:none;">'
|
||||
f'<span>{_esc(c["icon"] or "🗄️")}</span><span>{_esc(c["name"] or "Untitled")}</span></a>'
|
||||
for c in collections)
|
||||
content_html = f"""
|
||||
<div style="max-width:860px;margin:0 auto;padding:40px 24px;">
|
||||
<h1 style="font-size:26px;display:flex;align-items:center;gap:10px;">
|
||||
{_esc(ts['name'])}{' <span style="font-size:13px;padding:2px 8px;border-radius:10px;background:rgba(76,154,255,.15);color:#4c9aff;">🔒 private</span>' if ts['private'] else ''}
|
||||
</h1>
|
||||
<p style="color:var(--text-dim);">{_esc(ts.get('description') or '')}</p>
|
||||
<div style="display:flex;gap:10px;font-size:12px;color:var(--text-dim);margin-bottom:24px;flex-wrap:wrap;">
|
||||
<span>Workspace: {_esc((ws["name"]) if ws else '')}</span>
|
||||
<span>·</span><span>Role: {_esc(ts['role'])}</span>
|
||||
<span>·</span><span>{len(pages) + len(collections)} items</span>
|
||||
</div>
|
||||
<h2 style="font-size:16px;margin:20px 0 8px;">Pages</h2>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||
{page_rows or '<p style="color:var(--text-dim);font-size:13px;">No pages yet.</p>'}
|
||||
</div>
|
||||
<h2 style="font-size:16px;margin:24px 0 8px;">Databases</h2>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||
{coll_rows or '<p style="color:var(--text-dim);font-size:13px;">No databases yet.</p>'}
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.ts-row:hover{{background:var(--bg-hover);}}
|
||||
</style>"""
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
env = 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")
|
||||
async def create_teamspace(request: Request):
|
||||
user = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name or len(name) > 120:
|
||||
raise HTTPException(400, "name required (max 120 chars)")
|
||||
wid = int(body.get("workspace_id") or 0)
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (wid,)).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(wid, user["id"])).fetchone()
|
||||
allowed = (ws["owner_id"] == user["id"] or (admin and admin["is_admin"])
|
||||
or (member and member["role"] in ("admin", "editor", "owner")))
|
||||
if not allowed:
|
||||
raise HTTPException(403, "Editor role required in the workspace")
|
||||
try:
|
||||
tsid = wiki.create_teamspace(wid, name, user["id"],
|
||||
description=body.get("description") or "",
|
||||
private=bool(body.get("private")))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from None
|
||||
audit_log(user, "teamspace.create", "teamspace", tsid, name, request)
|
||||
return JSONResponse(status_code=201, content={"id": tsid, "name": name})
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}")
|
||||
async def get_teamspace(teamspace_id: int, request: Request):
|
||||
user = _user(request)
|
||||
ts = _teamspace_or_404(teamspace_id, user["id"])
|
||||
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
|
||||
ts["member_count"] = len(wiki.teamspace_member_ids(teamspace_id))
|
||||
return ts
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}/members")
|
||||
async def list_members(teamspace_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_teamspace_or_404(teamspace_id, user["id"])
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT m.user_id, m.role, u.login, u.full_name FROM teamspace_members m
|
||||
JOIN users u ON u.id = m.user_id WHERE m.teamspace_id=? ORDER BY u.login""",
|
||||
(teamspace_id,)).fetchall()
|
||||
return {"members": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.put("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
|
||||
async def set_member(teamspace_id: int, member_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_teamspace_or_404(teamspace_id, user["id"])
|
||||
if not wiki.can_write_teamspace(user["id"], teamspace_id):
|
||||
raise HTTPException(403, "Editor role required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
role = body.get("role")
|
||||
if role not in wiki.TEAMSPACE_ROLES:
|
||||
raise HTTPException(400, f"role must be one of {', '.join(wiki.TEAMSPACE_ROLES)}")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT 1 FROM users WHERE id=?", (member_id,)).fetchone():
|
||||
raise HTTPException(404, "User not found")
|
||||
conn.execute(
|
||||
"""INSERT INTO teamspace_members (teamspace_id, user_id, role) VALUES (?,?,?)
|
||||
ON CONFLICT(teamspace_id, user_id) DO UPDATE SET role=excluded.role""",
|
||||
(teamspace_id, member_id, role))
|
||||
conn.commit()
|
||||
audit_log(user, "teamspace.member.set", "teamspace", teamspace_id,
|
||||
f"u{member_id}={role}", request)
|
||||
return {"status": "ok", "user_id": member_id, "role": role}
|
||||
|
||||
|
||||
@router.delete("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
|
||||
async def remove_member(teamspace_id: int, member_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_teamspace_or_404(teamspace_id, user["id"])
|
||||
if not wiki.can_write_teamspace(user["id"], teamspace_id):
|
||||
raise HTTPException(403, "Editor role required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM teamspace_members WHERE teamspace_id=? AND user_id=?",
|
||||
(teamspace_id, member_id))
|
||||
conn.commit()
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "Not a member")
|
||||
return {"status": "removed", "user_id": member_id}
|
||||
|
||||
|
||||
# ── verified pages ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/verification")
|
||||
async def get_verification(page_id: int, request: Request):
|
||||
_user(request)
|
||||
_page_or_404(page_id)
|
||||
return {"verification": wiki.verification(page_id)}
|
||||
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/verify")
|
||||
async def verify_page(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
page = _page_or_404(page_id)
|
||||
if not _can_verify(user, page):
|
||||
raise HTTPException(403, "Editor role required to verify a page")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
out = wiki.verify_page(page_id, user["id"],
|
||||
days=body.get("days") or wiki.VERIFICATION_DAYS_DEFAULT,
|
||||
note=body.get("note") or "")
|
||||
audit_log(user, "page.verify", "page", page_id, out.get("expires_at") or "", request)
|
||||
return {"verification": out}
|
||||
|
||||
|
||||
@router.delete("/api/v2/wiki/pages/{page_id}/verify")
|
||||
async def unverify_page(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_page_or_404(page_id)
|
||||
if not wiki.unverify_page(page_id):
|
||||
raise HTTPException(404, "Page is not verified")
|
||||
audit_log(user, "page.unverify", "page", page_id, "", request)
|
||||
return {"status": "unverified", "page_id": page_id}
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/verified")
|
||||
async def list_verified(request: Request):
|
||||
"""Verified (non-expired) pages of a workspace — the ✅ wiki index."""
|
||||
user = _user(request)
|
||||
wid = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT p.id, p.title, p.page_icon, p.teamspace_id,
|
||||
v.verified_at, v.expires_at, v.note, u.login
|
||||
FROM page_verifications v
|
||||
JOIN pages p ON p.id = v.page_id
|
||||
LEFT JOIN users u ON u.id = v.verified_by
|
||||
WHERE p.workspace_id=? AND p.deleted_at IS NULL""",
|
||||
(wid,)).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
item = dict(r)
|
||||
if wiki.is_expired(r):
|
||||
continue # badge lapsed → not listed
|
||||
if item["teamspace_id"] and not wiki.can_read_teamspace(user["id"],
|
||||
item["teamspace_id"]):
|
||||
continue # private teamspace → hidden
|
||||
item["active"] = True
|
||||
out.append(item)
|
||||
return {"pages": out}
|
||||
|
||||
|
||||
# ── follows ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/follow")
|
||||
async def follow_page(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_page_or_404(page_id)
|
||||
now = wiki.toggle_follow(page_id, user["id"])
|
||||
return {"page_id": page_id, "following": now}
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/followers")
|
||||
async def list_followers(page_id: int, request: Request):
|
||||
_user(request)
|
||||
_page_or_404(page_id)
|
||||
ids = wiki.followers(page_id)
|
||||
if not ids:
|
||||
return {"followers": []}
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT id, login, full_name FROM users WHERE id IN ({','.join('?' * len(ids))})",
|
||||
ids).fetchall()
|
||||
return {"followers": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ── comment reactions ──────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/comments/{comment_id}/reactions")
|
||||
async def react(comment_id: int, request: Request):
|
||||
user = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
emoji = (body.get("emoji") or "").strip()
|
||||
if not emoji:
|
||||
raise HTTPException(400, "emoji required")
|
||||
try:
|
||||
counts = wiki.toggle_reaction(comment_id, user["id"], emoji)
|
||||
except LookupError:
|
||||
raise HTTPException(404, "Comment not found") from None
|
||||
return {"comment_id": comment_id, "reactions": counts}
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/comments/{comment_id}/reactions")
|
||||
async def list_reactions(comment_id: int, request: Request):
|
||||
_user(request)
|
||||
return {"comment_id": comment_id, "reactions": wiki.reactions(comment_id)}
|
||||
|
||||
|
||||
# ── guest shares ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/guests")
|
||||
async def create_guest(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
page = _page_or_404(page_id)
|
||||
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
|
||||
raise HTTPException(403, "Editor role required to share")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
try:
|
||||
share = wiki.create_guest_share(page_id, body.get("email") or "",
|
||||
body.get("role") or "viewer",
|
||||
user["id"], days=body.get("days", 30))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
audit_log(user, "page.guest_share", "page", page_id, share["email"], request)
|
||||
return JSONResponse(status_code=201, content={
|
||||
"id": share["id"], "token": share["token"], "role": share["role"],
|
||||
"expires_at": share["expires_at"], "url": f"/g/{share['token']}"})
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/guests")
|
||||
async def list_guests(page_id: int, request: Request):
|
||||
_user(request)
|
||||
_page_or_404(page_id)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, email, role, expires_at, revoked, created_at FROM guest_shares"
|
||||
" WHERE page_id=? ORDER BY id DESC", (page_id,)).fetchall()
|
||||
return {"guests": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.delete("/api/v2/wiki/guests/{share_id}")
|
||||
async def revoke_guest(share_id: int, request: Request):
|
||||
user = _user(request)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT 1 FROM guest_shares WHERE id=?", (share_id,)).fetchone():
|
||||
raise HTTPException(404, "Guest share not found")
|
||||
conn.execute("UPDATE guest_shares SET revoked=1 WHERE id=?", (share_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "page.guest_revoke", "guest_share", share_id, "", request)
|
||||
return {"status": "revoked", "id": share_id}
|
||||
|
||||
|
||||
_GUEST_404 = """<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Link unavailable — FlowDeck</title>
|
||||
<style>body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:520px;
|
||||
margin:80px auto;padding:0 20px;color:#1f2328;text-align:center}
|
||||
h1{font-size:20px} p{color:#656d76;line-height:1.6}</style></head><body>
|
||||
<h1>This link is unavailable</h1>
|
||||
<p>It may have expired, been revoked, or never existed.<br>
|
||||
Ask the person who shared it with you for a new link.</p></body></html>"""
|
||||
|
||||
|
||||
@router.get("/g/{token}", response_class=HTMLResponse)
|
||||
async def guest_page(token: str, request: Request):
|
||||
"""Account-less page access (read-only or commenter). 404 if inactive."""
|
||||
share = wiki.resolve_guest_share(token)
|
||||
if not share:
|
||||
return HTMLResponse(_GUEST_404, status_code=404)
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id, title, content, created_at, updated_at, deleted_at"
|
||||
" FROM pages WHERE id=?", (share["page_id"],)).fetchone()
|
||||
if not page or page["deleted_at"]:
|
||||
return HTMLResponse(_GUEST_404, status_code=404)
|
||||
wiki.record_view(share["page_id"])
|
||||
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{page['title']} — FlowDeck guest</title>
|
||||
<style>body{{font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:760px;
|
||||
margin:40px auto;padding:0 20px;line-height:1.6;color:#1f2328}}
|
||||
.guest-banner{{background:#fff4e5;border:1px solid #ffd8a8;padding:10px 14px;
|
||||
border-radius:8px;margin-bottom:24px;font-size:14px}}
|
||||
pre{{background:#f6f8fa;padding:14px;border-radius:8px;overflow:auto;
|
||||
white-space:pre-wrap;word-break:break-word}}</style></head><body>
|
||||
<div class="guest-banner">You are viewing this page as a guest
|
||||
({share['role']}{' — expires ' + str(share['expires_at']) if share['expires_at'] else ''}).
|
||||
Editing is disabled.</div>
|
||||
<h1>{page['title']}</h1><pre>{page['content'] or ''}</pre></body></html>"""
|
||||
|
||||
|
||||
# ── page views ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/views")
|
||||
async def page_views(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
page = _page_or_404(page_id)
|
||||
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
|
||||
raise HTTPException(403, "Editor role required to read analytics")
|
||||
return wiki.view_stats(page_id, days=request.query_params.get("days", 30))
|
||||
|
||||
|
||||
# ── wiki home ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/home")
|
||||
async def wiki_home(request: Request):
|
||||
"""Aggregated knowledge home: verified pages + recents + teamspaces."""
|
||||
user = _user(request)
|
||||
wid = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
recents = conn.execute(
|
||||
"""SELECT id, title, page_icon, updated_at FROM pages
|
||||
WHERE workspace_id=? AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC LIMIT 20""", (wid,)).fetchall()
|
||||
verified = conn.execute(
|
||||
"""SELECT v.page_id, v.verified_at, v.expires_at FROM page_verifications v
|
||||
JOIN pages p ON p.id = v.page_id
|
||||
WHERE p.workspace_id=? AND p.deleted_at IS NULL
|
||||
AND (v.expires_at IS NULL OR v.expires_at > ?)""",
|
||||
(wid, __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc).replace(microsecond=0).isoformat()),
|
||||
).fetchall()
|
||||
return {"workspace_id": wid,
|
||||
"teamspaces": wiki.list_teamspaces(user["id"], wid),
|
||||
"verified": [dict(r) for r in verified],
|
||||
"recents": [dict(r) for r in recents]}
|
||||
|
||||
|
||||
@router.post("/api/v2/wiki/verify-expiry-sweep")
|
||||
async def sweep_expiry(request: Request):
|
||||
"""Notify verifiers whose ✅ expires within 7 days (idempotent-ish job)."""
|
||||
user = _user(request)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()[0]:
|
||||
raise HTTPException(403, "Admin required")
|
||||
sent = 0
|
||||
for row in wiki.expiring_verifications(days=7):
|
||||
create_notification(row["owner_id"], None, "page.verification_expiring",
|
||||
"Verification expiring soon",
|
||||
f"“{row['title']}” loses its verified badge on {row['expires_at']}.",
|
||||
resource_type="page", resource_id=row["page_id"])
|
||||
sent += 1
|
||||
return {"notified": sent}
|
||||
|
||||
|
||||
# ── blocks (mermaid / equation_inline / progress) ───────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/blocks/preview")
|
||||
async def preview_blocks(request: Request):
|
||||
"""Render v7.3 blocks to HTML (same renderer used by the export pipeline)."""
|
||||
_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
blocks = body.get("blocks")
|
||||
if not isinstance(blocks, list):
|
||||
raise HTTPException(400, "blocks must be a list")
|
||||
if len(blocks) > 200:
|
||||
raise HTTPException(400, "max 200 blocks per preview")
|
||||
from app.services.wiki_blocks import mmdc_available, render_block
|
||||
out = [{"type": b.get("type"), "html": render_block(b)} for b in blocks
|
||||
if isinstance(b, dict) and b.get("type") in ("mermaid", "equation_inline", "progress")]
|
||||
return {"rendered": out, "mmdc_available": mmdc_available()}
|
||||
@@ -0,0 +1,216 @@
|
||||
"""FlowDeck — Workers API (v7.0.0): CRUD, manual run, history, fork, usage."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import workers as worker_service
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
resolve_bearer_token,
|
||||
row_to_dict,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["workers"])
|
||||
|
||||
|
||||
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if sess:
|
||||
return sess
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired API token")
|
||||
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
return user
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
def _row_to_api(row) -> dict:
|
||||
d = row_to_dict(row)
|
||||
d.pop("code_py", None) # code only via ?include_code=1 or owner fetch
|
||||
return d
|
||||
|
||||
|
||||
@router.post("/api/v2/workers")
|
||||
async def create_worker(request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "Untitled worker").strip()[:200]
|
||||
code = body.get("code_py") or ""
|
||||
try:
|
||||
worker_service.validate_code(code)
|
||||
except worker_service.WorkerRejected as exc:
|
||||
raise HTTPException(400, f"code rejected: {exc}") from None
|
||||
slug = worker_service.unique_slug(body.get("slug") or name)
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO workers (slug, workspace_id, name, code_py, schedule_cron,
|
||||
shared, daily_budget_s, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?)""",
|
||||
(slug, body.get("workspace_id"), name, code,
|
||||
(body.get("schedule_cron") or "")[:60],
|
||||
1 if body.get("shared") else 0,
|
||||
max(1, min(int(body.get("daily_budget_s") or 60), 3600)),
|
||||
user["id"]))
|
||||
conn.commit()
|
||||
wid = cur.lastrowid
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (wid,)).fetchone()
|
||||
audit_log(user, "worker.create", "worker", wid, slug, request)
|
||||
return JSONResponse(status_code=201, content={**_row_to_api(row), "code_py": code})
|
||||
|
||||
|
||||
@router.get("/api/v2/workers")
|
||||
async def list_workers(request: Request):
|
||||
_auth_user(request)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM workers").fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM workers ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset)).fetchall()
|
||||
resp = JSONResponse([_row_to_api(r) for r in rows])
|
||||
for k, v in paginate_headers(total).items():
|
||||
resp.headers[k] = v
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/api/v2/workers/{worker_id}")
|
||||
async def get_worker(worker_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
out = _row_to_api(row)
|
||||
if (request.query_params.get("include_code") == "1" or row["created_by"] == user["id"]
|
||||
or user.get("is_admin")):
|
||||
out["code_py"] = row["code_py"]
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/api/v2/workers/{worker_id}")
|
||||
async def update_worker(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
if row["created_by"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Only the owner can update this worker")
|
||||
updates: dict = {}
|
||||
if "name" in body:
|
||||
updates["name"] = str(body["name"] or "")[:200]
|
||||
if "code_py" in body:
|
||||
try:
|
||||
worker_service.validate_code(body["code_py"] or "")
|
||||
except worker_service.WorkerRejected as exc:
|
||||
raise HTTPException(400, f"code rejected: {exc}") from None
|
||||
updates["code_py"] = body["code_py"] or ""
|
||||
if "schedule_cron" in body:
|
||||
updates["schedule_cron"] = str(body["schedule_cron"] or "")[:60]
|
||||
if "shared" in body:
|
||||
updates["shared"] = 1 if body["shared"] else 0
|
||||
if "daily_budget_s" in body:
|
||||
updates["daily_budget_s"] = max(1, min(int(body["daily_budget_s"] or 60), 3600))
|
||||
if "slug" in body and body["slug"] != row["slug"]:
|
||||
if not worker_service._SLUG_RE.match(str(body["slug"] or "")):
|
||||
raise HTTPException(400, "Invalid slug")
|
||||
if conn.execute("SELECT id FROM workers WHERE slug=? AND id!=?",
|
||||
(body["slug"], worker_id)).fetchone():
|
||||
raise HTTPException(409, "Slug already taken")
|
||||
updates["slug"] = body["slug"]
|
||||
if updates:
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
conn.execute(f"UPDATE workers SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(*updates.values(), worker_id))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
audit_log(user, "worker.update", "worker", worker_id, ",".join(updates), request)
|
||||
return _row_to_api(row)
|
||||
|
||||
|
||||
@router.delete("/api/v2/workers/{worker_id}")
|
||||
async def delete_worker(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
if row["created_by"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Only the owner can delete this worker")
|
||||
conn.execute("DELETE FROM workers WHERE id=?", (worker_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "worker.delete", "worker", worker_id, "", request)
|
||||
return {"status": "deleted", "id": worker_id}
|
||||
|
||||
|
||||
@router.post("/api/v2/workers/{worker_id}/run")
|
||||
async def run_worker_endpoint(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
if (row["created_by"] != user["id"] and not row["shared"]
|
||||
and not user.get("is_admin")):
|
||||
raise HTTPException(403, "Worker is private")
|
||||
import asyncio
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
out = await loop.run_in_executor(
|
||||
None, worker_service.run_worker, worker_id, body.get("ctx") or {})
|
||||
except HTTPException:
|
||||
raise
|
||||
audit_log(user, "worker.run", "worker", worker_id, out.get("status", ""), request)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/api/v2/workers/{worker_id}/runs")
|
||||
async def worker_runs(worker_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
limit, _offset = parse_pagination(request, default_limit=20)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM workers WHERE id=?", (worker_id,)).fetchone():
|
||||
raise HTTPException(404, "Worker not found")
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM worker_runs WHERE worker_id=? ORDER BY id DESC LIMIT ?",
|
||||
(worker_id, limit)).fetchall()
|
||||
return {"worker_id": worker_id, "runs": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/v2/workers/{worker_id}/fork")
|
||||
async def fork_worker_endpoint(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
out = worker_service.fork_worker(worker_id, user["id"])
|
||||
audit_log(user, "worker.fork", "worker", worker_id, "", request)
|
||||
return JSONResponse(status_code=201, content=out)
|
||||
|
||||
|
||||
@router.get("/api/v2/workers-usage")
|
||||
async def workers_usage(request: Request):
|
||||
user = _auth_user(request)
|
||||
ws_raw = request.query_params.get("workspace_id")
|
||||
wid = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
|
||||
return {"workspace_id": wid,
|
||||
"used_seconds_today": round(worker_service.daily_usage_s(wid), 2),
|
||||
"user_id": user.get("id")}
|
||||
+276
-23
@@ -2,15 +2,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import html
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
||||
@@ -23,15 +26,36 @@ def _current_user(request: Request) -> dict:
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
|
||||
|
||||
# ── Workspaces ──
|
||||
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
|
||||
|
||||
@router.get("")
|
||||
async def list_workspaces(request: Request):
|
||||
|
||||
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:
|
||||
rows = conn.execute("SELECT * FROM workspaces ORDER BY name").fetchall()
|
||||
return {"workspaces": [dict(r) for r in rows]}
|
||||
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.post("")
|
||||
async def create_workspace(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
@@ -56,6 +80,8 @@ async def create_workspace(request: Request):
|
||||
|
||||
@router.get("/{ws_id}/members")
|
||||
async 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=?",
|
||||
@@ -66,13 +92,14 @@ async def list_members(request: Request, ws_id: int):
|
||||
|
||||
@router.post("/{ws_id}/members")
|
||||
async def add_member(request: Request, ws_id: int):
|
||||
_require_ws_admin(request, ws_id)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
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))
|
||||
@@ -82,6 +109,7 @@ 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):
|
||||
_require_ws_admin(request, ws_id)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
@@ -95,6 +123,7 @@ 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):
|
||||
_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()
|
||||
@@ -128,6 +157,10 @@ async def add_comment(request: Request, page_id: int):
|
||||
cur = conn.execute("INSERT INTO comments (page_id, user_id, body, parent_id) VALUES (?,?,?,?)",
|
||||
(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})
|
||||
except Exception:
|
||||
logger.exception("add_comment")
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@@ -137,11 +170,17 @@ async def update_comment(request: Request, comment_id: int):
|
||||
b = body.get("body")
|
||||
resolved = body.get("resolved")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT page_id, resolved FROM comments WHERE id=?", (comment_id,)).fetchone()
|
||||
if b is not None:
|
||||
conn.execute("UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (b, comment_id))
|
||||
if resolved is not None:
|
||||
conn.execute("UPDATE comments SET resolved=? WHERE id=?", (int(resolved), comment_id))
|
||||
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"]})
|
||||
except Exception:
|
||||
logger.exception("update_comment")
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@@ -204,6 +243,10 @@ async def add_favorite(request: Request):
|
||||
(uid, page_id, collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})
|
||||
except Exception:
|
||||
logger.exception("add_favorite")
|
||||
return {"status": "favorited"}
|
||||
|
||||
|
||||
@@ -230,8 +273,9 @@ async def create_db_template(request: Request):
|
||||
cur = None
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO database_templates (name, description, schema_json) VALUES (?,?,?)",
|
||||
(body.get("name", "Template"), body.get("description", ""), json.dumps(body.get("schema", []))),
|
||||
"INSERT INTO database_templates (name, description, icon, schema_json) VALUES (?,?,?,?)",
|
||||
(body.get("name", "Template"), body.get("description", ""),
|
||||
body.get("icon", "📋"), json.dumps(body.get("schema", []))),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
@@ -239,22 +283,16 @@ async def create_db_template(request: Request):
|
||||
|
||||
@router.post("/templates/database/{tid}/apply")
|
||||
async def apply_db_template(request: Request, tid: int):
|
||||
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()
|
||||
if not tmpl:
|
||||
raise HTTPException(404, "Template not found")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,?)",
|
||||
(name, tmpl["description"], "📋", tmpl["schema_json"]),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)",
|
||||
(cur.lastrowid, "Default View", "table", "{}"),
|
||||
)
|
||||
collection_id = create_from_template(conn, name, dict(tmpl))
|
||||
conn.commit()
|
||||
return {"collection_id": cur.lastrowid, "name": name, "status": "created"}
|
||||
return {"collection_id": collection_id, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/templates/page")
|
||||
@@ -293,6 +331,12 @@ 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", {
|
||||
"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"}
|
||||
|
||||
|
||||
@@ -405,6 +449,188 @@ async def delete_dashboard(request: Request, collection_id: int, did: int):
|
||||
return {"id": did, "status": "deleted"}
|
||||
|
||||
|
||||
# ── v4.5.0: Sprints ──
|
||||
|
||||
@router.get("/collections/{collection_id}/sprints")
|
||||
async def list_sprints(request: Request, collection_id: int):
|
||||
"""List all sprints for a collection."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM sprints WHERE collection_id=? ORDER BY start_date DESC", (collection_id,)
|
||||
).fetchall()
|
||||
sprints = []
|
||||
for r in rows:
|
||||
s = dict(r)
|
||||
# Count pages in sprint
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM sprint_pages WHERE sprint_id=?", (r["id"],)
|
||||
).fetchone()["cnt"]
|
||||
s["page_count"] = count
|
||||
sprints.append(s)
|
||||
return {"sprints": sprints}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints")
|
||||
async def create_sprint(request: Request, collection_id: int):
|
||||
"""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", "")
|
||||
if not name or not start_date or not end_date:
|
||||
raise HTTPException(400, "name, start_date, end_date are required")
|
||||
|
||||
goal = body.get("goal", "")
|
||||
status = body.get("status", "planning")
|
||||
auto_complete = int(body.get("auto_complete", 1))
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO sprints (collection_id, name, start_date, end_date, goal, status, auto_complete) VALUES (?,?,?,?,?,?,?)",
|
||||
(collection_id, name, start_date, end_date, goal, status, auto_complete),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
|
||||
except Exception:
|
||||
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):
|
||||
"""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)
|
||||
).fetchone()
|
||||
if not sprint:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
|
||||
name = body.get("name", sprint["name"])
|
||||
start_date = body.get("start_date", sprint["start_date"])
|
||||
end_date = body.get("end_date", sprint["end_date"])
|
||||
goal = body.get("goal", sprint["goal"])
|
||||
status = body.get("status", sprint["status"])
|
||||
auto_complete = int(body.get("auto_complete", sprint["auto_complete"]))
|
||||
|
||||
conn.execute(
|
||||
"UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=?, auto_complete=? WHERE id=?",
|
||||
(name, start_date, end_date, goal, status, auto_complete, sid),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
|
||||
except Exception:
|
||||
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):
|
||||
"""Delete a sprint."""
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
|
||||
).fetchone()
|
||||
if not sprint:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
conn.execute("DELETE FROM sprints WHERE id=?", (sid,))
|
||||
conn.commit()
|
||||
return {"id": sid, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints/{sid}/assign")
|
||||
async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
|
||||
"""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")
|
||||
|
||||
velocity_points = body.get("velocity_points", 1)
|
||||
status_at_start = body.get("status_at_start", "")
|
||||
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute("SELECT id FROM sprints WHERE id=?", (sid,)).fetchone()
|
||||
if not sprint:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
page = conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO sprint_pages (sprint_id, page_id, status_at_start, velocity_points) VALUES (?,?,?,?)",
|
||||
(sid, page_id, status_at_start, velocity_points),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(409, "Page already assigned to this sprint") from None
|
||||
return {"sprint_id": sid, "page_id": page_id, "status": "assigned"}
|
||||
|
||||
|
||||
@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):
|
||||
"""Remove a page from a sprint."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(404, "Assignment not found")
|
||||
conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id))
|
||||
conn.commit()
|
||||
return {"sprint_id": sid, "page_id": page_id, "status": "removed"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/sprints/burndown/{sid}")
|
||||
async def sprint_burndown(request: Request, collection_id: int, sid: int):
|
||||
"""Calculate burndown data for a sprint."""
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
|
||||
).fetchone()
|
||||
if not sprint:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
|
||||
pages = conn.execute(
|
||||
"""SELECT sp.velocity_points, cp.property_values_json
|
||||
FROM sprint_pages sp JOIN collection_pages cp ON sp.page_id=cp.id
|
||||
WHERE sp.sprint_id=?""", (sid,)
|
||||
).fetchall()
|
||||
|
||||
total_points = sum(p["velocity_points"] for p in pages)
|
||||
completed = 0
|
||||
for p in pages:
|
||||
props = json.loads(p["property_values_json"])
|
||||
for v in props.values():
|
||||
if isinstance(v, str) and v.lower() in ("done", "complete", "completed", "terminé"):
|
||||
completed += p["velocity_points"]
|
||||
break
|
||||
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
start = date.fromisoformat(sprint["start_date"]) if sprint["start_date"] else today
|
||||
end = date.fromisoformat(sprint["end_date"]) if sprint["end_date"] else today
|
||||
total_days = max((end - start).days, 1)
|
||||
elapsed = max((today - start).days, 0)
|
||||
ideal_burn = total_points - (total_points * elapsed / total_days)
|
||||
|
||||
return {
|
||||
"sprint": sprint["name"],
|
||||
"total_points": total_points,
|
||||
"completed_points": completed,
|
||||
"remaining_points": total_points - completed,
|
||||
"ideal_remaining": round(ideal_burn, 1),
|
||||
"start_date": sprint["start_date"],
|
||||
"end_date": sprint["end_date"],
|
||||
"days_elapsed": elapsed,
|
||||
"days_total": total_days,
|
||||
}
|
||||
|
||||
|
||||
# ── CSV Import/Export ──
|
||||
|
||||
@router.post("/collections/{collection_id}/import/csv")
|
||||
@@ -465,6 +691,7 @@ async def export_csv(request: Request, collection_id: int):
|
||||
|
||||
@router.get("/webhooks")
|
||||
async 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]}
|
||||
@@ -472,12 +699,20 @@ async def list_webhooks(request: Request):
|
||||
|
||||
@router.post("/webhooks")
|
||||
async def create_webhook(request: Request):
|
||||
_require_admin(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
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 (?,?,?)",
|
||||
@@ -489,6 +724,7 @@ async def create_webhook(request: Request):
|
||||
|
||||
@router.delete("/webhooks/{wh_id}")
|
||||
async 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()
|
||||
@@ -499,22 +735,39 @@ async def delete_webhook(request: Request, wh_id: int):
|
||||
|
||||
@router.get("/public/{collection_id}")
|
||||
async def public_view(request: Request, collection_id: int):
|
||||
"""Simple public read-only view — no auth required."""
|
||||
"""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>""")
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
"""FlowDeck — AgentEngine: ReAct orchestrator (v4.14.0).
|
||||
|
||||
`objective → comprehension → context → reasoning ↔ action → result`.
|
||||
|
||||
The engine drives the LLM (which only emits tool intentions), gates each call
|
||||
through PermissionManager, executes it via ToolRegistry, journals every action
|
||||
to `agent_actions` with an undo snapshot, and yields a stream of SSE events so
|
||||
the UI can render reasoning + actions live. Since v4.14.0 a freshly created
|
||||
conversation is automatically renamed with a descriptive title derived from its
|
||||
content so the history stays easy to browse.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
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 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 … ? »)
|
||||
- **Pages** : le contenu est organisé en blocs (paragraphes, titres, listes, to-do, tableaux, images, formules, bases embarquées). La barre latérale liste les pages récentes, favoris, agents, partagées et publiées.
|
||||
- **Documents & espaces de travail** : un « document » est une page éditeur (type Notion) qui vit dans un espace de travail. Pour créer un document dans un espace : appelle `read_workspaces` (reprends le `workspace_name` ou l'id exact), puis `create_document`. Pour modifier un document existant : `read_document` puis `write_blocks` (blocs et/ou titre). Pour supprimer : `delete_document` (corbeille). `search_workspace` retrouve aussi les documents et les espaces par titre.
|
||||
- **Format des blocs** (pour `write_blocks`) : chaque bloc est un objet `{"type": "...", "content": "texte"}`. Le champ du texte s'appelle **`content`** (jamais `text`). Un script / code s'écrit dans un bloc `{"type": "code", "content": "...", "language": "powershell"}`. Les titres sont `heading_1`, `heading_2`, `heading_3`, `heading_4`. Autres types : paragraph, bulleted_list, numbered_list, to_do, quote, divider, toggle, callout.
|
||||
- **Collections (bases de données)** : des ensembles de pages structurées avec des propriétés (texte, nombre, sélection, dates…). Chaque collection peut avoir plusieurs vues : tableau, board (kanban), calendrier, galerie, liste, timeline, graphique, formulaire, carte, flux, gantt. Ajouter une propriété ou une vue = outils add_property / create_view.
|
||||
- **Créer du contenu** : « crée une collection X », « crée une page », « ajoute une propriété Statut à la collection Y » sont des actions que l'agent peut exécuter directement avec ses outils.
|
||||
- **Espaces de travail** : FlowDeck gère des espaces locaux et des dépôts Gitea/GitHub (pages privées dans un dépôt, issues reliées via read_gitea_issues). On change d'espace depuis le menu en bas à gauche (« Switch workspace »).
|
||||
- **Recherche** : la commande Ctrl+K / la barre de recherche du haut permet de retrouver pages et collections.
|
||||
- **Corbeille & Bibliothèque** : les pages supprimées vont dans la Corbeille ; Favoris / Récents / Partagés / Publiés se consultent dans la Bibliothèque.
|
||||
- **Réglages** : Paramètres (en bas à gauche → Settings) pour le compte, les notifications, les tags, les intégrations et la section « Agent & IA » (clés API, fournisseurs, modèle global).
|
||||
- **Agent IA** : ouvrable via le bouton 🤖 en bas à droite ou la section « Agents » du sidebar. On peut lui parler de la page ouverte, ou lui poser des questions générales sur l'utilisation de l'application.
|
||||
Quand la question est générale (« comment créer un kanban ? », « où sont mes favoris ? »), réponds de façon concise et guidée à partir de ces informations, sans inventer de fonctionnalités absentes."""
|
||||
|
||||
# Deterministic auto-title heuristics (used when no real LLM is configured, and
|
||||
# as a fallback when the generated title is unusable). Ordered by priority: the
|
||||
# first matching intent wins.
|
||||
_TITLE_INTENTS = (
|
||||
("Création", ("créer", "crée", "crées", "création", "nouveau", "nouvelle",
|
||||
"create", "creation")),
|
||||
("Ajout", ("ajouter", "ajoute", "ajout d", "ajoutons", "add")),
|
||||
("Renommage", ("renommer", "renomme", "renommage", "rename")),
|
||||
("Suppression", ("supprimer", "supprime", "suppression", "delete")),
|
||||
("Déplacement", ("déplacer", "déplace", "déplacement", "move")),
|
||||
("Mise à jour", ("modifier", "modifie", "modification", "mets à jour",
|
||||
"met à jour", "mettre à jour", "update", "éditer")),
|
||||
("Analyse", ("analyser", "analyse")),
|
||||
("Résumé", ("résumer", "résume", "résumé", "resume")),
|
||||
("Traduction", ("traduire", "traduis", "traduit", "traduction", "translate")),
|
||||
("Planification", ("planifier", "planifie", "préparer", "prépare", "organiser",
|
||||
"organise", "sprint", "agenda")),
|
||||
("Recherche", ("chercher", "cherche", "rechercher", "recherche", "trouver",
|
||||
"trouve", "liste", "lister", "search", "find")),
|
||||
)
|
||||
|
||||
_TITLE_TYPES = (
|
||||
("collection", "collection", ("collection", "base de données", "database", "db")),
|
||||
("propriété", "propriété", ("propriété", "property")),
|
||||
("vue", "vue", (" vue", "view")),
|
||||
("board", "board", ("board", "kanban")),
|
||||
("sprint", "sprint", ("sprint",)),
|
||||
("document", "document", ("document", "note de réunion", "compte-rendu", "compte rendu")),
|
||||
("tâche", "tâche", ("tâche", "task", "tache")),
|
||||
("issue", "issue", ("issue",)),
|
||||
)
|
||||
|
||||
|
||||
class AgentEngine:
|
||||
def __init__(self, user_id: int, workspace_id: int | None = None,
|
||||
llm: LLMClient | None = None):
|
||||
self.user_id = user_id
|
||||
self.workspace_id = workspace_id
|
||||
self.llm = llm or LLMClient()
|
||||
self.tools = ToolRegistry()
|
||||
self.ctx = ContextBuilder(user_id, workspace_id)
|
||||
self.perms = PermissionManager(user_id)
|
||||
self._tokens = 0
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _load_agent(self, conversation_id: int) -> dict:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT a.* FROM agents a JOIN agent_conversations c ON c.agent_id=a.id WHERE c.id=?",
|
||||
(conversation_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
row = {"id": None, "name": "FlowDeck Agent", "icon": "🤖", "agent_type": "personal",
|
||||
"system_instructions": "", "model": settings.llm_model,
|
||||
"scope_json": "{}", "approval_mode": "auto"}
|
||||
return dict(row)
|
||||
|
||||
def _build_system_prompt(self, agent: dict, skills=None) -> str:
|
||||
if skills is None:
|
||||
skills = []
|
||||
if isinstance(skills, dict):
|
||||
skills = [skills]
|
||||
lines = [
|
||||
"Tu es FlowDeck Agent, un agent IA qui réalise des tâches dans le workspace FlowDeck.",
|
||||
"Tu réfléchis (reasoning) puis agis en appelant les outils disponibles.",
|
||||
"Appelle UN ou PLUSIEURS outils pour atteindre l'objectif, puis conclus avec une réponse finale.",
|
||||
"N'invente jamais d'IDs : utilise ceux fournis dans le contexte.",
|
||||
f"Workspace courant : {self.workspace_id}.",
|
||||
]
|
||||
if agent.get("system_instructions"):
|
||||
lines.append(f"\nInstructions de l'agent {agent.get('name','')}:\n{agent['system_instructions']}")
|
||||
# Plusieurs skills peuvent être appliqués au même post : chacun injecte
|
||||
# son prompt dans les instructions système.
|
||||
for skill in skills:
|
||||
if skill:
|
||||
lines.append(f"\nSkill appliquée « {skill.get('name','')} »:\n{skill.get('prompt_template','')}")
|
||||
# L'utilisateur peut poser des questions d'aide sans contexte de document ;
|
||||
# le guide intégré permet d'y répondre (aucun outil requis).
|
||||
lines.append("\n" + APP_GUIDE)
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Main run (async generator of SSE events) ──
|
||||
|
||||
async def run(self, conversation_id: int, objective: str, *, model: str | None = None,
|
||||
mentions: list[str] | None = None, files: list[dict] | None = None,
|
||||
skill_id: int | None = None, skill_ids: list[int] | None = None,
|
||||
extra_context: str | None = None):
|
||||
agent = self._load_agent(conversation_id)
|
||||
scope = json.loads(agent.get("scope_json") or "{}")
|
||||
approval_mode = agent.get("approval_mode") or "auto"
|
||||
model = model or agent.get("model") or settings.llm_model
|
||||
|
||||
ids = list(skill_ids or [])
|
||||
if skill_id and skill_id not in ids:
|
||||
ids.append(skill_id)
|
||||
skills = [s for s in (self._load_skill(i, scope) for i in ids) if s]
|
||||
system = self._build_system_prompt(agent, skills)
|
||||
context = self.ctx.build(mentions=mentions, files=files)
|
||||
if extra_context and extra_context.strip():
|
||||
context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip()
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": f"{objective}\n\n# Contexte\n{context}"},
|
||||
]
|
||||
|
||||
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).
|
||||
try:
|
||||
suggested = self._suggest_title(objective, None)
|
||||
if suggested:
|
||||
self._update_conversation(conversation_id, title=suggested[:80])
|
||||
except Exception: # noqa: BLE001 — never break a run because of the title
|
||||
logger.exception("Auto-title failed for conversation #%s", conversation_id)
|
||||
|
||||
tool_schema = self.tools.schema(scope)
|
||||
final_text = None
|
||||
used_model = model or "" # peut être ajusté par un repli de modèle (404/410)
|
||||
|
||||
try:
|
||||
# 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
|
||||
|
||||
response = await asyncio.wait_for(
|
||||
self.llm.complete(messages, model=model, tools=tool_schema, stream=True),
|
||||
timeout=settings.agent_run_timeout_seconds,
|
||||
)
|
||||
self._tokens += response.usage.get("total_tokens", 0) or 0
|
||||
|
||||
if getattr(response, "notice", ""):
|
||||
yield self._event("notice", {"message": response.notice})
|
||||
|
||||
used_model = getattr(response, "model", "") or used_model
|
||||
|
||||
if response.text and response.text.strip():
|
||||
yield self._event("reasoning", {"content": response.text})
|
||||
|
||||
if not response.tool_calls:
|
||||
messages.append({"role": "assistant", "content": response.text or ""})
|
||||
final_text = response.text or self._no_tool_message(response)
|
||||
yield self._event("final", {"content": final_text})
|
||||
break
|
||||
|
||||
# L'API de chat exige que le message assistant qui *annonce* les appels
|
||||
# d'outils porte les `tool_calls` (avec id), puis que chaque résultat
|
||||
# d'outil soit fourni avec le `tool_call_id` correspondant. Sans cela
|
||||
# la passe suivante est refusée par le fournisseur (et l'agent retombait
|
||||
# silencieusement sur le mock hors-ligne).
|
||||
tool_specs = []
|
||||
for idx, call in enumerate(response.tool_calls):
|
||||
call_id = call.get("id") or f"call_{conversation_id}_{idx}_{self._tokens}"
|
||||
tool_specs.append({
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": call["name"],
|
||||
"arguments": call.get("arguments_raw")
|
||||
or json.dumps(call.get("arguments") or {}, ensure_ascii=False),
|
||||
},
|
||||
})
|
||||
assistant_msg = {"role": "assistant", "content": response.text or ""}
|
||||
assistant_msg["tool_calls"] = tool_specs
|
||||
messages.append(assistant_msg)
|
||||
|
||||
for idx, call in enumerate(response.tool_calls):
|
||||
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:
|
||||
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})
|
||||
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": f"Permission refusée: {detail}"}, ensure_ascii=False),
|
||||
})
|
||||
denied = True
|
||||
|
||||
if not denied:
|
||||
result = await self.tools.execute(tool, args, user_id=self.user_id)
|
||||
|
||||
if result.status == "success":
|
||||
yield self._event("action", {
|
||||
"tool": tool, "status": result.status,
|
||||
"target_type": result.target_type, "target_id": result.target_id,
|
||||
"message": result.message,
|
||||
})
|
||||
self._log_action(conversation_id, tool, args, result.data, "success",
|
||||
target_type=result.target_type, target_id=result.target_id,
|
||||
undo=result.undo)
|
||||
messages.append({
|
||||
"role": "tool", "tool_call_id": call_id,
|
||||
"content": json.dumps({"status": "ok", "result": result.data, "target_id": result.target_id}, ensure_ascii=False),
|
||||
})
|
||||
else:
|
||||
yield self._event("action", {"tool": tool, "status": "error", "detail": result.message})
|
||||
self._log_action(conversation_id, tool, args, {}, "error", detail=result.message)
|
||||
messages.append({
|
||||
"role": "tool", "tool_call_id": call_id,
|
||||
"content": json.dumps({"status": "error", "message": result.message}, ensure_ascii=False),
|
||||
})
|
||||
|
||||
if final_text is None:
|
||||
final_text = "Objectif traité. Consultez le journal des actions pour le détail."
|
||||
yield self._event("final", {"content": final_text})
|
||||
|
||||
self._persist_message(conversation_id, "assistant", final_text,
|
||||
model=used_model, tokens=self._tokens)
|
||||
await self._autotitle(conversation_id, objective, final_text)
|
||||
# v6.4.0: emit agent.run.finished (outbound webhooks only).
|
||||
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")
|
||||
|
||||
# ── Skills ──
|
||||
|
||||
def _load_skill(self, skill_id: int, scope: dict | None) -> dict | None:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
skill = dict(row)
|
||||
allowed = json.loads(skill.get("allowed_tools_json") or "[]")
|
||||
if allowed:
|
||||
skill["prompt_template"] = (skill.get("prompt_template") or "") + \
|
||||
"\nOutils autorisés: " + ", ".join(allowed)
|
||||
return skill
|
||||
|
||||
# ── Audit & persistence ──
|
||||
|
||||
def _log_action(self, conversation_id, tool, args, result, status,
|
||||
*, target_type="", target_id=None, undo=None, detail=""):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO agent_actions
|
||||
(conversation_id, tool_name, target_type, target_id, payload_json,
|
||||
result_json, status, undo_snapshot_json, executed_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(conversation_id, tool, target_type,
|
||||
str(target_id) if target_id is not None else None,
|
||||
json.dumps(args, ensure_ascii=False),
|
||||
json.dumps(result, ensure_ascii=False, default=str),
|
||||
status,
|
||||
json.dumps(undo or {}, ensure_ascii=False),
|
||||
self.user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def _persist_message(self, conversation_id, role, content, *, model="", tokens=0):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO agent_messages (conversation_id, role, content, model, tokens_used) VALUES (?,?,?,?,?)",
|
||||
(conversation_id, role, content, model, tokens),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def _update_conversation(self, conversation_id, *, status=None, title=None):
|
||||
with get_conn() as conn:
|
||||
sets, params = ["updated_at=CURRENT_TIMESTAMP"], []
|
||||
if status:
|
||||
sets.append("status=?")
|
||||
params.append(status)
|
||||
if title:
|
||||
sets.append("title=?")
|
||||
params.append(title)
|
||||
params.append(conversation_id)
|
||||
conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
|
||||
# ── Misc ──
|
||||
|
||||
@staticmethod
|
||||
def _event(etype: str, data: dict) -> dict:
|
||||
return {"type": etype, **data}
|
||||
|
||||
@staticmethod
|
||||
def _no_tool_message(response) -> str:
|
||||
return "Je n'ai pas d'action à proposer pour cet objectif. Posez-moi une question plus précise ou demandez-moi de créer un élément."
|
||||
|
||||
@staticmethod
|
||||
def _exc_detail(exc: Exception) -> str:
|
||||
detail = getattr(exc, "detail", None)
|
||||
return detail if isinstance(detail, str) else str(exc)
|
||||
|
||||
# ── Auto-title (v4.14.0) ──
|
||||
|
||||
async def _autotitle(self, conversation_id: int, objective: str, final_text: str | None):
|
||||
"""Rename the conversation with a descriptive title derived from the
|
||||
*latest* user request. Runs after every AI call so the history list
|
||||
always reflects the current topic and stays easy to browse."""
|
||||
try:
|
||||
suggested = self._suggest_title(objective, final_text)
|
||||
if suggested:
|
||||
self._update_conversation(conversation_id, title=suggested[:80])
|
||||
except Exception: # noqa: BLE001 — never break a run because of the title
|
||||
logger.exception("Auto-title failed for conversation #%s", conversation_id)
|
||||
|
||||
@classmethod
|
||||
def _suggest_title(cls, objective: str | None, final_text: str | None) -> str:
|
||||
"""Produce a short descriptive title from the user objective (offline-safe)."""
|
||||
text = (objective or "").split("\n# Contexte", 1)[0].strip() or (final_text or "").strip()
|
||||
if not text:
|
||||
return "Conversation"
|
||||
# Strip the composer prefixes ("Contexte « X »", "Skill « Y »") that the
|
||||
# frontend prepends before the real user text.
|
||||
text = re.sub(
|
||||
r"(?:Contexte\s*«[^»]*»|Skill\s*«[^»]*»|Skill\s+«[^»]*»)(?:\s*[,;\n.])+\s*",
|
||||
"", text,
|
||||
).strip()
|
||||
if not text:
|
||||
return "Conversation"
|
||||
low = text.lower()
|
||||
|
||||
intent = None
|
||||
for label, words in _TITLE_INTENTS:
|
||||
if any(w in low for w in words):
|
||||
intent = label
|
||||
break
|
||||
|
||||
type_label = next(
|
||||
(t for t, _noun, words in _TITLE_TYPES if any(w in low for w in words)), None
|
||||
)
|
||||
has_workspace = any(w in low for w in ("workspace", "espace de travail"))
|
||||
quotes = [q.strip() for q in re.findall(r'[«"]([^«»"]{1,80})[»"]', text) if q.strip()]
|
||||
subject = quotes[0] if quotes else None
|
||||
ws = quotes[-1] if (has_workspace and len(quotes) > 1) else None
|
||||
|
||||
def _clean(s: str) -> str:
|
||||
return re.sub(r"\s+", " ", s).strip(" .;:-")
|
||||
|
||||
if not subject and type_label:
|
||||
noun = next((n for t, n, _w in _TITLE_TYPES if t == type_label), type_label)
|
||||
m = re.search(
|
||||
rf"\b{noun}\b\s*(?:nomm[ée]e?\s+|appel[ée]e?\s+|intitul[ée]e?\s+)?"
|
||||
r'[«"]?\s*([A-Za-zÀ-ÿ0-9][A-Za-zÀ-ÿ0-9_ \-]{1,60}?)\s*[»"]?',
|
||||
text, re.IGNORECASE,
|
||||
)
|
||||
if m:
|
||||
subject = m.group(1).strip()
|
||||
|
||||
if intent and subject:
|
||||
core = f"{intent} {type_label or 'élément'} « {subject} »" \
|
||||
if type_label else f"{intent} « {subject} »"
|
||||
if ws:
|
||||
core += f" (dans {ws})"
|
||||
return _clean(core)
|
||||
|
||||
generic = _clean(text)
|
||||
return generic[:70] if generic else "Conversation"
|
||||
|
||||
|
||||
def undo_action(action_id: int) -> bool:
|
||||
"""Reverse a single agent action using its stored undo snapshot.
|
||||
|
||||
Returns True on success. Marks the action row `reverted`.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
action = conn.execute("SELECT * FROM agent_actions WHERE id=?", (action_id,)).fetchone()
|
||||
if not action:
|
||||
raise ValueError(f"Action #{action_id} introuvable")
|
||||
undo = json.loads(action["undo_snapshot_json"] or "{}")
|
||||
op, table, rid = undo.get("action"), undo.get("table"), undo.get("id")
|
||||
if not op or not table or rid is None:
|
||||
raise ValueError(f"Action #{action_id} n'a pas de snapshot annulable")
|
||||
|
||||
if op == "delete":
|
||||
conn.execute(f"DELETE FROM {table} WHERE id=?", (rid,))
|
||||
elif op == "softdelete":
|
||||
conn.execute(f"UPDATE {table} SET deleted_at=NULL WHERE id=?", (rid,))
|
||||
elif op == "insert":
|
||||
snapshot = undo.get("snapshot")
|
||||
if not snapshot:
|
||||
raise ValueError("Snapshot manquant pour insert")
|
||||
cols = ", ".join(snapshot.keys())
|
||||
ph = ", ".join("?" for _ in snapshot)
|
||||
conn.execute(f"INSERT INTO {table} ({cols}) VALUES ({ph})", list(snapshot.values()))
|
||||
elif op == "update":
|
||||
snapshot = undo.get("snapshot")
|
||||
if table == "collection_pages":
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(snapshot, ensure_ascii=False), undo.get("title", ""), rid),
|
||||
)
|
||||
elif table == "pages":
|
||||
if isinstance(snapshot, dict):
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(snapshot.get("content", ""),
|
||||
snapshot.get("content_format", "markdown"),
|
||||
snapshot.get("title", ""), rid),
|
||||
)
|
||||
else:
|
||||
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(snapshot, rid))
|
||||
else:
|
||||
raise ValueError(f"Table non gérée pour rollback: {table}")
|
||||
else:
|
||||
raise ValueError(f"Opération de rollback inconnue: {op}")
|
||||
|
||||
conn.execute("UPDATE agent_actions SET status='reverted' WHERE id=?", (action_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
@@ -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())
|
||||
@@ -0,0 +1,330 @@
|
||||
"""FlowDeck — AI Writing Assist (v5.9.0).
|
||||
|
||||
Headless, tool-free writing helpers used by the editor (slash commands,
|
||||
inline autocomplete) and the database table (AI property suggestions).
|
||||
|
||||
All actions share one entry point, :meth:`AIWritingService.run`, which builds a
|
||||
tight prompt, calls the configured LLM (or the deterministic offline mock) and
|
||||
returns plain Markdown. `properties` additionally returns a structured
|
||||
``suggestions`` mapping so the caller can fill collection properties.
|
||||
|
||||
The service never talks to the DB directly — the router resolves the caller's
|
||||
provider/key and the page context before delegating here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.services.llm_client import LLMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WRITING_ACTIONS = ("write", "summarize", "translate", "continue", "autocomplete", "properties")
|
||||
|
||||
_MAX_CONTEXT = 20000
|
||||
|
||||
# Property name → (type, offline default) used by the deterministic fallback so
|
||||
# the feature stays useful without a connected provider.
|
||||
_OFFLINE_PROPERTY_DEFAULTS = (
|
||||
(("status", "état", "etat", "stage"), "select", "To do"),
|
||||
(("priority", "priorité", "priorite"), "select", "Medium"),
|
||||
(("done", "terminé", "termine", "complété", "complete"), "checkbox", False),
|
||||
(("summary", "résumé", "resume", "description", "notes"), "text", ""),
|
||||
)
|
||||
|
||||
_SYSTEM_WRITING = (
|
||||
"Tu es l'assistant d'écriture de FlowDeck. "
|
||||
"Réponds UNIQUEMENT avec le contenu demandé, en Markdown léger "
|
||||
"(paragraphes, listes à puces, titres si utile). "
|
||||
"N'ajoute aucun préambule, aucun commentaire, aucun bloc de code autour du texte."
|
||||
)
|
||||
|
||||
|
||||
class AIWritingService:
|
||||
"""Deterministic, provider-agnostic writing assistant."""
|
||||
|
||||
def __init__(self, user_id: int | None = None, provider: str | None = None,
|
||||
model: str | None = None, api_key: str | None = None,
|
||||
api_base: str | None = None):
|
||||
self.user_id = user_id
|
||||
self.provider = (provider or "").strip().lower() or None
|
||||
self.model = (model or "").strip() or None
|
||||
self._api_key = api_key
|
||||
self._api_base = api_base
|
||||
|
||||
# ── LLM plumbing ──
|
||||
|
||||
def _client(self) -> LLMClient:
|
||||
provider = self.provider
|
||||
api_key = self._api_key
|
||||
api_base = self._api_base
|
||||
if provider and self.user_id:
|
||||
try:
|
||||
from app.services.llm_config import get_user_llm_key
|
||||
row = get_user_llm_key(self.user_id, provider)
|
||||
if row and row.get("api_key"):
|
||||
api_key = row["api_key"]
|
||||
api_base = (row.get("api_base") or "").strip() or api_base
|
||||
except Exception: # noqa: BLE001 — never fail on key lookup
|
||||
pass
|
||||
return LLMClient(provider=provider, api_key=api_key, api_base=api_base)
|
||||
|
||||
async def _complete(self, prompt: str, context: str = "") -> tuple[str, str, bool]:
|
||||
llm = self._client()
|
||||
offline = llm.provider == "offline" or not llm._has_credentials()
|
||||
user_content = prompt
|
||||
if context and context.strip():
|
||||
user_content += "\n\n# Contexte\n" + context.strip()[:_MAX_CONTEXT]
|
||||
messages = [
|
||||
{"role": "system", "content": _SYSTEM_WRITING},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
resp = await llm.complete(messages, model=self.model, tools=None, stream=False)
|
||||
return (resp.text or "").strip(), (resp.model or self.model or ""), offline
|
||||
|
||||
# ── Public API ──
|
||||
|
||||
async def run(self, action: str, *, prompt: str = "", context: str = "",
|
||||
target_language: str = "English", prefix: str = "",
|
||||
title: str = "", properties: list | None = None) -> dict:
|
||||
action = (action or "").strip().lower()
|
||||
if action not in WRITING_ACTIONS:
|
||||
raise ValueError(f"Action inconnue: {action or '(vide)'}")
|
||||
|
||||
if action == "properties":
|
||||
suggestions = await self.suggest_properties(
|
||||
context=context, title=title, properties=properties or [])
|
||||
return {"ok": True, "action": action, "text": "", "suggestions": suggestions,
|
||||
"model": self.model or "", "offline": self._offline_hint()}
|
||||
|
||||
prompt_text = self._build_prompt(
|
||||
action, prompt=prompt, context=context,
|
||||
target_language=target_language, prefix=prefix, title=title)
|
||||
|
||||
# No connected provider → deterministic, dependency-free output (the raw
|
||||
# offline planner echoes the prompt, which is wrong for continue/autocomplete).
|
||||
if self._offline_hint():
|
||||
return {"ok": True, "action": action, "model": "",
|
||||
"offline": True,
|
||||
"text": self._offline_text(action, prompt=prompt, context=context,
|
||||
target_language=target_language,
|
||||
prefix=prefix, title=title)}
|
||||
try:
|
||||
text, model, offline = await self._complete(prompt_text, context=context)
|
||||
except Exception as exc: # noqa: BLE001 — surface provider errors to the UI
|
||||
logger.warning("AI writing '%s' failed: %s", action, exc)
|
||||
return {"ok": False, "action": action, "error": str(exc),
|
||||
"text": "", "model": self.model or "", "offline": False}
|
||||
if not text:
|
||||
text = self._offline_text(action, prompt=prompt, context=context,
|
||||
target_language=target_language,
|
||||
prefix=prefix, title=title)
|
||||
offline = True
|
||||
return {"ok": True, "action": action, "text": text,
|
||||
"model": model, "offline": offline}
|
||||
|
||||
# ── Prompt building ──
|
||||
|
||||
def _build_prompt(self, action: str, *, prompt: str, context: str,
|
||||
target_language: str, prefix: str, title: str) -> str:
|
||||
if action == "write":
|
||||
subject = (prompt or title or "ce document").strip()
|
||||
return (f"Rédige le contenu demandé : {subject}. "
|
||||
"Fournis un texte structuré et directement utilisable.")
|
||||
if action == "summarize":
|
||||
return ("Résume le contenu fourni de façon structurée et concise : "
|
||||
"un court paragraphe d'introduction puis 3 à 5 points clés à puces.")
|
||||
if action == "translate":
|
||||
lang = (target_language or "English").strip()
|
||||
return (f"Traduis l'intégralité du contenu fourni en {lang}, "
|
||||
"en conservant fidèlement sa structure (titres, listes, paragraphes). "
|
||||
"Ne traduis pas les noms propres et les termes techniques.")
|
||||
if action == "continue":
|
||||
return ("Poursuis naturellement le texte fourni. "
|
||||
"Écris un à trois paragraphes cohérents avec le style et le sujet, "
|
||||
"sans répéter ce qui précède et sans introduction.")
|
||||
if action == "autocomplete":
|
||||
return (f"Complète la phrase en cours par une suite courte et pertinente "
|
||||
f"(maximum 20 mots). Ne répète pas le texte déjà écrit, ne mets "
|
||||
f"aucun préambule. Texte en cours : {prefix!r}")
|
||||
return prompt
|
||||
|
||||
# ── Offline deterministic fallbacks ──
|
||||
|
||||
def _offline_hint(self) -> bool:
|
||||
try:
|
||||
llm = self._client()
|
||||
return llm.provider == "offline" or not llm._has_credentials()
|
||||
except Exception: # noqa: BLE001
|
||||
return True
|
||||
|
||||
def _offline_text(self, action: str, *, prompt: str, context: str,
|
||||
target_language: str, prefix: str, title: str) -> str:
|
||||
if action == "summarize":
|
||||
return self._offline_summary(context)
|
||||
if action == "translate":
|
||||
return (f"⚠️ **Traduction hors-ligne indisponible** — aucun modèle d'IA connecté.\n\n"
|
||||
f"Connectez un fournisseur dans **Paramètres → Agent & IA** pour traduire "
|
||||
f"ce document en {target_language or 'English'}.")
|
||||
if action == "autocomplete":
|
||||
return self._offline_autocomplete(prefix)
|
||||
if action == "continue":
|
||||
return ("Suite du contenu : développez ici le point précédent avec un exemple "
|
||||
"concret, puis ouvrez la prochaine idée en une phrase de transition.")
|
||||
subject = (prompt or title or "ce document").strip()
|
||||
return (f"## {subject}\n"
|
||||
"\n"
|
||||
"Présentation générale du sujet : objectif, contexte et public visé en "
|
||||
"quelques phrases. (Contenu généré hors-ligne — connectez une clé API "
|
||||
"pour une rédaction complète.)\n"
|
||||
"\n"
|
||||
"## Points clés\n"
|
||||
"• Idée principale 1 et son argument.\n"
|
||||
"• Idée principale 2 avec un exemple concret.\n"
|
||||
"\n"
|
||||
"## Prochaines étapes\n"
|
||||
"• Relire, compléter et mettre en forme ce contenu.")
|
||||
|
||||
@staticmethod
|
||||
def _offline_summary(context: str) -> str:
|
||||
text = (context or "").strip()
|
||||
if not text:
|
||||
return "Résumé : aucun contenu fourni à résumer."
|
||||
headings = re.findall(r"^#{1,4}\s+(.+)$", text, flags=re.MULTILINE)
|
||||
sentences = re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text))
|
||||
lead = next((s.strip() for s in sentences if len(s.strip()) > 40), sentences[0].strip())
|
||||
out = ["**Résumé**", "", lead[:400], ""]
|
||||
bullets = []
|
||||
if headings:
|
||||
bullets = [f"• {h.strip()}" for h in headings[:5]]
|
||||
else:
|
||||
for s in sentences[1:6]:
|
||||
s = s.strip()
|
||||
if len(s) > 30:
|
||||
bullets.append(f"• {s[:180]}")
|
||||
if bullets:
|
||||
out.append("**Points clés**")
|
||||
out.extend(bullets)
|
||||
return "\n".join(out)
|
||||
|
||||
@staticmethod
|
||||
def _offline_autocomplete(prefix: str) -> str:
|
||||
prefix = (prefix or "").strip()
|
||||
if len(prefix) < 8:
|
||||
return ""
|
||||
return " Cette section détaille les points clés à retenir."
|
||||
|
||||
# ── AI properties ──
|
||||
|
||||
async def suggest_properties(self, *, context: str = "", title: str = "",
|
||||
properties: list | None = None) -> dict:
|
||||
"""Return ``{property_name: value}`` suggestions for a collection page.
|
||||
|
||||
``properties`` is a list of ``{name, type}`` dicts. With a connected
|
||||
provider the model is asked for a JSON object; offline we derive
|
||||
deterministic defaults from the property names so the UI stays useful.
|
||||
"""
|
||||
properties = properties or []
|
||||
if not properties:
|
||||
return {}
|
||||
names = [str(p.get("name", "")).strip() for p in properties if isinstance(p, dict)]
|
||||
names = [n for n in names if n]
|
||||
|
||||
llm = self._client()
|
||||
offline = llm.provider == "offline" or not llm._has_credentials()
|
||||
if not offline:
|
||||
schema = {str(p.get("name")): str(p.get("type", "text")) for p in properties if isinstance(p, dict)}
|
||||
prompt = (
|
||||
"À partir du titre et du contenu du document, propose une valeur pour "
|
||||
"chaque propriété. Réponds STRICTEMENT par un objet JSON "
|
||||
"{\"nom_propriété\": valeur} sans texte autour.\n"
|
||||
f"Propriétés attendues : {json.dumps(schema, ensure_ascii=False)}\n"
|
||||
f"Titre : {title or '(sans titre)'}"
|
||||
)
|
||||
try:
|
||||
text, _, _ = await self._complete(prompt, context=context)
|
||||
parsed = self._parse_json_object(text)
|
||||
if parsed:
|
||||
return self._coerce_suggestions(parsed, properties)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("AI properties failed: %s", exc)
|
||||
# fall through to deterministic defaults
|
||||
return self._offline_suggestions(title, context, properties)
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_object(text: str) -> dict:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return {}
|
||||
m = re.search(r"\{.*\}", text, flags=re.DOTALL)
|
||||
if not m:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(m.group(0))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def _coerce_suggestions(self, parsed: dict, properties: list) -> dict:
|
||||
out: dict = {}
|
||||
by_name = {str(p.get("name", "")).strip().lower(): p for p in properties if isinstance(p, dict)}
|
||||
for key, value in parsed.items():
|
||||
prop = by_name.get(str(key).strip().lower())
|
||||
if not prop:
|
||||
continue
|
||||
out[str(prop.get("name"))] = self._coerce_value(value, prop.get("type", "text"))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _coerce_value(value, prop_type: str):
|
||||
ptype = (prop_type or "text").lower()
|
||||
if ptype == "checkbox":
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "oui", "vrai", "x")
|
||||
if ptype == "number":
|
||||
try:
|
||||
num = float(value)
|
||||
return int(num) if num.is_integer() else num
|
||||
except (TypeError, ValueError):
|
||||
return value
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _offline_suggestions(title: str, context: str, properties: list) -> dict:
|
||||
out: dict = {}
|
||||
summary_text = ""
|
||||
if context:
|
||||
summary_text = re.sub(r"\s+", " ", context).strip()
|
||||
first = re.split(r"(?<=[.!?])\s+", summary_text)
|
||||
summary_text = next((s for s in first if len(s) > 40), summary_text)[:180]
|
||||
for prop in properties:
|
||||
if not isinstance(prop, dict):
|
||||
continue
|
||||
name = str(prop.get("name", "")).strip()
|
||||
if not name:
|
||||
continue
|
||||
low = name.lower()
|
||||
ptype = (prop.get("type") or "text").lower()
|
||||
matched = False
|
||||
for keys, _ptype, default in _OFFLINE_PROPERTY_DEFAULTS:
|
||||
if any(k in low for k in keys):
|
||||
if "summary" in keys or "résumé" in keys or "resume" in keys or "description" in keys or "notes" in keys:
|
||||
out[name] = summary_text or (title or "")
|
||||
else:
|
||||
out[name] = default
|
||||
matched = True
|
||||
break
|
||||
if not matched and ptype in ("text", "title"):
|
||||
if "name" in low or "titre" in low or "title" in low:
|
||||
out[name] = title or ""
|
||||
return out
|
||||
|
||||
|
||||
async def run_action(action: str, **kwargs) -> dict:
|
||||
"""Module-level convenience wrapper (used by tests and simple callers)."""
|
||||
return await AIWritingService().run(action, **kwargs)
|
||||
@@ -0,0 +1,313 @@
|
||||
"""FlowDeck — helpers for API v2 (v6.3.0).
|
||||
|
||||
Pagination, ISO-8601, RFC7807 errors, hierarchical scopes, Bearer auth.
|
||||
No duplication: thin wrappers over existing services.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Header, HTTPException, Request
|
||||
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:
|
||||
if not value:
|
||||
return None
|
||||
# SQLite stores "YYYY-MM-DD HH:MM:SS" or with T; convert to UTC Z
|
||||
try:
|
||||
# try with seconds
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S.%f"):
|
||||
try:
|
||||
dt = datetime.strptime(value[:19], fmt[:8] if "." in value else fmt)
|
||||
# SQLite has no tz => assume UTC
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.isoformat().replace("+00:00", "Z")
|
||||
except ValueError:
|
||||
continue
|
||||
# fallback: if already ISO with T/Z, return as-is
|
||||
if "T" in value:
|
||||
return value
|
||||
return value
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
def row_to_dict(row, *, iso_fields: tuple[str, ...] = ("created_at", "updated_at", "created_at_ts", "last_login", "joined_at", "accessed_at", "fired_at", "start_date", "end_date", "logged_at", "last_seen_at", "last_used_at", "verified_at", "last_login_at")) -> dict:
|
||||
if row is None:
|
||||
return {}
|
||||
d = dict(row)
|
||||
for k in list(d.keys()):
|
||||
if k in iso_fields and d[k]:
|
||||
iso = to_iso8601(str(d[k]))
|
||||
if iso:
|
||||
d[k] = iso
|
||||
# parse *_json columns
|
||||
if k.endswith("_json") and isinstance(d[k], str):
|
||||
try:
|
||||
d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k])
|
||||
except Exception:
|
||||
logger.exception("row_to_dict")
|
||||
return d
|
||||
|
||||
# ── Pagination ────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_pagination(request: Request, default_limit: int = 30, max_limit: int = 100) -> tuple[int, int]:
|
||||
try:
|
||||
limit = int(request.query_params.get("limit", str(default_limit)))
|
||||
except ValueError:
|
||||
limit = default_limit
|
||||
try:
|
||||
offset = int(request.query_params.get("offset", "0"))
|
||||
except ValueError:
|
||||
offset = 0
|
||||
limit = max(1, min(limit, max_limit))
|
||||
offset = max(0, offset)
|
||||
return limit, offset
|
||||
|
||||
def paginate_headers(total: int) -> dict[str, str]:
|
||||
return {"X-Total-Count": str(total)}
|
||||
|
||||
# ── Scopes (hierarchical: read < write < admin) ──────────────────────────
|
||||
|
||||
SCOPE_RANK = {"read": 1, "write": 2, "admin": 3}
|
||||
VALID_SCOPES = set(SCOPE_RANK.keys())
|
||||
|
||||
def normalize_scopes(raw: str | None) -> set[str]:
|
||||
if not raw:
|
||||
return set()
|
||||
parts = [p.strip().lower() for p in raw.split(",") if p.strip()]
|
||||
return {p for p in parts if p in VALID_SCOPES}
|
||||
|
||||
def has_scope(token_scopes: str | None, required: str) -> bool:
|
||||
req_rank = SCOPE_RANK.get(required, 99)
|
||||
# token with higher rank satisfies lower requirement
|
||||
# admin => write => read
|
||||
token_set = normalize_scopes(token_scopes)
|
||||
if not token_set:
|
||||
return False
|
||||
# effective rank = max rank among token scopes
|
||||
eff = max((SCOPE_RANK.get(s, 0) for s in token_set), default=0)
|
||||
return eff >= req_rank
|
||||
|
||||
def validate_scopes_input(scopes_raw: str | None) -> str:
|
||||
if not scopes_raw:
|
||||
return "read"
|
||||
parts = [p.strip().lower() for p in scopes_raw.split(",") if p.strip()]
|
||||
for p in parts:
|
||||
if p not in VALID_SCOPES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid scope: {p}. Valid: read, write, admin")
|
||||
if not parts:
|
||||
return "read"
|
||||
# dedup preserve order
|
||||
seen = []
|
||||
for p in parts:
|
||||
if p not in seen:
|
||||
seen.append(p)
|
||||
return ",".join(seen)
|
||||
|
||||
# ── Bearer auth (unified) ─────────────────────────────────────────────────
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
def resolve_bearer_token(token: str) -> dict | None:
|
||||
"""Resolve Bearer token to user dict. Returns None if invalid/expired/revoked.
|
||||
Supports api_tokens (hashed), extension_devices (hashed), and legacy user_tokens (plain).
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
# dev-only fallback
|
||||
if token == "fd-public-key":
|
||||
if not settings.public_api_insecure_ok:
|
||||
return None
|
||||
# return a synthetic admin-like user? Use first admin or id 1
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE is_admin=1 ORDER BY id LIMIT 1").fetchone()
|
||||
if row:
|
||||
d = dict(row)
|
||||
d["_token_id"] = None
|
||||
d["_token_scopes"] = "read,write,admin"
|
||||
d["_token_hash"] = None
|
||||
return d
|
||||
row = conn.execute("SELECT id, login, full_name, email, is_admin FROM users ORDER BY id LIMIT 1").fetchone()
|
||||
if row:
|
||||
d = dict(row)
|
||||
d["_token_id"] = None
|
||||
d["_token_scopes"] = "read,write,admin"
|
||||
d["_token_hash"] = None
|
||||
return d
|
||||
return None
|
||||
th = _hash_token(token)
|
||||
with get_conn() as conn:
|
||||
# 1) api_tokens
|
||||
row = conn.execute("SELECT id, user_id, scopes, expires_at, revoked FROM api_tokens WHERE token_hash=?", (th,)).fetchone()
|
||||
if row:
|
||||
if row["revoked"]:
|
||||
return None
|
||||
exp = row["expires_at"]
|
||||
if exp:
|
||||
try:
|
||||
# compare as timestamp; SQLite format "YYYY-MM-DD HH:MM:SS"
|
||||
# parse to epoch
|
||||
dt = datetime.fromisoformat(str(exp).replace("Z", "+00:00")) if "T" in str(exp) else datetime.strptime(str(exp)[:19], "%Y-%m-%d %H:%M:%S")
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
if dt.timestamp() < time.time():
|
||||
return None
|
||||
except Exception:
|
||||
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)
|
||||
d["_token_id"] = row["id"]
|
||||
d["_token_scopes"] = row["scopes"] or "read,write"
|
||||
d["_token_hash"] = th
|
||||
# touch last_used_at best-effort
|
||||
try:
|
||||
conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
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()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
d = dict(u)
|
||||
d["_token_id"] = None
|
||||
d["_token_scopes"] = row["scopes"] or "read,write"
|
||||
d["_token_hash"] = th
|
||||
return d
|
||||
# 3) legacy user_tokens (plain storage)
|
||||
row = conn.execute("SELECT gitea_user_id FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["gitea_user_id"],)).fetchone()
|
||||
if u:
|
||||
d = dict(u)
|
||||
d["_token_id"] = None
|
||||
d["_token_scopes"] = "read,write"
|
||||
d["_token_hash"] = th
|
||||
return d
|
||||
return None
|
||||
|
||||
def get_bearer_user(request: Request, authorization: str | None = Header(default=None)) -> dict:
|
||||
# Prefer explicit Authorization header, fallback to lowercase
|
||||
auth = authorization or request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if not auth or not auth.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="API token required. Use Authorization: Bearer <token>")
|
||||
token = auth[7:].strip()
|
||||
user = resolve_bearer_token(token)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired API token")
|
||||
return user
|
||||
|
||||
def require_scope(required: str):
|
||||
def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict:
|
||||
user = get_bearer_user(request, authorization)
|
||||
scopes = user.get("_token_scopes") or "read"
|
||||
if not has_scope(scopes, required):
|
||||
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {required}, token scopes: {scopes}")
|
||||
return user
|
||||
return _dep
|
||||
|
||||
# ── RFC 7807 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def problem_response(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
title_map = {
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "Not Found",
|
||||
409: "Conflict",
|
||||
422: "Unprocessable Entity",
|
||||
429: "Too Many Requests",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
status = exc.status_code
|
||||
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
|
||||
body = {
|
||||
"type": f"https://flowdeck/api/errors/{status}",
|
||||
"title": title_map.get(status, "Error"),
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
"instance": str(request.url.path),
|
||||
}
|
||||
return JSONResponse(status_code=status, content=body, media_type="application/problem+json")
|
||||
|
||||
# ── Audit ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def audit_log(user: dict, action: str, resource_type: str = "", resource_id: str | int = "", detail: str = "", request: Request | None = None) -> None:
|
||||
try:
|
||||
ip = ""
|
||||
if request and request.client:
|
||||
ip = request.client.host or ""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO api_audit_log (user_id, token_id, action, resource_type, resource_id, ip_address, detail) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(user.get("id"), user.get("_token_id"), action, resource_type, str(resource_id), ip, detail[:1000]),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("audit_log")
|
||||
|
||||
# ── Rate limit per token (in-memory) ─────────────────────────────────────
|
||||
|
||||
_v2_rate_store: dict[str, tuple[float, int]] = {}
|
||||
def check_v2_rate_limit(token_hash: str | None, ip: str) -> bool:
|
||||
"""Return True if allowed, False if 429. Uses api_v2_rate_limit_per_token."""
|
||||
key = token_hash or f"ip:{ip}"
|
||||
now = time.time()
|
||||
window = 60.0
|
||||
max_req = settings.api_v2_rate_limit_per_token
|
||||
start, count = _v2_rate_store.get(key, (now, 0))
|
||||
if now - start > window:
|
||||
_v2_rate_store[key] = (now, 1)
|
||||
return True
|
||||
if count >= max_req:
|
||||
return False
|
||||
_v2_rate_store[key] = (start, count + 1)
|
||||
return True
|
||||
|
||||
# ── Idempotency ───────────────────────────────────────────────────────────
|
||||
|
||||
def check_idempotency(request: Request, user_id: int) -> dict | None:
|
||||
key = request.headers.get("Idempotency-Key") or request.headers.get("idempotency-key")
|
||||
if not key:
|
||||
return None
|
||||
key = key.strip()[:200]
|
||||
if not key:
|
||||
return None
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT response_json, status_code FROM idempotency_keys WHERE key=? AND user_id=?", (key, user_id)).fetchone()
|
||||
if row:
|
||||
try:
|
||||
data = json.loads(row["response_json"])
|
||||
return {"data": data, "status": row["status_code"], "key": key}
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def store_idempotency(key: str, user_id: int, data: Any, status_code: int = 200) -> None:
|
||||
if not key:
|
||||
return
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO idempotency_keys (key, user_id, response_json, status_code) VALUES (?, ?, ?, ?)",
|
||||
(key.strip()[:200], user_id, json.dumps(data), status_code),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("store_idempotency")
|
||||
@@ -0,0 +1,835 @@
|
||||
"""FlowDeck — Automations engine (v5.1.0).
|
||||
|
||||
Implements the "if-this-then-that" rule engine: automations match an event (or a
|
||||
cron schedule, or a clickable button), optionally guard on a condition, then run
|
||||
a list of actions.
|
||||
|
||||
Condition clauses (``condition_json``), all combined with AND:
|
||||
{"property": "Status", "op": "eq", "value": "Done"}
|
||||
{"property": "Priority", "op": "not_contains", "value": "Low"}
|
||||
{"property": "Assignee", "op": "is_empty"}
|
||||
{"property": "Estimate", "op": "changed"} (only event triggers)
|
||||
Flags:
|
||||
op in {eq, neq, contains, not_contains, is_empty, is_not_empty, changed}
|
||||
|
||||
Actions (``actions_json``), executed sequentially:
|
||||
{"type": "webhook", "url": "...", "secret": "..."}
|
||||
{"type": "set_property", "property": "Status", "value": "Done"}
|
||||
{"type": "create_page", "collection_id": 3, "title": "...", "properties": {...}}
|
||||
{"type": "notify", "message": "Automation fired"}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import notifications
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COND_OPS = {"eq", "neq", "contains", "not_contains", "is_empty", "is_not_empty", "changed"}
|
||||
# Events that fire on collection pages (payload carries a `properties` dict).
|
||||
PAGE_PROP_EVENTS = {"page.created", "page.updated", "page.deleted"}
|
||||
|
||||
|
||||
def _prop_value(props: dict, key) -> tuple[bool, object]:
|
||||
"""Resolve a property value by id or name. Returns ``(found, value)``.
|
||||
|
||||
``props`` may be keyed by property id (FlowDeckDB UI) or name (agent / API).
|
||||
"""
|
||||
if props is None:
|
||||
return False, None
|
||||
if key is None:
|
||||
return True, None
|
||||
skey = str(key)
|
||||
if skey in props:
|
||||
return True, props[skey]
|
||||
if isinstance(key, int) and str(key) in props:
|
||||
return True, props[str(key)]
|
||||
return False, None
|
||||
|
||||
|
||||
def match_condition_props(props: dict, before_props: dict | None, clause: dict) -> bool:
|
||||
"""Evaluate a single condition clause against page property values."""
|
||||
op = clause.get("op", "eq")
|
||||
if op not in COND_OPS:
|
||||
return False
|
||||
if op == "changed":
|
||||
key = clause.get("property")
|
||||
if before_props is None:
|
||||
return False
|
||||
found_before, before_val = _prop_value(before_props, key)
|
||||
found_after, after_val = _prop_value(props, key)
|
||||
return found_before and found_after and before_val != after_val
|
||||
|
||||
found, val = _prop_value(props, clause.get("property"))
|
||||
|
||||
if op == "is_empty":
|
||||
if not found:
|
||||
return True
|
||||
return val is None or str(val).strip() == ""
|
||||
if op == "is_not_empty":
|
||||
return found and val is not None and str(val).strip() != ""
|
||||
|
||||
if not found:
|
||||
return False
|
||||
want = clause.get("value")
|
||||
if op == "eq":
|
||||
return _norm(val) == _norm(want)
|
||||
if op == "neq":
|
||||
return _norm(val) != _norm(want)
|
||||
if op == "contains":
|
||||
return _norm(want) in _norm(val) if _norm(val) else False
|
||||
if op == "not_contains":
|
||||
return _norm(want) not in _norm(val) if _norm(val) else True
|
||||
return False
|
||||
|
||||
|
||||
def _norm(v) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
if isinstance(v, (list, dict)):
|
||||
return json.dumps(v)
|
||||
return str(v)
|
||||
|
||||
|
||||
def evaluate_conditions(condition_json, props: dict | None, before_props: dict | None = None) -> bool:
|
||||
"""Evaluate the stored condition list (AND of all clauses). Empty list → True."""
|
||||
try:
|
||||
clauses = json.loads(condition_json) if isinstance(condition_json, str) else (condition_json or [])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
clauses = []
|
||||
for clause in clauses or []:
|
||||
if not match_condition_props(props, before_props, clause):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_page_context(page_id: int, collection_id: int) -> dict:
|
||||
"""Load a collection page's property values for condition evaluation."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {"page_id": page_id, "collection_id": collection_id,
|
||||
"title": "", "properties": {}, "icon": "file"}
|
||||
try:
|
||||
props = json.loads(row["property_values_json"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
props = {}
|
||||
return {"page_id": page_id, "collection_id": collection_id,
|
||||
"title": row["title"], "icon": row["icon"], "properties": props}
|
||||
|
||||
|
||||
def _save_run(automation_id: int, trigger_source: str, status: str, detail: str,
|
||||
collection_id: int | None = None, page_id: int | None = None) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO automation_runs
|
||||
(automation_id, trigger_source, status, detail, collection_id, page_id)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(automation_id, trigger_source, status, detail, collection_id, page_id),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE automations SET run_count=run_count+1, last_run_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(automation_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _maybe_convert_prediction(value, props: dict) -> tuple[bool, object]:
|
||||
"""Allow action values to interpolate other page properties: e.g. [[Assignee]] or {{title}}."""
|
||||
if not isinstance(value, str):
|
||||
return True, value
|
||||
replaced = value
|
||||
for key in props:
|
||||
if "[[" + str(key) + "]]" in replaced:
|
||||
replaced = replaced.replace("[[" + str(key) + "]]", str(props[key]))
|
||||
if "{{title}}" in replaced:
|
||||
replaced = replaced.replace("{{title}}", str(props.get("title", "")))
|
||||
if "{{id}}" in replaced:
|
||||
replaced = replaced.replace("{{id}}", str(props.get("page_id", "")))
|
||||
return True, replaced
|
||||
|
||||
|
||||
async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
|
||||
"""Execute a single action. Returns a human summary. Raises on failure."""
|
||||
atype = action.get("type")
|
||||
|
||||
if atype == "webhook":
|
||||
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:
|
||||
headers["X-FlowDeck-Secret"] = secret
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(url, json=context, headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"webhook returned HTTP {resp.status_code}")
|
||||
return f"webhook → {url} ({resp.status_code})"
|
||||
|
||||
if atype == "set_property":
|
||||
prop = action.get("property")
|
||||
value = action.get("value")
|
||||
page_id = context.get("page_id")
|
||||
if not prop or not page_id:
|
||||
raise ValueError("set_property requires property + page context")
|
||||
_, resolved = _maybe_convert_prediction(value, context)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT property_values_json, collection_id FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"page {page_id} not found")
|
||||
try:
|
||||
props = json.loads(row["property_values_json"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
props = {}
|
||||
props[prop] = resolved
|
||||
from app.routers.collections import _validate_page_properties
|
||||
_validate_page_properties(conn, row["collection_id"], props, exclude_page_id=page_id)
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(props), page_id),
|
||||
)
|
||||
conn.commit()
|
||||
return f"set property {prop} = {resolved}"
|
||||
|
||||
if atype == "create_page":
|
||||
coll_id = action.get("collection_id")
|
||||
title = action.get("title", "Automation page")
|
||||
properties = action.get("properties", {}) or {}
|
||||
if not coll_id:
|
||||
raise ValueError("create_page requires a collection_id")
|
||||
_, resolved_title = _maybe_convert_prediction(title, context)
|
||||
resolved_props = {}
|
||||
for k, v in properties.items():
|
||||
_, pv = _maybe_convert_prediction(v, context)
|
||||
resolved_props[k] = pv
|
||||
with get_conn() as conn:
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
|
||||
(coll_id,),
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
|
||||
(coll_id, resolved_title, max_pos, json.dumps(resolved_props)),
|
||||
)
|
||||
conn.commit()
|
||||
return f"created page {cur.lastrowid} in collection {coll_id}"
|
||||
|
||||
if atype == "notify":
|
||||
message = action.get("message", "Automation fired")
|
||||
user_id = action.get("user_id")
|
||||
if not user_id:
|
||||
user_id = context.get("created_by") or 1
|
||||
_, resolved = _maybe_convert_prediction(message, context)
|
||||
notifications.create_notification(
|
||||
user_id=user_id,
|
||||
actor_id=context.get("created_by") or 1,
|
||||
ntype="page",
|
||||
title=context.get("automation_name", "Automation"),
|
||||
message=resolved,
|
||||
resource_type="collection_page" if context.get("page_id") else "page",
|
||||
resource_id=context.get("page_id") or context.get("collection_id") or 0,
|
||||
url=context.get("url", ""),
|
||||
)
|
||||
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}")
|
||||
|
||||
|
||||
async def run_automation(automation_id: int, trigger_source: str, context: dict) -> dict:
|
||||
"""Load, condition-check and execute an automation. Records a run row."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automations WHERE id=?", (automation_id,)).fetchone()
|
||||
if not row:
|
||||
return {"status": "skipped", "detail": "automation not found"}
|
||||
auto = dict(row)
|
||||
|
||||
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):
|
||||
_save_run(automation_id, trigger_source, "skipped", "condition not met",
|
||||
context.get("collection_id"), context.get("page_id"))
|
||||
return {"status": "skipped", "detail": "condition not met"}
|
||||
|
||||
try:
|
||||
actions = json.loads(auto["actions_json"]) if auto["actions_json"] else []
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
actions = []
|
||||
|
||||
ctx = dict(context)
|
||||
ctx["automation_name"] = auto["name"]
|
||||
ctx["created_by"] = auto["created_by"] or ctx.get("created_by")
|
||||
|
||||
results = []
|
||||
try:
|
||||
for action in actions or []:
|
||||
results.append(await _run_action(action, ctx, trigger_source))
|
||||
detail = "; ".join(results)
|
||||
_save_run(automation_id, trigger_source, "fired", detail,
|
||||
ctx.get("collection_id"), ctx.get("page_id"))
|
||||
# v6.4.0: emit automation.fired (goes through fire_event → outbound
|
||||
# webhooks, but NOT back through automations to avoid recursion).
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh("automation.fired", {
|
||||
"automation_id": automation_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 — record every failure in history
|
||||
logger.warning("Automation %s failed: %s", automation_id, exc)
|
||||
_save_run(automation_id, trigger_source, "error", str(exc),
|
||||
ctx.get("collection_id"), ctx.get("page_id"))
|
||||
return {"status": "error", "detail": str(exc)}
|
||||
|
||||
|
||||
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
|
||||
await fire_webhooks(event, payload)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Webhook dispatch failed for %s", event)
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM automations
|
||||
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') ═══════════
|
||||
|
||||
_SUPPORTED_CRON = {
|
||||
"*/1": 1, "*/5": 5, "*/10": 10, "*/15": 15, "*/30": 30,
|
||||
"*/2": 2, "*/3": 3, "*/6": 6, "*/12": 12, "*/20": 20, "*/45": 45,
|
||||
}
|
||||
|
||||
|
||||
def cron_due(expression: str, last_run_at: str | None, now: datetime | None = None) -> bool:
|
||||
"""True when a ``*/N`-style or fixed-minute cron expression is due.
|
||||
|
||||
Supports ``*/15 * * * *`` (every N minutes) and ``*/N`` alone, plus exact
|
||||
``H * * * *`` at minute H of every hour. ``@hourly`` / ``@daily`` also work.
|
||||
"""
|
||||
expr = (expression or "").strip().lower()
|
||||
if not expr:
|
||||
return False
|
||||
now = now or datetime.utcnow()
|
||||
minute = now.minute
|
||||
fields = expr.split()
|
||||
|
||||
if expr in ("@hourly", "hourly"):
|
||||
if last_run_at is None:
|
||||
return True
|
||||
try:
|
||||
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
|
||||
except Exception:
|
||||
return True
|
||||
return (now - last.replace(tzinfo=None)) >= timedelta(minutes=60)
|
||||
|
||||
if expr in ("@daily", "daily"):
|
||||
if last_run_at is None:
|
||||
return True
|
||||
try:
|
||||
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
|
||||
except Exception:
|
||||
return True
|
||||
return (now - last.replace(tzinfo=None)) >= timedelta(hours=24)
|
||||
|
||||
# "*/N * * * *" → every N minutes
|
||||
if fields and fields[0].startswith("*/"):
|
||||
val = fields[0][2:]
|
||||
if not val.isdigit() or int(val) not in _SUPPORTED_CRON.values():
|
||||
return False
|
||||
n = int(val)
|
||||
if last_run_at is None:
|
||||
return True
|
||||
try:
|
||||
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
|
||||
except Exception:
|
||||
return True
|
||||
return (now - last.replace(tzinfo=None)) >= timedelta(minutes=n)
|
||||
|
||||
# "H * * * *" → at a fixed minute of each hour
|
||||
if len(fields) == 5 and fields[0].isdigit():
|
||||
return int(fields[0]) == minute
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def automation_scheduler():
|
||||
"""Background loop: fire due cron automations (checked every 60s)."""
|
||||
while True:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
auto = dict(row)
|
||||
try:
|
||||
if cron_due(auto["cron_expression"], auto["last_run_at"]):
|
||||
context = {
|
||||
"collection_id": auto["collection_id"] or 0,
|
||||
"page_id": None,
|
||||
"properties": None,
|
||||
}
|
||||
await run_automation(auto["id"], "cron", context)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Cron automation %s errored", auto["id"])
|
||||
# v7.0.0: workers on a cron schedule share the same 60s loop.
|
||||
try:
|
||||
from app.services.workers import run_due_workers
|
||||
await run_due_workers()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("worker cron iteration failed")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("automation_scheduler iteration failed")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
# ═══════════ v7.0.0 — multi-step automations (triggers/conditions/delay) ══
|
||||
|
||||
STEP_KINDS = ("trigger", "condition", "delay", "action")
|
||||
STEP_ACTION_TYPES = ("webhook", "set_property", "create_page", "notify",
|
||||
"slack", "email", "forge_issue", "agent_trigger")
|
||||
ALL_MODE_WINDOW_S = 300.0
|
||||
|
||||
# mode=all bookkeeping (single-process): automation_id -> {event: timestamp}.
|
||||
_ALL_PENDING: dict[int, dict[str, float]] = {}
|
||||
|
||||
|
||||
def reset_all_pending() -> None:
|
||||
"""Test helper: clear the mode=all arrival window."""
|
||||
_ALL_PENDING.clear()
|
||||
|
||||
|
||||
def _secret_value(stored: str | None) -> str:
|
||||
"""Decrypt a Fernet secret, falling back to raw plaintext (legacy/tests)."""
|
||||
if not stored:
|
||||
return ""
|
||||
try:
|
||||
from app.services.sso_provisioning import decrypt_secret
|
||||
decrypted = decrypt_secret(stored)
|
||||
if decrypted:
|
||||
return decrypted
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if isinstance(stored, str) and not stored.startswith("gAAAAA"):
|
||||
return stored
|
||||
return ""
|
||||
|
||||
|
||||
def validate_step(kind: str, config: dict) -> None:
|
||||
"""Validate a step payload. Raises ValueError with a human message."""
|
||||
from fastapi import HTTPException
|
||||
if kind not in STEP_KINDS:
|
||||
raise HTTPException(400, f"invalid kind: {kind!r} (want trigger|condition|delay|action)")
|
||||
config = config or {}
|
||||
if kind == "trigger":
|
||||
if not config.get("event"):
|
||||
raise HTTPException(400, "trigger step requires an event")
|
||||
elif kind == "condition":
|
||||
if config.get("op", "eq") not in COND_OPS:
|
||||
raise HTTPException(400, f"invalid op: {config.get('op')!r}")
|
||||
elif kind == "delay":
|
||||
try:
|
||||
seconds = int(config.get("seconds", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "delay step requires integer seconds") from None
|
||||
if seconds < 0 or seconds > 86400:
|
||||
raise HTTPException(400, "delay seconds must be 0..86400")
|
||||
elif kind == "action":
|
||||
if config.get("type") not in STEP_ACTION_TYPES:
|
||||
raise HTTPException(400, f"invalid action type: {config.get('type')!r}")
|
||||
|
||||
|
||||
def get_steps(automation_id: int) -> list[dict]:
|
||||
"""Ordered steps of an automation (empty when legacy single-mode)."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM automation_steps WHERE automation_id=? ORDER BY position, id",
|
||||
(automation_id,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
try:
|
||||
d["config"] = json.loads(d.get("config_json") or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
d["config"] = {}
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _steps_by_kind(steps: list[dict]) -> dict[str, list[dict]]:
|
||||
grouped: dict[str, list[dict]] = {"trigger": [], "condition": [],
|
||||
"delay": [], "action": []}
|
||||
for s in steps:
|
||||
if s.get("kind") in grouped:
|
||||
grouped[s["kind"]].append(s)
|
||||
return grouped
|
||||
|
||||
|
||||
def _step_trigger_matches(step_cfg: dict, event: str, payload: dict,
|
||||
automation_collection_id: int | None) -> bool:
|
||||
if step_cfg.get("event") != event:
|
||||
return False
|
||||
want_coll = step_cfg.get("collection_id") or automation_collection_id
|
||||
if want_coll and payload.get("collection_id") != want_coll:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _run_with_steps(auto: dict, steps: list[dict], trigger_source: str,
|
||||
context: dict) -> dict:
|
||||
"""Execute a chained automation. Records one run row with per-step detail."""
|
||||
grouped = _steps_by_kind(steps)
|
||||
props = context.get("properties")
|
||||
before = context.get("before_properties")
|
||||
# Legacy single condition still applies on top of step conditions.
|
||||
if not evaluate_conditions(auto.get("condition_json") or "[]", props, before):
|
||||
_save_run(auto["id"], trigger_source, "skipped", "condition not met",
|
||||
context.get("collection_id"), context.get("page_id"))
|
||||
return {"status": "skipped", "detail": "condition not met"}
|
||||
for cond in grouped["condition"]:
|
||||
cfg = cond.get("config") or {}
|
||||
if not match_condition_props(props, before, {
|
||||
"property": cfg.get("property"), "op": cfg.get("op", "eq"),
|
||||
"value": cfg.get("value")}):
|
||||
_save_run(auto["id"], trigger_source, "skipped",
|
||||
f"step condition not met: {cfg.get('property')}",
|
||||
context.get("collection_id"), context.get("page_id"))
|
||||
return {"status": "skipped", "detail": "step condition not met"}
|
||||
|
||||
ctx = dict(context)
|
||||
ctx["automation_name"] = auto["name"]
|
||||
ctx["created_by"] = auto["created_by"] or ctx.get("created_by")
|
||||
ordered = sorted(steps, key=lambda s: (s.get("position", 0), s.get("id", 0)))
|
||||
results = []
|
||||
try:
|
||||
for step in ordered:
|
||||
kind = step.get("kind")
|
||||
cfg = step.get("config") or {}
|
||||
if kind in ("trigger", "condition"):
|
||||
continue
|
||||
if kind == "delay":
|
||||
seconds = max(0, min(int(cfg.get("seconds", 0)), 300))
|
||||
if seconds:
|
||||
await asyncio.sleep(seconds)
|
||||
results.append(f"delay {cfg.get('seconds', 0)}s")
|
||||
elif kind == "action":
|
||||
summary = await _run_action({"type": cfg.get("type"), **cfg}, ctx,
|
||||
trigger_source)
|
||||
results.append(summary)
|
||||
detail = "; ".join(results) or "no steps executed"
|
||||
_save_run(auto["id"], trigger_source, "fired", detail,
|
||||
ctx.get("collection_id"), ctx.get("page_id"))
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh("automation.fired", {
|
||||
"automation_id": auto["id"], "name": auto["name"],
|
||||
"trigger": trigger_source, "collection_id": ctx.get("collection_id"),
|
||||
"page_id": ctx.get("page_id"), "detail": detail})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("automation.fired webhook dispatch failed")
|
||||
return {"status": "fired", "detail": detail}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Automation %s (steps) failed: %s", auto["id"], exc)
|
||||
_save_run(auto["id"], trigger_source, "error", str(exc),
|
||||
ctx.get("collection_id"), ctx.get("page_id"))
|
||||
return {"status": "error", "detail": str(exc)}
|
||||
|
||||
|
||||
async def _maybe_run_stepped(auto: dict, trigger_source: str, context: dict) -> dict | None:
|
||||
"""Run via steps when the automation has any; None → use legacy path."""
|
||||
steps = get_steps(auto["id"])
|
||||
if not steps:
|
||||
return None
|
||||
return await _run_with_steps(auto, steps, trigger_source, context)
|
||||
|
||||
|
||||
def _match_stepped_automations(event: str, payload: dict) -> list[tuple[dict, list[dict]]]:
|
||||
"""Automations (enabled) whose trigger steps match ``event`` + collection."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT a.* FROM automations a
|
||||
JOIN automation_steps s ON s.automation_id = a.id
|
||||
WHERE a.enabled=1 AND s.kind='trigger' GROUP BY a.id"""
|
||||
).fetchall()
|
||||
matched = []
|
||||
for row in rows:
|
||||
auto = dict(row)
|
||||
steps = get_steps(auto["id"])
|
||||
triggers = [s for s in steps if s.get("kind") == "trigger"]
|
||||
if any(_step_trigger_matches(t.get("config") or {}, event, payload,
|
||||
auto.get("collection_id")) for t in triggers):
|
||||
matched.append((auto, triggers))
|
||||
return matched
|
||||
|
||||
|
||||
async def fire_stepped_event(event: str, payload: dict) -> None:
|
||||
"""Dispatch ``event`` to step-based automations (mode any/all).
|
||||
|
||||
Called from :func:`fire_event` after the legacy matcher. Unknown events
|
||||
(not in the webhook catalogue) still work here — steps are independent
|
||||
from outbound webhooks.
|
||||
"""
|
||||
now = time.time()
|
||||
for auto, triggers in _match_stepped_automations(event, payload):
|
||||
# Skip automations already handled by the legacy matcher to avoid
|
||||
# double runs (legacy = trigger_type event + no steps).
|
||||
if not get_steps(auto["id"]):
|
||||
continue
|
||||
mode = (auto.get("trigger_mode") or "any").lower()
|
||||
if mode == "all":
|
||||
pending = _ALL_PENDING.setdefault(auto["id"], {})
|
||||
pending[event] = now
|
||||
# Expire arrivals outside the window.
|
||||
for ev in [e for e, ts in pending.items() if now - ts > ALL_MODE_WINDOW_S]:
|
||||
del pending[ev]
|
||||
wanted = {t.get("config", {}).get("event") for t in triggers}
|
||||
if not wanted <= set(pending):
|
||||
continue
|
||||
_ALL_PENDING.pop(auto["id"], None)
|
||||
context = dict(payload)
|
||||
context["event"] = event
|
||||
await run_automation(auto["id"], "event", context)
|
||||
|
||||
|
||||
# ── v7.0.0 action backends (module-level = monkeypatchable in tests) ───────
|
||||
|
||||
async def _post_slack(webhook_url: str, text: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(webhook_url, json={"text": text})
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"slack webhook returned HTTP {resp.status_code}")
|
||||
return f"slack → ({resp.status_code})"
|
||||
|
||||
|
||||
async def _send_email_action(to: str, subject: str, body: str, context: dict) -> str:
|
||||
from app.services import mailer
|
||||
address = (to or "").strip()
|
||||
if address.startswith("user:"):
|
||||
try:
|
||||
uid = int(address.split(":", 1)[1])
|
||||
except ValueError:
|
||||
raise ValueError(f"bad email target: {to!r}") from None
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT email FROM users WHERE id=?", (uid,)).fetchone()
|
||||
address = (row["email"] if row and row["email"] else "")
|
||||
if not address:
|
||||
raise ValueError(f"user {uid} has no email")
|
||||
if not address:
|
||||
address = None
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT email FROM users WHERE id=?",
|
||||
(context.get("created_by") or 1,)).fetchone()
|
||||
if row and row["email"]:
|
||||
address = row["email"]
|
||||
if not address:
|
||||
return "email skipped (no recipient)"
|
||||
ok = mailer.send_email(address, subject or "FlowDeck automation", body or "")
|
||||
return f"email → {address}" if ok else "email skipped (SMTP not configured)"
|
||||
|
||||
|
||||
async def _create_forge_issue(provider: str, owner: str, repo: str, title: str,
|
||||
body: str, labels: list | None = None,
|
||||
user_id: int | None = None) -> str:
|
||||
token = ""
|
||||
if user_id:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider=?",
|
||||
(user_id, provider)).fetchone()
|
||||
token = (row["access_token"] if row else "") or ""
|
||||
if provider == "github":
|
||||
if not token:
|
||||
raise ValueError("github action needs a linked GitHub account (token)")
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/issues",
|
||||
headers={"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json"},
|
||||
json={"title": title, "body": body,
|
||||
"labels": labels or []} if labels else {"title": title, "body": body},
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"github returned HTTP {resp.status_code}")
|
||||
return f"github issue #{resp.json().get('number')} in {owner}/{repo}"
|
||||
# gitea (default)
|
||||
from app.services.gitea_client import GiteaClient
|
||||
gitea = GiteaClient(user_token=token or None)
|
||||
issue = await gitea.create_issue(owner, repo, title, body)
|
||||
return f"gitea issue #{issue.get('number')} in {owner}/{repo}"
|
||||
|
||||
|
||||
async def _run_linked_agent(agent_id: int, user_id: int, workspace_id: int | None,
|
||||
message: str, context: dict) -> str:
|
||||
from app.services.agent_engine import AgentEngine
|
||||
with get_conn() as conn:
|
||||
agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not agent:
|
||||
raise ValueError(f"agent {agent_id} not found")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
|
||||
VALUES (?,?,?,?)""",
|
||||
(agent_id, user_id,
|
||||
f"Automation: {context.get('automation_name', 'run')}",
|
||||
json.dumps({"workspace_id": workspace_id})),
|
||||
)
|
||||
conv_id = cur.lastrowid
|
||||
conn.commit()
|
||||
objective = ((agent["system_instructions"] or "").strip()
|
||||
or f"Exécute l'agent « {agent['name']} ».")
|
||||
if message:
|
||||
objective = f"{objective}\n\n{message}"
|
||||
engine = AgentEngine(user_id, workspace_id, agent["model"] or None)
|
||||
final = ""
|
||||
async for _ev in engine.run(conv_id, objective, model=agent["model"]):
|
||||
pass
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content FROM agent_messages WHERE conversation_id=? AND role='assistant'"
|
||||
" ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone()
|
||||
final = (row["content"][:300] if row and row["content"] else "")
|
||||
return f"agent « {agent['name']} » ran (conversation {conv_id})" + (f": {final}" if final else "")
|
||||
|
||||
|
||||
# ── v7.0.0 native DB button ────────────────────────────────────────────────
|
||||
|
||||
async def press_button(collection_id: int, row_id: int, prop_ref: str | int,
|
||||
user_id: int) -> dict:
|
||||
"""Run the automation linked to a ``button`` property cell."""
|
||||
with get_conn() as conn:
|
||||
if isinstance(prop_ref, int) or str(prop_ref).isdigit():
|
||||
prop = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=? AND collection_id=?",
|
||||
(int(prop_ref), collection_id)).fetchone()
|
||||
else:
|
||||
prop = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? AND name=?",
|
||||
(collection_id, prop_ref)).fetchone()
|
||||
if not prop:
|
||||
raise ValueError("button property not found")
|
||||
prop = dict(prop)
|
||||
if prop.get("prop_type") != "button":
|
||||
raise ValueError("property is not a button")
|
||||
auto_id = prop.get("button_automation_id")
|
||||
if not auto_id:
|
||||
raise ValueError("button has no linked automation")
|
||||
row = conn.execute(
|
||||
"SELECT id FROM collection_pages WHERE id=? AND collection_id=?",
|
||||
(row_id, collection_id)).fetchone()
|
||||
if not row:
|
||||
raise ValueError("row not found")
|
||||
context = get_page_context(row_id, collection_id)
|
||||
context["created_by"] = user_id
|
||||
return await run_automation(auto_id, "button", context)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""FlowDeck — v5.2.0 Automatic backups (daily SQLite snapshot)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _backup_dir() -> Path:
|
||||
d = Path(settings.backup_dir)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _db_path() -> Path:
|
||||
return settings.db_path
|
||||
|
||||
|
||||
def backup_db(now: datetime | None = None) -> str | None:
|
||||
"""Snapshot the SQLite database into ``backup_dir`` (WAL-safe).
|
||||
|
||||
Returns the backup filename, or None when backup is disabled or the
|
||||
database file does not exist.
|
||||
"""
|
||||
if not settings.backup_enabled:
|
||||
return None
|
||||
db_path = _db_path()
|
||||
if str(db_path) == ":memory:" or not Path(db_path).is_file():
|
||||
return None
|
||||
|
||||
now = now or datetime.now()
|
||||
# Checkpoint the WAL so the backup is consistent.
|
||||
try:
|
||||
import sqlite3
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
except Exception:
|
||||
logger.exception("backup_db")
|
||||
|
||||
dest_dir = _backup_dir()
|
||||
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
|
||||
dest = dest_dir / filename
|
||||
shutil.copy2(db_path, dest)
|
||||
|
||||
# Prune old backups, keeping ``backup_keep`` most recent files.
|
||||
prune_old_backups()
|
||||
logger.info("Backup created: %s", dest)
|
||||
return filename
|
||||
|
||||
|
||||
def list_backups() -> list[dict]:
|
||||
"""List existing backup files (name, size bytes, mtime)."""
|
||||
files = []
|
||||
for p in _backup_dir().glob("flowdeck-*.db"):
|
||||
stat = p.stat()
|
||||
files.append({
|
||||
"filename": p.name,
|
||||
"size": stat.st_size,
|
||||
"modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
})
|
||||
files.sort(key=lambda f: f["filename"], reverse=True)
|
||||
return files
|
||||
|
||||
|
||||
def prune_old_backups(keep: int | None = None) -> int:
|
||||
"""Delete the oldest backup files beyond ``keep``. Returns count removed."""
|
||||
keep = keep if keep is not None else settings.backup_keep
|
||||
files = sorted(_backup_dir().glob("flowdeck-*.db"), reverse=True)
|
||||
removed = 0
|
||||
for p in files[keep:]:
|
||||
try:
|
||||
p.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
logger.warning("Could not prune backup %s", p)
|
||||
return removed
|
||||
|
||||
|
||||
def last_backup_age_hours() -> float | None:
|
||||
"""Hours since the most recent backup (None if none exists)."""
|
||||
files = list(_backup_dir().glob("flowdeck-*.db"))
|
||||
if not files:
|
||||
return None
|
||||
newest = max(files, key=lambda p: p.stat().st_mtime)
|
||||
age = time.time() - newest.stat().st_mtime
|
||||
return age / 3600
|
||||
|
||||
|
||||
def backup_due() -> bool:
|
||||
"""True when a backup should run now (interval elapsed since last one)."""
|
||||
age = last_backup_age_hours()
|
||||
if age is None:
|
||||
return True
|
||||
return age >= settings.backup_interval_hours
|
||||
|
||||
|
||||
async def backup_scheduler():
|
||||
"""Background loop: run a backup once per interval (default daily)."""
|
||||
while True:
|
||||
try:
|
||||
if backup_due():
|
||||
backup_db()
|
||||
except Exception as exc: # never let the loop die
|
||||
logger.warning("backup_scheduler error: %s", exc)
|
||||
await __import__("asyncio").sleep(3600) # re-check hourly
|
||||
@@ -0,0 +1,103 @@
|
||||
"""FlowDeck — Built-in page (block) templates (v5.12.0).
|
||||
|
||||
Global page templates used by the « + New page » picker. Built-ins live here
|
||||
(code, versioned); user templates live in the ``page_global_templates``
|
||||
table. Block shapes match the editor's storage format (see
|
||||
``app/routers/board.py::save_page_blocks``) — ids are assigned client-side.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def _b(btype: str, content: str = "", **extra) -> dict:
|
||||
out = {"type": btype, "content": content}
|
||||
out.update(extra)
|
||||
return out
|
||||
|
||||
|
||||
BUILTIN_TEMPLATES: dict[str, dict] = {
|
||||
"empty": {
|
||||
"name": "Empty",
|
||||
"icon": "📄",
|
||||
"description": "A blank page.",
|
||||
"blocks": [_b("paragraph")],
|
||||
},
|
||||
"meeting_notes": {
|
||||
"name": "Meeting notes",
|
||||
"icon": "🗒️",
|
||||
"description": "Attendees, agenda, notes, action items.",
|
||||
"blocks": [
|
||||
_b("heading_1", "Meeting notes"),
|
||||
_b("callout", "Date: · Time: · Attendees: ", icon="📅"),
|
||||
_b("heading_2", "Agenda"),
|
||||
_b("bulleted_list", "Topic 1"),
|
||||
_b("bulleted_list", "Topic 2"),
|
||||
_b("heading_2", "Notes"),
|
||||
_b("paragraph"),
|
||||
_b("heading_2", "Decisions"),
|
||||
_b("bulleted_list"),
|
||||
_b("heading_2", "Action items"),
|
||||
_b("to_do", "Owner — due date", checked=False),
|
||||
_b("to_do", "", checked=False),
|
||||
],
|
||||
},
|
||||
"weekly_report": {
|
||||
"name": "Weekly report",
|
||||
"icon": "📊",
|
||||
"description": "Wins, in progress, blockers, next week.",
|
||||
"blocks": [
|
||||
_b("heading_1", "Week of [[fddate:2026-01-05]]"),
|
||||
_b("heading_2", "🎉 Wins"),
|
||||
_b("bulleted_list"),
|
||||
_b("heading_2", "🚧 In progress"),
|
||||
_b("bulleted_list"),
|
||||
_b("heading_2", "⛔ Blockers"),
|
||||
_b("bulleted_list"),
|
||||
_b("heading_2", "🗓️ Next week"),
|
||||
_b("to_do", "", checked=False),
|
||||
],
|
||||
},
|
||||
"todo_list": {
|
||||
"name": "To-do list",
|
||||
"icon": "✅",
|
||||
"description": "A simple checklist.",
|
||||
"blocks": [
|
||||
_b("heading_1", "To-do"),
|
||||
_b("to_do", "", checked=False),
|
||||
_b("to_do", "", checked=False),
|
||||
_b("to_do", "", checked=False),
|
||||
],
|
||||
},
|
||||
"project_doc": {
|
||||
"name": "Project doc",
|
||||
"icon": "🚀",
|
||||
"description": "Goals, status, team, links.",
|
||||
"blocks": [
|
||||
_b("heading_1", "Project title"),
|
||||
_b("callout", "One-line description of the project.", icon="💡"),
|
||||
_b("heading_2", "Goals"),
|
||||
_b("numbered_list"),
|
||||
_b("heading_2", "Status"),
|
||||
_b("toggle", "This week", expanded=True, children=[_b("paragraph")]),
|
||||
_b("heading_2", "Team"),
|
||||
_b("bulleted_list"),
|
||||
_b("heading_2", "Resources"),
|
||||
_b("bulleted_list"),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def template_list() -> list[dict]:
|
||||
"""Public shape of the built-in templates for the picker UI."""
|
||||
return [
|
||||
{"key": key, "name": t["name"], "icon": t["icon"],
|
||||
"description": t["description"], "builtin": True}
|
||||
for key, t in BUILTIN_TEMPLATES.items()
|
||||
]
|
||||
|
||||
|
||||
def blocks_json_for(key: str) -> str | None:
|
||||
t = BUILTIN_TEMPLATES.get(key)
|
||||
return json.dumps(t["blocks"]) if t else None
|
||||
@@ -0,0 +1,528 @@
|
||||
"""FlowDeck — external calendar sync (v7.1.0).
|
||||
|
||||
Bidirectional sync between a collection (date property) and an external
|
||||
calendar: Google Calendar (REST) or any CalDAV server (raw REPORT/PUT, no
|
||||
extra dependency). Tokens are Fernet-encrypted at rest.
|
||||
|
||||
Matching: ``collection_pages.external_event_id`` ↔ remote event id.
|
||||
Conflicts (both sides changed since ``last_sync``): last-write-wins +
|
||||
in-app ``calendar.conflict`` notification (manual edit resolves).
|
||||
|
||||
See ``docs/V71_Calendar_Meetings.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROVIDERS = ("google", "caldav")
|
||||
SYNC_LOOKBACK_DAYS = 30
|
||||
SYNC_LOOKAHEAD_DAYS = 90
|
||||
|
||||
|
||||
class SyncError(RuntimeError):
|
||||
"""Raised when the remote calendar cannot be reached/authorized."""
|
||||
|
||||
|
||||
# ── links ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _encrypt_tokens(creds: dict) -> str:
|
||||
from app.services.sso_provisioning import encrypt_secret
|
||||
return encrypt_secret(json.dumps(creds or {}))
|
||||
|
||||
|
||||
def _decrypt_tokens(tokens_enc: str) -> dict:
|
||||
if not tokens_enc:
|
||||
return {}
|
||||
try:
|
||||
from app.services.sso_provisioning import decrypt_secret
|
||||
raw = decrypt_secret(tokens_enc)
|
||||
if raw:
|
||||
return json.loads(raw)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try: # legacy plaintext (tests)
|
||||
data = json.loads(tokens_enc)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
|
||||
|
||||
def save_link(user_id: int, provider: str, collection_id: int,
|
||||
credentials: dict, calendar_id: str = "primary",
|
||||
date_property: str = "") -> dict:
|
||||
if provider not in PROVIDERS:
|
||||
raise ValueError(f"provider must be google|caldav, got {provider!r}")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?",
|
||||
(collection_id,)).fetchone():
|
||||
raise ValueError("collection not found")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO calendar_links
|
||||
(user_id, provider, tokens_enc, calendar_id, collection_id, date_property)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
ON CONFLICT(user_id, provider, calendar_id) DO UPDATE SET
|
||||
tokens_enc=excluded.tokens_enc, collection_id=excluded.collection_id,
|
||||
date_property=excluded.date_property""",
|
||||
(user_id, provider, _encrypt_tokens(credentials),
|
||||
calendar_id or "primary", collection_id, date_property or ""))
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM calendar_links WHERE user_id=? AND provider=? AND calendar_id=?",
|
||||
(user_id, provider, calendar_id or "primary")).fetchone()
|
||||
_ = cur
|
||||
out = dict(row)
|
||||
out.pop("tokens_enc", None)
|
||||
return out
|
||||
|
||||
|
||||
def list_links(user_id: int) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, user_id, provider, calendar_id, collection_id,"
|
||||
" date_property, last_sync, created_at FROM calendar_links WHERE user_id=?"
|
||||
" ORDER BY id", (user_id,)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_link(user_id: int, link_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM calendar_links WHERE id=? AND user_id=?",
|
||||
(link_id, user_id))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def _load_link(link_id: int) -> dict | None:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM calendar_links WHERE id=?", (link_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ── remote I/O (module-level = monkeypatchable) ────────────────────────────
|
||||
|
||||
def _remote_event(eid: str, title: str, start: str, description: str = "",
|
||||
updated: str = "") -> dict:
|
||||
return {"id": str(eid), "title": title or "Untitled", "start": start,
|
||||
"description": description or "", "updated": updated or ""}
|
||||
|
||||
|
||||
async def google_list_events(tokens: dict, calendar_id: str,
|
||||
time_min: str, time_max: str) -> list[dict]:
|
||||
access = tokens.get("access_token", "")
|
||||
if not access:
|
||||
raise SyncError("google link has no access_token — relink the calendar")
|
||||
url = (f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}"
|
||||
f"/events?singleEvents=true&orderBy=startTime"
|
||||
f"&timeMin={time_min}&timeMax={time_max}")
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(url, headers={"Authorization": f"Bearer {access}"})
|
||||
if resp.status_code == 401:
|
||||
raise SyncError("google token expired — relink the calendar")
|
||||
if resp.status_code >= 400:
|
||||
raise SyncError(f"google returned HTTP {resp.status_code}")
|
||||
out = []
|
||||
for item in resp.json().get("items", []):
|
||||
start = (item.get("start") or {}).get("dateTime") or (item.get("start") or {}).get("date") or ""
|
||||
out.append(_remote_event(item.get("id", ""), item.get("summary", ""),
|
||||
start, item.get("description", ""),
|
||||
item.get("updated", "")))
|
||||
return out
|
||||
|
||||
|
||||
async def google_push_event(tokens: dict, calendar_id: str, event: dict,
|
||||
remote_id: str = "") -> str:
|
||||
access = tokens.get("access_token", "")
|
||||
if not access:
|
||||
raise SyncError("google link has no access_token — relink the calendar")
|
||||
body = {"summary": event.get("title", ""),
|
||||
"description": event.get("description", ""),
|
||||
"start": {"date": event.get("start", "")[:10]},
|
||||
"end": {"date": event.get("start", "")[:10]}}
|
||||
base = f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}/events"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
if remote_id:
|
||||
resp = await client.patch(f"{base}/{remote_id}",
|
||||
headers={"Authorization": f"Bearer {access}"}, json=body)
|
||||
else:
|
||||
resp = await client.post(base, headers={"Authorization": f"Bearer {access}"},
|
||||
json=body)
|
||||
if resp.status_code == 401:
|
||||
raise SyncError("google token expired — relink the calendar")
|
||||
if resp.status_code >= 400:
|
||||
raise SyncError(f"google returned HTTP {resp.status_code}")
|
||||
return str(resp.json().get("id", remote_id or ""))
|
||||
|
||||
|
||||
_CALDAV_REPORT = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop><D:getetag/><C:calendar-data/></D:prop>
|
||||
<C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT">
|
||||
<C:time-range start="{start}" end="{end}"/>
|
||||
</C:comp-filter></C:comp-filter></C:filter>
|
||||
</C:calendar-query>"""
|
||||
|
||||
|
||||
def _parse_caldav_events(xml_text: str) -> list[dict]:
|
||||
"""Minimal multistatus → event parser (UID/SUMMARY/DTSTART/DESCRIPTION)."""
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
events = []
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
except ET.ParseError:
|
||||
return []
|
||||
ns = {"D": "DAV:", "C": "urn:ietf:params:xml:ns:caldav"}
|
||||
for resp in root.findall("D:response", ns):
|
||||
href = resp.findtext("D:href", default="", namespaces=ns)
|
||||
data_el = resp.find(".//{urn:ietf:params:xml:ns:caldav}calendar-data")
|
||||
if data_el is None or not data_el.text:
|
||||
continue
|
||||
ics = data_el.text
|
||||
uid = re.search(r"^UID:(.+)$", ics, re.M)
|
||||
summary = re.search(r"^SUMMARY:(.+)$", ics, re.M)
|
||||
dtstart = re.search(r"^DTSTART(?:;[^:]*)?:(.+)$", ics, re.M)
|
||||
desc = re.search(r"^DESCRIPTION:(.+)$", ics, re.M)
|
||||
events.append(_remote_event(
|
||||
(uid.group(1).strip() if uid else href.strip("/").split("/")[-1]),
|
||||
summary.group(1).strip() if summary else "Untitled",
|
||||
_ics_to_date(dtstart.group(1).strip()) if dtstart else "",
|
||||
desc.group(1).strip() if desc else ""))
|
||||
return events
|
||||
|
||||
|
||||
def _ics_to_date(value: str) -> str:
|
||||
value = value.strip()
|
||||
if len(value) >= 8 and value[:8].isdigit():
|
||||
return f"{value[:4]}-{value[4:6]}-{value[6:8]}"
|
||||
return value[:10]
|
||||
|
||||
|
||||
def _event_to_ics(uid: str, title: str, date: str, description: str = "") -> str:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
day = (date or "")[:10].replace("-", "")
|
||||
return (f"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//FlowDeck//Sync//EN\r\n"
|
||||
f"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:{stamp}\r\nDTSTART;VALUE=DATE:{day}\r\n"
|
||||
f"SUMMARY:{title}\r\nDESCRIPTION:{description}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n")
|
||||
|
||||
|
||||
async def caldav_list_events(creds: dict, time_min: str, time_max: str) -> list[dict]:
|
||||
url = creds.get("url", "")
|
||||
if not url:
|
||||
raise SyncError("caldav link needs a calendar url")
|
||||
auth = (creds.get("username", ""), creds.get("password", ""))
|
||||
body = _CALDAV_REPORT.format(
|
||||
start=time_min.replace("-", "").split("T")[0] + "T000000Z",
|
||||
end=time_max.replace("-", "").split("T")[0] + "T000000Z")
|
||||
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
resp = await client.request("REPORT", url, content=body,
|
||||
headers={"Depth": "1",
|
||||
"Content-Type": "application/xml"})
|
||||
if resp.status_code == 401:
|
||||
raise SyncError("caldav rejected credentials")
|
||||
if resp.status_code >= 400:
|
||||
raise SyncError(f"caldav returned HTTP {resp.status_code}")
|
||||
return _parse_caldav_events(resp.text)
|
||||
|
||||
|
||||
async def caldav_push_event(creds: dict, event: dict, remote_id: str = "") -> str:
|
||||
url = (creds.get("url", "") or "").rstrip("/")
|
||||
if not url:
|
||||
raise SyncError("caldav link needs a calendar url")
|
||||
auth = (creds.get("username", ""), creds.get("password", ""))
|
||||
uid = remote_id or f"flowdeck-{uuid.uuid4().hex}@flowdeck"
|
||||
href = f"{url}/{uid}.ics" if not remote_id else (
|
||||
remote_id if remote_id.startswith("http") else f"{url}/{remote_id}")
|
||||
ics = _event_to_ics(uid.split("@")[0], event.get("title", ""),
|
||||
event.get("start", ""), event.get("description", ""))
|
||||
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
resp = await client.put(href, content=ics, headers={"Content-Type": "text/calendar"})
|
||||
if resp.status_code >= 400:
|
||||
raise SyncError(f"caldav returned HTTP {resp.status_code}")
|
||||
return uid
|
||||
|
||||
|
||||
# ── mapping + sync ─────────────────────────────────────────────────────────
|
||||
|
||||
def _date_prop_id(conn, collection_id: int, wanted: str = "") -> tuple[str, str] | None:
|
||||
props = conn.execute(
|
||||
"SELECT id, name FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,)).fetchall()
|
||||
if wanted:
|
||||
for p in props:
|
||||
if str(p["id"]) == str(wanted) or p["name"] == wanted:
|
||||
return str(p["id"]), p["name"]
|
||||
return None
|
||||
for p in props:
|
||||
# prop_type lives in the row; fetch full rows only when needed
|
||||
full = conn.execute("SELECT prop_type FROM collection_properties WHERE id=?",
|
||||
(p["id"],)).fetchone()
|
||||
if full and full["prop_type"] == "date":
|
||||
return str(p["id"]), p["name"]
|
||||
return None
|
||||
|
||||
|
||||
def _row_date(values: dict, prop_id: str, prop_name: str) -> str:
|
||||
raw = values.get(prop_id, values.get(prop_name, ""))
|
||||
if isinstance(raw, dict):
|
||||
raw = raw.get("date") or raw.get("value") or ""
|
||||
return str(raw or "")
|
||||
|
||||
|
||||
def _to_epoch(value: str | None) -> float:
|
||||
if not value:
|
||||
return 0.0
|
||||
text = str(value).strip()
|
||||
try:
|
||||
if text.endswith("Z"):
|
||||
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = datetime.fromisoformat(text[:19] if "T" in text else text[:19])
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.timestamp()
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
return time.mktime(time.strptime(text[:10], "%Y-%m-%d"))
|
||||
except Exception: # noqa: BLE001
|
||||
return 0.0
|
||||
|
||||
|
||||
def _window() -> tuple[str, str]:
|
||||
now = datetime.now(UTC)
|
||||
start = (now - timedelta(days=SYNC_LOOKBACK_DAYS)).strftime("%Y-%m-%dT00:00:00Z")
|
||||
end = (now + timedelta(days=SYNC_LOOKAHEAD_DAYS)).strftime("%Y-%m-%dT00:00:00Z")
|
||||
return start, end
|
||||
|
||||
|
||||
async def sync_link(link_id: int) -> dict:
|
||||
"""One bidirectional sync pass. Returns {pulled, pushed, conflicts}."""
|
||||
link = _load_link(link_id)
|
||||
if not link:
|
||||
raise ValueError("link not found")
|
||||
creds = _decrypt_tokens(link.get("tokens_enc") or "")
|
||||
collection_id = link.get("collection_id")
|
||||
if not collection_id:
|
||||
raise ValueError("link has no collection")
|
||||
with get_conn() as conn:
|
||||
date_prop = _date_prop_id(conn, collection_id, link.get("date_property") or "")
|
||||
if not date_prop:
|
||||
raise ValueError("collection has no date property")
|
||||
prop_id, prop_name = date_prop
|
||||
|
||||
tmin, tmax = _window()
|
||||
if link["provider"] == "google":
|
||||
remote = await google_list_events(creds, link.get("calendar_id") or "primary",
|
||||
tmin, tmax)
|
||||
else:
|
||||
remote = await caldav_list_events(creds, tmin, tmax)
|
||||
|
||||
last_sync = _to_epoch(link.get("last_sync"))
|
||||
pulled = pushed = conflicts = 0
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, property_values_json, updated_at,"
|
||||
" COALESCE(external_event_id, '') AS xid FROM collection_pages"
|
||||
" WHERE collection_id=?", (collection_id,)).fetchall()
|
||||
local = {r["xid"]: dict(r) for r in rows if r["xid"]}
|
||||
seen_remote: set[str] = set()
|
||||
touched: set[int] = set() # rows written by this pull pass — never push back
|
||||
|
||||
for ev in remote:
|
||||
eid = ev.get("id", "")
|
||||
if not eid:
|
||||
continue
|
||||
seen_remote.add(eid)
|
||||
day = (ev.get("start") or "")[:10]
|
||||
if eid not in local:
|
||||
values: dict = {}
|
||||
values[prop_id] = day
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages"
|
||||
" WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, position, property_values_json, external_event_id)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(collection_id, ev.get("title") or "Untitled", max_pos,
|
||||
json.dumps(values), eid))
|
||||
pulled += 1
|
||||
continue
|
||||
row = local[eid]
|
||||
try:
|
||||
values = json.loads(row["property_values_json"] or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
values = {}
|
||||
local_day = _row_date(values, prop_id, prop_name)[:10]
|
||||
remote_newer = _to_epoch(ev.get("updated")) > _to_epoch(row["updated_at"])
|
||||
local_dirty = _to_epoch(row["updated_at"]) > last_sync and local_day != day
|
||||
if remote_newer and local_dirty and local_day and day and local_day != day:
|
||||
# Conflict: both sides moved → last-write-wins + notify.
|
||||
if _to_epoch(ev.get("updated")) >= _to_epoch(row["updated_at"]):
|
||||
values[prop_id] = day
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?,"
|
||||
" updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(values), row["id"]))
|
||||
touched.add(row["id"])
|
||||
conflicts += 1
|
||||
_notify_conflict(conn, link, row, ev)
|
||||
elif day and day != local_day:
|
||||
values[prop_id] = day
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?,"
|
||||
" updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(values), row["id"]))
|
||||
touched.add(row["id"])
|
||||
pulled += 1
|
||||
|
||||
# Push local changes (created locally or edited after last_sync).
|
||||
for xid, row in local.items():
|
||||
if row["id"] in touched:
|
||||
continue
|
||||
if xid in seen_remote:
|
||||
# Edited locally since last sync and remote unchanged → push.
|
||||
if last_sync and _to_epoch(row["updated_at"]) > last_sync:
|
||||
await _push(link, creds, row, prop_id, prop_name, xid)
|
||||
pushed += 1
|
||||
continue
|
||||
# Remote deleted the event → drop the local id (keep the row).
|
||||
conn.execute("UPDATE collection_pages SET external_event_id='' WHERE id=?",
|
||||
(row["id"],))
|
||||
# Rows never linked and recently touched → create remotely.
|
||||
fresh = conn.execute(
|
||||
"SELECT id, title, property_values_json, updated_at FROM collection_pages"
|
||||
" WHERE collection_id=? AND COALESCE(external_event_id, '')=''",
|
||||
(collection_id,)).fetchall()
|
||||
for row in fresh:
|
||||
try:
|
||||
values = json.loads(row["property_values_json"] or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
values = {}
|
||||
day = _row_date(values, prop_id, prop_name)[:10]
|
||||
if not day:
|
||||
continue
|
||||
new_id = await _push(link, creds, dict(row), prop_id, prop_name, "")
|
||||
conn.execute("UPDATE collection_pages SET external_event_id=? WHERE id=?",
|
||||
(new_id, row["id"]))
|
||||
pushed += 1
|
||||
|
||||
conn.execute("UPDATE calendar_links SET last_sync=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(link_id,))
|
||||
conn.commit()
|
||||
return {"pulled": pulled, "pushed": pushed, "conflicts": conflicts}
|
||||
|
||||
|
||||
async def _push(link: dict, creds: dict, row: dict, prop_id: str,
|
||||
prop_name: str, remote_id: str) -> str:
|
||||
try:
|
||||
values = json.loads(row.get("property_values_json") or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
values = {}
|
||||
event = {"title": row.get("title") or "Untitled",
|
||||
"start": _row_date(values, prop_id, prop_name),
|
||||
"description": ""}
|
||||
if link["provider"] == "google":
|
||||
return await google_push_event(creds, link.get("calendar_id") or "primary",
|
||||
event, remote_id)
|
||||
return await caldav_push_event(creds, event, remote_id)
|
||||
|
||||
|
||||
def _notify_conflict(conn, link: dict, row: dict, ev: dict) -> None:
|
||||
try:
|
||||
from app.services.notifications import create_notification
|
||||
create_notification(
|
||||
link["user_id"], link["user_id"], "calendar",
|
||||
"Calendar sync conflict",
|
||||
f"« {row.get('title') or 'Untitled'} » changed on both sides;"
|
||||
f" kept the newest ({ev.get('start', '')[:10]}). Edit the row to resolve.",
|
||||
resource_type="collection", resource_id=link.get("collection_id") or 0,
|
||||
url=f"/db/{link.get('collection_id')}", conn=conn, commit=False)
|
||||
except Exception: # noqa: BLE001 — notify must never break sync
|
||||
pass
|
||||
|
||||
|
||||
async def calendar_sync_scheduler(interval_seconds: int = 900) -> None:
|
||||
"""Background loop: sync every link with a collection (15 min default)."""
|
||||
while True:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
ids = [r["id"] for r in conn.execute(
|
||||
"SELECT id FROM calendar_links WHERE collection_id IS NOT NULL"
|
||||
).fetchall()]
|
||||
for link_id in ids:
|
||||
try:
|
||||
await sync_link(link_id)
|
||||
except Exception as exc: # noqa: BLE001 — one link must not kill the loop
|
||||
logger.debug("calendar sync link %s failed: %s", link_id, exc)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("calendar_sync_scheduler: %s", exc)
|
||||
await asyncio.sleep(interval_seconds)
|
||||
|
||||
|
||||
# ── free/busy ──────────────────────────────────────────────────────────────
|
||||
|
||||
def freebusy(collection_id: int, date_from: str, date_to: str,
|
||||
date_property: str = "") -> dict:
|
||||
"""Busy/free weekdays in [date_from, date_to] (day granularity).
|
||||
|
||||
Expands recurrence rules server-side (``recurrence.expand_rule``).
|
||||
"""
|
||||
from app.services import recurrence as _rec
|
||||
try:
|
||||
start = datetime.strptime(date_from[:10], "%Y-%m-%d").date()
|
||||
end = datetime.strptime(date_to[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise ValueError("use YYYY-MM-DD dates") from None
|
||||
if end < start or (end - start).days > 370:
|
||||
raise ValueError("range must be 1..370 days")
|
||||
with get_conn() as conn:
|
||||
date_prop = _date_prop_id(conn, collection_id, date_property)
|
||||
if not date_prop:
|
||||
raise ValueError("collection has no date property")
|
||||
prop_id, prop_name = date_prop
|
||||
rows = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,)).fetchall()
|
||||
busy: set[str] = set()
|
||||
for r in rows:
|
||||
try:
|
||||
values = json.loads(r["property_values_json"] or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
base = _row_date(values, prop_id, prop_name)
|
||||
if not base:
|
||||
continue
|
||||
rec = (values.get("__recurrence__") or {})
|
||||
rule = rec.get(prop_id) or rec.get(prop_name)
|
||||
if rule:
|
||||
try:
|
||||
for occ in _rec.expand_rule(
|
||||
base, rule, start.isoformat(), end.isoformat()):
|
||||
busy.add(occ[:10])
|
||||
except Exception: # noqa: BLE001 — bad rule, use base date only
|
||||
busy.add(base[:10])
|
||||
else:
|
||||
if start.isoformat() <= base[:10] <= end.isoformat():
|
||||
busy.add(base[:10])
|
||||
days, free = [], []
|
||||
day = start
|
||||
while day <= end:
|
||||
iso = day.isoformat()
|
||||
days.append({"date": iso, "busy": iso in busy,
|
||||
"weekend": day.weekday() >= 5})
|
||||
if iso not in busy and day.weekday() < 5:
|
||||
free.append(iso)
|
||||
day += timedelta(days=1)
|
||||
return {"collection_id": collection_id, "from": start.isoformat(),
|
||||
"to": end.isoformat(), "days": days, "free_weekdays": free}
|
||||
@@ -6,7 +6,6 @@ without breaking the existing board routes.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -49,7 +48,7 @@ class GiteaBoardCompat:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_card(card_row, gitea_issue: Optional[dict] = None) -> dict:
|
||||
def from_card(card_row, gitea_issue: dict | None = None) -> dict:
|
||||
"""Convertit une card legacy en pseudo collection_page."""
|
||||
title = gitea_issue.get("title", f"Card #{card_row['id']}") if gitea_issue else f"Card #{card_row['id']}"
|
||||
priority = card_row.get("priority", "Medium")
|
||||
@@ -83,7 +82,7 @@ class GiteaBoardCompat:
|
||||
return [GiteaBoardCompat.from_board(dict(r)) for r in rows]
|
||||
|
||||
@staticmethod
|
||||
def get_board_as_collection(owner: str, repo: str) -> Optional[dict]:
|
||||
def get_board_as_collection(owner: str, repo: str) -> dict | None:
|
||||
"""Récupère un board spécifique comme collection."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -95,7 +94,7 @@ class GiteaBoardCompat:
|
||||
return GiteaBoardCompat.from_board(dict(row))
|
||||
|
||||
@staticmethod
|
||||
def get_board_cards(owner: str, repo: str, gitea_issues: Optional[list[dict]] = None) -> list[dict]:
|
||||
def get_board_cards(owner: str, repo: str, gitea_issues: list[dict] | None = None) -> list[dict]:
|
||||
"""Récupère les cartes d'un board comme collection_pages."""
|
||||
with get_conn() as conn:
|
||||
board = conn.execute(
|
||||
@@ -120,7 +119,7 @@ class GiteaBoardCompat:
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> Optional[int]:
|
||||
def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> int | None:
|
||||
"""Sync un board Gitea vers une vraie collection.
|
||||
|
||||
Crée ou met à jour une collection liée à Gitea et importe les pages.
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""FlowDeck — Agent context builder (v4.14.0).
|
||||
|
||||
Collects a compact, permission-filtered snapshot of the active workspace so the
|
||||
LLM can reason about real entities (workspaces, documents, collections, pages,
|
||||
Gitea issues) without touching the database directly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
"""Builds the textual context that accompanies each agent run."""
|
||||
|
||||
def __init__(self, user_id: int, workspace_id: int | None = None):
|
||||
self.user_id = user_id
|
||||
self.workspace_id = workspace_id
|
||||
|
||||
def build(self, *, mentions: list[str] | None = None,
|
||||
files: list[dict] | None = None,
|
||||
include_collections: bool = True) -> str:
|
||||
"""Return a compact Markdown-ish snapshot of the workspace context."""
|
||||
sections: list[str] = []
|
||||
|
||||
if include_collections:
|
||||
sections.append(self._collections_context())
|
||||
sections.append(self._pages_context())
|
||||
sections.append(self._documents_context())
|
||||
sections.append(self._workspaces_context())
|
||||
if mentions:
|
||||
sections.append(self._mentions_context(mentions))
|
||||
if files:
|
||||
sections.append(self._files_context(files))
|
||||
|
||||
return "\n\n".join(s for s in sections if s)
|
||||
|
||||
# ── Internals ──
|
||||
|
||||
def _collections_context(self) -> str:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, icon, is_locked, schema_json FROM collections ORDER BY name"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "## Collections\n(no collections yet)"
|
||||
lines = ["## Collections"]
|
||||
lines.append("Collection IDs: " + ", ".join(str(r["id"]) for r in rows))
|
||||
for r in rows:
|
||||
props = json.loads(r["schema_json"]) if r["schema_json"] else []
|
||||
schema = ", ".join(p if isinstance(p, str) else p.get("name", "?") for p in props) or "none"
|
||||
lock = " [LOCKED]" if r["is_locked"] else ""
|
||||
lines.append(f"- #{r['id']} {r['icon']} **{r['name']}** (schema: {schema}){lock}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _pages_context(self, limit: int = 40) -> str:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, collection_id, title, property_values_json "
|
||||
"FROM collection_pages ORDER BY updated_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "## Pages\n(no pages yet)"
|
||||
lines = ["## Recent pages"]
|
||||
for r in rows:
|
||||
props = json.loads(r["property_values_json"]) if r["property_values_json"] else {}
|
||||
summary = ", ".join(str(v) for v in props.values() if v) if props else ""
|
||||
lines.append(f"- page #{r['id']} in collection #{r['collection_id']}: **{r['title']}**{(' — ' + summary) if summary else ''}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _documents_context(self, limit: int = 30) -> str:
|
||||
"""Recent editor documents (`pages`), usable with create/read/update tools."""
|
||||
with get_conn() as conn:
|
||||
ws_names = dict(
|
||||
conn.execute("SELECT id, name FROM workspaces").fetchall()
|
||||
)
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace_id, content_format, parent_id "
|
||||
"FROM pages WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "## Documents\n(aucun document)"
|
||||
lines = ["## Documents (pages éditeur — outils: read_document, write_blocks, create_document)"]
|
||||
for r in rows:
|
||||
ws_name = ws_names.get(r["workspace_id"], str(r["workspace_id"]) if r["workspace_id"] else "racine")
|
||||
lines.append(f"- document #{r['id']} **{r['title'] or 'Sans titre'}** (espace: {ws_name})")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _workspaces_context(self) -> str:
|
||||
"""Workspaces accessible to the current user (with counts)."""
|
||||
base_sql = (
|
||||
"SELECT w.id, w.name, {role} AS role, "
|
||||
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id=w.id AND p.deleted_at IS NULL) AS document_count "
|
||||
"FROM workspaces w {join} {where} ORDER BY w.name"
|
||||
)
|
||||
with get_conn() as conn:
|
||||
if self.user_id is None:
|
||||
rows = conn.execute(
|
||||
base_sql.format(role="'owner'", join="", where=""), []
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
base_sql.format(
|
||||
role="COALESCE(wm.role, CASE WHEN w.owner_id=? THEN 'owner' ELSE 'viewer' END)",
|
||||
join="LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=?",
|
||||
where="WHERE w.owner_id=? OR wm.user_id IS NOT NULL",
|
||||
),
|
||||
(self.user_id, self.user_id, self.user_id),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "## Espaces de travail\n(aucun espace)"
|
||||
lines = ["## Espaces de travail (outil: read_workspaces)"]
|
||||
for r in rows:
|
||||
lines.append(f"- espace #{r['id']} **{r['name']}** ({r['role']}, {r['document_count']} document(s))")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _mentions_context(self, mentions: list[str]) -> str:
|
||||
"""Resolve @document:x / @collection:x / @page:y / @repo:o/r mentions.
|
||||
|
||||
Mentions bring the *actual content* of the referenced object into the
|
||||
context so the LLM can summarise / rewrite / analyse it directly without
|
||||
needing a read tool round-trip (and so the offline mock stays useful).
|
||||
"""
|
||||
lines = ["## Mentioned context"]
|
||||
for m in mentions:
|
||||
if m.startswith("document:"):
|
||||
pid = m.split(":", 1)[1]
|
||||
lines.append(self._single_document(pid))
|
||||
elif m.startswith("collection:"):
|
||||
cid = m.split(":", 1)[1]
|
||||
lines.append(self._single_collection(cid))
|
||||
elif m.startswith("page:"):
|
||||
pid = m.split(":", 1)[1]
|
||||
lines.append(self._single_page(pid))
|
||||
elif m.startswith("repo:"):
|
||||
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
|
||||
elif m == "ws":
|
||||
lines.append("- @ws: full workspace context included above")
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _blocks_to_text(content: str, limit: int = 9000) -> str:
|
||||
"""Flatten a ``blocks`` document JSON into plain readable text.
|
||||
|
||||
Collects the textual payload of each block (content, title, caption,
|
||||
children, meeting notes/summary) — enough for the LLM to reason about a
|
||||
mentioned editor page without the full block schema.
|
||||
"""
|
||||
try:
|
||||
blocks = json.loads(content or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return ""
|
||||
if not isinstance(blocks, list):
|
||||
return ""
|
||||
|
||||
_TEXT_KEYS = ("content", "title", "caption", "plain_text", "notes", "summary")
|
||||
out: list[str] = []
|
||||
total = 0
|
||||
|
||||
def walk(node):
|
||||
nonlocal total
|
||||
if total >= limit:
|
||||
return
|
||||
if isinstance(node, dict):
|
||||
for k in _TEXT_KEYS:
|
||||
v = node.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
line = v.replace("\r\n", "\n").strip()
|
||||
out.append(line)
|
||||
total += len(line) + 1
|
||||
if total >= limit:
|
||||
return
|
||||
for v in node.values():
|
||||
walk(v)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
walk(item)
|
||||
|
||||
walk(blocks)
|
||||
return "\n".join(out)[:limit]
|
||||
|
||||
def _single_document(self, pid: str) -> str:
|
||||
"""Full editor-document mention: title + workspace + real content."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT p.*, w.name AS ws_name FROM pages p "
|
||||
"LEFT JOIN workspaces w ON w.id=p.workspace_id "
|
||||
"WHERE p.id=? AND p.deleted_at IS NULL",
|
||||
(pid,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return f"- document #{pid}: not found"
|
||||
title = row["title"] or "Sans titre"
|
||||
ws = row["ws_name"] or ""
|
||||
loc = f" (espace: {ws})" if ws else ""
|
||||
fmt = row["content_format"] or "blocks"
|
||||
raw = row["content"] or ""
|
||||
if fmt == "markdown":
|
||||
body = raw.strip()
|
||||
elif fmt == "file":
|
||||
body = ""
|
||||
else:
|
||||
body = self._blocks_to_text(raw)
|
||||
head = f"- document #{row['id']} **{title}**{loc}"
|
||||
if body:
|
||||
return f"{head}:\n{body[:9000]}"
|
||||
return head
|
||||
|
||||
def _single_collection(self, cid: str) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
if not row:
|
||||
return f"- collection #{cid}: not found"
|
||||
return f"- collection #{row['id']} {row['icon']} **{row['name']}**"
|
||||
|
||||
def _single_page(self, pid: str) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, title, property_values_json FROM collection_pages WHERE id=?", (pid,)).fetchone()
|
||||
if not row:
|
||||
return f"- page #{pid}: not found"
|
||||
props = json.loads(row["property_values_json"]) if row["property_values_json"] else {}
|
||||
return f"- page #{row['id']} **{row['title']}** props={json.dumps(props, ensure_ascii=False)}"
|
||||
|
||||
def _files_context(self, files: list[dict]) -> str:
|
||||
return "## Attached files\n" + "\n".join(
|
||||
f"- {f.get('name', 'file')} ({f.get('size', '?')} bytes)" for f in files
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""FlowDeck — Database templates (v5.3.0).
|
||||
|
||||
Defines the built-in (seeded) database templates, materializes a template's
|
||||
schema into real ``collection_properties`` rows, and creates a collection from
|
||||
a template. Templates are stored in ``database_templates`` (name, icon,
|
||||
description, schema_json).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
# ── Built-in templates ──
|
||||
# Each schema entry: {"name", "type", "options"?: [{name, color}]}.
|
||||
SEED_TEMPLATES: list[dict] = [
|
||||
{
|
||||
"name": "Project tracker",
|
||||
"icon": "🚀",
|
||||
"description": "Suivi de projets avec statut, priorité et échéances.",
|
||||
"schema": [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Status", "type": "status", "options": [
|
||||
{"name": "Not started", "color": "gray"},
|
||||
{"name": "In progress", "color": "blue"},
|
||||
{"name": "Done", "color": "green"},
|
||||
]},
|
||||
{"name": "Priority", "type": "select", "options": [
|
||||
{"name": "Low", "color": "gray"},
|
||||
{"name": "Medium", "color": "yellow"},
|
||||
{"name": "High", "color": "orange"},
|
||||
{"name": "Urgent", "color": "red"},
|
||||
]},
|
||||
{"name": "Due date", "type": "date"},
|
||||
{"name": "Assignee", "type": "person"},
|
||||
{"name": "Tags", "type": "multi_select"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "CRM / Contacts",
|
||||
"icon": "👥",
|
||||
"description": "Gestion des contacts et prospects.",
|
||||
"schema": [
|
||||
{"name": "Name", "type": "title"},
|
||||
{"name": "Email", "type": "email"},
|
||||
{"name": "Phone", "type": "phone"},
|
||||
{"name": "Company", "type": "text"},
|
||||
{"name": "Stage", "type": "status", "options": [
|
||||
{"name": "Lead", "color": "gray"},
|
||||
{"name": "Prospect", "color": "blue"},
|
||||
{"name": "Customer", "color": "green"},
|
||||
]},
|
||||
{"name": "Tags", "type": "multi_select"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Task list",
|
||||
"icon": "✅",
|
||||
"description": "Liste de tâches simple avec assignation et échéance.",
|
||||
"schema": [
|
||||
{"name": "Task", "type": "title"},
|
||||
{"name": "Status", "type": "status", "options": [
|
||||
{"name": "To do", "color": "gray"},
|
||||
{"name": "In progress", "color": "blue"},
|
||||
{"name": "Done", "color": "green"},
|
||||
]},
|
||||
{"name": "Priority", "type": "select", "options": [
|
||||
{"name": "Low", "color": "gray"},
|
||||
{"name": "Medium", "color": "yellow"},
|
||||
{"name": "High", "color": "red"},
|
||||
]},
|
||||
{"name": "Due date", "type": "date"},
|
||||
{"name": "Assignee", "type": "person"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Content calendar",
|
||||
"icon": "📅",
|
||||
"description": "Planification de contenu et de publications.",
|
||||
"schema": [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Type", "type": "select", "options": [
|
||||
{"name": "Article", "color": "blue"},
|
||||
{"name": "Video", "color": "orange"},
|
||||
{"name": "Social", "color": "green"},
|
||||
{"name": "Newsletter", "color": "purple"},
|
||||
]},
|
||||
{"name": "Status", "type": "status", "options": [
|
||||
{"name": "Draft", "color": "gray"},
|
||||
{"name": "In review", "color": "yellow"},
|
||||
{"name": "Published", "color": "green"},
|
||||
]},
|
||||
{"name": "Publish date", "type": "date"},
|
||||
{"name": "Category", "type": "select"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Meeting notes",
|
||||
"icon": "🗒️",
|
||||
"description": "Notes de réunion avec participants, agenda, notes et actions.",
|
||||
"schema": [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Date", "type": "date"},
|
||||
{"name": "Attendees", "type": "person"},
|
||||
{"name": "Status", "type": "status", "options": [
|
||||
{"name": "Scheduled", "color": "gray"},
|
||||
{"name": "Done", "color": "green"},
|
||||
]},
|
||||
{"name": "Agenda", "type": "text"},
|
||||
{"name": "Notes", "type": "text"},
|
||||
{"name": "Action items", "type": "multi_select"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Reading list",
|
||||
"icon": "📚",
|
||||
"description": "Articles, livres et ressources à lire.",
|
||||
"schema": [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "URL", "type": "url"},
|
||||
{"name": "Status", "type": "status", "options": [
|
||||
{"name": "To read", "color": "gray"},
|
||||
{"name": "Reading", "color": "blue"},
|
||||
{"name": "Done", "color": "green"},
|
||||
]},
|
||||
{"name": "Notes", "type": "text"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def materialize_properties(conn, collection_id: int, schema: list) -> None:
|
||||
"""Insert ``collection_properties`` rows from a template ``schema``.
|
||||
|
||||
The ``title`` property is represented by ``collection_pages.title`` and is
|
||||
not created as a column. Rows are inserted in schema order.
|
||||
"""
|
||||
position = 0
|
||||
for prop in schema:
|
||||
name = (prop.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
prop_type = prop.get("type", "text")
|
||||
if prop_type == "title":
|
||||
continue
|
||||
options = prop.get("options") or []
|
||||
# idempotency guard — skip if a same-named property already exists
|
||||
exists = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND name=?",
|
||||
(collection_id, name),
|
||||
).fetchone()
|
||||
if exists:
|
||||
continue
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, number_format, position)
|
||||
VALUES (?, ?, ?, ?, 'number', ?)""",
|
||||
(collection_id, name, prop_type, json.dumps(options), position),
|
||||
)
|
||||
position += 1
|
||||
|
||||
|
||||
def create_from_template(
|
||||
conn,
|
||||
name: str,
|
||||
template: dict,
|
||||
*,
|
||||
parent_page_id: int | None = None,
|
||||
workspace_id: int | None = None,
|
||||
) -> int:
|
||||
"""Create a collection from a template (with properties + default view).
|
||||
|
||||
``template`` may be a DB row (sqlite Row) or a dict; it must expose
|
||||
``icon``, ``description`` and ``schema_json`` (JSON-encoded schema).
|
||||
"""
|
||||
icon = template.get("icon") if isinstance(template, dict) else template["icon"]
|
||||
description = template.get("description") if isinstance(template, dict) else template["description"]
|
||||
schema_json = template.get("schema_json") if isinstance(template, dict) else template["schema_json"]
|
||||
try:
|
||||
schema = json.loads(schema_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
schema = []
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)""",
|
||||
(name, description or "", icon or "📋", json.dumps(schema),
|
||||
parent_page_id, workspace_id),
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
|
||||
materialize_properties(conn, collection_id, schema)
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(collection_id, "Default View", "table", json.dumps({
|
||||
"visible_properties": ["Title"],
|
||||
"sorts": [],
|
||||
"filters": [],
|
||||
})),
|
||||
)
|
||||
return collection_id
|
||||
@@ -0,0 +1,278 @@
|
||||
"""FlowDeck — Media embeds (v5.5.0): provider detection + iframe rewriting.
|
||||
|
||||
Maps a raw http(s) URL to a provider-specific embed URL so that one generic
|
||||
``embed`` block can render YouTube, Vimeo, Figma, Google Maps, Google
|
||||
Docs/Sheets/Slides, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch,
|
||||
X/Twitter, Pinterest, Microsoft Office docs… exactly like Notion's universal
|
||||
embed.
|
||||
|
||||
Unknown/showable URLs (PDF, images, direct video/audio files, plain http)
|
||||
fall back to a plain iframe so the link is still visible inline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
|
||||
|
||||
def _q(params, key):
|
||||
vals = params.get(key)
|
||||
return vals[0] if vals else ""
|
||||
|
||||
|
||||
def _host_matches(netloc: str, host: str) -> bool:
|
||||
"""True when ``netloc`` is ``host`` or one of its subdomains."""
|
||||
netloc = (netloc or "").lower().split(":")[0]
|
||||
host = host.lower()
|
||||
return netloc == host or netloc.endswith("." + host)
|
||||
|
||||
|
||||
def _embed_youtube(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"/(?:v|shorts|embed|live)/([A-Za-z0-9_-]{6,20})", path)
|
||||
vid = m.group(1) if m else _q(params, "v")
|
||||
if not vid:
|
||||
# youtu.be/<id> (short link) — the id is the first path segment.
|
||||
seg = path.strip("/").split("/")[0]
|
||||
if re.fullmatch(r"[A-Za-z0-9_-]{6,20}", seg or ""):
|
||||
vid = seg
|
||||
if not vid:
|
||||
return None
|
||||
start = _q(params, "t") or _q(params, "start")
|
||||
frag = f"?start={start}" if start else ""
|
||||
return f"https://www.youtube.com/embed/{vid}{frag}"
|
||||
|
||||
|
||||
def _embed_vimeo(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"/(\d{6,12})", path)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://player.vimeo.com/video/{m.group(1)}"
|
||||
|
||||
|
||||
def _embed_loom(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"/(?:embed/|share/)?([0-9a-f]{32})", path)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://www.loom.com/embed/{m.group(1)}"
|
||||
|
||||
|
||||
def _embed_figma(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
clean = url.split("?", 1)[0]
|
||||
if "figma.com/file/" not in clean and "figma.com/proto/" not in clean and "figma.com/design/" not in clean:
|
||||
return None
|
||||
return "https://www.figma.com/embed?embed_host=flowdeck&url=" + quote(clean, safe="")
|
||||
|
||||
|
||||
def _embed_map(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "google.com/maps" not in url and "maps.app.goo.gl" not in url:
|
||||
return None
|
||||
return "https://maps.google.com/maps?q=" + quote(url, safe="") + "&output=embed"
|
||||
|
||||
|
||||
def _embed_gdocs(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(
|
||||
r"docs\.google\.com/(document|spreadsheets|presentation|forms)/d/([A-Za-z0-9_-]+)", url
|
||||
)
|
||||
if not m:
|
||||
return None
|
||||
kind, doc_id = m.group(1), m.group(2)
|
||||
if kind == "forms":
|
||||
return f"https://docs.google.com/forms/d/{doc_id}/viewform?embedded=true"
|
||||
return f"https://docs.google.com/{kind}/d/{doc_id}/preview"
|
||||
|
||||
|
||||
def _embed_codepen(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"codepen\.io/([^/]+)/pen/([^/?#]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://codepen.io/{m.group(1)}/embed/{m.group(2)}?default-tab=result"
|
||||
|
||||
|
||||
def _embed_miro(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"miro\.com/app/(?:board|live-embed)/([^/?#]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://miro.com/app/live-embed/{m.group(1)}"
|
||||
|
||||
|
||||
def _embed_spotify(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"/(track|playlist|album|episode|show|artist)/([A-Za-z0-9]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://open.spotify.com/embed/{m.group(1)}/{m.group(2)}"
|
||||
|
||||
|
||||
def _embed_soundcloud(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "soundcloud.com" not in url:
|
||||
return None
|
||||
return "https://w.soundcloud.com/player/?url=" + quote(url, safe="") + "&color=%2300aaff"
|
||||
|
||||
|
||||
def _embed_twitch(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "twitch.tv" not in url:
|
||||
return None
|
||||
parent = (ctx.get("parent") or "localhost").replace("https://", "").replace("http://", "").split("/")[0]
|
||||
video = re.search(r"twitch\.tv/videos/(\d+)", url)
|
||||
if video:
|
||||
return f"https://player.twitch.tv/?video={video.group(1)}&parent={parent}"
|
||||
m = re.search(r"twitch\.tv/([^/?#]+)", url)
|
||||
if not m or m.group(1) in ("videos", "directory"):
|
||||
return None
|
||||
return f"https://player.twitch.tv/?channel={m.group(1)}&parent={parent}"
|
||||
|
||||
|
||||
def _embed_twitter(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "twitter.com" not in url and "x.com" not in url:
|
||||
return None
|
||||
m = re.search(r"/status(?:es)?/(\d+)", url)
|
||||
if not m:
|
||||
return f"https://platform.twitter.com/embed/Tweet.html?url={quote(url, safe='')}"
|
||||
return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(1)}"
|
||||
|
||||
|
||||
def _embed_pinterest(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "pinterest" not in url:
|
||||
return None
|
||||
return f"https://pinterest.com/pin/embed?url={quote(url, safe='')}"
|
||||
|
||||
|
||||
def _embed_office(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
low = url.lower().split("?", 1)[0]
|
||||
if low.endswith((".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp")):
|
||||
return "https://view.officeapps.live.com/op/embed.aspx?src=" + quote(url, safe="")
|
||||
if "officeapps.live.com" in low or "sharepoint.com" in low or "1drv.ms" in low:
|
||||
return "https://view.officeapps.live.com/op/embed.aspx?src=" + quote(url, safe="")
|
||||
return None
|
||||
|
||||
|
||||
def _embed_files(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
"""Direct media: PDF/images/videos/audio can live in a plain iframe."""
|
||||
return url
|
||||
|
||||
|
||||
# (host, handler) — order matters: more specific hosts first.
|
||||
_HANDLERS = (
|
||||
("youtube.com", _embed_youtube),
|
||||
("youtu.be", _embed_youtube),
|
||||
("vimeo.com", _embed_vimeo),
|
||||
("loom.com", _embed_loom),
|
||||
("figma.com", _embed_figma),
|
||||
("docs.google.com", _embed_gdocs),
|
||||
("google.com/maps", _embed_map),
|
||||
("maps.app.goo.gl", _embed_map),
|
||||
("codepen.io", _embed_codepen),
|
||||
("miro.com", _embed_miro),
|
||||
("open.spotify.com", _embed_spotify),
|
||||
("spotify.com", _embed_spotify),
|
||||
("soundcloud.com", _embed_soundcloud),
|
||||
("twitch.tv", _embed_twitch),
|
||||
("twitter.com", _embed_twitter),
|
||||
("x.com", _embed_twitter),
|
||||
("pinterest.", _embed_pinterest),
|
||||
("office.com", _embed_office),
|
||||
("officeapps.live.com", _embed_office),
|
||||
("sharepoint.com", _embed_office),
|
||||
("1drv.ms", _embed_office),
|
||||
)
|
||||
|
||||
|
||||
_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*):")
|
||||
|
||||
|
||||
def _parse(url: str):
|
||||
raw = url.strip()
|
||||
if not raw:
|
||||
return None, None, None
|
||||
m = _SCHEME_RE.match(raw)
|
||||
if m:
|
||||
if m.group(1).lower() not in ("http", "https"):
|
||||
return None, None, None # mailto:, tel:, javascript:, data:…
|
||||
else:
|
||||
raw = "https://" + raw
|
||||
u = urlparse(raw)
|
||||
if u.scheme not in ("http", "https") or not u.netloc:
|
||||
return None, None, None
|
||||
host = u.hostname or ""
|
||||
if "." not in host and host != "localhost":
|
||||
return None, None, None # a bare word is not a URL
|
||||
return raw, u, parse_qs(u.query)
|
||||
|
||||
|
||||
def embed_src(url: str, *, parent: str = "") -> str | None:
|
||||
"""Return the embeddable iframe src for a URL, or None if it can't embed."""
|
||||
raw, u, params = _parse(url)
|
||||
if raw is None:
|
||||
return None
|
||||
ctx = {"parent": parent}
|
||||
netloc = (u.netloc or "").lower()
|
||||
for needle, handler in _HANDLERS:
|
||||
if "/" in needle or needle.endswith("."):
|
||||
if needle in raw.lower():
|
||||
return handler(raw, u.path, params, ctx)
|
||||
elif _host_matches(netloc, needle):
|
||||
return handler(raw, u.path, params, ctx)
|
||||
# Office documents hosted on arbitrary domains.
|
||||
office = _embed_office(raw, u.path, params, ctx)
|
||||
if office:
|
||||
return office
|
||||
return _embed_files(raw, u.path, params, ctx)
|
||||
|
||||
|
||||
_IMAGE_EXT = re.compile(r"\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$", re.I)
|
||||
_PDF_EXT = re.compile(r"\.pdf$", re.I)
|
||||
_VIDEO_EXT = re.compile(r"\.(mp4|webm|ogg|ogv|mov|m4v)$", re.I)
|
||||
_AUDIO_EXT = re.compile(r"\.(mp3|wav|ogg|oga|m4a|flac|aac)$", re.I)
|
||||
|
||||
|
||||
def inline_kind(url: str) -> str | None:
|
||||
"""Best inline renderer for a URL: 'iframe' | 'image' | 'pdf' | 'video'
|
||||
| 'audio'. Returns None when the URL should open in a new tab."""
|
||||
raw, u, _params = _parse(url)
|
||||
if raw is None:
|
||||
return None
|
||||
path = u.path or ""
|
||||
if _IMAGE_EXT.search(path):
|
||||
return "image"
|
||||
if _PDF_EXT.search(path):
|
||||
return "pdf"
|
||||
if _VIDEO_EXT.search(path):
|
||||
return "video"
|
||||
if _AUDIO_EXT.search(path):
|
||||
return "audio"
|
||||
return "iframe"
|
||||
|
||||
|
||||
def provider(url: str) -> str:
|
||||
"""Human-readable provider name for a URL (used by the editor)."""
|
||||
raw, u, _params = _parse(url)
|
||||
if raw is None:
|
||||
return ""
|
||||
netloc = (u.netloc or "").lower()
|
||||
for needle, _handler in _HANDLERS:
|
||||
if "/" in needle or needle.endswith("."):
|
||||
if needle in raw.lower():
|
||||
return needle.split(".")[0].rstrip(".")
|
||||
elif _host_matches(netloc, needle):
|
||||
name = needle.split(".")[0]
|
||||
return "youtube" if name == "youtu" else name
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_embed(url: str, *, parent: str = "") -> dict:
|
||||
"""Resolve a URL to ``{src, kind, provider}`` for the generic embed block."""
|
||||
kind = inline_kind(url)
|
||||
return {
|
||||
"src": embed_src(url, parent=parent) or "",
|
||||
"kind": kind or "",
|
||||
"provider": provider(url),
|
||||
}
|
||||
|
||||
|
||||
def embed_html(src: str, *, height: int = 520) -> str:
|
||||
"""A responsive, borderless iframe for a provider embed URL."""
|
||||
return (
|
||||
f'<iframe src="{src}" loading="lazy" '
|
||||
f'style="width:100%;height:{height}px;border:none;border-radius:8px;background:#000;" '
|
||||
f'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; '
|
||||
f'picture-in-picture" allowfullscreen></iframe>'
|
||||
)
|
||||
@@ -0,0 +1,892 @@
|
||||
"""FlowDeck — Export service (v4.7.2).
|
||||
|
||||
Four types of export, all generated server-side:
|
||||
- Markdown (``page_to_markdown``): title + blocks + récursif sous-pages
|
||||
- HTML (``page_to_standalone_html``): document autonome (styles inline)
|
||||
- PDF (``page_to_pdf_bytes``): convertit un HTML print-friendly
|
||||
- Site (``build_static_site``): site statique multi-pages (zip)
|
||||
|
||||
Supports the three ways a page's content can be stored:
|
||||
- content_format == "blocks" -> JSON list of blocks in ``content``
|
||||
- content_format == "markdown" -> raw Markdown in ``content``
|
||||
- content_format == "file" -> ``content`` is JSON metadata; the real text
|
||||
lives in an uploaded file on disk (uploads/workspace_*). We read it back so
|
||||
an exported document carries its actual content, not just its title.
|
||||
"""
|
||||
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.db import get_conn
|
||||
|
||||
# ═══════════════ Helpers ═══════════════
|
||||
|
||||
def _text(v: str, *, escape: bool = True) -> str:
|
||||
"""Normalize a block's content string."""
|
||||
s = (v or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
if escape:
|
||||
s = (s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">"))
|
||||
return s
|
||||
|
||||
|
||||
def _sanitize_id(block_id) -> str:
|
||||
if not block_id:
|
||||
return ""
|
||||
return "".join(ch for ch in str(block_id) if ch.isalnum())
|
||||
|
||||
|
||||
def _page_title(page: dict) -> str:
|
||||
return (page.get("title") or "Untitled").strip() or "Untitled"
|
||||
|
||||
|
||||
def _blocks_of(page: dict) -> list:
|
||||
content = page.get("content") or ""
|
||||
fmt = page.get("content_format") or "blocks"
|
||||
if fmt != "blocks" or not content:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def _block_text(b: dict) -> str:
|
||||
return _text(b.get("content"), escape=False)
|
||||
|
||||
|
||||
# ── Source resolution: read real textual content for ANY page type ──
|
||||
|
||||
# Extensions whose content is plain text / code / markdown (textual exportable).
|
||||
_TEXTUAL_EXTS = {
|
||||
"md", "markdown", "txt", "log", "text",
|
||||
"py", "js", "ts", "jsx", "tsx", "html", "htm", "css", "json", "xml",
|
||||
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "sh", "bash", "zsh",
|
||||
"ps1", "bat", "cmd", "rb", "go", "rs", "java", "c", "cpp", "h", "hpp",
|
||||
"php", "swift", "kt", "scala", "sql", "r", "vue", "svelte", "astro",
|
||||
"properties", "gitignore", "dockerfile", "makefile",
|
||||
}
|
||||
_CODE_LANG = {
|
||||
"py": "python", "js": "javascript", "ts": "typescript", "jsx": "javascript",
|
||||
"tsx": "typescript", "html": "html", "htm": "html", "css": "css",
|
||||
"json": "json", "xml": "xml", "yaml": "yaml", "yml": "yaml",
|
||||
"toml": "toml", "ini": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
|
||||
"sh": "bash", "bash": "bash", "zsh": "bash", "ps1": "powershell",
|
||||
"bat": "batch", "cmd": "batch", "rb": "ruby", "go": "go", "rs": "rust",
|
||||
"java": "java", "c": "c", "cpp": "cpp", "h": "c", "hpp": "cpp",
|
||||
"php": "php", "swift": "swift", "kt": "kotlin", "scala": "scala",
|
||||
"sql": "sql", "r": "r", "vue": "html", "svelte": "html",
|
||||
"astro": "html", "properties": "ini", "md": "markdown",
|
||||
"markdown": "markdown", "txt": "plaintext", "log": "plaintext",
|
||||
"text": "plaintext",
|
||||
}
|
||||
_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"))
|
||||
|
||||
|
||||
def _file_meta(page: dict) -> dict:
|
||||
try:
|
||||
meta = json.loads(page.get("content") or "{}")
|
||||
return meta if isinstance(meta, dict) else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _file_text(page: dict) -> str | None:
|
||||
"""Return the textual content of an uploaded ``file`` page, or None.
|
||||
|
||||
Only reads plain-text / code / markdown files. Binary (PDF, images…)
|
||||
returns None and is skipped by exporters (nothing meaningful to include).
|
||||
"""
|
||||
if (page.get("content_format") or "") != "file":
|
||||
return None
|
||||
meta = _file_meta(page)
|
||||
rel = (meta.get("file_path") or "").replace("\\", "/").strip()
|
||||
if not rel or ".." in rel.replace("\\", "/").split("/") or not rel.startswith("uploads/"):
|
||||
return None
|
||||
name = (rel.rsplit("/", 1)[-1] or "").lower()
|
||||
ext = name.rsplit(".", 1)[-1] if "." in name else ""
|
||||
mime = (meta.get("mime_type") or "").lower()
|
||||
if not (ext in _TEXTUAL_EXTS or mime.startswith("text/")):
|
||||
return None
|
||||
try:
|
||||
full = (_data_root() / rel).resolve()
|
||||
root = _data_root().resolve()
|
||||
if root not in full.parents:
|
||||
return None
|
||||
return full.read_text(encoding="utf-8", errors="replace")
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _page_source(page: dict):
|
||||
"""Return (kind, payload) describing where the page's real content lives.
|
||||
|
||||
kind ∈ {"blocks", "md", "code"}:
|
||||
- "blocks": payload is the block list (block editor pages)
|
||||
- "md" : payload is raw Markdown text
|
||||
- "code" : payload is (text, language)
|
||||
An empty/unsupported page yields ("blocks", []).
|
||||
"""
|
||||
fmt = (page.get("content_format") or "blocks")
|
||||
content = page.get("content") or ""
|
||||
|
||||
if fmt == "blocks":
|
||||
return "blocks", _blocks_of(page)
|
||||
|
||||
if fmt == "markdown":
|
||||
if content.strip():
|
||||
return "md", content
|
||||
return "blocks", []
|
||||
|
||||
if fmt == "file":
|
||||
text = _file_text(page)
|
||||
if text is None:
|
||||
return "blocks", []
|
||||
meta = _file_meta(page)
|
||||
name = (meta.get("file_path") or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
|
||||
ext = name.rsplit(".", 1)[-1] if "." in name else ""
|
||||
mime = (meta.get("mime_type") or "").lower()
|
||||
if ext in ("md", "markdown") or mime in _MARKDOWN_MIMES or mime.startswith("text/markdown"):
|
||||
return "md", text
|
||||
lang = _CODE_LANG.get(ext, "plaintext")
|
||||
return "code", (text, lang)
|
||||
|
||||
# Unknown format (e.g. legacy) -> try to dump as raw text
|
||||
if content.strip():
|
||||
return "md", content
|
||||
return "blocks", []
|
||||
|
||||
|
||||
# ── GFM pipe-table parsing (raw markdown → "table" block) ──
|
||||
|
||||
_SEP_CELL = re.compile(r"^:?-+:?$")
|
||||
|
||||
|
||||
def _split_pipe_cells(line: str) -> list[str]:
|
||||
"""Split a GFM pipe row into trimmed cell strings."""
|
||||
s = line.strip()
|
||||
if s.startswith("|"):
|
||||
s = s[1:]
|
||||
if s.endswith("|") and not s.endswith(r"\|"):
|
||||
s = s[:-1]
|
||||
# split on unescaped pipes
|
||||
cells: list[str] = []
|
||||
cur: list[str] = []
|
||||
i = 0
|
||||
while i < len(s):
|
||||
ch = s[i]
|
||||
if ch == "\\" and i + 1 < len(s) and s[i + 1] == "|":
|
||||
cur.append("|")
|
||||
i += 2
|
||||
continue
|
||||
if ch == "|":
|
||||
cells.append("".join(cur).strip())
|
||||
cur = []
|
||||
i += 1
|
||||
continue
|
||||
cur.append(ch)
|
||||
i += 1
|
||||
cells.append("".join(cur).strip())
|
||||
return cells
|
||||
|
||||
|
||||
def _is_table_delimiter(line: str) -> bool:
|
||||
s = line.strip()
|
||||
if not s:
|
||||
return False
|
||||
if s.startswith("|"):
|
||||
s = s[1:]
|
||||
if s.endswith("|"):
|
||||
s = s[:-1]
|
||||
cells = [c.strip() for c in s.split("|")]
|
||||
return bool(cells) and all(_SEP_CELL.match(c) for c in cells)
|
||||
|
||||
|
||||
def _parse_table_at(lines: list[str], i: int, n: int):
|
||||
"""If a GFM table starts at index i (header row + delimiter row), return
|
||||
(table_block, next_index). Otherwise return None."""
|
||||
header_cells = _split_pipe_cells(lines[i])
|
||||
if len(header_cells) <= 1:
|
||||
return None
|
||||
if i + 1 >= n or not _is_table_delimiter(lines[i + 1]):
|
||||
return None
|
||||
sep_cells = _split_pipe_cells(lines[i + 1])
|
||||
align = []
|
||||
for c in sep_cells[: len(header_cells)]:
|
||||
c = c.strip()
|
||||
if c.startswith(":") and c.endswith(":"):
|
||||
align.append("center")
|
||||
elif c.endswith(":"):
|
||||
align.append("right")
|
||||
else:
|
||||
align.append("left")
|
||||
rows = [header_cells]
|
||||
j = i + 2
|
||||
while j < n:
|
||||
s = lines[j].strip()
|
||||
if not s or not s.startswith("|"):
|
||||
break
|
||||
cells = _split_pipe_cells(lines[j])
|
||||
rows.append(cells)
|
||||
j += 1
|
||||
width = max(len(r) for r in rows)
|
||||
def pad(r):
|
||||
return r + [""] * (width - len(r))
|
||||
align = (align + ["left"] * width)[:width]
|
||||
return (
|
||||
{
|
||||
"type": "table",
|
||||
"has_header": True,
|
||||
"align": align,
|
||||
"rows": [pad(r) for r in rows],
|
||||
},
|
||||
j,
|
||||
)
|
||||
|
||||
|
||||
def _table_to_markdown(b: dict) -> str:
|
||||
rows = b.get("rows") or []
|
||||
if not rows:
|
||||
return ""
|
||||
align = b.get("align") or []
|
||||
width = max(len(r) for r in rows)
|
||||
align = (align + ["left"] * width)[:width]
|
||||
has_header = b.get("has_header", True)
|
||||
out: list[str] = []
|
||||
|
||||
def rowline(r):
|
||||
cells = list(r) + [""] * (width - len(r))
|
||||
return "| " + " | ".join(cells) + " |"
|
||||
|
||||
start = 0
|
||||
if has_header:
|
||||
out.append(rowline(rows[0]))
|
||||
seps = []
|
||||
for a in align:
|
||||
if a == "center":
|
||||
seps.append(":---:")
|
||||
elif a == "right":
|
||||
seps.append("---:")
|
||||
else:
|
||||
seps.append(":---")
|
||||
out.append("| " + " | ".join(seps) + " |")
|
||||
start = 1
|
||||
for ri in range(start, len(rows)):
|
||||
out.append(rowline(rows[ri]))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _table_to_html(b: dict) -> str:
|
||||
rows = b.get("rows") or []
|
||||
if not rows:
|
||||
return ""
|
||||
align = b.get("align") or []
|
||||
width = max(len(r) for r in rows)
|
||||
align = (align + ["left"] * width)[:width]
|
||||
|
||||
def cell_html(tag, text, a):
|
||||
style = f' style="text-align:{a};"' if a and a != "left" else ""
|
||||
return f"<{tag}{style}>{_text(text)}</{tag}>"
|
||||
|
||||
has_header = b.get("has_header", True)
|
||||
first_col = b.get("first_col_header", False)
|
||||
header_rows = 1 if has_header else 0
|
||||
head = ""
|
||||
if header_rows:
|
||||
head_rows = []
|
||||
hr = rows[0]
|
||||
cells = list(hr) + [""] * (width - len(hr))
|
||||
head_cells = []
|
||||
for ci, c in enumerate(cells):
|
||||
tag = "th" if first_col and ci == 0 else "th"
|
||||
head_cells.append(cell_html(tag, c, align[ci]))
|
||||
head_rows.append("<tr>" + "".join(head_cells) + "</tr>")
|
||||
head = "<thead>" + "".join(head_rows) + "</thead>"
|
||||
tbody_rows = rows[header_rows:]
|
||||
body_rows = []
|
||||
for r in tbody_rows:
|
||||
cells = list(r) + [""] * (width - len(r))
|
||||
row_cells = []
|
||||
for ci, c in enumerate(cells):
|
||||
tag = "th" if first_col and ci == 0 else "td"
|
||||
row_cells.append(cell_html(tag, c, align[ci]))
|
||||
body_rows.append("<tr>" + "".join(row_cells) + "</tr>")
|
||||
body = "<tbody>" + "".join(body_rows) + "</tbody>"
|
||||
return f'<table class="ftable">{head}{body}</table>'
|
||||
|
||||
|
||||
# ── Markdown renderer (raw markdown → exportable fragments) ──
|
||||
|
||||
def _md_to_blocks(md: str) -> list:
|
||||
"""Convert raw Markdown text into the same lightweight block list the
|
||||
editor produces (headings, lists, to-do, quote, code, divider, paragraph).
|
||||
|
||||
Kept intentionally simple: inline formatting (bold/links) is preserved as
|
||||
literal text, matching how the block editor treats imported .md files.
|
||||
"""
|
||||
blocks: list = []
|
||||
buf = md.replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = buf.split("\n")
|
||||
i = 0
|
||||
n = len(lines)
|
||||
para: list[str] = []
|
||||
|
||||
def flush_para():
|
||||
nonlocal para
|
||||
if para:
|
||||
blocks.append({"type": "paragraph", "content": "\n".join(para).strip()})
|
||||
para = []
|
||||
|
||||
while i < n:
|
||||
line = lines[i].rstrip()
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
flush_para()
|
||||
i += 1
|
||||
continue
|
||||
if stripped.startswith("```") or stripped.startswith("~~~"):
|
||||
flush_para()
|
||||
fence = stripped[0:3]
|
||||
lang = stripped[3:].strip()
|
||||
i += 1
|
||||
code: list[str] = []
|
||||
while i < n and not lines[i].strip().startswith(fence):
|
||||
code.append(lines[i])
|
||||
i += 1
|
||||
if i < n:
|
||||
i += 1 # closing fence
|
||||
blocks.append({"type": "code", "content": "\n".join(code), "language": lang})
|
||||
continue
|
||||
if stripped.startswith("|"):
|
||||
# GFM pipe table: header row immediately followed by a delimiter row
|
||||
parsed = _parse_table_at(lines, i, n)
|
||||
if parsed is not None:
|
||||
flush_para()
|
||||
tbl, i = parsed
|
||||
blocks.append(tbl)
|
||||
continue
|
||||
m = re.match(r"^(#{1,6})\s+(.*)$", stripped)
|
||||
if m and line == stripped: # ATX heading must be whole line
|
||||
level = len(m.group(1))
|
||||
flush_para()
|
||||
blocks.append({"type": f"heading_{min(level, 4)}", "content": m.group(2).strip()})
|
||||
i += 1
|
||||
continue
|
||||
if stripped == "---" or stripped == "***" or stripped == "___":
|
||||
flush_para()
|
||||
blocks.append({"type": "divider", "content": ""})
|
||||
i += 1
|
||||
continue
|
||||
if re.match(r"^\s*[-*+]\s+\[[ xX]\]\s+", line):
|
||||
flush_para()
|
||||
while i < n:
|
||||
s = lines[i].strip()
|
||||
m2 = re.match(r"^[-*+]\s+\[([ xX])\]\s+(.*)$", s)
|
||||
if not m2:
|
||||
break
|
||||
blocks.append({
|
||||
"type": "to_do",
|
||||
"content": m2.group(2).strip(),
|
||||
"checked": m2.group(1).lower() == "x",
|
||||
})
|
||||
i += 1
|
||||
continue
|
||||
if re.match(r"^\s*[-*+]\s+", line):
|
||||
flush_para()
|
||||
while i < n:
|
||||
s = lines[i].strip()
|
||||
m2 = re.match(r"^[-*+]\s+(.*)$", s)
|
||||
if not m2:
|
||||
break
|
||||
blocks.append({"type": "bulleted_list", "content": m2.group(1).strip()})
|
||||
i += 1
|
||||
continue
|
||||
if re.match(r"^\s*\d+[.)]\s+", line):
|
||||
flush_para()
|
||||
while i < n:
|
||||
s = lines[i].strip()
|
||||
m2 = re.match(r"^\d+[.)]\s+(.*)$", s)
|
||||
if not m2:
|
||||
break
|
||||
blocks.append({"type": "numbered_list", "content": m2.group(1).strip()})
|
||||
i += 1
|
||||
continue
|
||||
mq = re.match(r"^>\s?(.*)$", stripped)
|
||||
if mq and line == stripped:
|
||||
flush_para()
|
||||
while i < n:
|
||||
s = lines[i].strip()
|
||||
m2 = re.match(r"^>\s?(.*)$", s)
|
||||
if not m2:
|
||||
break
|
||||
para.append(m2.group(1))
|
||||
i += 1
|
||||
blocks.append({"type": "quote", "content": "\n".join(para)})
|
||||
para = []
|
||||
continue
|
||||
para.append(stripped)
|
||||
i += 1
|
||||
flush_para()
|
||||
return blocks
|
||||
|
||||
|
||||
def _page_blocks(page: dict) -> list:
|
||||
"""Blocks used for HTML/PDF rendering regardless of storage format."""
|
||||
kind, payload = _page_source(page)
|
||||
if kind == "blocks":
|
||||
return payload
|
||||
if kind == "code":
|
||||
text, lang = payload
|
||||
return [{"type": "code", "content": text, "language": lang}] if text else []
|
||||
if kind == "md":
|
||||
return _md_to_blocks(payload)
|
||||
return []
|
||||
|
||||
|
||||
def _page_markdown_source(page: dict) -> str:
|
||||
"""Raw markdown when the page IS markdown-sourced, else empty string."""
|
||||
kind, payload = _page_source(page)
|
||||
if kind == "md":
|
||||
return payload
|
||||
return ""
|
||||
|
||||
|
||||
def markdown_to_blocks(md: str) -> list:
|
||||
"""Public wrapper around the GFM→blocks parser (used by page import)."""
|
||||
return _md_to_blocks(md)
|
||||
|
||||
|
||||
# ═══════════════ Markdown ═══════════════
|
||||
|
||||
def blocks_to_markdown(blocks: list) -> str:
|
||||
"""Convert a block array to Markdown (server-side, all block types)."""
|
||||
out: list[str] = []
|
||||
for b in blocks or []:
|
||||
t = b.get("type", "paragraph")
|
||||
c = _block_text(b)
|
||||
if t == "heading_1":
|
||||
out.append(f"# {c}")
|
||||
elif t == "heading_2":
|
||||
out.append(f"## {c}")
|
||||
elif t == "heading_3":
|
||||
out.append(f"### {c}")
|
||||
elif t == "heading_4":
|
||||
out.append(f"#### {c}")
|
||||
elif t == "bulleted_list":
|
||||
out.append(f"- {c}")
|
||||
elif t == "numbered_list":
|
||||
out.append(f"1. {c}")
|
||||
elif t == "to_do":
|
||||
out.append(f"{'- [x]' if b.get('checked') else '- [ ]'} {c}")
|
||||
elif t == "quote":
|
||||
out.append(f"> {c}")
|
||||
elif t == "divider":
|
||||
out.append("---")
|
||||
elif t == "code":
|
||||
lang = b.get("language") or ""
|
||||
out.append(f"```{lang}\n{c}\n```")
|
||||
elif t == "toggle":
|
||||
out.append(f"### {c}")
|
||||
if b.get("children"):
|
||||
out.append(blocks_to_markdown(b["children"]))
|
||||
elif t == "math":
|
||||
out.append(f"$$\n{c}\n$$")
|
||||
elif t == "table_of_contents":
|
||||
out.append("[TOC]")
|
||||
elif t == "columns":
|
||||
for child in b.get("children") or []:
|
||||
out.append(blocks_to_markdown([child]))
|
||||
elif t == "image":
|
||||
src = b.get("src") or ""
|
||||
alt = (b.get("alt") or "").strip() or "image"
|
||||
out.append(f"")
|
||||
elif t == "video":
|
||||
out.append(f"[Video]({b.get('src') or ''})")
|
||||
elif t == "audio":
|
||||
out.append(f"[Audio]({b.get('src') or ''})")
|
||||
elif t == "bookmark":
|
||||
url = b.get("url") or b.get("src") or ""
|
||||
title = (b.get("title") or "").strip()
|
||||
out.append(f"[{title or url}]({url})" if title else url)
|
||||
elif t == "embed":
|
||||
url = b.get("src") or ""
|
||||
if b.get("embed_type") in ("pdf", "download", None, ""):
|
||||
out.append(f"[{url}]({url})" if url else "[embed]")
|
||||
else:
|
||||
out.append(f"[{url}]({url})" if url else "[embed]")
|
||||
elif t == "table":
|
||||
out.append(_table_to_markdown(b))
|
||||
elif t == "synced":
|
||||
synced_id = b.get("synced_id")
|
||||
if synced_id:
|
||||
try:
|
||||
from app.services.synced_blocks import get_synced_block
|
||||
sb = get_synced_block(synced_id)
|
||||
if sb and sb.get("content"):
|
||||
resolved = json.loads(sb["content"])
|
||||
if isinstance(resolved, list):
|
||||
out.append(blocks_to_markdown(resolved))
|
||||
else:
|
||||
out.append(str(resolved))
|
||||
except Exception:
|
||||
out.append(f"[Synced block {synced_id}]")
|
||||
else:
|
||||
out.append(c)
|
||||
return "\n\n".join(filter(None, out))
|
||||
|
||||
|
||||
def _child_pages(page: dict) -> list:
|
||||
"""Immediate non-deleted children of a page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM pages WHERE parent_id=? AND deleted_at IS NULL "
|
||||
"ORDER BY COALESCE(sort_order, created_at) ASC, id ASC",
|
||||
(page["id"],),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def page_to_markdown(page: dict, *, include_children: bool = True) -> str:
|
||||
"""Markdown for a single page, with optional sub-pages appended."""
|
||||
parts = [f"# {_page_title(page)}", ""]
|
||||
md_source = _page_markdown_source(page)
|
||||
if md_source:
|
||||
parts.append(md_source.strip())
|
||||
else:
|
||||
md = blocks_to_markdown(_page_blocks(page))
|
||||
if md:
|
||||
parts.append(md)
|
||||
md = "\n\n".join(filter(None, parts)).rstrip()
|
||||
|
||||
if include_children:
|
||||
for sub in _child_pages(page):
|
||||
sub_md = page_to_markdown(sub, include_children=True)
|
||||
if sub_md:
|
||||
md += f"\n\n---\n\n{sub_md}"
|
||||
return md
|
||||
|
||||
|
||||
# ═══════════════ HTML ═══════════════
|
||||
|
||||
def blocks_to_html(blocks: list) -> str:
|
||||
"""Convert a block array to a self-contained HTML fragment."""
|
||||
parts: list[str] = []
|
||||
for b in blocks or []:
|
||||
t = b.get("type", "paragraph")
|
||||
c = _text(b.get("content"))
|
||||
if t == "heading_1":
|
||||
parts.append(f'<h1 id="h-{_sanitize_id(b.get("id"))}">{c}</h1>')
|
||||
elif t == "heading_2":
|
||||
parts.append(f'<h2 id="h-{_sanitize_id(b.get("id"))}">{c}</h2>')
|
||||
elif t == "heading_3":
|
||||
parts.append(f'<h3 id="h-{_sanitize_id(b.get("id"))}">{c}</h3>')
|
||||
elif t == "heading_4":
|
||||
parts.append(f'<h4 id="h-{_sanitize_id(b.get("id"))}">{c}</h4>')
|
||||
elif t == "bulleted_list":
|
||||
parts.append(f"<li>{c}</li>")
|
||||
elif t == "numbered_list":
|
||||
parts.append(f"<li>{c}</li>")
|
||||
elif t == "to_do":
|
||||
checked = "checked" if b.get("checked") else ""
|
||||
style = "text-decoration:line-through;opacity:.55;" if b.get("checked") else ""
|
||||
parts.append(
|
||||
f'<div class="todo"><input type="checkbox" {checked} disabled>'
|
||||
f'<span style="{style}">{c}</span></div>'
|
||||
)
|
||||
elif t == "toggle":
|
||||
children = blocks_to_html(b.get("children") or [])
|
||||
parts.append(f"<details open><summary>{c}</summary>{children}</details>")
|
||||
elif t == "quote":
|
||||
parts.append(f"<blockquote>{c}</blockquote>")
|
||||
elif t == "divider":
|
||||
parts.append("<hr>")
|
||||
elif t == "code":
|
||||
lang = b.get("language") or ""
|
||||
label = f'<div class="code-lang">{_text(lang)}</div>' if lang else ""
|
||||
parts.append(f"<pre>{label}<code>{c}</code></pre>")
|
||||
elif t == "math":
|
||||
parts.append(f'<div class="math">\\[{c}\\]</div>')
|
||||
elif t == "table_of_contents":
|
||||
toc = [x for x in (blocks or [])
|
||||
if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip()]
|
||||
if toc:
|
||||
items = "".join(
|
||||
f'<div style="margin-left:{max(0, int(x["type"].split("_")[-1]) - 1) * 14}px;">'
|
||||
f'<a href="#h-{_sanitize_id(x.get("id"))}">{_text(x.get("content"))}</a></div>'
|
||||
for x in toc
|
||||
)
|
||||
parts.append(f'<nav class="toc"><div class="toc-title">On this page</div>{items}</nav>')
|
||||
elif t == "columns":
|
||||
cols = "".join(
|
||||
f'<div class="column">{blocks_to_html([child])}</div>'
|
||||
for child in (b.get("children") or [])
|
||||
)
|
||||
parts.append(f'<div class="columns">{cols}</div>')
|
||||
elif t == "callout":
|
||||
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"))
|
||||
parts.append(f'<figure><img src="{src}" alt="{alt}" class="fd-img" data-full="{src}"><figcaption>{alt}</figcaption></figure>')
|
||||
elif t == "video":
|
||||
src = b.get("src") or ""
|
||||
if src:
|
||||
parts.append(f'<video controls preload="metadata" style="max-width:100%;border-radius:8px;"><source src="{src}"></video>')
|
||||
elif t == "audio":
|
||||
src = b.get("src") or ""
|
||||
if src:
|
||||
parts.append(f'<audio controls preload="metadata" style="width:100%;"><source src="{src}"></audio>')
|
||||
elif t == "bookmark":
|
||||
url = b.get("url") or b.get("src") or ""
|
||||
title = _text(b.get("title")) or url
|
||||
desc = _text(b.get("description"))
|
||||
img = b.get("image") or ""
|
||||
site = _text(b.get("site_name")) or ""
|
||||
img_html = f'<img src="{img}" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">' if img else ""
|
||||
desc_html = f'<div style="font-size:13px;color:#57606a;margin-top:4px;">{desc}</div>' if desc else ""
|
||||
site_html = f'<div style="font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">{site}</div>' if site else ""
|
||||
parts.append(
|
||||
f'<a href="{_text(url)}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;">'
|
||||
f'<div style="display:flex;gap:14px;align-items:center;border:1px solid #d8dee4;border-radius:10px;'
|
||||
f'padding:14px 16px;margin:14px 0;background:#f9fafb;">'
|
||||
f'<div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">{title}</div>'
|
||||
f'{desc_html}{site_html}</div>{img_html}</div></a>'
|
||||
)
|
||||
elif t == "embed":
|
||||
url = b.get("src") or ""
|
||||
emb = (b.get("embed_type") or "")
|
||||
if emb in ("inline_dbs", "collection"):
|
||||
parts.append('<div class="embed-note">[Embedded content]</div>')
|
||||
elif emb == "download":
|
||||
parts.append(f'<a href="{_text(url)}" download>⬇ {_text(b.get("file_name") or "Download")}</a>')
|
||||
elif emb == "pdf" and url:
|
||||
parts.append(f'<iframe src="{_text(url)}" style="width:100%;height:70vh;border:none;border-radius:8px;"></iframe>')
|
||||
elif url:
|
||||
from app.services.embeds import embed_src
|
||||
src = b.get("embed_src") or embed_src(url) or url
|
||||
height = 520
|
||||
if b.get("height"):
|
||||
try:
|
||||
height = int(b["height"])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
parts.append(
|
||||
f'<div style="position:relative;width:100%;height:{height}px;border-radius:8px;overflow:hidden;'
|
||||
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
|
||||
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
|
||||
)
|
||||
elif t == "table":
|
||||
parts.append(_table_to_html(b))
|
||||
elif t == "synced":
|
||||
synced_id = b.get("synced_id")
|
||||
if synced_id:
|
||||
try:
|
||||
from app.services.synced_blocks import get_synced_block
|
||||
sb = get_synced_block(synced_id)
|
||||
if sb and sb.get("content"):
|
||||
resolved = json.loads(sb["content"])
|
||||
if isinstance(resolved, list):
|
||||
parts.append(blocks_to_html(resolved))
|
||||
else:
|
||||
parts.append(f"<p>{_text(resolved)}</p>")
|
||||
except Exception:
|
||||
parts.append(f"<p>[Synced block {synced_id}]</p>")
|
||||
else:
|
||||
parts.append(f"<p>{c}</p>")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _standalone_css() -> str:
|
||||
return """
|
||||
:root{color-scheme:light;}
|
||||
*{box-sizing:border-box;}
|
||||
body{margin:0;font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;color:#1f2328;background:#fff;line-height:1.65;}
|
||||
.wrap{max-width:780px;margin:0 auto;padding:48px 32px 96px;}
|
||||
h1{font-size:2.4rem;line-height:1.2;margin:0 0 8px;}
|
||||
h2{font-size:1.7rem;border-bottom:1px solid #ececec;padding-bottom:6px;margin:32px 0 12px;}
|
||||
h3{font-size:1.35rem;margin:24px 0 8px;}
|
||||
h4{font-size:1.1rem;margin:20px 0 6px;}
|
||||
p{margin:8px 0;}
|
||||
li{margin:4px 0;}
|
||||
ol{list-style:decimal;padding-left:24px;}
|
||||
ul{list-style:disc;padding-left:24px;}
|
||||
blockquote{border-left:4px solid #d0d7de;margin:12px 0;padding:4px 16px;color:#57606a;}
|
||||
hr{border:none;border-top:1px solid #eaeef2;margin:24px 0;}
|
||||
pre{background:#f6f8fa;border-radius:8px;padding:16px 20px;overflow-x:auto;font-size:14px;}
|
||||
code{font-family:'SFMono-Regular',Consolas,monospace;background:#f6f8fa;border-radius:4px;padding:2px 5px;font-size:.9em;}
|
||||
pre code{background:none;padding:0;font-size:13px;}
|
||||
.code-lang{font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px;}
|
||||
details{background:#f6f8fa;border:1px solid #eaeef2;border-radius:8px;padding:10px 14px;margin:10px 0;}
|
||||
details summary{cursor:pointer;font-weight:600;}
|
||||
details[open] summary{margin-bottom:8px;}
|
||||
.todo{display:flex;align-items:flex-start;gap:8px;margin:4px 0;}
|
||||
.todo input{margin-top:5px;}
|
||||
.toc{border:1px solid #eaeef2;border-radius:8px;padding:16px 20px;margin:12px 0;}
|
||||
.toc-title{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#57606a;margin-bottom:10px;}
|
||||
.toc a{color:#0969da;text-decoration:none;display:block;padding:4px 0;}
|
||||
.columns{display:flex;gap:14px;margin:12px 0;align-items:stretch;}
|
||||
.column{flex:1;min-width:0;background:#f9fafb;border:1px solid #eaeef2;border-radius:8px;padding:12px 14px;box-sizing:border-box;}
|
||||
.callout{display:flex;gap:10px;align-items:flex-start;border:1px solid #e0e7ff;border-radius:8px;padding:14px 18px;margin:12px 0;font-size:15px;}
|
||||
.callout>span{font-size:20px;flex-shrink:0;}
|
||||
.math{margin:14px 0;overflow-x:auto;}
|
||||
figure{margin:16px 0;text-align:center;}
|
||||
figure img{max-width:100%;border-radius:8px;}
|
||||
figcaption{font-size:13px;color:#8b949e;margin-top:6px;}
|
||||
.ftable{width:100%;border-collapse:collapse;margin:16px 0;font-size:14.5px;line-height:1.45;}
|
||||
.ftable th,.ftable td{border:1px solid #d8dee4;padding:7px 12px;vertical-align:top;}
|
||||
.ftable th{background:#f6f8fa;font-weight:600;}
|
||||
.ftable tr:nth-child(even) td{background:#fcfcfd;}
|
||||
.footer{margin-top:56px;padding-top:16px;border-top:1px solid #eaeef2;color:#8b949e;font-size:12px;display:flex;justify-content:space-between;}
|
||||
a{color:#0969da;}
|
||||
@media print{body{background:#fff;}.wrap{padding:0;max-width:100%;}}
|
||||
"""
|
||||
|
||||
|
||||
def page_to_standalone_html(
|
||||
page: dict,
|
||||
*,
|
||||
include_children: bool = True,
|
||||
base_url: str = "",
|
||||
) -> str:
|
||||
"""Return a standalone, self-contained HTML document for a page."""
|
||||
title = _page_title(page)
|
||||
body = blocks_to_html(_page_blocks(page))
|
||||
|
||||
meta_updated = page.get("updated_at") or ""
|
||||
footer = f"<div class='footer'><span>FlowDeck · {_page_title(page)}</span><span>{meta_updated}</span></div>"
|
||||
|
||||
sub_html = ""
|
||||
if include_children:
|
||||
for sub in _child_pages(page):
|
||||
sub_html += '\n<hr style="border:none">\n<div class="subpage">'
|
||||
sub_html += page_to_standalone_html(sub, include_children=True, base_url=base_url)
|
||||
sub_html += "</div>"
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{_text(title)}</title>
|
||||
<style>{_standalone_css()}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>{_text(title)}</h1>
|
||||
{body}
|
||||
{sub_html}
|
||||
{footer}
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ═══════════════ PDF ═══════════════
|
||||
|
||||
def _pdf_html(page: dict) -> str:
|
||||
"""A print-friendly, minimal-CSS HTML for PDF conversion."""
|
||||
title = _page_title(page)
|
||||
body = blocks_to_html(_page_blocks(page))
|
||||
return f"""<html><head><meta charset="utf-8"><title>{_text(title)}</title>
|
||||
<style>
|
||||
body{{font-family:Helvetica,Arial,sans-serif;color:#1f2328;font-size:12px;line-height:1.5;}}
|
||||
h1{{font-size:26px;margin:0 0 10px;}}
|
||||
h2{{font-size:19px;border-bottom:1px solid #ddd;padding-bottom:4px;margin:22px 0 8px;}}
|
||||
h3{{font-size:16px;margin:18px 0 6px;}}
|
||||
h4{{font-size:14px;margin:14px 0 4px;}}
|
||||
p,li{{margin:4px 0;}}
|
||||
pre{{background:#f4f4f4;padding:10px;font-size:10px;white-space:pre-wrap;}}
|
||||
code{{font-family:monospace;font-size:10px;}}
|
||||
blockquote{{border-left:3px solid #ccc;margin:8px 0;padding:2px 12px;font-style:italic;}}
|
||||
table{{border-collapse:collapse;width:100%;}}
|
||||
.ftable{{border-collapse:collapse;width:100%;margin:10px 0;}}
|
||||
.ftable th,.ftable td{{border:1px solid #999;padding:5px 8px;}}
|
||||
.ftable th{{background:#f0f0f0;font-weight:bold;}}
|
||||
hr{{border:none;border-top:1px solid #ddd;margin:16px 0;}}
|
||||
.todo{{margin:4px 0;}}
|
||||
.math{{font-style:italic;margin:10px 0;}}
|
||||
.callout{{background:#f0f4ff;border:1px solid #dbe4ff;border-radius:6px;padding:8px 12px;margin:8px 0;}}
|
||||
.footer{{margin-top:30px;padding-top:8px;border-top:1px solid #ddd;font-size:9px;color:#888;}}
|
||||
</style></head><body>
|
||||
<h1>{_text(title)}</h1>
|
||||
{body}
|
||||
<div class="footer">FlowDeck · {_text(title)} · {page.get("updated_at") or ""}</div>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def page_to_pdf_bytes(page: dict) -> bytes:
|
||||
"""Render a page to a PDF.
|
||||
|
||||
Primary engine: WeasyPrint — renders colour emoji and proper CSS tables
|
||||
(needs system libs: pango + fonts; available in the Docker image).
|
||||
Fallback: xhtml2pdf (pure Python) when WeasyPrint's native libraries are
|
||||
absent (e.g. a Windows dev host) — text/table content still exports,
|
||||
though emoji are limited to monochrome by the engine.
|
||||
"""
|
||||
# 1) WeasyPrint (best fidelity: colour emoji, CSS tables)
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
html = page_to_standalone_html(page, include_children=False)
|
||||
return HTML(string=html, base_url=_data_root().as_uri() + "/").write_pdf()
|
||||
except Exception: # ImportError or missing native libs (OSError) -> fallback
|
||||
pass
|
||||
# 2) xhtml2pdf fallback (pure Python)
|
||||
from xhtml2pdf import pisa
|
||||
|
||||
src = _pdf_html(page)
|
||||
buf = io.BytesIO()
|
||||
pdf = pisa.CreatePDF(src, dest=buf, encoding="utf-8")
|
||||
if pdf.err:
|
||||
raise RuntimeError(f"PDF generation failed: {pdf.err}")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ═══════════════ Static site (zip) ═══════════════
|
||||
|
||||
def _site_index_html(pages: list[dict]) -> str:
|
||||
"""Build the index.html of the static site (list of all pages)."""
|
||||
def link(p: dict) -> str:
|
||||
title = _page_title(p)
|
||||
return f'<li><a href="{quote(title, safe="")}.html">{_text(title)}</a></li>'
|
||||
|
||||
items = "".join(link(p) for p in pages)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<title>FlowDeck Site</title>
|
||||
<style>body{{font-family:system-ui,sans-serif;max-width:720px;margin:40px auto;padding:0 20px;color:#1f2328;}}
|
||||
a{{color:#0969da;text-decoration:none;}}li{{margin:8px 0;}}</style></head>
|
||||
<body><h1>FlowDeck Site</h1><ul>{items}</ul></body></html>"""
|
||||
|
||||
|
||||
def build_static_site_bytes(root_page: dict) -> bytes:
|
||||
"""Build a full static site as a zip: index.html + one HTML file per page."""
|
||||
pages = [root_page] + _child_pages(root_page)
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("index.html", _site_index_html(pages))
|
||||
for p in pages:
|
||||
title = _page_title(p)
|
||||
name = f"{quote(title, safe='')}.html"
|
||||
z.writestr(name, page_to_standalone_html(p, include_children=False))
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,72 @@
|
||||
"""FlowDeck — v5.2.0 Forge abstraction (Gitea / GitHub).
|
||||
|
||||
``ForgeAdapter`` defines the minimal contract a forge client must expose for the
|
||||
``projects`` table sync and the issue/board integration. ``GiteaAdapter`` wraps
|
||||
the existing ``GiteaClient``; ``GitHubAdapter`` (in ``github_adapter.py``) is the
|
||||
GitHub implementation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class ForgeAdapter(ABC):
|
||||
"""Common forge API surface used by FlowDeck (v5.2.0)."""
|
||||
|
||||
kind = "base"
|
||||
|
||||
@abstractmethod
|
||||
async def validate_token(self) -> bool:
|
||||
"""True when the stored credentials still work."""
|
||||
|
||||
@abstractmethod
|
||||
async def list_repos(self, page: int = 1) -> list[dict]:
|
||||
"""List repositories for the authenticated user."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_repo_info(self, owner: str, repo: str) -> dict:
|
||||
"""Repository metadata (default_branch, clone_url, language, …)."""
|
||||
|
||||
|
||||
def normalize_repo(repo: dict, proj_type: str) -> dict:
|
||||
"""Project a forge repo dict onto the ``projects`` table columns."""
|
||||
full_name = repo.get("full_name", "") or repo.get("fullName", "")
|
||||
owner, _, name = full_name.partition("/")
|
||||
return {
|
||||
"name": name or repo.get("name", ""),
|
||||
"owner": owner or repo.get("owner", {}).get("login", "") if isinstance(repo.get("owner"), dict) else (owner or ""),
|
||||
"proj_type": proj_type,
|
||||
"forge_id": str(repo.get("id", "") or ""),
|
||||
"clone_url": repo.get("clone_url", "") or repo.get("ssh_url", ""),
|
||||
"default_branch": repo.get("default_branch", ""),
|
||||
"language": repo.get("language", ""),
|
||||
"description": (repo.get("description") or "") or "",
|
||||
}
|
||||
|
||||
|
||||
class GiteaAdapter(ForgeAdapter):
|
||||
"""Adapt the existing GiteaClient to the ForgeAdapter contract."""
|
||||
|
||||
kind = "gitea"
|
||||
|
||||
def __init__(self, client) -> None: # client = GiteaClient instance
|
||||
self._client = client
|
||||
|
||||
async def validate_token(self) -> bool:
|
||||
try:
|
||||
await self._client.get_user_repos(page=1, limit=1)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def list_repos(self, page: int = 1) -> list[dict]:
|
||||
return await self._client.get_user_repos(page=page, limit=30)
|
||||
|
||||
async def get_repo_info(self, owner: str, repo: str) -> dict:
|
||||
async with __import__("httpx").AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._client._base}/repos/{owner}/{repo}",
|
||||
headers=self._client._headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
@@ -1,8 +1,8 @@
|
||||
"""FlowDeck — Formula Engine (v1.5.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Any, Callable, Optional
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
class FormulaEngine:
|
||||
@@ -214,7 +214,7 @@ class FormulaEngine:
|
||||
return val.split("...")[0]
|
||||
return val
|
||||
|
||||
def _end(self, ctx: dict, s: Any) -> Optional[str]:
|
||||
def _end(self, ctx: dict, s: Any) -> str | None:
|
||||
"""Extract end date from a date range."""
|
||||
val = str(s)
|
||||
if "..." in val:
|
||||
|
||||
@@ -73,6 +73,33 @@ class GiteaClient:
|
||||
self._set_cache(cache_key, data)
|
||||
return data
|
||||
|
||||
async def get_repo_info(self, owner: str, repo: str) -> dict:
|
||||
"""Repository metadata for an unfurl card (owner/name/branch/…)."""
|
||||
cache_key = f"repo_info:{owner}:{repo}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}",
|
||||
headers=self._headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
info = resp.json()
|
||||
repo_info = {
|
||||
"id": info.get("id"),
|
||||
"name": info.get("name"),
|
||||
"owner": (info.get("owner") or {}).get("login", owner),
|
||||
"full_name": info.get("full_name") or f"{owner}/{repo}",
|
||||
"clone_url": info.get("clone_url", ""),
|
||||
"html_url": info.get("html_url", ""),
|
||||
"default_branch": info.get("default_branch", "main"),
|
||||
"description": info.get("description") or "",
|
||||
"language": info.get("language") or "",
|
||||
}
|
||||
self._set_cache(cache_key, repo_info)
|
||||
return repo_info
|
||||
|
||||
async def get_user_orgs(self) -> list[dict]:
|
||||
cache_key = "user_orgs"
|
||||
cached = self._cached(cache_key)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""FlowDeck — GitHub API adapter with caching."""
|
||||
"""FlowDeck — GitHub API adapter with caching (v5.2.0: ForgeAdapter)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
@@ -8,26 +8,36 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.services.forge_adapter import ForgeAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TTL = 30 # seconds
|
||||
|
||||
|
||||
class GitHubAdapter:
|
||||
class GitHubAdapter(ForgeAdapter):
|
||||
"""Async GitHub API client (v3 REST) with simple TTL cache.
|
||||
|
||||
Authenticated via OAuth2 Bearer token.
|
||||
Authenticated via OAuth2 Bearer token. Implements the ``ForgeAdapter``
|
||||
interface so Gitea and GitHub repos can be synced identically.
|
||||
"""
|
||||
|
||||
def __init__(self, access_token: str, ttl: int = DEFAULT_TTL) -> None:
|
||||
kind = "github"
|
||||
|
||||
def __init__(self, access_token: str, ttl: int = DEFAULT_TTL,
|
||||
transport: httpx.BaseTransport | None = None) -> None:
|
||||
self._base = "https://api.github.com"
|
||||
self._headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
self._transport = transport
|
||||
self._cache: dict[str, tuple[datetime, Any]] = {}
|
||||
self._ttl = timedelta(seconds=ttl)
|
||||
|
||||
def _client(self) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(timeout=15, transport=self._transport)
|
||||
|
||||
# ── cache helpers ──
|
||||
|
||||
def _cached(self, key: str) -> Any | None:
|
||||
@@ -48,7 +58,7 @@ class GitHubAdapter:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/user/repos",
|
||||
headers=self._headers,
|
||||
@@ -81,7 +91,7 @@ class GitHubAdapter:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with self._client() as client:
|
||||
# Resolve default branch commit SHA if not provided
|
||||
if sha is None:
|
||||
repo_info = await client.get(
|
||||
@@ -128,7 +138,7 @@ class GitHubAdapter:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||
headers=self._headers,
|
||||
@@ -146,11 +156,118 @@ class GitHubAdapter:
|
||||
self._set_cache(cache_key, content)
|
||||
return content
|
||||
|
||||
# ── repo info (ForgeAdapter) ──
|
||||
|
||||
async def get_repo_info(self, owner: str, repo: str) -> dict:
|
||||
"""Repository metadata: default_branch, clone_url, languages, …"""
|
||||
cache_key = f"repo_info:{owner}:{repo}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with self._client() 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"),
|
||||
"clone_url": info.get("clone_url", ""),
|
||||
"default_branch": info.get("default_branch", "main"),
|
||||
"description": info.get("description") or "",
|
||||
"language": info.get("language") or "",
|
||||
"html_url": info.get("html_url", ""),
|
||||
}
|
||||
# Languages are a separate endpoint.
|
||||
try:
|
||||
lang_resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/languages",
|
||||
headers=self._headers,
|
||||
)
|
||||
if lang_resp.status_code == 200:
|
||||
langs = lang_resp.json()
|
||||
if langs:
|
||||
repo_info["language"] = max(langs, key=langs.get)
|
||||
except Exception:
|
||||
logger.exception("get_repo_info")
|
||||
|
||||
self._set_cache(cache_key, repo_info)
|
||||
return repo_info
|
||||
|
||||
async def get_languages(self, owner: str, repo: str) -> dict:
|
||||
"""Bytes per language for a repo."""
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/languages",
|
||||
headers=self._headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ── issues / labels / milestones ──
|
||||
|
||||
async def list_issues(self, owner: str, repo: str, state: str = "all") -> list[dict]:
|
||||
"""List issues (pull requests are filtered out)."""
|
||||
issues: list[dict] = []
|
||||
for page in range(1, 6):
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues",
|
||||
headers=self._headers,
|
||||
params={"state": state, "per_page": 100, "page": page},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
batch = resp.json()
|
||||
if not batch:
|
||||
break
|
||||
issues.extend(i for i in batch if "pull_request" not in i)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
return issues
|
||||
|
||||
async def list_labels(self, owner: str, repo: str) -> list[dict]:
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/labels",
|
||||
headers=self._headers,
|
||||
params={"per_page": 100},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_milestones(self, owner: str, repo: str, state: str = "all") -> list[dict]:
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/milestones",
|
||||
headers=self._headers,
|
||||
params={"state": state, "per_page": 100},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_repo_files(self, owner: str, repo: str, path: str = "") -> list[dict]:
|
||||
"""Flatten the repo tree into file entries (``path``, ``size``)."""
|
||||
tree = await self.get_repo_tree(owner, repo)
|
||||
prefix = path.strip("/")
|
||||
files = [
|
||||
{"path": item["path"], "size": item.get("size", 0)}
|
||||
for item in tree
|
||||
if item.get("type") == "blob" and item.get("path")
|
||||
]
|
||||
if prefix:
|
||||
files = [f for f in files if f["path"].startswith(prefix + "/") or f["path"] == prefix]
|
||||
return files
|
||||
|
||||
# ── token validation ──
|
||||
|
||||
async def validate_token(self) -> bool:
|
||||
"""Check whether the access token is still valid."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/user",
|
||||
headers=self._headers,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""FlowDeck — importers package (v5.6.0).
|
||||
|
||||
Importing this package registers every built-in importer. Use
|
||||
:func:`parse_upload` to detect a source and :func:`run_import` to persist it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.importers import ( # noqa: F401 - registration side effects
|
||||
bookmarks,
|
||||
calendar,
|
||||
docx,
|
||||
html_notes,
|
||||
markdown,
|
||||
notion,
|
||||
obsidian,
|
||||
opml,
|
||||
outline,
|
||||
pdf,
|
||||
standard_notes,
|
||||
tabular,
|
||||
)
|
||||
from app.services.importers.base import ( # noqa: F401
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
all_importers,
|
||||
detect_importer,
|
||||
get_importer,
|
||||
list_sources,
|
||||
)
|
||||
from app.services.importers.jobs import ( # noqa: F401
|
||||
create_job,
|
||||
get_job,
|
||||
list_jobs,
|
||||
parse_upload,
|
||||
start_import_job,
|
||||
)
|
||||
from app.services.importers.pipeline import preview_result, resolve_relations, run_import # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"ImportAttachment",
|
||||
"ImportPage",
|
||||
"ImportResult",
|
||||
"Importer",
|
||||
"all_importers",
|
||||
"detect_importer",
|
||||
"get_importer",
|
||||
"list_sources",
|
||||
"create_job",
|
||||
"get_job",
|
||||
"list_jobs",
|
||||
"parse_upload",
|
||||
"start_import_job",
|
||||
"preview_result",
|
||||
"run_import",
|
||||
"resolve_relations",
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""FlowDeck — shared helpers for note importers (frontmatter, wikilinks)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try: # PyYAML ships transitively via uvicorn[standard]
|
||||
import yaml
|
||||
except Exception: # pragma: no cover - fallback parser below
|
||||
yaml = None
|
||||
|
||||
|
||||
_FRONTMATTER_RE = re.compile(r"^\ufeff?---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
|
||||
_WIKILINK_RE = re.compile(r"(!?)\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]")
|
||||
|
||||
|
||||
def split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
||||
"""Split YAML frontmatter from the body. Returns ``(metadata, body)``."""
|
||||
m = _FRONTMATTER_RE.match(text)
|
||||
if not m:
|
||||
return {}, text
|
||||
raw = m.group(1)
|
||||
body = text[m.end():]
|
||||
if yaml is not None:
|
||||
try:
|
||||
meta = yaml.safe_load(raw)
|
||||
if isinstance(meta, dict):
|
||||
return meta, body
|
||||
except Exception: # noqa: BLE001 - fall back to the simple parser
|
||||
pass
|
||||
return _simple_yaml(raw), body
|
||||
|
||||
|
||||
def _simple_yaml(raw: str) -> dict[str, Any]:
|
||||
"""Minimal YAML subset parser (scalars, inline lists, block lists)."""
|
||||
meta: dict[str, Any] = {}
|
||||
current: str | None = None
|
||||
for line in raw.splitlines():
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
if line.lstrip().startswith("- ") and current:
|
||||
meta.setdefault(current, [])
|
||||
if isinstance(meta[current], list):
|
||||
meta[current].append(_scalar(line.lstrip()[2:].strip()))
|
||||
continue
|
||||
if ":" in line:
|
||||
key, _, value = line.partition(":")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
current = key
|
||||
if not value:
|
||||
meta[key] = []
|
||||
elif value.startswith("[") and value.endswith("]"):
|
||||
inner = value[1:-1].strip()
|
||||
meta[key] = [_scalar(v.strip()) for v in inner.split(",") if v.strip()] if inner else []
|
||||
else:
|
||||
meta[key] = _scalar(value)
|
||||
return meta
|
||||
|
||||
|
||||
def _scalar(value: str) -> Any:
|
||||
v = value.strip().strip('"').strip("'")
|
||||
if v.lower() in ("true", "false"):
|
||||
return v.lower() == "true"
|
||||
if re.fullmatch(r"-?\d+", v):
|
||||
return int(v)
|
||||
if re.fullmatch(r"-?\d+\.\d+", v):
|
||||
return float(v)
|
||||
return v
|
||||
|
||||
|
||||
def convert_wikilinks(text: str, *, embeds: bool = True) -> str:
|
||||
"""Turn Obsidian/Logseq ``[[link]]`` into Markdown links and ``![[img]]``
|
||||
into Markdown images so the block converter can render them."""
|
||||
|
||||
def repl(m: re.Match) -> str:
|
||||
bang, target, alias = m.group(1), m.group(2).strip(), m.group(3)
|
||||
label = (alias or target).strip()
|
||||
if bang == "!":
|
||||
return f"" if embeds else label
|
||||
return f"[{label}]({target})"
|
||||
|
||||
return _WIKILINK_RE.sub(repl, text)
|
||||
|
||||
|
||||
def normalize_title(value: Any) -> str:
|
||||
return str(value).strip() if value is not None else ""
|
||||
|
||||
|
||||
def coerce_tags(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(v).strip().lstrip("#") for v in value if str(v).strip()]
|
||||
if isinstance(value, str):
|
||||
parts = re.split(r"[,\s]+", value)
|
||||
return [p.strip().lstrip("#") for p in parts if p.strip()]
|
||||
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()
|
||||
@@ -0,0 +1,147 @@
|
||||
"""FlowDeck — unified import framework (v5.6.0, Phase 0).
|
||||
|
||||
Defines the normalized data model shared by every importer and the registry
|
||||
used to auto-detect a source. An :class:`Importer` turns an uploaded file into
|
||||
an :class:`ImportResult` (pages, attachments, warnings, stats) which the
|
||||
pipeline (:mod:`app.services.importers.pipeline`) persists into FlowDeck.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
def decode_text(data: bytes) -> str:
|
||||
"""Best-effort decode of uploaded bytes (BOM aware, latin-1 fallback)."""
|
||||
for enc in ("utf-8-sig", "utf-8", "utf-16", "latin-1"):
|
||||
try:
|
||||
return data.decode(enc)
|
||||
except (UnicodeDecodeError, UnicodeError):
|
||||
continue
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportAttachment:
|
||||
"""A binary asset extracted from an archive/vault."""
|
||||
|
||||
source_path: str
|
||||
filename: str
|
||||
data: bytes = b""
|
||||
mime: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportPage:
|
||||
"""One page to create. ``markdown`` is converted to blocks by the pipeline
|
||||
unless ``blocks`` is already provided. ``collection`` marks a database
|
||||
(Notion database, Excel sheet…) whose rows become ``collection_pages``."""
|
||||
|
||||
title: str = "Untitled"
|
||||
markdown: str = ""
|
||||
blocks: list[dict] = field(default_factory=list)
|
||||
source_path: str = ""
|
||||
parent_path: str = ""
|
||||
properties: dict[str, Any] = field(default_factory=dict)
|
||||
collection: dict[str, Any] | None = None
|
||||
external_id: str = ""
|
||||
page_id: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportResult:
|
||||
"""Normalized output of any importer."""
|
||||
|
||||
source: str = ""
|
||||
pages: list[ImportPage] = field(default_factory=list)
|
||||
attachments: list[ImportAttachment] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
stats: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def warn(self, message: str) -> None:
|
||||
if message and message not in self.warnings:
|
||||
self.warnings.append(message)
|
||||
|
||||
def finalize(self) -> ImportResult:
|
||||
self.stats.setdefault("pages", len(self.pages))
|
||||
self.stats.setdefault("collections", sum(1 for p in self.pages if p.collection))
|
||||
self.stats.setdefault("attachments", len(self.attachments))
|
||||
self.stats.setdefault("warnings", len(self.warnings))
|
||||
return self
|
||||
|
||||
|
||||
class Importer(ABC):
|
||||
"""Base class for a source importer."""
|
||||
|
||||
source_id: str = ""
|
||||
label: str = ""
|
||||
description: str = ""
|
||||
extensions: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
"""Return True when this importer recognizes the uploaded file."""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
"""Parse the upload into a normalized :class:`ImportResult`."""
|
||||
|
||||
def info(self) -> dict[str, Any]:
|
||||
return {
|
||||
"source_id": self.source_id,
|
||||
"label": self.label,
|
||||
"description": self.description,
|
||||
"extensions": list(self.extensions),
|
||||
}
|
||||
|
||||
|
||||
REGISTRY: list[Importer] = []
|
||||
|
||||
|
||||
def register_importer(cls: type[Importer]) -> type[Importer]:
|
||||
"""Class decorator registering an importer instance."""
|
||||
REGISTRY.append(cls())
|
||||
REGISTRY.sort(key=lambda i: i.order)
|
||||
return cls
|
||||
|
||||
|
||||
def all_importers() -> list[Importer]:
|
||||
return list(REGISTRY)
|
||||
|
||||
|
||||
def get_importer(source_id: str) -> Importer | None:
|
||||
for imp in REGISTRY:
|
||||
if imp.source_id == source_id:
|
||||
return imp
|
||||
return None
|
||||
|
||||
|
||||
def detect_importer(filename: str, data: bytes) -> Importer | None:
|
||||
"""First importer that recognizes the file, else None."""
|
||||
for imp in REGISTRY:
|
||||
try:
|
||||
if imp.detect(filename, data):
|
||||
return imp
|
||||
except Exception: # noqa: BLE001 - a broken detector must not break detection
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def list_sources() -> list[dict[str, Any]]:
|
||||
return [imp.info() for imp in REGISTRY]
|
||||
|
||||
|
||||
def make_collection(name: str, schema: list[dict], rows: list[dict],
|
||||
*, source_path: str = "", external_id: str = "") -> ImportPage:
|
||||
"""Build an ImportPage carrying a collection spec (database import).
|
||||
|
||||
``rows`` entries are ``{"title": str, "properties": {name: value}}``.
|
||||
"""
|
||||
return ImportPage(
|
||||
title=name or "Imported database",
|
||||
collection={"name": name or "Imported database", "schema": schema, "rows": rows},
|
||||
source_path=source_path or name,
|
||||
external_id=external_id or source_path or name,
|
||||
)
|
||||
@@ -0,0 +1,284 @@
|
||||
"""FlowDeck — bookmark importers (v5.6.0, Phase 4).
|
||||
|
||||
Raindrop.io, Pocket, Readwise, Shaarli and generic Netscape bookmark files are
|
||||
normalized into a FlowDeck collection (URL, description, tags, created date).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.services.importers._common import coerce_tags
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
make_collection,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_SCHEMA = [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "URL", "type": "url"},
|
||||
{"name": "Description", "type": "text"},
|
||||
{"name": "Tags", "type": "multi_select"},
|
||||
{"name": "Created", "type": "date"},
|
||||
]
|
||||
|
||||
_TAG_RE = re.compile(
|
||||
r"<h3[^>]*>(?P<folder>.*?)</h3>|<a\s+(?P<attrs>[^>]*?)>(?P<title>.*?)</a>",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_ATTR_RE = re.compile(r'([a-zA-Z_:-]+)\s*=\s*"([^"]*)"')
|
||||
|
||||
|
||||
def _strip_tags(value: str) -> str:
|
||||
return re.sub(r"<[^>]+>", "", value or "").strip()
|
||||
|
||||
|
||||
def _iso_from_epoch(value: Any) -> str:
|
||||
try:
|
||||
return datetime.fromtimestamp(int(str(value)[:10]), tz=UTC).date().isoformat()
|
||||
except (ValueError, TypeError, OSError, OverflowError):
|
||||
return ""
|
||||
|
||||
|
||||
def _iso(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if re.fullmatch(r"\d{10}", text):
|
||||
return _iso_from_epoch(text)
|
||||
return text[:10] if re.match(r"^\d{4}-\d{2}-\d{2}", text) else text
|
||||
|
||||
|
||||
def _row(title: str, url: str, description: str = "", tags=None, created: str = "") -> dict:
|
||||
props: dict[str, Any] = {}
|
||||
if url:
|
||||
props["URL"] = url
|
||||
if description:
|
||||
props["Description"] = description
|
||||
tag_list = coerce_tags(tags)
|
||||
if tag_list:
|
||||
props["Tags"] = tag_list
|
||||
if created:
|
||||
props["Created"] = created
|
||||
return {"title": (title or url or "Bookmark").strip()[:200], "properties": props}
|
||||
|
||||
|
||||
def parse_netscape(html: str) -> list[dict]:
|
||||
"""Parse a Netscape bookmark file (browser / Pocket / Raindrop HTML)."""
|
||||
rows: list[dict] = []
|
||||
folder = ""
|
||||
for match in _TAG_RE.finditer(html):
|
||||
if match.group("folder") is not None:
|
||||
folder = _strip_tags(match.group("folder"))
|
||||
continue
|
||||
attrs = dict(_ATTR_RE.findall(match.group("attrs") or ""))
|
||||
url = attrs.get("href") or attrs.get("HREF") or ""
|
||||
if not url:
|
||||
continue
|
||||
title = _strip_tags(match.group("title"))
|
||||
tags = attrs.get("tags") or attrs.get("TAGS") or folder
|
||||
rows.append(_row(title, url, tags=tags, created=_iso_from_epoch(attrs.get("add_date", ""))))
|
||||
return rows
|
||||
|
||||
|
||||
def _csv_rows(text: str) -> list[dict]:
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
return [{(k or "").strip().lower(): v for k, v in row.items()} for row in reader]
|
||||
|
||||
|
||||
class _BookmarkBase(Importer):
|
||||
source_id = "bookmarks"
|
||||
label = "Signets"
|
||||
description = ""
|
||||
order = 44
|
||||
keywords: tuple[str, ...] = ()
|
||||
|
||||
def _hint(self, filename: str, text: str) -> bool:
|
||||
low = filename.lower()
|
||||
return any(k in low or k in text.lower() for k in self.keywords)
|
||||
|
||||
|
||||
@register_importer
|
||||
class RaindropImporter(_BookmarkBase):
|
||||
source_id = "raindrop"
|
||||
label = "Raindrop.io"
|
||||
description = "Export Raindrop.io (CSV ou HTML) → collection de signets."
|
||||
extensions = (".csv", ".html", ".htm")
|
||||
order = 46
|
||||
keywords = ("raindrop",)
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
text = decode_text(data)
|
||||
if not self._hint(filename, text):
|
||||
return False
|
||||
return filename.lower().endswith((".csv", ".html", ".htm"))
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
text = decode_text(data)
|
||||
rows: list[dict] = []
|
||||
if filename.lower().endswith(".csv"):
|
||||
for r in _csv_rows(text):
|
||||
rows.append(_row(
|
||||
r.get("title", ""), r.get("url", ""),
|
||||
r.get("note") or r.get("excerpt") or "",
|
||||
r.get("tags", ""), _iso(r.get("created", "")),
|
||||
))
|
||||
else:
|
||||
rows = parse_netscape(text)
|
||||
result.pages.append(make_collection("Raindrop", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class PocketImporter(_BookmarkBase):
|
||||
source_id = "pocket"
|
||||
label = "Pocket"
|
||||
description = "Export Pocket (CSV ou HTML) → collection de signets."
|
||||
extensions = (".csv", ".html", ".htm")
|
||||
order = 45
|
||||
keywords = ("pocket",)
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
text = decode_text(data)
|
||||
if not self._hint(filename, text):
|
||||
return False
|
||||
return filename.lower().endswith((".csv", ".html", ".htm"))
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
text = decode_text(data)
|
||||
rows: list[dict] = []
|
||||
if filename.lower().endswith(".csv"):
|
||||
for r in _csv_rows(text):
|
||||
rows.append(_row(
|
||||
r.get("title", ""), r.get("url", ""),
|
||||
"", r.get("tags", ""), _iso(r.get("time_added", "")),
|
||||
))
|
||||
else:
|
||||
rows = parse_netscape(text)
|
||||
result.pages.append(make_collection("Pocket", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class ReadwiseImporter(_BookmarkBase):
|
||||
source_id = "readwise"
|
||||
label = "Readwise"
|
||||
description = "Export Readwise (highlights CSV) → collection de surlignages."
|
||||
extensions = (".csv", ".md", ".markdown")
|
||||
order = 47
|
||||
keywords = ("readwise",)
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
text = decode_text(data)
|
||||
if not self._hint(filename, text):
|
||||
return False
|
||||
if filename.lower().endswith(".csv"):
|
||||
header = text.splitlines()[0].lower() if text.strip() else ""
|
||||
return "highlight" in header or "book title" in header
|
||||
return True
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
text = decode_text(data)
|
||||
schema = [
|
||||
{"name": "Highlight", "type": "title"},
|
||||
{"name": "Book", "type": "text"},
|
||||
{"name": "Author", "type": "text"},
|
||||
{"name": "Note", "type": "text"},
|
||||
{"name": "Tags", "type": "multi_select"},
|
||||
{"name": "Highlighted at", "type": "date"},
|
||||
]
|
||||
rows: list[dict] = []
|
||||
if filename.lower().endswith(".csv"):
|
||||
for r in _csv_rows(text):
|
||||
props: dict[str, Any] = {}
|
||||
if r.get("book title"):
|
||||
props["Book"] = r["book title"]
|
||||
if r.get("book author"):
|
||||
props["Author"] = r["book author"]
|
||||
if r.get("note"):
|
||||
props["Note"] = r["note"]
|
||||
tags = coerce_tags(r.get("document tags") or r.get("tags"))
|
||||
if tags:
|
||||
props["Tags"] = tags
|
||||
created = _iso(r.get("highlighted at", ""))
|
||||
if created:
|
||||
props["Highlighted at"] = created
|
||||
rows.append({"title": (r.get("highlight") or "Highlight").strip()[:200], "properties": props})
|
||||
else:
|
||||
rows = [{"title": ln.lstrip("-* ").strip()[:200], "properties": {}} for ln in text.splitlines() if ln.strip()]
|
||||
result.pages.append(make_collection("Readwise", schema, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class ShaarliImporter(_BookmarkBase):
|
||||
source_id = "shaarli"
|
||||
label = "Shaarli"
|
||||
description = "Export Shaarli (JSON) → collection de signets."
|
||||
extensions = (".json",)
|
||||
order = 48
|
||||
keywords = ("shaarli",)
|
||||
|
||||
def _records(self, data: bytes) -> list[dict] | None:
|
||||
try:
|
||||
obj = json.loads(decode_text(data))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
if isinstance(obj, dict) and isinstance(obj.get("links"), list):
|
||||
obj = obj["links"]
|
||||
if isinstance(obj, list) and obj and all(isinstance(x, dict) and x.get("url") for x in obj):
|
||||
return obj
|
||||
return None
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if not filename.lower().endswith(".json"):
|
||||
return False
|
||||
return self._records(data) is not None
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
records = self._records(data) or []
|
||||
rows = [
|
||||
_row(r.get("title", ""), r.get("url", ""), r.get("description", ""),
|
||||
r.get("tags", ""), _iso(r.get("created", "")))
|
||||
for r in records
|
||||
]
|
||||
result.pages.append(make_collection("Shaarli", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class BookmarksImporter(_BookmarkBase):
|
||||
source_id = "bookmarks"
|
||||
label = "Signets HTML (navigateur)"
|
||||
description = "Fichier de signets Netscape HTML (Chrome/Firefox/Edge…)."
|
||||
extensions = (".html", ".htm")
|
||||
order = 49
|
||||
keywords = ()
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if not filename.lower().endswith((".html", ".htm")):
|
||||
return False
|
||||
text = decode_text(data)[:4000].lower()
|
||||
return "netscape-bookmark-file" in text or "<dt><a href" in text
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
rows = parse_netscape(decode_text(data))
|
||||
result.pages.append(make_collection("Bookmarks", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""FlowDeck — iCalendar (.ics) importer (v5.6.0, Phase 4).
|
||||
|
||||
Parses VEVENT blocks (RFC 5545, best-effort) into a FlowDeck calendar
|
||||
collection (start/end, all-day, location, description).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
make_collection,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_SCHEMA = [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Start", "type": "date"},
|
||||
{"name": "End", "type": "date"},
|
||||
{"name": "All day", "type": "checkbox"},
|
||||
{"name": "Location", "type": "text"},
|
||||
{"name": "Description", "type": "text"},
|
||||
{"name": "Calendar", "type": "text"},
|
||||
]
|
||||
_UNESCAPE = [("\\n", "\n"), ("\\N", "\n"), ("\\,", ","), ("\\;", ";"), ("\\\\", "\\")]
|
||||
|
||||
|
||||
def _unfold(text: str) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||
if raw[:1] in (" ", "\t") and lines:
|
||||
lines[-1] += raw[1:]
|
||||
else:
|
||||
lines.append(raw)
|
||||
return lines
|
||||
|
||||
|
||||
def _unescape(value: str) -> str:
|
||||
for src, dst in _UNESCAPE:
|
||||
value = value.replace(src, dst)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _parse_prop(line: str) -> tuple[str, dict[str, str], str]:
|
||||
if ":" not in line:
|
||||
return "", {}, ""
|
||||
head, _, value = line.partition(":")
|
||||
parts = head.split(";")
|
||||
name = parts[0].upper()
|
||||
params: dict[str, str] = {}
|
||||
for p in parts[1:]:
|
||||
if "=" in p:
|
||||
k, _, v = p.partition("=")
|
||||
params[k.upper()] = v
|
||||
return name, params, value
|
||||
|
||||
|
||||
def _iso_datetime(value: str, params: dict[str, str]) -> tuple[str, bool]:
|
||||
"""Return (iso, is_all_day)."""
|
||||
v = value.strip()
|
||||
if params.get("VALUE") == "DATE" or re.fullmatch(r"\d{8}", v):
|
||||
m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})", v)
|
||||
return (f"{m.group(1)}-{m.group(2)}-{m.group(3)}", True) if m else ("", True)
|
||||
m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)", v)
|
||||
if not m:
|
||||
return v, False
|
||||
date = f"{m.group(1)}-{m.group(2)}-{m.group(3)}T{m.group(4)}:{m.group(5)}:{m.group(6)}"
|
||||
return (date + "+00:00" if m.group(7) else date), False
|
||||
|
||||
|
||||
@register_importer
|
||||
class IcsImporter(Importer):
|
||||
source_id = "ics"
|
||||
label = "Calendrier (.ics)"
|
||||
description = "Export iCalendar (Google/Outlook/Apple) → collection d'événements."
|
||||
extensions = (".ics", ".ical")
|
||||
order = 33
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if filename.lower().endswith((".ics", ".ical")):
|
||||
return True
|
||||
return "BEGIN:VCALENDAR" in decode_text(data)[:2000]
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
lines = _unfold(decode_text(data))
|
||||
calendar = ""
|
||||
rows: list[dict] = []
|
||||
event: dict[str, Any] | None = None
|
||||
for line in lines:
|
||||
upper = line.strip().upper()
|
||||
if upper == "BEGIN:VEVENT":
|
||||
event = {}
|
||||
continue
|
||||
if upper == "END:VEVENT":
|
||||
if event is not None:
|
||||
rows.append(_event_row(event, calendar))
|
||||
event = None
|
||||
continue
|
||||
name, params, value = _parse_prop(line.strip())
|
||||
if name == "X-WR-CALNAME" and not event:
|
||||
calendar = _unescape(value)
|
||||
if event is None:
|
||||
continue
|
||||
if name == "SUMMARY":
|
||||
event["title"] = _unescape(value)
|
||||
elif name == "DTSTART":
|
||||
event["start"], event["all_day"] = _iso_datetime(value, params)
|
||||
elif name == "DTEND":
|
||||
event["end"], _ = _iso_datetime(value, params)
|
||||
elif name == "LOCATION":
|
||||
event["location"] = _unescape(value)
|
||||
elif name == "DESCRIPTION":
|
||||
event["description"] = _unescape(value)
|
||||
elif name == "UID":
|
||||
event["uid"] = value
|
||||
result.pages.append(make_collection("Calendar", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
def _event_row(event: dict[str, Any], calendar: str) -> dict:
|
||||
props: dict[str, Any] = {}
|
||||
if event.get("start"):
|
||||
props["Start"] = event["start"]
|
||||
if event.get("end"):
|
||||
props["End"] = event["end"]
|
||||
props["All day"] = bool(event.get("all_day"))
|
||||
if event.get("location"):
|
||||
props["Location"] = event["location"]
|
||||
if event.get("description"):
|
||||
props["Description"] = event["description"]
|
||||
if calendar:
|
||||
props["Calendar"] = calendar
|
||||
return {"title": (event.get("title") or "Event").strip()[:200], "properties": props}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""FlowDeck — Word (.docx) importer (v5.6.0, Phase 3).
|
||||
|
||||
Converts a Word document (including Google Docs Takeout ``.docx`` exports) into
|
||||
a FlowDeck page: headings, lists, tables and inline images.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportResult,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_HEADING_STYLES = {
|
||||
"title": 1, "heading 1": 1, "heading 2": 2, "heading 3": 3,
|
||||
"heading 4": 4, "heading 5": 4, "heading 6": 4,
|
||||
}
|
||||
_MIME = {
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
||||
".emf": "image/emf", ".wmf": "image/wmf", ".tiff": "image/tiff",
|
||||
}
|
||||
|
||||
|
||||
def _escape_cell(text: str) -> str:
|
||||
return text.strip().replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def _table_markdown(table) -> str:
|
||||
rows: list[list[str]] = []
|
||||
for row in table.rows:
|
||||
rows.append([_escape_cell(cell.text) for cell in row.cells])
|
||||
if not rows:
|
||||
return ""
|
||||
width = max(len(r) for r in rows)
|
||||
rows = [r + [""] * (width - len(r)) for r in rows]
|
||||
header = "| " + " | ".join(rows[0]) + " |"
|
||||
sep = "| " + " | ".join(["---"] * width) + " |"
|
||||
body = "\n".join("| " + " | ".join(r) + " |" for r in rows[1:])
|
||||
return "\n".join(x for x in (header, sep, body) if x)
|
||||
|
||||
|
||||
@register_importer
|
||||
class DocxImporter(Importer):
|
||||
source_id = "docx"
|
||||
label = "Word / Google Docs (.docx)"
|
||||
description = "Document Word : titres, listes, tableaux et images."
|
||||
extensions = (".docx", ".docm")
|
||||
order = 36
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
return filename.lower().endswith((".docx", ".docm"))
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
try:
|
||||
from docx import Document
|
||||
from docx.oxml.ns import qn
|
||||
except ImportError:
|
||||
result.warn("python-docx n'est pas installé : import Word indisponible")
|
||||
return result.finalize()
|
||||
try:
|
||||
doc = Document(io.BytesIO(data))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Document illisible : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
lines: list[str] = []
|
||||
for para in doc.paragraphs:
|
||||
text = para.text.strip()
|
||||
style = (para.style.name or "").lower() if para.style else ""
|
||||
images = self._paragraph_images(doc, para, qn, result)
|
||||
if text:
|
||||
level = _HEADING_STYLES.get(style)
|
||||
if level:
|
||||
lines.append("#" * level + " " + text)
|
||||
elif "list bullet" in style or "list paragraph" in style:
|
||||
lines.append("- " + text)
|
||||
elif "list number" in style:
|
||||
lines.append("1. " + text)
|
||||
elif style == "quote":
|
||||
lines.append("> " + text)
|
||||
else:
|
||||
lines.append(text)
|
||||
lines.extend(images)
|
||||
for table in doc.tables:
|
||||
md = _table_markdown(table)
|
||||
if md:
|
||||
lines.append(md)
|
||||
|
||||
markdown = re.sub(r"\n{3,}", "\n\n", "\n\n".join(lines)).strip()
|
||||
title = Path(filename).stem or "Document"
|
||||
result.pages.append(_page(title, markdown, filename))
|
||||
return result.finalize()
|
||||
|
||||
def _paragraph_images(self, doc, para, qn, result: ImportResult) -> list[str]:
|
||||
images: list[str] = []
|
||||
for blip in para._p.iter(qn("a:blip")):
|
||||
rid = blip.get(qn("r:embed")) or blip.get(qn("r:link"))
|
||||
if not rid:
|
||||
continue
|
||||
part = doc.part.related_parts.get(rid)
|
||||
if part is None or not hasattr(part, "blob"):
|
||||
continue
|
||||
name = Path(str(part.partname)).name or f"image_{len(result.attachments)}.png"
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=name, filename=name, data=part.blob,
|
||||
mime=_MIME.get(Path(name).suffix.lower(), "application/octet-stream"),
|
||||
))
|
||||
images.append(f"")
|
||||
return images
|
||||
|
||||
|
||||
def _page(title: str, markdown: str, filename: str):
|
||||
from app.services.importers.base import ImportPage
|
||||
|
||||
return ImportPage(title=title, markdown=markdown, source_path=filename, external_id=filename)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""FlowDeck — forge (Gitea/GitHub) issues importer (v5.6.0, Phase 4).
|
||||
|
||||
Pulls a repository's issues, labels and milestones through a forge adapter and
|
||||
normalizes them into FlowDeck collections.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.services.importers.base import ImportResult, make_collection
|
||||
|
||||
|
||||
def _label_names(issue: dict) -> list[str]:
|
||||
labels = issue.get("labels") or []
|
||||
names: list[str] = []
|
||||
for label in labels:
|
||||
if isinstance(label, dict):
|
||||
name = label.get("name") or label.get("title")
|
||||
else:
|
||||
name = str(label)
|
||||
if name and name not in names:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def _milestone_name(issue: dict) -> str:
|
||||
milestone = issue.get("milestone")
|
||||
if isinstance(milestone, dict):
|
||||
return str(milestone.get("title") or milestone.get("name") or "")
|
||||
return str(milestone or "")
|
||||
|
||||
|
||||
def _assignees(issue: dict) -> list[str]:
|
||||
out: list[str] = []
|
||||
for key in ("assignees", "assignee"):
|
||||
value = issue.get(key)
|
||||
if isinstance(value, list):
|
||||
for a in value:
|
||||
login = a.get("login") if isinstance(a, dict) else str(a)
|
||||
if login and login not in out:
|
||||
out.append(login)
|
||||
elif isinstance(value, dict):
|
||||
login = value.get("login")
|
||||
if login and login not in out:
|
||||
out.append(login)
|
||||
elif isinstance(value, str) and value and value not in out:
|
||||
out.append(value)
|
||||
return out
|
||||
|
||||
|
||||
_ISSUE_SCHEMA = [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Number", "type": "number"},
|
||||
{"name": "State", "type": "select", "options": [
|
||||
{"name": "open", "color": "green"}, {"name": "closed", "color": "red"},
|
||||
]},
|
||||
{"name": "Labels", "type": "multi_select"},
|
||||
{"name": "Milestone", "type": "text"},
|
||||
{"name": "Assignee", "type": "text"},
|
||||
{"name": "Created", "type": "date"},
|
||||
{"name": "Updated", "type": "date"},
|
||||
{"name": "URL", "type": "url"},
|
||||
{"name": "Body", "type": "text"},
|
||||
]
|
||||
|
||||
|
||||
def build_issues_result(
|
||||
issues: list[dict],
|
||||
*,
|
||||
labels: list[dict] | None = None,
|
||||
milestones: list[dict] | None = None,
|
||||
owner: str = "",
|
||||
repo: str = "",
|
||||
provider: str = "",
|
||||
) -> ImportResult:
|
||||
"""Normalize forge issues/labels/milestones into an ImportResult."""
|
||||
result = ImportResult(source=f"forge:{provider}" if provider else "forge")
|
||||
name = f"{owner}/{repo} issues".strip("/ ") or "Issues"
|
||||
rows: list[dict] = []
|
||||
for issue in issues:
|
||||
if issue.get("pull_request"):
|
||||
continue
|
||||
props: dict[str, Any] = {}
|
||||
if issue.get("number") is not None:
|
||||
props["Number"] = issue["number"]
|
||||
if issue.get("state"):
|
||||
props["State"] = issue["state"]
|
||||
label_names = _label_names(issue)
|
||||
if label_names:
|
||||
props["Labels"] = label_names
|
||||
milestone = _milestone_name(issue)
|
||||
if milestone:
|
||||
props["Milestone"] = milestone
|
||||
assignees = _assignees(issue)
|
||||
if assignees:
|
||||
props["Assignee"] = ", ".join(assignees)
|
||||
if issue.get("created_at"):
|
||||
props["Created"] = issue["created_at"]
|
||||
if issue.get("updated_at"):
|
||||
props["Updated"] = issue["updated_at"]
|
||||
if issue.get("html_url"):
|
||||
props["URL"] = issue["html_url"]
|
||||
if issue.get("body"):
|
||||
props["Body"] = issue["body"]
|
||||
title = issue.get("title") or f"#{issue.get('number', '')}".strip()
|
||||
rows.append({"title": title[:200], "properties": props})
|
||||
|
||||
result.pages.append(make_collection(
|
||||
name, _ISSUE_SCHEMA, rows,
|
||||
source_path=f"{provider}:{owner}/{repo}:issues",
|
||||
external_id=f"{provider}:{owner}/{repo}:issues",
|
||||
))
|
||||
result.stats["rows"] = len(rows)
|
||||
|
||||
if labels:
|
||||
label_schema = [
|
||||
{"name": "Name", "type": "title"},
|
||||
{"name": "Color", "type": "text"},
|
||||
{"name": "Description", "type": "text"},
|
||||
]
|
||||
label_rows = [{
|
||||
"title": (lbl.get("name") or lbl.get("title") or "Label")[:200],
|
||||
"properties": {
|
||||
k: v for k, v in (
|
||||
("Color", lbl.get("color")),
|
||||
("Description", lbl.get("description")),
|
||||
) if v
|
||||
},
|
||||
} for lbl in labels]
|
||||
result.pages.append(make_collection(
|
||||
f"{owner}/{repo} labels".strip("/ "), label_schema, label_rows,
|
||||
source_path=f"{provider}:{owner}/{repo}:labels",
|
||||
external_id=f"{provider}:{owner}/{repo}:labels",
|
||||
))
|
||||
|
||||
if milestones:
|
||||
ms_schema = [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "State", "type": "select", "options": [
|
||||
{"name": "open", "color": "green"}, {"name": "closed", "color": "red"},
|
||||
]},
|
||||
{"name": "Due date", "type": "date"},
|
||||
{"name": "Description", "type": "text"},
|
||||
]
|
||||
ms_rows = []
|
||||
for ms in milestones:
|
||||
props: dict[str, Any] = {}
|
||||
if ms.get("state"):
|
||||
props["State"] = ms["state"]
|
||||
if ms.get("due_on"):
|
||||
props["Due date"] = ms["due_on"]
|
||||
if ms.get("description"):
|
||||
props["Description"] = ms["description"]
|
||||
ms_rows.append({"title": (ms.get("title") or "Milestone")[:200], "properties": props})
|
||||
result.pages.append(make_collection(
|
||||
f"{owner}/{repo} milestones".strip("/ "), ms_schema, ms_rows,
|
||||
source_path=f"{provider}:{owner}/{repo}:milestones",
|
||||
external_id=f"{provider}:{owner}/{repo}:milestones",
|
||||
))
|
||||
|
||||
return result.finalize()
|
||||
|
||||
|
||||
class GiteaForgeAdapter:
|
||||
"""Adapts a :class:`GiteaClient` to the ``list_*`` interface used here."""
|
||||
|
||||
def __init__(self, client) -> None:
|
||||
self._client = client
|
||||
|
||||
async def list_issues(self, owner: str, repo: str, state: str = "all") -> list[dict]:
|
||||
issues: list[dict] = []
|
||||
for page in range(1, 6):
|
||||
batch = await self._client.get_issues(owner, repo, state=state, page=page, limit=50)
|
||||
if not batch:
|
||||
break
|
||||
issues.extend(batch)
|
||||
if len(batch) < 50:
|
||||
break
|
||||
return issues
|
||||
|
||||
async def list_labels(self, owner: str, repo: str) -> list[dict]:
|
||||
return await self._client.get_labels(owner, repo)
|
||||
|
||||
async def list_milestones(self, owner: str, repo: str, state: str = "all") -> list[dict]:
|
||||
return await self._client.get_milestones(owner, repo, state=state)
|
||||
|
||||
async def list_repo_files(self, owner: str, repo: str, path: str = "") -> list[dict]:
|
||||
"""Recursively flatten Gitea repo contents into file entries."""
|
||||
files: list[dict] = []
|
||||
pending = [path.strip("/")]
|
||||
while pending and len(files) < 5000:
|
||||
current = pending.pop()
|
||||
items = await self._client.get_repo_contents(owner, repo, current)
|
||||
for item in items:
|
||||
if item.get("type") == "dir":
|
||||
pending.append(item.get("path") or item.get("name"))
|
||||
elif item.get("type") == "file":
|
||||
files.append({"path": item.get("path") or item.get("name"), "size": item.get("size", 0)})
|
||||
return files
|
||||
|
||||
async def get_file_content(self, owner: str, repo: str, path: str) -> str:
|
||||
return await self._client.get_file_content(owner, repo, path)
|
||||
|
||||
|
||||
async def fetch_forge_issues(
|
||||
adapter,
|
||||
owner: str,
|
||||
repo: str,
|
||||
*,
|
||||
provider: str = "",
|
||||
state: str = "all",
|
||||
include_labels: bool = True,
|
||||
include_milestones: bool = True,
|
||||
) -> ImportResult:
|
||||
"""Fetch issues (and optionally labels/milestones) then normalize them."""
|
||||
issues = await adapter.list_issues(owner, repo, state)
|
||||
labels = None
|
||||
milestones = None
|
||||
if include_labels:
|
||||
try:
|
||||
labels = await adapter.list_labels(owner, repo)
|
||||
except Exception: # noqa: BLE001 - labels are optional
|
||||
labels = None
|
||||
if include_milestones:
|
||||
try:
|
||||
milestones = await adapter.list_milestones(owner, repo, state)
|
||||
except Exception: # noqa: BLE001
|
||||
milestones = None
|
||||
return build_issues_result(
|
||||
issues, labels=labels, milestones=milestones,
|
||||
owner=owner, repo=repo, provider=provider,
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""FlowDeck — forge repository file importer (v5.6.0, Phase 5).
|
||||
|
||||
Imports a Gitea/GitHub repository's text files as pages, preserving the folder
|
||||
hierarchy. Markdown files become pages; other text files become code blocks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.export import _CODE_LANG, _TEXTUAL_EXTS
|
||||
from app.services.importers.base import ImportPage, ImportResult
|
||||
|
||||
_MD_EXTS = {"md", "markdown"}
|
||||
|
||||
|
||||
def _ext(path: str) -> str:
|
||||
return Path(path).suffix.lower().lstrip(".")
|
||||
|
||||
|
||||
def build_repo_result(
|
||||
files: list[tuple[str, str]],
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
provider: str = "",
|
||||
) -> ImportResult:
|
||||
"""Turn ``[(path, content)]`` into pages with folder hierarchy."""
|
||||
result = ImportResult(source=f"forge-repo:{provider}" if provider else "forge-repo")
|
||||
for path, content in files:
|
||||
clean = path.replace("\\", "/").strip("/")
|
||||
if not clean:
|
||||
continue
|
||||
ext = _ext(clean)
|
||||
if ext in _MD_EXTS:
|
||||
markdown = content
|
||||
else:
|
||||
lang = _CODE_LANG.get(ext, "")
|
||||
markdown = f"```{lang}\n{content.rstrip()}\n```"
|
||||
parts = clean.split("/")
|
||||
result.pages.append(ImportPage(
|
||||
title=parts[-1] or clean,
|
||||
markdown=markdown,
|
||||
source_path=clean,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
external_id=f"{provider}:{owner}/{repo}:{clean}",
|
||||
))
|
||||
result.stats["rows"] = len(result.pages)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
async def fetch_forge_repo(
|
||||
adapter,
|
||||
owner: str,
|
||||
repo: str,
|
||||
*,
|
||||
provider: str = "",
|
||||
path: str = "",
|
||||
max_files: int = 200,
|
||||
max_file_bytes: int = 512_000,
|
||||
) -> ImportResult:
|
||||
"""List a repo's files and fetch the textual ones."""
|
||||
try:
|
||||
metas = await adapter.list_repo_files(owner, repo, path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result = ImportResult(source=f"forge-repo:{provider}" if provider else "forge-repo")
|
||||
result.warn(f"Arborescence illisible : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
files: list[tuple[str, str]] = []
|
||||
for meta in metas:
|
||||
file_path = meta.get("path") or ""
|
||||
if _ext(file_path) not in _TEXTUAL_EXTS:
|
||||
continue
|
||||
if int(meta.get("size") or 0) > max_file_bytes:
|
||||
continue
|
||||
if len(files) >= max_files:
|
||||
break
|
||||
try:
|
||||
content = await adapter.get_file_content(owner, repo, file_path)
|
||||
except Exception: # noqa: BLE001 - skip unreadable files
|
||||
continue
|
||||
if not content or content == "[binary file]":
|
||||
continue
|
||||
files.append((file_path, content))
|
||||
return build_repo_result(files, owner=owner, repo=repo, provider=provider)
|
||||
@@ -0,0 +1,300 @@
|
||||
"""FlowDeck — HTML notes & Google Keep importer (v5.6.0, Phase 1).
|
||||
|
||||
Covers HTML exports from Apple Notes, Bear, Ulysses and OneNote, plus the
|
||||
Google Takeout ``Keep`` JSON/HTML format. HTML is converted to Markdown and then
|
||||
to FlowDeck blocks by the pipeline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
from bs4 import BeautifulSoup, NavigableString, Tag
|
||||
|
||||
from app.services.importers._common import coerce_tags, normalize_title
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_HTML_EXTS = (".html", ".htm")
|
||||
|
||||
|
||||
def _inline(node: Tag) -> str:
|
||||
out: list[str] = []
|
||||
for child in node.children:
|
||||
if isinstance(child, NavigableString):
|
||||
out.append(str(child))
|
||||
elif isinstance(child, Tag):
|
||||
name = child.name.lower()
|
||||
if name in ("strong", "b"):
|
||||
out.append(f"**{_inline(child).strip()}**")
|
||||
elif name in ("em", "i"):
|
||||
out.append(f"*{_inline(child).strip()}*")
|
||||
elif name == "code":
|
||||
out.append(f"`{child.get_text()}`")
|
||||
elif name == "br":
|
||||
out.append("\n")
|
||||
elif name == "a":
|
||||
href = child.get("href", "")
|
||||
label = _inline(child).strip() or href
|
||||
out.append(f"[{label}]({href})" if href else label)
|
||||
elif name == "img":
|
||||
src = child.get("src", "")
|
||||
alt = child.get("alt", "")
|
||||
out.append(f"" if src else "")
|
||||
elif name in ("del", "s", "strike"):
|
||||
out.append(f"~~{_inline(child).strip()}~~")
|
||||
else:
|
||||
out.append(_inline(child))
|
||||
return re.sub(r"[ \t]+", " ", "".join(out))
|
||||
|
||||
|
||||
def _table(node: Tag) -> str:
|
||||
rows: list[list[str]] = []
|
||||
for tr in node.find_all("tr"):
|
||||
cells = tr.find_all(["th", "td"])
|
||||
rows.append([_inline(c).strip().replace("|", "\\|") for c in cells])
|
||||
if not rows:
|
||||
return ""
|
||||
width = max(len(r) for r in rows)
|
||||
rows = [r + [""] * (width - len(r)) for r in rows]
|
||||
header = "| " + " | ".join(rows[0]) + " |"
|
||||
sep = "| " + " | ".join(["---"] * width) + " |"
|
||||
body = "\n".join("| " + " | ".join(r) + " |" for r in rows[1:])
|
||||
return "\n".join(x for x in (header, sep, body) if x)
|
||||
|
||||
|
||||
def _block(node: Tag, depth: int = 0) -> str:
|
||||
name = node.name.lower()
|
||||
if name in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
return "#" * int(name[1]) + " " + _inline(node).strip()
|
||||
if name == "p":
|
||||
return _inline(node).strip()
|
||||
if name in ("ul", "ol"):
|
||||
lines = []
|
||||
for i, li in enumerate(node.find_all("li", recursive=False)):
|
||||
marker = f"{i + 1}." if name == "ol" else "-"
|
||||
text = _inline(li).strip()
|
||||
lines.append(f"{' ' * depth}{marker} {text}")
|
||||
return "\n".join(lines)
|
||||
if name == "blockquote":
|
||||
return "\n".join(f"> {ln}" for ln in _inline(node).strip().splitlines())
|
||||
if name == "pre":
|
||||
code = node.get_text()
|
||||
lang = ""
|
||||
cls = " ".join(node.get("class", [])) if node.get("class") else ""
|
||||
m = re.search(r"(?:language|lang)-([\w+-]+)", cls)
|
||||
if m:
|
||||
lang = m.group(1)
|
||||
return f"```{lang}\n{code.rstrip()}\n```"
|
||||
if name == "hr":
|
||||
return "---"
|
||||
if name == "table":
|
||||
return _table(node)
|
||||
if name == "img":
|
||||
src = node.get("src", "")
|
||||
return f"" if src else ""
|
||||
if name in ("div", "section", "article", "body", "main", "html", "span", "font", "center"):
|
||||
inner = "\n\n".join(
|
||||
_block(c, depth) for c in node.children if isinstance(c, Tag)
|
||||
).strip()
|
||||
if inner:
|
||||
return inner
|
||||
text = _inline(node).strip()
|
||||
return text
|
||||
return _inline(node).strip()
|
||||
|
||||
|
||||
def _html_to_markdown(html: str) -> str:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for tag in soup(["script", "style", "head", "nav", "footer"]):
|
||||
tag.decompose()
|
||||
root = soup.body or soup
|
||||
blocks = [_block(c) for c in root.children if isinstance(c, Tag)]
|
||||
md = "\n\n".join(b for b in blocks if b and b.strip())
|
||||
return re.sub(r"\n{3,}", "\n\n", md).strip()
|
||||
|
||||
|
||||
def _title_from_html(html: str, fallback: str) -> str:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
if soup.title and soup.title.string:
|
||||
return soup.title.string.strip()
|
||||
h1 = soup.find(["h1", "h2"])
|
||||
if h1:
|
||||
return h1.get_text().strip()
|
||||
return fallback
|
||||
|
||||
|
||||
@register_importer
|
||||
class HtmlNotesImporter(Importer):
|
||||
source_id = "html_notes"
|
||||
label = "HTML (Apple Notes, Bear, Ulysses, OneNote)"
|
||||
description = "Fichiers HTML ou archive .zip (notes exportées en HTML)."
|
||||
extensions = (".html", ".htm", ".zip")
|
||||
order = 50
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
low = filename.lower()
|
||||
if low.endswith(_HTML_EXTS):
|
||||
return True
|
||||
if low.endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
return any(n.lower().endswith(_HTML_EXTS) for n in names)
|
||||
return False
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
entries: list[tuple[str, bytes]] = []
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
for name in zf.namelist():
|
||||
if name.endswith("/"):
|
||||
continue
|
||||
clean = name.replace("\\", "/")
|
||||
if clean.lower().endswith(_HTML_EXTS):
|
||||
entries.append((clean, zf.read(name)))
|
||||
else:
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=clean,
|
||||
filename=clean.rsplit("/", 1)[-1],
|
||||
data=zf.read(name),
|
||||
))
|
||||
else:
|
||||
entries.append((filename, data))
|
||||
|
||||
for name, payload in entries:
|
||||
html = decode_text(payload)
|
||||
fallback = name.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||
parts = name.replace("\\", "/").split("/")
|
||||
result.pages.append(ImportPage(
|
||||
title=_title_from_html(html, fallback) or "Untitled",
|
||||
markdown=_html_to_markdown(html),
|
||||
source_path=name,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
external_id=name,
|
||||
))
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class GoogleKeepImporter(Importer):
|
||||
source_id = "google_keep"
|
||||
label = "Google Keep (Takeout)"
|
||||
description = "Export Google Takeout : Keep/*.json (notes, listes, labels, pièces jointes)."
|
||||
extensions = (".json", ".zip")
|
||||
order = 40
|
||||
|
||||
def _is_keep_json(self, data: bytes) -> bool:
|
||||
try:
|
||||
obj = json.loads(decode_text(data))
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
return isinstance(obj, dict) and any(
|
||||
k in obj for k in ("textContent", "listContent", "isTrashed", "color")
|
||||
)
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
low = filename.lower()
|
||||
if low.endswith(".json"):
|
||||
return self._is_keep_json(data)
|
||||
if low.endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
for n in zf.namelist():
|
||||
if n.lower().endswith(".json") and "keep" in n.lower():
|
||||
try:
|
||||
if self._is_keep_json(zf.read(n)):
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return False
|
||||
|
||||
def _page_from_keep(self, obj: dict, name: str) -> ImportPage | None:
|
||||
if obj.get("isTrashed"):
|
||||
return None
|
||||
title = normalize_title(obj.get("title"))
|
||||
lines: list[str] = []
|
||||
for item in obj.get("listContent") or []:
|
||||
mark = "x" if item.get("isChecked") else " "
|
||||
lines.append(f"- [{mark}] {item.get('text', '')}")
|
||||
if obj.get("textContent"):
|
||||
lines.insert(0, obj["textContent"])
|
||||
body = "\n\n".join(lines)
|
||||
if not title:
|
||||
first = next((ln for ln in body.splitlines() if ln.strip()), "")
|
||||
first = re.sub(r"^[-*+]\s*(\[[ xX]\]\s*)?", "", first).strip()
|
||||
title = first[:60] or "Note"
|
||||
labels = coerce_tags(obj.get("labels"))
|
||||
props = {"tags": labels} if labels else {}
|
||||
return ImportPage(
|
||||
title=title,
|
||||
markdown=body,
|
||||
source_path=name,
|
||||
parent_path="",
|
||||
properties=props,
|
||||
external_id=name,
|
||||
)
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
for name in zf.namelist():
|
||||
if name.endswith("/"):
|
||||
continue
|
||||
clean = name.replace("\\", "/")
|
||||
if clean.lower().endswith(".json") and "keep" in clean.lower():
|
||||
try:
|
||||
obj = json.loads(decode_text(zf.read(name)))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
page = self._page_from_keep(obj, clean)
|
||||
if page:
|
||||
result.pages.append(page)
|
||||
elif "/keep/" in clean.lower() and not clean.lower().endswith(".json"):
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=clean,
|
||||
filename=clean.rsplit("/", 1)[-1],
|
||||
data=zf.read(name),
|
||||
))
|
||||
return result.finalize()
|
||||
|
||||
try:
|
||||
obj = json.loads(decode_text(data))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"JSON invalide : {exc}")
|
||||
return result.finalize()
|
||||
if isinstance(obj, list):
|
||||
for i, item in enumerate(obj):
|
||||
if isinstance(item, dict):
|
||||
page = self._page_from_keep(item, f"{filename}#{i}")
|
||||
if page:
|
||||
result.pages.append(page)
|
||||
else:
|
||||
page = self._page_from_keep(obj, filename)
|
||||
if page:
|
||||
result.pages.append(page)
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""FlowDeck — background import jobs (v5.6.0, Phase 0).
|
||||
|
||||
Small in-process job manager used for large uploads (vaults, zips): the upload
|
||||
is parsed and persisted in a worker thread while the UI polls job status.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportResult,
|
||||
detect_importer,
|
||||
get_importer,
|
||||
)
|
||||
from app.services.importers.pipeline import run_import
|
||||
|
||||
_JOBS: dict[str, dict[str, Any]] = {}
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def parse_upload(filename: str, data: bytes, source_id: str | None = None) -> tuple[Importer | None, ImportResult]:
|
||||
"""Detect (or use) an importer and parse the upload synchronously."""
|
||||
imp = get_importer(source_id) if source_id else None
|
||||
if imp is None:
|
||||
imp = detect_importer(filename, data)
|
||||
if imp is None:
|
||||
return None, ImportResult(source=source_id or "unknown", warnings=["Format non reconnu"])
|
||||
return imp, imp.parse(filename, data)
|
||||
|
||||
|
||||
def _record(job: dict, *, status: str | None = None, error: str = "",
|
||||
report: dict | None = None, progress: int | None = None) -> None:
|
||||
with _LOCK:
|
||||
if status:
|
||||
job["status"] = status
|
||||
if error:
|
||||
job["error"] = error
|
||||
if report is not None:
|
||||
job["report"] = report
|
||||
if progress is not None:
|
||||
job["progress"] = progress
|
||||
job["updated_at"] = time.time()
|
||||
_persist(job)
|
||||
|
||||
|
||||
def _persist(job: dict) -> None:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO import_jobs (id, source, filename, status, error, report_json, created_at, updated_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(id) DO UPDATE SET status=excluded.status, error=excluded.error, "
|
||||
"report_json=excluded.report_json, updated_at=excluded.updated_at",
|
||||
(job["id"], job["source"], job["filename"], job["status"], job.get("error", ""),
|
||||
_json(job.get("report")), job["created_at"], job["updated_at"]),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception: # noqa: BLE001 - persistence is best-effort
|
||||
pass
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
import json
|
||||
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
return "{}"
|
||||
|
||||
|
||||
def create_job(source: str, filename: str) -> dict:
|
||||
job = {
|
||||
"id": uuid.uuid4().hex[:16],
|
||||
"source": source,
|
||||
"filename": filename,
|
||||
"status": "queued",
|
||||
"progress": 0,
|
||||
"error": "",
|
||||
"report": None,
|
||||
"created_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
with _LOCK:
|
||||
_JOBS[job["id"]] = job
|
||||
_persist(job)
|
||||
return job
|
||||
|
||||
|
||||
def get_job(job_id: str) -> dict | None:
|
||||
with _LOCK:
|
||||
job = _JOBS.get(job_id)
|
||||
return dict(job) if job else None
|
||||
|
||||
|
||||
def list_jobs(limit: int = 50) -> list[dict]:
|
||||
with _LOCK:
|
||||
jobs = sorted(_JOBS.values(), key=lambda j: j["created_at"], reverse=True)
|
||||
return [dict(j) for j in jobs[:limit]]
|
||||
|
||||
|
||||
def start_import_job(
|
||||
*,
|
||||
filename: str,
|
||||
data: bytes,
|
||||
source_id: str | None,
|
||||
workspace_id: int | None,
|
||||
workspace_name: str | None,
|
||||
user_login: str,
|
||||
parent_page_id: int | None,
|
||||
target_collection_id: int | None,
|
||||
dedup: bool = True,
|
||||
mapping: dict[str, str] | None = None,
|
||||
mode: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a job and run parse + persist in a background thread."""
|
||||
job = create_job(source_id or "auto", filename)
|
||||
_record(job, status="running", progress=5)
|
||||
|
||||
def worker() -> None:
|
||||
try:
|
||||
imp, result = parse_upload(filename, data, source_id)
|
||||
if imp is None:
|
||||
_record(job, status="error", error="Format non reconnu")
|
||||
return
|
||||
_record(job, progress=40)
|
||||
report = run_import(
|
||||
result,
|
||||
workspace_id=workspace_id,
|
||||
workspace_name=workspace_name,
|
||||
user_login=user_login,
|
||||
parent_page_id=parent_page_id,
|
||||
target_collection_id=target_collection_id,
|
||||
dedup=dedup,
|
||||
mapping=mapping,
|
||||
mode=mode,
|
||||
)
|
||||
_record(job, status="done", progress=100, report=report)
|
||||
except Exception as exc: # noqa: BLE001 - surface the error to the UI
|
||||
_record(job, status="error", error=f"{exc}", report={
|
||||
"traceback": traceback.format_exc()[-2000:],
|
||||
})
|
||||
|
||||
threading.Thread(target=worker, name=f"import-{job['id']}", daemon=True).start()
|
||||
return job
|
||||
@@ -0,0 +1,87 @@
|
||||
"""FlowDeck — generic Markdown / text importer (v5.6.0, Phase 1)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_MD_EXTS = (".md", ".markdown", ".txt", ".mdx")
|
||||
|
||||
|
||||
def _title_from_name(name: str) -> str:
|
||||
base = name.replace("\\", "/").rsplit("/", 1)[-1]
|
||||
for ext in (".markdown", ".markdown", ".mdx", ".md", ".txt"):
|
||||
if base.lower().endswith(ext):
|
||||
base = base[: -len(ext)]
|
||||
break
|
||||
return base.strip() or "Untitled"
|
||||
|
||||
|
||||
@register_importer
|
||||
class MarkdownImporter(Importer):
|
||||
source_id = "markdown"
|
||||
label = "Markdown / texte"
|
||||
description = "Fichiers .md/.markdown/.txt ou archive .zip de fichiers Markdown."
|
||||
extensions = (".md", ".markdown", ".txt", ".zip")
|
||||
order = 90
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
low = filename.lower()
|
||||
if low.endswith(_MD_EXTS):
|
||||
return True
|
||||
if low.endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
return bool(names) and all(n.lower().endswith(_MD_EXTS) for n in names)
|
||||
return False
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
entries = sorted(
|
||||
(n for n in zf.namelist()
|
||||
if not n.endswith("/") and n.lower().endswith(_MD_EXTS)),
|
||||
key=lambda n: (n.count("/"), n.lower()),
|
||||
)
|
||||
if not entries:
|
||||
result.warn("Aucun fichier Markdown trouvé dans l'archive")
|
||||
return result.finalize()
|
||||
for name in entries:
|
||||
try:
|
||||
text = decode_text(zf.read(name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Lecture impossible : {name} ({exc})")
|
||||
continue
|
||||
parts = name.replace("\\", "/").split("/")
|
||||
result.pages.append(ImportPage(
|
||||
title=_title_from_name(name),
|
||||
markdown=text,
|
||||
source_path=name,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
external_id=name,
|
||||
))
|
||||
return result.finalize()
|
||||
|
||||
text = decode_text(data)
|
||||
result.pages.append(ImportPage(
|
||||
title=_title_from_name(filename),
|
||||
markdown=text,
|
||||
source_path=filename,
|
||||
external_id=filename,
|
||||
))
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""FlowDeck — Notion export importer (v5.6.0, Phase 1, amélioration v5.4.0).
|
||||
|
||||
Imports a Notion "Export as Markdown & CSV" ``.zip``: complete page hierarchy,
|
||||
databases (``.csv``) turned into FlowDeck collections, and image attachments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
import zipfile
|
||||
from urllib.parse import unquote
|
||||
|
||||
from app.services.importers._common import split_frontmatter
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
from app.services.importers.tabular import rows_to_collection
|
||||
|
||||
_HASH_RE = re.compile(r"\s+[0-9a-f]{32}$")
|
||||
_MD_LINK_RE = re.compile(r"\]\(([^)]+)\.md\)")
|
||||
|
||||
|
||||
def _clean_name(name: str) -> str:
|
||||
base = unquote(name.replace("\\", "/").rsplit("/", 1)[-1])
|
||||
base = re.sub(r"\.(md|csv|markdown)$", "", base, flags=re.IGNORECASE)
|
||||
return _HASH_RE.sub("", base).strip() or "Untitled"
|
||||
|
||||
|
||||
def _strip_hash_link(match: re.Match) -> str:
|
||||
target = unquote(match.group(1)).strip()
|
||||
return f"]({_HASH_RE.sub('', target).strip() or target})"
|
||||
|
||||
|
||||
@register_importer
|
||||
class NotionImporter(Importer):
|
||||
source_id = "notion"
|
||||
label = "Notion (export .zip)"
|
||||
description = "Export Notion Markdown & CSV : hiérarchie, databases → collections, images."
|
||||
extensions = (".zip",)
|
||||
order = 20
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if not filename.lower().endswith(".zip"):
|
||||
return False
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
md = [n for n in names if n.lower().endswith(".md")]
|
||||
csvs = [n for n in names if n.lower().endswith(".csv")]
|
||||
if not md:
|
||||
return False
|
||||
if csvs:
|
||||
return True
|
||||
return any(_HASH_RE.search(unquote(n.rsplit("/", 1)[-1])) for n in md)
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
md_names = [n for n in names if n.lower().endswith(".md")]
|
||||
csv_names = [n for n in names if n.lower().endswith(".csv")]
|
||||
|
||||
pages_by_title: dict[str, ImportPage] = {}
|
||||
for name in sorted(md_names, key=lambda n: (n.count("/"), n.lower())):
|
||||
try:
|
||||
text = decode_text(zf.read(name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Lecture impossible : {name} ({exc})")
|
||||
continue
|
||||
meta, body = split_frontmatter(text)
|
||||
title = _clean_name(name)
|
||||
body = _MD_LINK_RE.sub(_strip_hash_link, body)
|
||||
first_line = body.lstrip().splitlines()[0] if body.strip() else ""
|
||||
if first_line.strip().startswith("# ") and first_line.strip()[2:].strip() == title:
|
||||
body = "\n".join(body.lstrip().splitlines()[1:]).lstrip("\n")
|
||||
parts = unquote(name).replace("\\", "/").split("/")
|
||||
page = ImportPage(
|
||||
title=title,
|
||||
markdown=body,
|
||||
source_path=name,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
properties={k: v for k, v in meta.items() if k != "title"},
|
||||
external_id=name,
|
||||
)
|
||||
pages_by_title.setdefault(title, page)
|
||||
result.pages.append(page)
|
||||
|
||||
for name in sorted(csv_names, key=lambda n: (n.count("/"), n.lower())):
|
||||
try:
|
||||
text = decode_text(zf.read(name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Lecture impossible : {name} ({exc})")
|
||||
continue
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
headers = [h for h in (reader.fieldnames or []) if h is not None]
|
||||
rows = [dict(r) for r in reader]
|
||||
title = _clean_name(name)
|
||||
spec_page = rows_to_collection(title, headers, rows)
|
||||
parts = unquote(name).replace("\\", "/").split("/")
|
||||
existing = pages_by_title.get(title)
|
||||
if existing is not None:
|
||||
existing.collection = spec_page.collection
|
||||
existing.source_path = existing.source_path or name
|
||||
else:
|
||||
spec_page.parent_path = "/".join(parts[:-1])
|
||||
spec_page.source_path = name
|
||||
spec_page.external_id = name
|
||||
result.pages.append(spec_page)
|
||||
|
||||
for name in names:
|
||||
low = name.lower()
|
||||
if low.endswith((".md", ".csv")):
|
||||
continue
|
||||
try:
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=name,
|
||||
filename=unquote(name).replace("\\", "/").rsplit("/", 1)[-1],
|
||||
data=zf.read(name),
|
||||
))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""FlowDeck — Obsidian vault importer (v5.6.0, Phase 1).
|
||||
|
||||
Imports a vault exported as a ``.zip``: Markdown notes (with YAML frontmatter),
|
||||
the folder hierarchy, ``[[wikilinks]]``/``![[embeds]]`` and binary attachments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from app.services.importers._common import (
|
||||
coerce_tags,
|
||||
convert_wikilinks,
|
||||
normalize_title,
|
||||
split_frontmatter,
|
||||
)
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_SKIP_DIRS = (".obsidian/", ".trash/", ".git/", ".DS_Store")
|
||||
|
||||
|
||||
def _mime_for(name: str) -> str:
|
||||
import mimetypes
|
||||
|
||||
return mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||
|
||||
|
||||
@register_importer
|
||||
class ObsidianImporter(Importer):
|
||||
source_id = "obsidian"
|
||||
label = "Obsidian (vault .zip)"
|
||||
description = "Vault Obsidian : notes Markdown, frontmatter YAML, wikilinks, pièces jointes."
|
||||
extensions = (".zip",)
|
||||
order = 10
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if not filename.lower().endswith(".zip"):
|
||||
return False
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
names = zf.namelist()
|
||||
if any("/.obsidian/" in n or n.startswith(".obsidian/") for n in names):
|
||||
return True
|
||||
# Heuristic: mostly-markdown archive containing wikilinks.
|
||||
md = [n for n in names if n.lower().endswith(".md")]
|
||||
if not md:
|
||||
return False
|
||||
for n in md[:20]:
|
||||
try:
|
||||
if "[[" in decode_text(zf.read(n)):
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return False
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
notes = [n for n in names if n.lower().endswith(".md")]
|
||||
assets = [n for n in names if not n.lower().endswith(".md")]
|
||||
|
||||
for name in assets:
|
||||
clean = name.replace("\\", "/")
|
||||
if any(part in clean for part in _SKIP_DIRS) or clean.split("/")[-1].startswith("."):
|
||||
continue
|
||||
try:
|
||||
payload = zf.read(name)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=name,
|
||||
filename=clean.rsplit("/", 1)[-1],
|
||||
data=payload,
|
||||
mime=_mime_for(name),
|
||||
))
|
||||
|
||||
for name in sorted(notes, key=lambda n: (n.count("/"), n.lower())):
|
||||
clean = name.replace("\\", "/")
|
||||
if any(part in clean for part in _SKIP_DIRS):
|
||||
continue
|
||||
try:
|
||||
text = decode_text(zf.read(name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Lecture impossible : {name} ({exc})")
|
||||
continue
|
||||
meta, body = split_frontmatter(text)
|
||||
body = convert_wikilinks(body)
|
||||
parts = clean.split("/")
|
||||
title = normalize_title(meta.get("title")) or parts[-1][:-3]
|
||||
props = dict(meta)
|
||||
props.pop("title", None)
|
||||
tags = coerce_tags(meta.get("tags"))
|
||||
if tags:
|
||||
props["tags"] = tags
|
||||
result.pages.append(ImportPage(
|
||||
title=title or "Untitled",
|
||||
markdown=body,
|
||||
source_path=clean,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
properties=props,
|
||||
external_id=clean,
|
||||
))
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""FlowDeck — OPML importer (v5.6.0, Phase 4).
|
||||
|
||||
Imports an OPML outline (RSS readers, feed lists) as a collection of feeds.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any
|
||||
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
make_collection,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_SCHEMA = [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Feed URL", "type": "url"},
|
||||
{"name": "Site URL", "type": "url"},
|
||||
{"name": "Type", "type": "select", "options": [
|
||||
{"name": "rss", "color": "orange"},
|
||||
{"name": "folder", "color": "gray"},
|
||||
]},
|
||||
{"name": "Folder", "type": "text"},
|
||||
]
|
||||
|
||||
|
||||
@register_importer
|
||||
class OpmlImporter(Importer):
|
||||
source_id = "opml"
|
||||
label = "OPML (flux RSS)"
|
||||
description = "Outline OPML → collection de flux (titre, URL, dossier)."
|
||||
extensions = (".opml", ".xml")
|
||||
order = 34
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if filename.lower().endswith(".opml"):
|
||||
return True
|
||||
head = decode_text(data)[:1000].lower()
|
||||
return "<opml" in head and "<outline" in head
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
rows: list[dict] = []
|
||||
try:
|
||||
root = ET.fromstring(decode_text(data))
|
||||
except ET.ParseError as exc:
|
||||
result.warn(f"OPML invalide : {exc}")
|
||||
return result.finalize()
|
||||
for outline in root.iter("outline"):
|
||||
attrs = {k.lower(): v for k, v in outline.attrib.items()}
|
||||
feed = attrs.get("xmlurl")
|
||||
title = attrs.get("title") or attrs.get("text") or feed or ""
|
||||
if not feed and not title:
|
||||
continue
|
||||
props: dict[str, Any] = {}
|
||||
if feed:
|
||||
props["Feed URL"] = feed
|
||||
props["Type"] = "rss"
|
||||
else:
|
||||
props["Type"] = "folder"
|
||||
if attrs.get("htmlurl"):
|
||||
props["Site URL"] = attrs["htmlurl"]
|
||||
folder = _folder_of(outline, root)
|
||||
if folder:
|
||||
props["Folder"] = folder
|
||||
rows.append({"title": title[:200] or "Feed", "properties": props})
|
||||
result.pages.append(make_collection("OPML feeds", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
def _folder_of(node: ET.Element, root: ET.Element) -> str:
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
parts: list[str] = []
|
||||
current = parents.get(node)
|
||||
while current is not None:
|
||||
attrs = {k.lower(): v for k, v in current.attrib.items()}
|
||||
if not attrs.get("xmlurl"):
|
||||
label = attrs.get("title") or attrs.get("text")
|
||||
if label:
|
||||
parts.append(label)
|
||||
current = parents.get(current)
|
||||
return " / ".join(reversed(parts))
|
||||
@@ -0,0 +1,164 @@
|
||||
"""FlowDeck — Logseq / Roam Research outliner importer (v5.6.0, Phase 1)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
from app.services.importers._common import (
|
||||
coerce_tags,
|
||||
convert_wikilinks,
|
||||
normalize_title,
|
||||
split_frontmatter,
|
||||
)
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_LOGSEQ_MARKERS = ("property::", "logseq/", "journals/")
|
||||
_ROAM_MARKERS = ("{{[[TODO]]}}", "{{[[DONE]]}}", "{{[[query]]}}")
|
||||
_PROP_RE = re.compile(r"^\s*([a-zA-Z][\w-]*)::\s*(.*)$")
|
||||
|
||||
|
||||
def _journal_title(name: str) -> str:
|
||||
m = re.match(r"^(\d{4})[_-](\d{2})[_-](\d{2})", name)
|
||||
if m:
|
||||
return f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
|
||||
return name
|
||||
|
||||
|
||||
def _clean_outline(text: str) -> tuple[dict, str]:
|
||||
meta, body = split_frontmatter(text)
|
||||
lines_out: list[str] = []
|
||||
for line in body.splitlines():
|
||||
m = _PROP_RE.match(line)
|
||||
if m and line.lstrip().startswith("-"):
|
||||
continue
|
||||
# Logseq properties appear as bare ``key:: value`` lines too.
|
||||
m2 = _PROP_RE.match(line)
|
||||
if m2 and not line.lstrip().startswith(("-", "*", "#", "|")):
|
||||
key = m2.group(1)
|
||||
if key not in meta:
|
||||
meta[key] = m2.group(2).strip()
|
||||
continue
|
||||
lines_out.append(line)
|
||||
body = "\n".join(lines_out)
|
||||
# Roam task markers → GFM checkboxes.
|
||||
body = body.replace("{{[[TODO]]}}", "[ ] ").replace("{{[[DONE]]}}", "[x] ")
|
||||
# Block references ((uuid)) → plain anchors.
|
||||
body = re.sub(r"\(\(([0-9a-fA-F-]{6,})\)\)", r"[[\1]]", body)
|
||||
body = convert_wikilinks(body)
|
||||
return meta, body
|
||||
|
||||
|
||||
class _OutlineBase(Importer):
|
||||
markers: tuple[str, ...] = ()
|
||||
property_syntax = False
|
||||
source_id = "outline"
|
||||
label = "Outliner"
|
||||
description = ""
|
||||
order = 30
|
||||
|
||||
def _text_matches(self, text: str) -> bool:
|
||||
if any(m in text for m in self.markers if not m.endswith("/")):
|
||||
return True
|
||||
return bool(self.property_syntax and re.search(r"^\s*[a-zA-Z][\w-]*::", text, re.M))
|
||||
|
||||
def _looks_like(self, filename: str, data: bytes) -> bool:
|
||||
low = filename.lower()
|
||||
if low.endswith((".md", ".markdown", ".txt")):
|
||||
return self._text_matches(decode_text(data))
|
||||
if low.endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
names = zf.namelist()
|
||||
if any(m in name for m in self.markers if m.endswith("/") for name in names):
|
||||
return True
|
||||
for n in [x for x in names if x.lower().endswith(".md")][:10]:
|
||||
try:
|
||||
if self._text_matches(decode_text(zf.read(n))):
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return False
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
return self._looks_like(filename, data)
|
||||
|
||||
def _emit(self, result: ImportResult, name: str, text: str, is_journal: bool) -> None:
|
||||
meta, body = _clean_outline(text)
|
||||
parts = name.replace("\\", "/").split("/")
|
||||
raw_title = parts[-1].rsplit(".", 1)[0]
|
||||
title = normalize_title(meta.get("title")) or (
|
||||
_journal_title(raw_title) if is_journal else raw_title
|
||||
)
|
||||
props = {k: v for k, v in meta.items() if k != "title"}
|
||||
tags = coerce_tags(meta.get("tags"))
|
||||
if tags:
|
||||
props["tags"] = tags
|
||||
result.pages.append(ImportPage(
|
||||
title=title or "Untitled",
|
||||
markdown=body,
|
||||
source_path=name,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
properties=props,
|
||||
external_id=name,
|
||||
))
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
for name in [n for n in names if not n.lower().endswith(".md")]:
|
||||
try:
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=name,
|
||||
filename=name.replace("\\", "/").rsplit("/", 1)[-1],
|
||||
data=zf.read(name),
|
||||
))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for name in sorted(
|
||||
(n for n in names if n.lower().endswith(".md")),
|
||||
key=lambda n: (n.count("/"), n.lower()),
|
||||
):
|
||||
try:
|
||||
text = decode_text(zf.read(name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Lecture impossible : {name} ({exc})")
|
||||
continue
|
||||
self._emit(result, name, text, is_journal="journal" in name.lower())
|
||||
return result.finalize()
|
||||
|
||||
text = decode_text(data)
|
||||
self._emit(result, filename, text, is_journal=False)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class LogseqImporter(_OutlineBase):
|
||||
source_id = "logseq"
|
||||
label = "Logseq"
|
||||
description = "Outliner Logseq : pages/journal, propriétés `key:: value`, block refs."
|
||||
markers = ("property::", "logseq/", "journals/")
|
||||
property_syntax = True
|
||||
|
||||
|
||||
@register_importer
|
||||
class RoamImporter(_OutlineBase):
|
||||
source_id = "roam"
|
||||
label = "Roam Research"
|
||||
description = "Outliner Roam : `{{[[TODO]]}}`, block refs, wikilinks."
|
||||
markers = _ROAM_MARKERS
|
||||
@@ -0,0 +1,94 @@
|
||||
"""FlowDeck — PDF importer (v5.6.0, Phase 3).
|
||||
|
||||
Best-effort text + image extraction from a PDF into a FlowDeck page (fidelity
|
||||
depends on the source PDF; scanned documents have no text layer).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
||||
".tiff": "image/tiff", ".tif": "image/tiff"}
|
||||
|
||||
|
||||
@register_importer
|
||||
class PdfImporter(Importer):
|
||||
source_id = "pdf"
|
||||
label = "PDF"
|
||||
description = "Extraction texte + images d'un PDF (fidélité limitée)."
|
||||
extensions = (".pdf",)
|
||||
order = 37
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
return filename.lower().endswith(".pdf")
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except ImportError:
|
||||
result.warn("pypdf n'est pas installé : import PDF indisponible")
|
||||
return result.finalize()
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(data))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"PDF illisible : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
chunks: list[str] = []
|
||||
empty_pages = 0
|
||||
for index, page in enumerate(reader.pages, start=1):
|
||||
try:
|
||||
text = (page.extract_text() or "").strip()
|
||||
except Exception: # noqa: BLE001
|
||||
text = ""
|
||||
if text:
|
||||
if len(reader.pages) > 1:
|
||||
chunks.append(f"## Page {index}\n\n{text}")
|
||||
else:
|
||||
chunks.append(text)
|
||||
else:
|
||||
empty_pages += 1
|
||||
chunks.extend(self._page_images(page, index, result))
|
||||
|
||||
if empty_pages:
|
||||
result.warn(f"{empty_pages} page(s) sans couche texte (document scanné ?)")
|
||||
markdown = re.sub(r"\n{3,}", "\n\n", "\n\n".join(chunks)).strip()
|
||||
title = Path(filename).stem or "Document"
|
||||
result.pages.append(ImportPage(
|
||||
title=title, markdown=markdown, source_path=filename, external_id=filename,
|
||||
))
|
||||
return result.finalize()
|
||||
|
||||
def _page_images(self, page, index: int, result: ImportResult) -> list[str]:
|
||||
images: list[str] = []
|
||||
try:
|
||||
page_images = list(page.images)
|
||||
except Exception: # noqa: BLE001
|
||||
return images
|
||||
for i, image in enumerate(page_images, start=1):
|
||||
name = getattr(image, "name", "") or f"page{index}_img{i}.png"
|
||||
name = Path(name).name
|
||||
try:
|
||||
payload = image.data
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if not payload:
|
||||
continue
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=f"page{index}/{name}", filename=name, data=payload,
|
||||
mime=_MIME.get(Path(name).suffix.lower(), "application/octet-stream"),
|
||||
))
|
||||
images.append(f"")
|
||||
return images
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user