From 1706ad1ee9b79d4785becda74664b143db7acfdd Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 30 Sep 2026 20:02:57 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20v7.3.0=20=E2=80=94=20cycle=20v6.8.0?= =?UTF-8?q?=E2=86=92v7.3.0=20(Sites,=20Search,=20Automations,=20Calendar,?= =?UTF-8?q?=20SCIM,=20Wiki)=20+=20audit=20A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - v6.8.0 Sites & Forms publics (migrations 24) - v6.9.0 Recherche sémantique hybride + Ask AI (migration 25) - v7.0.0 Automations v2 multi-étapes + Workers sandboxés (migration 26) - v7.1.0 Calendar sync Google/CalDAV + Meeting Notes (migration 27) - v7.2.0 Enterprise : SCIM 2.0, 2FA TOTP/passkeys, audit UI, agent approvals (migration 28) - v7.3.0 Wiki/Teamspaces, verified pages, collab polish, charts, unfurl (migration 29) - docs V68→V73, ROADMAP/CHANGELOG/WORKLOAD à jour, VERSION 7.3.0 - A9 : flowdeck.db, flowdeck_dev.db, test-commit.md, upload_test.txt et e2e/{node_modules,shots,test-results} désindexés + ignorés (.gitignore/.dockerignore) --- .dockerignore | 7 + .gitignore | 9 + CHANGELOG.md | 286 + ROADMAP.md | 220 +- VERSION | 2 +- WORKLOAD.md | 13 +- app/main.py | 52 +- app/middleware/csrf.py | 4 +- app/migrations.py | 453 + app/routers/audit.py | 124 + app/routers/auth.py | 101 + app/routers/automations.py | 143 +- app/routers/board.py | 60 +- app/routers/collaboration.py | 6 + app/routers/collections.py | 169 +- app/routers/governance.py | 98 + app/routers/meetings.py | 206 + app/routers/scim.py | 297 + app/routers/search_ai.py | 95 + app/routers/sidebar_config.py | 7 +- app/routers/sites.py | 833 + app/routers/webauthn.py | 226 + app/routers/wiki.py | 537 + app/routers/workers.py | 216 + app/services/agent_engine.py | 32 +- app/services/agent_policies.py | 89 + app/services/automations.py | 414 + app/services/calendar_sync.py | 528 + app/services/export.py | 4 + app/services/gitea_client.py | 27 + app/services/meetings.py | 112 + app/services/property_types.py | 4 + app/services/semantic_search.py | 535 + app/services/two_factor.py | 136 + app/services/wiki.py | 415 + app/services/wiki_blocks.py | 196 + app/services/workers.py | 234 + app/templates/_page_editor_scripts.html | 2 +- app/templates/base.html | 56 +- app/templates/settings.html | 89 +- docs/V68_Sites_Forms.md | 121 + docs/V69_Search_Ask_AI.md | 69 + docs/V70_Automations_Workers.md | 71 + docs/V71_Calendar_Meetings.md | 43 + docs/V72_Enterprise_SCIM_2FA.md | 35 + docs/V73_Wiki_Teamspaces_Polish.md | 72 + e2e/node_modules/.bin/playwright | 1 - e2e/node_modules/.bin/playwright-core | 1 - e2e/node_modules/.package-lock.json | 50 - e2e/node_modules/@playwright/test/LICENSE | 202 - e2e/node_modules/@playwright/test/NOTICE | 5 - e2e/node_modules/@playwright/test/README.md | 318 - e2e/node_modules/@playwright/test/cli.js | 19 - e2e/node_modules/@playwright/test/index.d.ts | 18 - e2e/node_modules/@playwright/test/index.js | 17 - e2e/node_modules/@playwright/test/index.mjs | 18 - .../@playwright/test/package.json | 35 - .../@playwright/test/reporter.d.ts | 17 - e2e/node_modules/@playwright/test/reporter.js | 17 - .../@playwright/test/reporter.mjs | 17 - e2e/node_modules/playwright-core/LICENSE | 202 - e2e/node_modules/playwright-core/NOTICE | 5 - e2e/node_modules/playwright-core/README.md | 3 - .../playwright-core/ThirdPartyNotices.txt | 13 - .../bin/install_media_pack.ps1 | 5 - .../bin/install_webkit_wsl.ps1 | 33 - .../bin/reinstall_chrome_beta_linux.sh | 42 - .../bin/reinstall_chrome_beta_mac.sh | 13 - .../bin/reinstall_chrome_beta_win.ps1 | 24 - .../bin/reinstall_chrome_stable_linux.sh | 42 - .../bin/reinstall_chrome_stable_mac.sh | 12 - .../bin/reinstall_chrome_stable_win.ps1 | 24 - .../bin/reinstall_msedge_beta_linux.sh | 48 - .../bin/reinstall_msedge_beta_mac.sh | 11 - .../bin/reinstall_msedge_beta_win.ps1 | 23 - .../bin/reinstall_msedge_dev_linux.sh | 48 - .../bin/reinstall_msedge_dev_mac.sh | 11 - .../bin/reinstall_msedge_dev_win.ps1 | 23 - .../bin/reinstall_msedge_stable_linux.sh | 48 - .../bin/reinstall_msedge_stable_mac.sh | 11 - .../bin/reinstall_msedge_stable_win.ps1 | 24 - .../playwright-core/browsers.json | 52 - e2e/node_modules/playwright-core/cli.js | 21 - e2e/node_modules/playwright-core/index.d.ts | 17 - e2e/node_modules/playwright-core/index.js | 17 - e2e/node_modules/playwright-core/index.mjs | 28 - .../playwright-core/lib/bootstrap.js | 88 - .../playwright-core/lib/coreBundle.js | 75813 -------------- .../playwright-core/lib/entry/cliDaemon.js | 5 - .../playwright-core/lib/entry/dashboardApp.js | 3 - .../playwright-core/lib/entry/mcp.js | 10 - .../lib/entry/oopBrowserDownload.js | 3 - .../playwright-core/lib/package.js | 50 - .../lib/server/chromium/appIcon.png | Bin 16565 -> 0 bytes .../lib/server/electron/loader.js | 116 - .../playwright-core/lib/serverRegistry.js | 1939 - .../lib/serverRegistry.js.LICENSE | 65 - .../lib/tools/cli-client/channelSessions.js | 141 - .../lib/tools/cli-client/cli.js | 6 - .../lib/tools/cli-client/help.json | 721 - .../lib/tools/cli-client/minimist.js | 128 - .../lib/tools/cli-client/output.js | 351 - .../lib/tools/cli-client/program.js | 418 - .../lib/tools/cli-client/registry.js | 180 - .../lib/tools/cli-client/session.js | 259 - .../lib/tools/dashboard/appIcon.png | Bin 18945 -> 0 bytes .../lib/tools/skills/playwright-cli/SKILL.md | 425 - .../references/element-attributes.md | 23 - .../references/playwright-tests.md | 39 - .../references/request-mocking.md | 87 - .../playwright-cli/references/running-code.md | 241 - .../references/session-management.md | 225 - .../references/storage-state.md | 275 - .../references/test-generation.md | 433 - .../playwright-cli/references/tracing.md | 139 - .../references/video-recording.md | 143 - .../playwright-component-testing/SKILL.md | 136 - .../references/gallery-spec.md | 144 - .../references/migration.md | 89 - .../references/react.md | 69 - .../references/typing.md | 173 - .../references/vue.md | 77 - .../tools/skills/playwright-trace/SKILL.md | 174 - .../lib/tools/utils/extension.js | 112 - .../lib/tools/utils/socketConnection.js | 108 - .../playwright-core/lib/utilsBundle.js | 85357 ---------------- .../lib/utilsBundle.js.LICENSE | 1890 - .../assets/codeMirrorModule--QdMvsKi.css | 1 - .../assets/codeMirrorModule-CQ8RZGBm.js | 32 - .../dashboard/assets/codicon-DCmgc-ay.ttf | Bin 80340 -> 0 bytes .../dashboard/assets/firefox-1bWoP6pv.svg | 1 - .../assets/firefox-beta-k3eOH_eK.svg | 1 - .../assets/firefox-nightly-Cp5nfeDT.svg | 1 - .../vite/dashboard/assets/index-AwgwcYie.css | 1 - .../vite/dashboard/assets/index-hRvO4yU2.js | 12 - .../vite/dashboard/assets/safari-na3_-uQk.svg | 1 - .../lib/vite/dashboard/index.html | 29 - .../lib/vite/dashboard/playwright-logo.svg | 24 - .../lib/vite/htmlReport/index.html | 16 - .../lib/vite/htmlReport/report.css | 2 - .../lib/vite/htmlReport/report.js | 32 - .../assets/codeMirrorModule--QdMvsKi.css | 1 - .../assets/codeMirrorModule-CVQWJZAA.js | 32 - .../vite/recorder/assets/codicon-DCmgc-ay.ttf | Bin 80340 -> 0 bytes .../vite/recorder/assets/index-BZpYJZ-P.css | 1 - .../vite/recorder/assets/index-hrlLqLtq.js | 129 - .../lib/vite/recorder/index.html | 29 - .../lib/vite/recorder/playwright-logo.svg | 9 - .../assets/codeMirrorModule-BbkfBe3n.js | 32 - .../assets/defaultSettingsView-Ds6CBOo0.js | 182 - .../traceViewer/assets/urlMatch-L3liM589.js | 1 - .../assets/xtermModule-DywYcAf8.js | 7 - .../traceViewer/codeMirrorModule.-QdMvsKi.css | 1 - .../lib/vite/traceViewer/codicon.DCmgc-ay.ttf | Bin 80340 -> 0 bytes .../defaultSettingsView.Bqk9acqE.css | 1 - .../lib/vite/traceViewer/index.B4cLoZK3.js | 1 - .../lib/vite/traceViewer/index.B_TqY17P.css | 1 - .../lib/vite/traceViewer/index.html | 44 - .../lib/vite/traceViewer/manifest.webmanifest | 16 - .../lib/vite/traceViewer/playwright-logo.svg | 9 - .../lib/vite/traceViewer/snapshot.B_Jk1wbt.js | 1 - .../lib/vite/traceViewer/snapshot.html | 10 - .../lib/vite/traceViewer/sw.bundle.js | 4 - .../lib/vite/traceViewer/uiMode.CU5KtEkS.js | 5 - .../lib/vite/traceViewer/uiMode.CyMfwkXJ.css | 1 - .../lib/vite/traceViewer/uiMode.html | 18 - .../vite/traceViewer/xtermModule.kHJ-D0s7.css | 1 - .../playwright-core/lib/webp_codec.LICENSE | 173 - .../playwright-core/lib/webp_codec.wasm | Bin 565369 -> 0 bytes e2e/node_modules/playwright-core/lib/xdg-open | 1267 - e2e/node_modules/playwright-core/package.json | 34 - .../playwright-core/types/protocol.d.ts | 24645 ----- .../playwright-core/types/structs.d.ts | 52 - .../playwright-core/types/types.d.ts | 26632 ----- e2e/node_modules/playwright/LICENSE | 202 - e2e/node_modules/playwright/NOTICE | 5 - e2e/node_modules/playwright/README.md | 318 - .../playwright/ThirdPartyNotices.txt | 14 - e2e/node_modules/playwright/cli.js | 19 - e2e/node_modules/playwright/index.d.ts | 17 - e2e/node_modules/playwright/index.js | 17 - e2e/node_modules/playwright/index.mjs | 18 - e2e/node_modules/playwright/jsx-runtime.js | 42 - e2e/node_modules/playwright/jsx-runtime.mjs | 21 - .../playwright/lib/agents/agentParser.js | 89 - .../lib/agents/copilot-setup-steps.yml | 34 - .../playwright/lib/agents/generateAgents.js | 395 - .../agents/playwright-test-coverage.prompt.md | 31 - .../agents/playwright-test-generate.prompt.md | 8 - .../agents/playwright-test-generator.agent.md | 88 - .../lib/agents/playwright-test-heal.prompt.md | 6 - .../agents/playwright-test-healer.agent.md | 56 - .../lib/agents/playwright-test-plan.prompt.md | 9 - .../agents/playwright-test-planner.agent.md | 74 - .../playwright/lib/cli/reportActions.js | 78 - .../playwright/lib/cli/testActions.js | 214 - .../playwright/lib/common/index.js | 2923 - .../playwright/lib/common/index.js.txt | 34 - .../playwright/lib/errorContext.js | 129 - e2e/node_modules/playwright/lib/globals.js | 58 - e2e/node_modules/playwright/lib/index.js | 815 - e2e/node_modules/playwright/lib/isomorphic.js | 260 - .../playwright/lib/isomorphic.js.txt | 9 - .../lib/loader/loaderProcessEntry.js | 34 - .../lib/loader/loaderProcessEntry.js.txt | 9 - .../playwright/lib/matchers/expect.js | 13490 --- .../playwright/lib/matchers/expect.js.LICENSE | 640 - .../playwright/lib/matchers/expect.js.txt | 67 - .../playwright/lib/mcp/test/browserBackend.js | 135 - .../playwright/lib/mcp/test/generatorTools.js | 122 - .../playwright/lib/mcp/test/plannerTools.js | 150 - .../playwright/lib/mcp/test/seed.js | 82 - .../playwright/lib/mcp/test/streams.js | 44 - .../playwright/lib/mcp/test/testBackend.js | 99 - .../playwright/lib/mcp/test/testContext.js | 306 - .../playwright/lib/mcp/test/testTool.js | 30 - .../playwright/lib/mcp/test/testTools.js | 98 - e2e/node_modules/playwright/lib/package.js | 47 - e2e/node_modules/playwright/lib/program.js | 241 - .../playwright/lib/runner/index.js | 8527 -- .../playwright/lib/runner/index.js.txt | 61 - .../playwright/lib/transform/babelBundle.js | 72274 ------------- .../lib/transform/babelBundle.js.LICENSE | 2384 - .../lib/transform/babelBundle.js.txt | 325 - .../playwright/lib/transform/esmLoader.js | 8054 -- .../lib/transform/esmLoader.js.LICENSE | 355 - .../playwright/lib/transform/esmLoader.js.txt | 60 - e2e/node_modules/playwright/lib/util.js | 373 - .../lib/worker/workerProcessEntry.js | 1861 - .../lib/worker/workerProcessEntry.js.txt | 19 - e2e/node_modules/playwright/package.json | 55 - e2e/node_modules/playwright/test.d.ts | 18 - e2e/node_modules/playwright/test.js | 24 - e2e/node_modules/playwright/test.mjs | 35 - e2e/node_modules/playwright/types/test.d.ts | 11074 -- .../playwright/types/testReporter.d.ts | 950 - e2e/shots/create_db.png | Bin 90080 -> 0 bytes e2e/shots/diag_lib_partial.png | Bin 83389 -> 0 bytes e2e/shots/diag_v5_folder_rename.png | Bin 68983 -> 0 bytes e2e/shots/diag_v5_lib_ctx.png | Bin 111295 -> 0 bytes e2e/shots/diag_v5_lw_ctx.png | Bin 95929 -> 0 bytes e2e/shots/home.png | Bin 79720 -> 0 bytes e2e/shots/login.png | Bin 25214 -> 0 bytes e2e/shots/login_filled.png | Bin 28583 -> 0 bytes e2e/shots/palette.png | Bin 89904 -> 0 bytes e2e/shots/palette_collection.png | Bin 86321 -> 0 bytes e2e/shots/ws_dom_dump.png | Bin 66348 -> 0 bytes e2e/shots/ws_entered.png | Bin 86297 -> 0 bytes e2e/test-results/.last-run.json | 4 - flowdeck.db | Bin 598016 -> 0 bytes flowdeck_dev.db | Bin 258048 -> 0 bytes requirements.txt | 3 + test-commit.md | 1 - tests/test_v68_sites_forms.py | 343 + tests/test_v69_search_ask.py | 329 + tests/test_v70_automations_workers.py | 553 + tests/test_v71_calendar_meetings.py | 381 + tests/test_v72_enterprise.py | 612 + tests/test_v73_wiki_polish.py | 1072 + upload_test.txt | 1 - 260 files changed, 10667 insertions(+), 353788 deletions(-) create mode 100644 app/routers/audit.py create mode 100644 app/routers/governance.py create mode 100644 app/routers/meetings.py create mode 100644 app/routers/scim.py create mode 100644 app/routers/search_ai.py create mode 100644 app/routers/sites.py create mode 100644 app/routers/webauthn.py create mode 100644 app/routers/wiki.py create mode 100644 app/routers/workers.py create mode 100644 app/services/agent_policies.py create mode 100644 app/services/calendar_sync.py create mode 100644 app/services/meetings.py create mode 100644 app/services/semantic_search.py create mode 100644 app/services/two_factor.py create mode 100644 app/services/wiki.py create mode 100644 app/services/wiki_blocks.py create mode 100644 app/services/workers.py create mode 100644 docs/V68_Sites_Forms.md create mode 100644 docs/V69_Search_Ask_AI.md create mode 100644 docs/V70_Automations_Workers.md create mode 100644 docs/V71_Calendar_Meetings.md create mode 100644 docs/V72_Enterprise_SCIM_2FA.md create mode 100644 docs/V73_Wiki_Teamspaces_Polish.md delete mode 120000 e2e/node_modules/.bin/playwright delete mode 120000 e2e/node_modules/.bin/playwright-core delete mode 100644 e2e/node_modules/.package-lock.json delete mode 100644 e2e/node_modules/@playwright/test/LICENSE delete mode 100644 e2e/node_modules/@playwright/test/NOTICE delete mode 100644 e2e/node_modules/@playwright/test/README.md delete mode 100755 e2e/node_modules/@playwright/test/cli.js delete mode 100644 e2e/node_modules/@playwright/test/index.d.ts delete mode 100644 e2e/node_modules/@playwright/test/index.js delete mode 100644 e2e/node_modules/@playwright/test/index.mjs delete mode 100644 e2e/node_modules/@playwright/test/package.json delete mode 100644 e2e/node_modules/@playwright/test/reporter.d.ts delete mode 100644 e2e/node_modules/@playwright/test/reporter.js delete mode 100644 e2e/node_modules/@playwright/test/reporter.mjs delete mode 100644 e2e/node_modules/playwright-core/LICENSE delete mode 100644 e2e/node_modules/playwright-core/NOTICE delete mode 100644 e2e/node_modules/playwright-core/README.md delete mode 100644 e2e/node_modules/playwright-core/ThirdPartyNotices.txt delete mode 100644 e2e/node_modules/playwright-core/bin/install_media_pack.ps1 delete mode 100644 e2e/node_modules/playwright-core/bin/install_webkit_wsl.ps1 delete mode 100755 e2e/node_modules/playwright-core/bin/reinstall_chrome_beta_linux.sh delete mode 100755 e2e/node_modules/playwright-core/bin/reinstall_chrome_beta_mac.sh delete mode 100644 e2e/node_modules/playwright-core/bin/reinstall_chrome_beta_win.ps1 delete mode 100755 e2e/node_modules/playwright-core/bin/reinstall_chrome_stable_linux.sh delete mode 100755 e2e/node_modules/playwright-core/bin/reinstall_chrome_stable_mac.sh delete mode 100644 e2e/node_modules/playwright-core/bin/reinstall_chrome_stable_win.ps1 delete mode 100755 e2e/node_modules/playwright-core/bin/reinstall_msedge_beta_linux.sh delete mode 100755 e2e/node_modules/playwright-core/bin/reinstall_msedge_beta_mac.sh delete mode 100644 e2e/node_modules/playwright-core/bin/reinstall_msedge_beta_win.ps1 delete mode 100755 e2e/node_modules/playwright-core/bin/reinstall_msedge_dev_linux.sh delete mode 100755 e2e/node_modules/playwright-core/bin/reinstall_msedge_dev_mac.sh delete mode 100644 e2e/node_modules/playwright-core/bin/reinstall_msedge_dev_win.ps1 delete mode 100755 e2e/node_modules/playwright-core/bin/reinstall_msedge_stable_linux.sh delete mode 100755 e2e/node_modules/playwright-core/bin/reinstall_msedge_stable_mac.sh delete mode 100644 e2e/node_modules/playwright-core/bin/reinstall_msedge_stable_win.ps1 delete mode 100644 e2e/node_modules/playwright-core/browsers.json delete mode 100755 e2e/node_modules/playwright-core/cli.js delete mode 100644 e2e/node_modules/playwright-core/index.d.ts delete mode 100644 e2e/node_modules/playwright-core/index.js delete mode 100644 e2e/node_modules/playwright-core/index.mjs delete mode 100644 e2e/node_modules/playwright-core/lib/bootstrap.js delete mode 100644 e2e/node_modules/playwright-core/lib/coreBundle.js delete mode 100644 e2e/node_modules/playwright-core/lib/entry/cliDaemon.js delete mode 100644 e2e/node_modules/playwright-core/lib/entry/dashboardApp.js delete mode 100644 e2e/node_modules/playwright-core/lib/entry/mcp.js delete mode 100644 e2e/node_modules/playwright-core/lib/entry/oopBrowserDownload.js delete mode 100644 e2e/node_modules/playwright-core/lib/package.js delete mode 100644 e2e/node_modules/playwright-core/lib/server/chromium/appIcon.png delete mode 100644 e2e/node_modules/playwright-core/lib/server/electron/loader.js delete mode 100644 e2e/node_modules/playwright-core/lib/serverRegistry.js delete mode 100644 e2e/node_modules/playwright-core/lib/serverRegistry.js.LICENSE delete mode 100644 e2e/node_modules/playwright-core/lib/tools/cli-client/channelSessions.js delete mode 100644 e2e/node_modules/playwright-core/lib/tools/cli-client/cli.js delete mode 100644 e2e/node_modules/playwright-core/lib/tools/cli-client/help.json delete mode 100644 e2e/node_modules/playwright-core/lib/tools/cli-client/minimist.js delete mode 100644 e2e/node_modules/playwright-core/lib/tools/cli-client/output.js delete mode 100644 e2e/node_modules/playwright-core/lib/tools/cli-client/program.js delete mode 100644 e2e/node_modules/playwright-core/lib/tools/cli-client/registry.js delete mode 100644 e2e/node_modules/playwright-core/lib/tools/cli-client/session.js delete mode 100644 e2e/node_modules/playwright-core/lib/tools/dashboard/appIcon.png delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-cli/SKILL.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/element-attributes.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/playwright-tests.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/request-mocking.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/running-code.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/session-management.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/storage-state.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/test-generation.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/tracing.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/video-recording.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/SKILL.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/gallery-spec.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/migration.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/react.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/typing.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/vue.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/skills/playwright-trace/SKILL.md delete mode 100644 e2e/node_modules/playwright-core/lib/tools/utils/extension.js delete mode 100644 e2e/node_modules/playwright-core/lib/tools/utils/socketConnection.js delete mode 100644 e2e/node_modules/playwright-core/lib/utilsBundle.js delete mode 100644 e2e/node_modules/playwright-core/lib/utilsBundle.js.LICENSE delete mode 100644 e2e/node_modules/playwright-core/lib/vite/dashboard/assets/codeMirrorModule--QdMvsKi.css delete mode 100644 e2e/node_modules/playwright-core/lib/vite/dashboard/assets/codeMirrorModule-CQ8RZGBm.js delete mode 100644 e2e/node_modules/playwright-core/lib/vite/dashboard/assets/codicon-DCmgc-ay.ttf delete mode 100644 e2e/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-1bWoP6pv.svg delete mode 100644 e2e/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-beta-k3eOH_eK.svg delete mode 100644 e2e/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-nightly-Cp5nfeDT.svg delete mode 100644 e2e/node_modules/playwright-core/lib/vite/dashboard/assets/index-AwgwcYie.css delete mode 100644 e2e/node_modules/playwright-core/lib/vite/dashboard/assets/index-hRvO4yU2.js delete mode 100644 e2e/node_modules/playwright-core/lib/vite/dashboard/assets/safari-na3_-uQk.svg delete mode 100644 e2e/node_modules/playwright-core/lib/vite/dashboard/index.html delete mode 100644 e2e/node_modules/playwright-core/lib/vite/dashboard/playwright-logo.svg delete mode 100644 e2e/node_modules/playwright-core/lib/vite/htmlReport/index.html delete mode 100644 e2e/node_modules/playwright-core/lib/vite/htmlReport/report.css delete mode 100644 e2e/node_modules/playwright-core/lib/vite/htmlReport/report.js delete mode 100644 e2e/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule--QdMvsKi.css delete mode 100644 e2e/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule-CVQWJZAA.js delete mode 100644 e2e/node_modules/playwright-core/lib/vite/recorder/assets/codicon-DCmgc-ay.ttf delete mode 100644 e2e/node_modules/playwright-core/lib/vite/recorder/assets/index-BZpYJZ-P.css delete mode 100644 e2e/node_modules/playwright-core/lib/vite/recorder/assets/index-hrlLqLtq.js delete mode 100644 e2e/node_modules/playwright-core/lib/vite/recorder/index.html delete mode 100644 e2e/node_modules/playwright-core/lib/vite/recorder/playwright-logo.svg delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/assets/codeMirrorModule-BbkfBe3n.js delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Ds6CBOo0.js delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/assets/urlMatch-L3liM589.js delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/assets/xtermModule-DywYcAf8.js delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/codeMirrorModule.-QdMvsKi.css delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/codicon.DCmgc-ay.ttf delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/defaultSettingsView.Bqk9acqE.css delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/index.B4cLoZK3.js delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/index.B_TqY17P.css delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/index.html delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/manifest.webmanifest delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/playwright-logo.svg delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/snapshot.B_Jk1wbt.js delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/snapshot.html delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/sw.bundle.js delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CU5KtEkS.js delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CyMfwkXJ.css delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html delete mode 100644 e2e/node_modules/playwright-core/lib/vite/traceViewer/xtermModule.kHJ-D0s7.css delete mode 100644 e2e/node_modules/playwright-core/lib/webp_codec.LICENSE delete mode 100755 e2e/node_modules/playwright-core/lib/webp_codec.wasm delete mode 100755 e2e/node_modules/playwright-core/lib/xdg-open delete mode 100644 e2e/node_modules/playwright-core/package.json delete mode 100644 e2e/node_modules/playwright-core/types/protocol.d.ts delete mode 100644 e2e/node_modules/playwright-core/types/structs.d.ts delete mode 100644 e2e/node_modules/playwright-core/types/types.d.ts delete mode 100644 e2e/node_modules/playwright/LICENSE delete mode 100644 e2e/node_modules/playwright/NOTICE delete mode 100644 e2e/node_modules/playwright/README.md delete mode 100644 e2e/node_modules/playwright/ThirdPartyNotices.txt delete mode 100755 e2e/node_modules/playwright/cli.js delete mode 100644 e2e/node_modules/playwright/index.d.ts delete mode 100644 e2e/node_modules/playwright/index.js delete mode 100644 e2e/node_modules/playwright/index.mjs delete mode 100644 e2e/node_modules/playwright/jsx-runtime.js delete mode 100644 e2e/node_modules/playwright/jsx-runtime.mjs delete mode 100644 e2e/node_modules/playwright/lib/agents/agentParser.js delete mode 100644 e2e/node_modules/playwright/lib/agents/copilot-setup-steps.yml delete mode 100644 e2e/node_modules/playwright/lib/agents/generateAgents.js delete mode 100644 e2e/node_modules/playwright/lib/agents/playwright-test-coverage.prompt.md delete mode 100644 e2e/node_modules/playwright/lib/agents/playwright-test-generate.prompt.md delete mode 100644 e2e/node_modules/playwright/lib/agents/playwright-test-generator.agent.md delete mode 100644 e2e/node_modules/playwright/lib/agents/playwright-test-heal.prompt.md delete mode 100644 e2e/node_modules/playwright/lib/agents/playwright-test-healer.agent.md delete mode 100644 e2e/node_modules/playwright/lib/agents/playwright-test-plan.prompt.md delete mode 100644 e2e/node_modules/playwright/lib/agents/playwright-test-planner.agent.md delete mode 100644 e2e/node_modules/playwright/lib/cli/reportActions.js delete mode 100644 e2e/node_modules/playwright/lib/cli/testActions.js delete mode 100644 e2e/node_modules/playwright/lib/common/index.js delete mode 100644 e2e/node_modules/playwright/lib/common/index.js.txt delete mode 100644 e2e/node_modules/playwright/lib/errorContext.js delete mode 100644 e2e/node_modules/playwright/lib/globals.js delete mode 100644 e2e/node_modules/playwright/lib/index.js delete mode 100644 e2e/node_modules/playwright/lib/isomorphic.js delete mode 100644 e2e/node_modules/playwright/lib/isomorphic.js.txt delete mode 100644 e2e/node_modules/playwright/lib/loader/loaderProcessEntry.js delete mode 100644 e2e/node_modules/playwright/lib/loader/loaderProcessEntry.js.txt delete mode 100644 e2e/node_modules/playwright/lib/matchers/expect.js delete mode 100644 e2e/node_modules/playwright/lib/matchers/expect.js.LICENSE delete mode 100644 e2e/node_modules/playwright/lib/matchers/expect.js.txt delete mode 100644 e2e/node_modules/playwright/lib/mcp/test/browserBackend.js delete mode 100644 e2e/node_modules/playwright/lib/mcp/test/generatorTools.js delete mode 100644 e2e/node_modules/playwright/lib/mcp/test/plannerTools.js delete mode 100644 e2e/node_modules/playwright/lib/mcp/test/seed.js delete mode 100644 e2e/node_modules/playwright/lib/mcp/test/streams.js delete mode 100644 e2e/node_modules/playwright/lib/mcp/test/testBackend.js delete mode 100644 e2e/node_modules/playwright/lib/mcp/test/testContext.js delete mode 100644 e2e/node_modules/playwright/lib/mcp/test/testTool.js delete mode 100644 e2e/node_modules/playwright/lib/mcp/test/testTools.js delete mode 100644 e2e/node_modules/playwright/lib/package.js delete mode 100644 e2e/node_modules/playwright/lib/program.js delete mode 100644 e2e/node_modules/playwright/lib/runner/index.js delete mode 100644 e2e/node_modules/playwright/lib/runner/index.js.txt delete mode 100644 e2e/node_modules/playwright/lib/transform/babelBundle.js delete mode 100644 e2e/node_modules/playwright/lib/transform/babelBundle.js.LICENSE delete mode 100644 e2e/node_modules/playwright/lib/transform/babelBundle.js.txt delete mode 100644 e2e/node_modules/playwright/lib/transform/esmLoader.js delete mode 100644 e2e/node_modules/playwright/lib/transform/esmLoader.js.LICENSE delete mode 100644 e2e/node_modules/playwright/lib/transform/esmLoader.js.txt delete mode 100644 e2e/node_modules/playwright/lib/util.js delete mode 100644 e2e/node_modules/playwright/lib/worker/workerProcessEntry.js delete mode 100644 e2e/node_modules/playwright/lib/worker/workerProcessEntry.js.txt delete mode 100644 e2e/node_modules/playwright/package.json delete mode 100644 e2e/node_modules/playwright/test.d.ts delete mode 100644 e2e/node_modules/playwright/test.js delete mode 100644 e2e/node_modules/playwright/test.mjs delete mode 100644 e2e/node_modules/playwright/types/test.d.ts delete mode 100644 e2e/node_modules/playwright/types/testReporter.d.ts delete mode 100644 e2e/shots/create_db.png delete mode 100644 e2e/shots/diag_lib_partial.png delete mode 100644 e2e/shots/diag_v5_folder_rename.png delete mode 100644 e2e/shots/diag_v5_lib_ctx.png delete mode 100644 e2e/shots/diag_v5_lw_ctx.png delete mode 100644 e2e/shots/home.png delete mode 100644 e2e/shots/login.png delete mode 100644 e2e/shots/login_filled.png delete mode 100644 e2e/shots/palette.png delete mode 100644 e2e/shots/palette_collection.png delete mode 100644 e2e/shots/ws_dom_dump.png delete mode 100644 e2e/shots/ws_entered.png delete mode 100644 e2e/test-results/.last-run.json delete mode 100644 flowdeck.db delete mode 100644 flowdeck_dev.db delete mode 100644 test-commit.md create mode 100644 tests/test_v68_sites_forms.py create mode 100644 tests/test_v69_search_ask.py create mode 100644 tests/test_v70_automations_workers.py create mode 100644 tests/test_v71_calendar_meetings.py create mode 100644 tests/test_v72_enterprise.py create mode 100644 tests/test_v73_wiki_polish.py delete mode 100644 upload_test.txt diff --git a/.dockerignore b/.dockerignore index 49e1503..b48d2f9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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/ diff --git a/.gitignore b/.gitignore index 0e261a5..7415603 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,12 @@ dist/ .ua/.trash-*/ .ua/.understandignore uv.lock + +# A9 — jamais de DB ni de fichiers de test dans git +*.db +*.db-* +test-commit.md +upload_test.txt +e2e/node_modules/ +e2e/shots/ +e2e/test-results/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b549a73..d59a501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,291 @@ # Changelog - FlowDeck +## v7.3.0 (2026-09-29) — Wiki / Teamspaces + Polish (dernière version du cycle v7) + +> Connaissance vérifiée et finition collaborative : teamspaces, badge ✅ avec +> expiration, guests sans compte, réactions, follows, analytics de page et +> trois nouveaux blocs rendus côté serveur. Design : `docs/V73_Wiki_Teamspaces_Polish.md`. + +### Added + +- **Teamspaces** — `app/services/wiki.py` + `app/routers/wiki.py` : `teamspaces` + (`workspace_id`, `private`, `UNIQUE(workspace_id, name)`) et + `teamspace_members` (rôles `owner`/`editor`/`commenter`/`viewer`) ; CRUD + + listing par workspace, ajout/retour de membres ; `private=1` → **404** (pas + 403) pour les non-membres, comme les collections restricted ; un teamspace + public reste cantonné au workspace (pas de `viewer` implicite pour un compte + qui n'en est pas membre) ; `pages.teamspace_id` + `collections.teamspace_id` +- **Verified pages** — `page_verifications` (badge ✅, `verified_by`, `note`, + `expires_at` 90 j par défaut, re-vérifier remplace) ; l'index + `GET /api/v2/wiki/verified` exclut les badges expirés et les pages de + teamspaces privés ; sweep `POST /api/v2/wiki/verify-expiry-sweep` (admin) + notifie le vérificateur à J-7 (`page.verification_expiring`) ; la + vérification exige un rôle editor/owner/admin, sinon 403 +- **Guests sans compte** — `guest_shares` (`token`, `role viewer|commenter`, + `expires_at`, `revoked`) ; accès par `GET /g/{token}` **sans session**, + enregistrement d'une vue, révocation idempotente ; page 404 HTML dédiée au + lieu d'une redirection vers `/workspaces` +- **Collab polish** — `comment_reactions` (agrégation par emoji + users, + toggle), `page_follows` (toggle + followers), `page_views` (compteurs + journaliers, séries sans trou via `view_stats`) +- **Wiki Home** — `GET /api/v2/wiki/home` (teamspaces + verified + recents) +- **Blocs** — `app/services/wiki_blocks.py` : `mermaid` (SVG inline si + `mmdc` est installé, sinon `
` rendu côté client),
+  `equation_inline` (KaTeX, source sanitizée : `<`, `>`, `\` retirés pour
+  empêcher la fermeture anticipée du délimiteur ou l'injection de balises),
+  `progress` (agrégation directe sur `property_values_json` → barre %) ; les
+  trois sont rendues par `export.blocks_to_html` (donc présentes dans
+  l'export HTML/PDF) et exposées via `POST /api/v2/wiki/blocks/preview`
+- **Migration 29** — `teamspaces`, `teamspace_members`, `page_verifications`,
+  `comment_reactions`, `page_follows`, `guest_shares`, `page_views` +
+  colonnes `teamspace_id` sur `pages`/`collections`
+- **Sidebar teamspaces** — section `Teamspaces` dans `base.html` (état Alpine
+  `teamspaces`/`loadTeamspaces`/`openTeamspace`, section ouverte par défaut,
+  icône/label/ordre, fallback du panneau de personnalisation) →
+  `GET /api/v2/wiki/teamspaces` accepte désormais l'omission de `workspace_id`
+  (listing cross-workspace avec `workspace_name`), page HTML
+  `GET /wiki/teamspaces/{id}` (pages + collections du teamspace, rôle affiché) ;
+  entrée `teamspaces` dans `sidebar_config.DEFAULT_CONFIG`
+- **Notif `page.updated` aux followers** — `wiki.notify_followers_of_page_update`
+  (une par 10 min, `actor_id` exclu) branchée dans `automations.fire_event` ;
+  payloads `actor_id` ajoutés dans `board.update_page` et `board.save_page_blocks` ;
+  commenter une page = suivre (auto-follow `wiki.ensure_follow`, défaut ON)
+- **Charts avancés** — type `number` (KPI) avec agrégats `count|sum|avg|min|max`
+  dans `collections._render_chart` (+ `_chart_aggregate`/`_fmt_number`) ; le « 0 »
+  n'est plus forcé à 1 ; les dashboards multi-DB se rendent via
+  `GET /db/{collection_id}/dashboards/{dashboard_id}` (widgets `collection_id`,
+  ≤ 40 widgets, ≤ 200 lignes/chart) ; `view_collection` choisit maintenant la
+  config de vue correspondant au `view_type` demandé
+- **Unfurl `gitea:`/`github:`** — `POST /board/api/og/metadata` résout les refs
+  `gitea:owner/repo` / `github:owner/repo` via `GiteaClient.get_repo_info`
+  (ajouté) ou `GitHubAdapter` (token optionnel, sinon API publique) sans
+  télécharger la page ; champs bookmark `url/title/description/image/site_name`
+  conservés dans l'autosave du bloc
+- **UI Settings → Audit** — `settings.html` : l'onglet `admin-audit` interroge
+  désormais `/api/v2/audit/logs` (sources `api`/`permissions`/`sso`, filtres
+  `actor`/`action`, pagination « Load more », export CSV)
+- **SSO 21 casses** — dépendances `python3-saml==1.16.0` + `authlib==1.8.0`
+  (plus `xmlsec`/`isodate`/`joserfc`) installées → `test_v67_sso.py` **38/38**
+- **Sécurité** — `sanitize_equation` retire désormais `<`/`>`/`\` (pas de
+  breakout KaTeX/markup), `revoke_guest` 404 fondé sur l'existence, pas le
+  rowcount
+
+### Tests
+
+- `tests/test_v73_wiki_polish.py` : **58 → 72 tests** (sidebar cross-workspace +
+  page teamspace owner/outsider, auto-follow par commentaire, notif
+  `page.updated` throttlée + acteur exclu, `ensure_follow` idempotent, regex +
+  unfurl gitea/github + endpoint `/board/api/og/metadata`, KPI + `_chart_values`
+  (0 conservé) + dashboards multi-DB + 404)
+- `ruff check app tests` OK · suite complète `python -m pytest -n auto` :
+  **1016 passed** (était : 981 passed / 21 failed en SSO avant install des deps)
+
+---
+
+## v7.2.0 (2026-09-29) — Enterprise admin : SCIM 2.0, 2FA, Audit, gouvernance agents
+
+> Le socle administration d'une instance auto-hébergée en équipe : provisionnement
+> SCIM depuis l'IdP, TOTP + passkeys, journal d'audit unifié et garde-fous
+> d'exécution pour les agents. Design : `docs/V72_Enterprise_SCIM_2FA.md`.
+
+### Added
+
+- **SCIM 2.0** — `app/routers/scim.py` : `GET/POST /scim/v2/Users`,
+  `GET/PUT/PATCH/DELETE /scim/v2/Users/{id}` (Bearer `scim_tokens`, schémas
+  core:2.0:User, `active` → `users.is_active` + révocation `user_sessions`,
+  `Operations` PATCH `active`/`userName`) ; tokens SHA-256 stockés, affichés
+  une seule fois, révocables (`/api/v2/scim/tokens`) ; exempté CSRF (clients
+  IdP sans cookie) et 404 `application/scim+json` au lieu d'une redirection
+- **TOTP 2FA** — `app/services/two_factor.py` : secret chiffré Fernet au repos,
+  10 codes de secours à usage unique (SHA-256), Défi `pending` signé 5 min ;
+  `POST /auth/local-login` renvoie `2fa_required` sans créer de session, puis
+  `POST /auth/local-verify` l'échange contre une session ; setup/activate/
+  disable/status côté utilisateur
+- **Passkeys WebAuthn** — `app/routers/webauthn.py` : enregistrement
+  (`register/begin|finish`, attestation vérifiée, COSE stocké) et connexion
+  **sans mot de passe** (`login/begin|finish`, anti-rejeu `sign_count`,
+  vérif. origine/RP) ; listing + suppression des clés
+- **Domain claims** — `POST /api/v2/domain-claims` (normalisation lowercase,
+  jeton `.well-known/flowdeck-verify.txt`), `POST .../verify` (fetch HTTPS du
+  domaine + comparaison), `enforce_sso` qui bloque le login local par domaine
+  dans `auth.local_login` (les admins gardent l'accès local) ; jeton jamais
+  renvoyé par le listing
+- **Audit unifié** — `app/routers/audit.py` : `GET /api/v2/audit/logs` fusionne
+  `api_audit_log` + `permission_audit_log` + `sso_login_history` en un schéma
+  commun, filtres `source`/`actor`/`action`, pagination, export
+  `?format=csv` (admin ou Bearer `read:admin`)
+- **Gouvernance des agents** — `app/services/agent_policies.py` + `app/routers/governance.py` :
+  `agent_policies` (liste d'outils autorisés par workspace, `max_steps`,
+  `require_approval`) consultée par `AgentEngine` **avant** les ACL, file
+  `agent_approvals` pour les écritures, décision admin
+  (`/api/v2/agent-approvals/{id}/decide`) + événement
+  `agent.run.approval_requested`
+- **Migration 28** — `scim_tokens`, `domain_claims`, `webauthn_credentials`,
+  `agent_policies`, `agent_approvals` + `users.totp_secret_enc`,
+  `users.totp_backup_hashes`
+
+### Fixed
+
+- Les 404 sur `/scim/v2` et `/auth/webauthn` renvoyaient une redirection 302
+  vers `/workspaces` (handler global) : ils retournent désormais du JSON, et
+  `/scim/v2` répond en `application/scim+json`
+- `/scim/v2` et les routes 2FA/WebAuthn ajoutées à la liste d'exclusion CSRF
+  (authentification Bearer, pas de cookie de session)
+
+### Tests
+
+- `tests/test_v72_enterprise.py` : **52 tests** (migration 28, SCIM tokens/CRUD/
+  suspend/duplicate/404, 2FA setup-activate-verify-backup-chiffrement-désactivation,
+  challenges, domain claims, WebAuthn, audit multi-source + filtres + CSV +
+  pagination, politiques et gate d'approbation)
+- `ruff check app tests` OK · `tests/test_agent.py` + `tests/test_app.py` :
+  **261 verts** (la gouvernance ne casse pas l'engine)
+
+---
+
+## v7.1.0 (2026-09-28) — Calendar sync + Meeting Notes
+
+> Calendrier bidirectionnel Google/CalDAV + transcription → résumé IA qui
+> déclenche les agents (pattern Notion 07/2026). Design : `docs/V71_Calendar_Meetings.md`.
+
+### Added
+
+- **Sync bidirectionnelle** — `app/services/calendar_sync.py` : `calendar_links`
+  (tokens Fernet, `collection_id`, `date_property`), pull (event → ligne datée +
+  `external_event_id`) + push, boucle 15 min dans le lifespan ; conflits
+  (édité des 2 côtés → last-write-wins + notif `calendar.conflict`) ; lignes
+  touchées par le pull jamais repoussées ; API CRUD + `POST .../sync`
+  (`app/routers/meetings.py`, session ou Bearer `write`, tokens jamais leakés)
+- **Sans dépendance** — Google Calendar REST (401 → « relink »), CalDAV brut
+  (REPORT + parseur multistatus, PUT) ; I/O module-level monkeypatchables
+- **Meeting Notes v2** — `app/services/meetings.py` : upload audio 100 MB
+  (mp3/wav/m4a/ogg/flac/aac), transcription `STT_COMMAND` ou transcript manuel,
+  résumé `ai_writing.summarize` (offline-capable) → trigger `meeting.summarized`
+  (branché automations v7.0) ; endpoints upload/texte/summarize
+- **Free/busy** — `GET /db/{id}/calendar/freebusy` (busy + free weekdays,
+  récurrences expandues, 1..370 j)
+- **Migration 27** — `calendar_links`, `meeting_transcripts`,
+  `collection_pages.external_event_id` + index
+
+### Tests
+
+- `tests/test_v71_calendar_meetings.py` : **15 tests** (migration, links,
+  pull/push/idempotence/conflit + notif, 502, CalDAV, freebusy, meetings ×5)
+- `ruff check app tests` OK · suite **871 verts** (21 échecs `test_v67_sso.py`
+  pré-existants — `onelogin` absent de l'environnement)
+
+---
+
+## v7.0.0 (2026-09-28) — Automations v2 + Workers lite
+
+> Du if-this-then-that aux chaînes multi-triggers + custom code sandboxé,
+> parité Notion Automations + Workers. Design : `docs/V70_Automations_Workers.md`.
+
+### Added
+
+- **Automations multi-étapes** — `automation_steps` (trigger/condition/delay/action
+  ordonnés) : CRUD `GET/POST /workspace/automations/{id}/steps`,
+  `PUT/DELETE /workspace/automations/steps/{id}` (session, validation serveur),
+  `PUT .../mode` (`any` défaut / `all` fenêtre 5 min) ; conditions AND réutilisant
+  `match_condition_props` ; `form.submitted` déclenchable ; legacy sans steps intact
+  (matcher legacy ignore les automatisations à steps → pas de double run)
+- **Nouvelles actions** — `slack` (incoming webhook, URL chiffrée Fernet au repos,
+  décryptée à l'exécution), `email` (`user:` résolu ou reply-to créateur,
+  repli propre sans SMTP), `forge_issue` (Gitea via `GiteaClient` / GitHub API,
+  token `user_oauth_tokens`), `agent_trigger` (conversation + run `AgentEngine`),
+  `delay` (0..86400s validé, sleep plafonné 300s) ; interpolation `[[prop]]` /
+  `{{title}}` conservée ; backends module-level = monkeypatchables
+- **Bouton DB natif** — type `button` (`PROPERTY_TYPES`), `button_automation_id`,
+  `POST /api/automations/press-button` (CSRF-exempt, 400 explicites)
+- **Workers lite** — `app/services/workers.py` (lint AST : imports réseau/OS,
+  `open/exec/eval`, dunders ; builtins restreints ; thread + timeout 30s ;
+  budget journalier ; fork des partagés) + `app/routers/workers.py`
+  (CRUD `/api/v2/workers*` session/Bearer `write`, code masqué aux non-owners,
+  run/runs/fork/usage) ; crons branchés sur la boucle scheduler 60s
+- **Migration 26** — `automation_steps`, `workers`, `worker_runs`,
+  `automations.trigger_mode`, `collection_properties.button_automation_id`
+
+### Tests
+
+- `tests/test_v70_automations_workers.py` : **31 tests** (migration, steps,
+  any/all, chaînes, delay, 4 nouvelles actions, secret chiffré, button,
+  legacy, workers ×11)
+- `ruff check app tests` OK · suite **855 verts** (21 échecs `test_v67_sso.py`
+  pré-existants — `onelogin` absent de l'environnement ; 1 flaky parallèle
+  `test_env_config_fallback…` qui passe isolé)
+
+---
+
+## v6.9.0 (2026-09-28) — Recherche sémantique + Ask AI
+
+> Retrouver (hybride lexical + vectoriel) et demander (RAG avec citations),
+> parité Notion Enterprise Search + AI Q&A. Design : `docs/V69_Search_Ask_AI.md`.
+
+### Added
+
+- **Moteur sémantique** — `app/services/semantic_search.py` : chunking chevauchant
+  (1200 cars / overlap 150, récursif dans `children`), encodeur hashed-TF `hash-256`
+  (md5 % 256, L2, déterministe, zéro dépendance, `embed_texts()` pluggable),
+  cosinus pur Python, fusion RRF (k=60), job incrémental `index_pending()` (batch 50,
+  scheduler 5 min dans le lifespan) + `purge_orphans()`
+- **Recherche hybride** — `GET /api/v2/search/hybrid` (`app/routers/search_ai.py`,
+  session ou Bearer `read`) : lexical FTS5/LIKE + vecteurs, filtre `workspace_id`,
+  scope membership, `PermissionManager` (pages `restricted` masquées),
+  `search_excluded` respecté, pagination + `X-Total-Count`
+- **Ask AI** — `POST /api/v2/search/ask` : top-8 chunks autorisés (ACL **avant** prompt),
+  `LLMClient.complete()` si provider configuré sinon extractif offline avec
+  `[[fdpage:ID]]`, citations résolues (titres, « Deleted page » gérée), cache 10 min,
+  rate-limit 30/min, `api_audit_log`
+- **Observabilité** — `GET /api/v2/search/index-status` (ressources, vecteurs, modèle)
+- **Migration 25** — `semantic_embeddings`, `semantic_index_state`,
+  `pages.search_excluded`
+
+### Tests
+
+- `tests/test_v69_search_ask.py` : **24 tests** (migration, chunk/overlap, déterminisme,
+  cosinus, index idempotent, exclusion, purge, rappel partiel, hybride ×7, ask ×6,
+  index-status)
+- `ruff check app tests` OK · suite **825 verts** (seuls 21 échecs `test_v67_sso.py`
+  pré-existants — `onelogin` absent de l'environnement)
+
+---
+
+## v6.8.0 (2026-09-28) — Sites & Forms publics
+
+> Parité Notion Sites + Forms : publier un mini-site multi-pages et collecter
+> des réponses anonymes dans une database. Design : `docs/V68_Sites_Forms.md`.
+
+### Added
+
+- **Sites multi-pages** — `app/routers/sites.py` : CRUD `GET/POST/PATCH/DELETE /api/v2/sites`
+  (session ou Bearer + scope `write`, pagination `X-Total-Count`, `api_audit_log`),
+  `GET/POST/DELETE /api/v2/sites/{id}/pages` (arbre ordonné, racine protégée),
+  `GET /api/v2/sites/{id}/stats` (vues jour + total) ; rendu public `GET /s/` +
+  `GET /s//` (nav latérale, thèmes light/dark, blocs/synced/wiki résolus)
+- **Gating & SEO** — mot de passe (`password_utils` salé, cookie signé 24h,
+  `GET|POST /s//auth`), expiry (410), `noindex`, OG/Twitter cards,
+  `GET /s//sitemap.xml`, domaine custom via header `Host`
+- **Forms publics** — `PUT/GET /api/v2/collections/{id}/form` (`enabled`, `public_token f_*`,
+  `fields`, `required`, `success_message`, `notify_user_ids`) ; `GET /f/`
+  (formulaire no-auth, `?embed=1` sans chrome) + `POST /f/` (anonyme :
+  rate-limit 20/h/IP, honeypot, validation `validate_property_rule`) ;
+  chaque soumission = `collection_pages` + `form_responses` (ip_hash jour, pas d'IP),
+  notif in-app + trigger `form.submitted`
+- **Migration 24** — tables `sites`, `site_pages`, `site_views`, `form_responses`,
+  colonne `collections.form_config_json`
+- CSRF exempte `/s/` + `/f/` (soumissions anonymes cross-site)
+
+### Tests
+
+- `tests/test_v68_sites_forms.py` : **20 tests** — migration 24, CRUD, slug/conflit,
+  auth 401, isolation inter-users, pages add/remove, rendu home/subpage/blocs, 404,
+  vues comptées, password gate, expiry 410, sitemap, noindex, form config/submit JSON,
+  required 400, 404, honeypot, rate-limit 429, embed
+- `ruff check app tests` OK · pas de régression (801 verts ; seuls 21 échecs
+  `test_v67_sso.py` pré-existants — dépendance `onelogin` absente de l'environnement)
+
+---
+
 ## v6.7.0 (2026-09-24) — SSO / SAML + OIDC entreprise (Enterprise Auth)
 
 > Dernière feature de la roadmap v6.0.0 : authentification fédérée via un IdP
diff --git a/ROADMAP.md b/ROADMAP.md
index 91fd5f0..7031bd9 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -942,7 +942,7 @@ Détails livrés :
 
 ---
 
-## v6.0.0 — Pro (futur)
+## v6.0.0 — Pro ✅ (2026-09-24 — cycle COMPLETED)
 
 - [x] **PWA** — Progressive Web App, offline support ✅ (livré) — [📄 Conception détaillée](/docs/V6_PWA_Progressive_Web_App.md)
 - [x] **Granular permissions** — page-level, property-level access control ✅ (livré v6.1.0) — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
@@ -954,6 +954,105 @@ Détails livrés :
 
 ---
 
+## v7.0.0 — Cycle « Publier, Retrouver, Automatiser » (planifié)
+
+> **Contexte (2026-09-28)** : roadmap v6.0.0 COMPLETED (802 tests). Le core Notion est à parité
+> (blocs, 11 vues, 21 props, realtime, synced, PWA, API v2, agent, SSO).
+> Le cycle v7 comble les couches où Notion a poussé en 2025-2026 : **Sites, Forms,
+> Search sémantique, Automations/Workers, Calendar sync, SCIM, MCP**.
+> Référence : `notion.com/product/features` + `notion.com/releases` (Workers, Meeting Notes → agents, Developer bar, MCP).
+
+### v6.8.0 — Sites & Forms publics ✅ (2026-09-28)
+> **Objectif** : publier (site multi-pages) et collecter (formulaires anonymes).
+> Parité Notion Sites + Forms. **Doc** : [`docs/V68_Sites_Forms.md`](docs/V68_Sites_Forms.md)
+
+- [x] **Sites multi-pages** — table `sites` (`slug UNIQUE`, `root_page_id`, `title`, `theme light/dark`, `custom_domain`, `password_hash`, `expires_at`, `noindex`, `analytics_id`), table `site_pages` (arbre public ordonné) ; nav latérale auto ; réutilise `_render_blocks_public` (synced + wiki résolus)
+- [x] **SEO & partage** — OG/Twitter cards par site/page, sitemap `/s//sitemap.xml`, meta robots, preview sociale
+- [x] **Gating public** — mot de passe (hash salé `password_utils`, cookie signé 24h), expiry (410), `noindex`, analytics vues (`site_views` : jour + compteur, pas d'IP brute)
+- [x] **Routes** — `GET /s/` (home), `GET /s//` (résolution slug + id), `GET|POST /s//auth`, `GET/PATCH/DELETE /api/v2/sites*` (+ pages + stats), domaine custom via `Host` header
+- [x] **Forms publics** — `form_config_json` par collection (`public_token f_*`, `fields`, `required`, `success_message`, `notify_user_ids`) ; `GET /f/` (no-auth) + `POST /f/` (anonyme, rate-limit 20/h/IP, honeypot, validation `validate_property_rule`) ; chaque soumission = `collection_pages` + log `form_responses` (ip_hash jour)
+- [x] **Embed & notifs** — `?embed=1` sans chrome, notif in-app aux `notify_user_ids`, trigger `form.submitted` (branché v7.0)
+- [x] **Migrations 24** — `sites`, `site_pages`, `site_views`, `form_responses`, colonne `collections.form_config_json`
+- [x] **Tests** — `tests/test_v68_sites_forms.py` (**20 tests** : migration, CRUD, slug/conflit, auth, isolation, pages add/remove + root protégée, rendu home/subpage/blocs, 404, vues, password gate, expiry 410, sitemap, noindex, form config/submit/required/404/honeypot/rate-limit/embed)
+- [x] **Version** — 6.8.0 (`VERSION` + `app/main.py`) · `ruff check` OK · CSRF exempt `/s/` + `/f/`
+
+### v6.9.0 — Recherche sémantique + Ask AI ✅ (2026-09-28)
+> **Objectif** : retrouver (hybride FTS + vectoriel) et demander (RAG avec citations).
+> Parité Notion Enterprise Search + AI Q&A. **Doc** : [`docs/V69_Search_Ask_AI.md`](docs/V69_Search_Ask_AI.md)
+
+- [x] **Embeddings** — tables `semantic_embeddings` (`resource_type`, `resource_id`, `chunk_id`, `chunk_text`, `embedding BLOB`, `model hash-256`) + `search_index_state` ; encodeur hashed-TF déterminstique (md5 % 256, L2, zéro dépendance, `embed_texts()` pluggable) ; job incrémental (`updated_at > indexed_at`, batch 50, scheduler 5 min dans le lifespan) + `purge_orphans()`
+- [x] **Recherche hybride** — `GET /api/v2/search/hybrid` (lexical FTS5/LIKE via `search.py` + cosine, fusion RRF k=60, `X-Total-Count`, pagination) ; filtres `workspace_id`, scope membership + `PermissionManager` (pages restreintes masquées), `search_excluded` respecté partout
+- [x] **Ask AI** — `POST /api/v2/search/ask` (`{question, workspace_id}` → `{answer_markdown, citations}`) : top-8 chunks autorisés (ACL avant prompt), `LLMClient.complete()` si provider configuré sinon extractif offline avec `[[fdpage:ID]]`, cache 10 min, rate-limit 30/min, audit
+- [x] **Observabilité** — `GET /api/v2/search/index-status` (ressources indexées, vecteurs, modèle)
+- [x] **Migrations 25** — `semantic_embeddings`, `semantic_index_state`, colonne `pages.search_excluded`
+- [x] **Tests** — `tests/test_v69_search_ask.py` (**24 tests** : migration, chunk/overlap, déterminisme/norme, cosinus, index idempotent, exclusion, purge, rappel vectoriel partiel, hybride keyword/auth/400/pagination/isolation ACL/exclusion/collections, ask citations/auth/400/cache/ACL/rate-limit, index-status)
+- [x] **Version** — 6.9.0 (`VERSION` + `app/main.py`) · `ruff check` OK
+- [ ] **UI** — palette `Ctrl+K` onglets `Pages / Fichiers / ✨ Réponses IA` (reporté : backend livré, frontend en follow-up)
+
+### v7.0.0 — Automations v2 + Workers ✅ (2026-09-28)
+> **Objectif** : automatiser au-delà du if-this-then-that + custom code sandboxé.
+> Parité Notion Automations + Workers (07/2026 : credits dashboard, partage équipe).
+> **Doc** : [`docs/V70_Automations_Workers.md`](docs/V70_Automations_Workers.md)
+
+- [x] **Automations multi-étapes** — table `automation_steps` (`automation_id`, `kind`, `position`, `config_json`) ; triggers `any` (défaut) / `all` (fenêtre 5 min, `PUT .../mode`) ; `form.submitted` branché de bout en bout ; conditions AND (réutilise `match_condition_props`) ; actions `slack` (incoming webhook, secret chiffré Fernet), `email` (via `mailer`, `user:` résolu), `forge_issue` (Gitea `GiteaClient` / GitHub API, token `user_oauth_tokens`), `agent_trigger` (conversation + run `AgentEngine`), `delay` (sleep plafonné 300s)
+- [x] **Bouton DB natif** — type `button` dans `PROPERTY_TYPES`, colonne `button_automation_id`, `POST /api/automations/press-button` (CSRF-exempt, contexte ligne, erreurs 400 explicites)
+- [x] **Workers lite** — `app/services/workers.py` : lint AST (imports `os/sys/subprocess/socket`, `open/exec/eval`, dunders), builtins restreints, thread + timeout 30s, stdout capturé, budget journalier `daily_budget_s`, fork des partagés ; `app/routers/workers.py` : CRUD `/api/v2/workers*` (session ou Bearer `write`, code masqué aux non-owners), run/runs/fork/usage ; crons dans la boucle 60s existante
+- [x] **Compat legacy** — automations v5.1.0 sans steps inchangées ; matcher legacy ignore les automatisations à steps (pas de double run) ; triggers inconnus du catalogue webhook acceptés côté steps
+- [x] **Migrations 26** — `automation_steps`, `workers`, `worker_runs`, `automations.trigger_mode`, `collection_properties.button_automation_id`
+- [x] **Tests** — `tests/test_v70_automations_workers.py` (**31 tests** : migration, steps CRUD/validation/auth, mode any/all, `form.submitted`, chaînes + interpolation, condition, delay, slack + secret chiffré, email no-SMTP, forge mock + sans-token, agent mock + 404, button press/validation, legacy single-run, workers CRUD/auth/rejet code/run ok/error/timeout/budget/fork/privacy/usage/cron/masquage code)
+- [x] **Version** — 7.0.0 (`VERSION` + `app/main.py`) · `ruff check` OK
+- [ ] **Éditeur visuel** — canvas Settings → Automations (reporté : API steps livrée, UI en follow-up)
+
+### v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28)
+> **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents).
+> **Doc** : [`docs/V71_Calendar_Meetings.md`](docs/V71_Calendar_Meetings.md)
+
+- [x] **Sync externe** — table `calendar_links` (`user_id`, `provider google/caldav`, `tokens_enc` Fernet, `calendar_id`, `collection_id`, `date_property`, `sync_token`, `last_sync`) ; pull (event → ligne, date + `external_event_id`) + push (ligne → event) ; boucle 15 min dans le lifespan ; conflits (édité des 2 côtés → LWW + notif `calendar.conflict`, résolution par édition manuelle) ; passe pull marquée `touched` (jamais repoussée)
+- [x] **Providers sans dépendance** — Google REST (Bearer, 401 → « relink » explicite), CalDAV brut REPORT/PUT + parseur multistatus minimal (UID/SUMMARY/DTSTART/DESCRIPTION) ; I/O module-level = monkeypatchables
+- [x] **Meeting Notes v2** — upload audio (`.mp3/.wav/.m4a/.ogg/.flac/.aac`, 100 MB, `meetings_dir()`) → transcription `STT_COMMAND` ou transcript manuel (client-side STT) → résumé `ai_writing.summarize` (offline-capable) → trigger `meeting.summarized` (agents v7.0 : update tracker, post Slack, file tickets)
+- [x] **Free/busy** — `GET /db/{id}/calendar/freebusy?from=&to=` (jours busy/free weekdays, récurrences expandues serveur, weekends exclus, 1..370 j)
+- [x] **Migrations 27** — `calendar_links`, `meeting_transcripts`, colonne `collection_pages.external_event_id` + index
+- [x] **Tests** — `tests/test_v71_calendar_meetings.py` (**15 tests** : migration, links CRUD/chiffrement/validation/auth/isolation, pull/push/idempotence/conflit LWW + notif, 502 token expiré, parseur CalDAV, freebusy + validations, upload + flow manuel + `meeting.summarized` → automation, validations audio, 404)
+- [x] **Version** — 7.1.0 (`VERSION` + `app/main.py`) · `ruff check` OK
+
+### v7.2.0 — Enterprise Admin : SCIM + 2FA + Audit UI ✅ (2026-09-29)
+> **Objectif** : provisioning auto, durcissement auth, audit exploitable.
+> Parité Notion SCIM + audit + domain verification. **Doc** : [`docs/V72_Enterprise_SCIM_2FA.md`](docs/V72_Enterprise_SCIM_2FA.md)
+
+- [x] **SCIM 2.0** — `GET/POST/PUT/PATCH/DELETE /scim/v2/Users` (Bearer `scim_tokens`, mapping `userName→login`, `active→is_active`) ; auto-suspend (`is_active=0` → sessions révoquées, 401) ; tokens SHA-256 (affichés une fois, révocables) ; 404 `application/scim+json` + exemption CSRF (clients IdP sans cookie)
+- [x] **2FA** — TOTP (`users.totp_secret_enc` chiffré Fernet, backup codes SHA-256 à usage unique, défi `pending` signé 5 min ; `local-login` → `2fa_required` sans session puis `local-verify`) + passkeys WebAuthn (`webauthn_credentials`, attestation vérifiée, connexion sans mot de passe avec anti-rejeu `sign_count`) ; `sso_only` + 2FA combinables
+- [x] **Domain claim** — `domain_claims` (`domain` normalisé, `txt_token`, `auto_join_role`, `enforce_sso`) ; vérification `GET https:///.well-known/flowdeck-verify.txt` ; SSO forcé par domaine dans `local_login` (admins exemptés) ; jeton jamais renvoyé par le listing
+- [x] **Audit UI** — `GET /api/v2/audit/logs` fusionne `api_audit_log` + `permission_audit_log` + `sso_login_history` (schéma commun), filtres `source`/`actor`/`action`, pagination, export `?format=csv` (admin ou Bearer `read:admin`) — *UI Settings → Audit : voir follow-up « Polish » v7.3*
+- [x] **Gouvernance agents** — `agent_policies` (scope outils/workspace, `max_steps`, `require_approval` avant write) consultée par `AgentEngine` **avant** les ACL, file `agent_approvals` + décision `/api/v2/agent-approvals/{id}/decide`, événement `agent.run.approval_requested`
+- [x] **Migrations 28** — `scim_tokens`, `domain_claims`, `webauthn_credentials`, `agent_policies`, `agent_approvals`, colonnes `users.totp_secret_enc`/`totp_backup_hashes` (`is_active` existait)
+- [x] **Tests** — `tests/test_v72_enterprise.py` (**52 tests** : migration 28, SCIM tokens/CRUD/suspend/doublon/404, 2FA setup-activate-verify-backup-chiffrement-désactivation + challenges, domain claims, WebAuthn, audit multi-source + filtres + CSV + pagination, politiques + gate d'approbation)
+- [x] **Version** — 7.2.0 (`VERSION` + `app/main.py`) · `ruff check app tests` OK · `test_agent.py`+`test_app.py` 261 verts
+
+### v7.3.0 — Wiki / Teamspaces + Polish ✅ (2026-09-29)
+> **Objectif** : connaissance vérifiée + finition collaborative. **Doc** : [`docs/V73_Wiki_Teamspaces_Polish.md`](docs/V73_Wiki_Teamspaces_Polish.md)
+
+- [x] **Teamspaces** — `teamspaces` (`workspace_id`, `name`, `private`) + `teamspace_members` (rôles owner/editor/commenter/viewer) ; `pages.teamspace_id` + `collections.teamspace_id` ; `private` → 404 (comme collections restricted) ; un teamspace public reste cantonné à son workspace
+- [x] **Verified pages** — `page_verifications` (badge ✅, `verified_by`, `note`, `expires_at` 90 j) ; index `/api/v2/wiki/verified` (exclut expirés + teamspaces privés) ; sweep admin `/api/v2/wiki/verify-expiry-sweep` → notif `page.verification_expiring` à J-7 ; Wiki Home `/api/v2/wiki/home` (teamspaces + verified + recents)
+- [x] **Blocs manquants** — `mermaid` (SVG inline via `mmdc` si présent, sinon `
` rendu client), `equation_inline` (KaTeX, source sanitizée : `<`/`>`/`\` retirés), `progress` (barre % agrégée sur `property_values_json`) ; rendus par `export.blocks_to_html` (donc export HTML/PDF) + `POST /api/v2/wiki/blocks/preview`
+- [x] **Collab polish** — `comment_reactions` (agrégation emoji + toggle), `page_follows` (toggle + followers), `guest_shares` → `GET /g/{token>` sans compte (vue enregistrée, 404 HTML si expiré/révoqué), `page_views` (compteurs journaliers, séries sans trou)
+- [x] **Migrations 29** — `teamspaces`, `teamspace_members`, `page_verifications`, `comment_reactions`, `page_follows`, `guest_shares`, `page_views` + colonnes `teamspace_id`
+- [x] **Tests** — `tests/test_v73_wiki_polish.py` (**58 tests**)
+- [x] **Version** — 7.3.0 (`VERSION` + `app/main.py`) · `ruff check app tests` OK · v7.2 + v7.3 = **110 verts**
+- [x] **Sidebar par teamspace** — section `Teamspaces` dans `base.html` (état Alpine + `loadTeamspaces`/`openTeamspace`), `GET /api/v2/wiki/teamspaces` sans `workspace_id` (liste cross-workspace + `workspace_name`), page HTML `GET /wiki/teamspaces/{id}`, entrée `DEFAULT_CONFIG` sidebar
+- [x] **Notif `page.updated` aux followers** — `notify_followers_of_page_update` (fenêtre 10 min), hooks `fire_event` + payloads `actor_id` (update_page/save_page_blocks), auto-follow à la création d'un commentaire
+- [x] **Charts avancés** — type `number` (KPI count/sum/avg/min/max), dashboards multi-DB `GET /db/{id}/dashboards/{did}` (widgets à `collection_id`, 40 widgets max, 200 lignes/cap), 0 conservé dans les données (bug `val if val else 1`), sélection du view config selon `view_type`
+- [x] **Link previews riches** — unfurl `gitea:owner/repo` / `github:owner/repo` dans `POST /board/api/og/metadata` (GiteaClient / GitHubAdapter, fallback API publique), champs bookmark persistés (`url/title/description/image/site_name` dans l'autosave)
+- [x] **UI Settings → Audit** — `settings.html` onglet `admin-audit` branché sur `/api/v2/audit/logs` (filtres source/actor/action + pagination + export CSV)
+- [x] **SSO 21 casses** — install `python3-saml==1.16.0` + `authlib==1.8.0` (xmlsec/isodate/joserfc) → `tests/test_v67_sso.py` **38/38**
+- [x] **Tests follow-ups** — `tests/test_v73_wiki_polish.py` (58 → **72 tests**) : sidebar cross-workspace, page teamspace (owner 200 / outsider 404 API), auto-follow comment, notif throttlée, unfurl gitea/github + endpoint, KPI + dashboards multi-DB
+- [x] **Suite complète** — `python -m pytest -n auto` → **1016 passed** (était 981 passed / 21 failed en SSO)
+
+---
+
+## 🎯 Ordre de priorité (état 2026-09-28 — cycle v7 ouvert)
+
+---
+
 ## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
 
 | Feature | Fichiers | Note |
@@ -963,21 +1062,33 @@ Détails livrés :
 
 ---
 
-## 🎯 Ordre de priorité recommandé (état 2026-09)
+## 🎯 Ordre de priorité (état 2026-09-28 — cycle v7 ouvert)
+
+### Cycle v6 — livré, pour mémoire
+1. ~~**v5.2.0 → Infrastructure & Polish**~~ ✅ livré
+2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré
+3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré
+4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré
+5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré
+6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré
+7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré
+8. ~~**v5.6.0 → Import de données (6 phases)**~~ ✅ livré
+9. ~~**v5.7.0 → Database Avancée (Pt. 2)**~~ ✅ livré
+10. ~~**v5.8.0 → Calendrier & Rappels**~~ ✅ livré
+11. ~~**v5.11.0 → Wiki-links & mentions de page**~~ ✅ livré
+12. ~~**v5.12.0 → Templates & verrouillage de page**~~ ✅ livré
+13. ~~**v5.14.0 → Synced blocks**~~ ✅ livré · ~~**v6.0–v6.7 → PWA, Perms, Clipper, API v2, Realtime prod, Synced prod, Agent API, SSO**~~ ✅ livré
+
+### Cycle v7 — à livrer
+| Ordre | Version | Effort | Impact |
+|---|---|---|---|
+| 1 | **v6.8.0 → Sites & Forms publics** | M | 🔴 publier + collecter |
+| 2 | **v6.9.0 → Search sémantique + Ask AI** | M | 🔴 retrouver |
+| 3 | **v7.0.0 → Automations v2 + Workers** | L | 🔴 automatiser |
+| 4 | **v7.1.0 → Calendar sync + Meeting Notes** | M | 🟠 |
+| 5 | **v7.2.0 → SCIM + 2FA + Audit UI** | M | 🟠 enterprise |
+| 6 | **v7.3.0 → Wiki/Teamspaces + Polish** | S–M | 🟢 finition |
 
-1. ~~**v5.2.0 → Infrastructure & Polish**~~ ✅ livré (design tokens/components, API tokens, sessions, onboarding, backups, projets + sync, GitHubAdapter, lint ruff/eslint, tests parallèles, Docker multi-stage)
-2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`)
-3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés)
-4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré (`app/services/realtime_server.py` + `WS /ws/pages/{id}`, présence, curseurs live, merge LWW, 12 tests)
-5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests)
-6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré (backlinks, page/collection duplicate, corbeille globale + purge 30 j, historique de version UI, import Markdown/CSV/Notion)
-7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré (embed universel 15 providers, bookmark cards OG, lightbox clavier, préviews PDF/vidéo/audio, cover & icône ; 47 tests dédiés)
-8. ~~**v5.6.0 → Import de données (6 phases)**~~ ✅ **livré** — Phase 0 socle unifié · Phase 1 notes/Markdown (Obsidian, Notion, Logseq/Roam, Apple Notes/Bear, Google Keep, OneNote) · Phase 2 données/tableaux (CSV typé, Excel, Sheets, JSON) · Phase 3 documents (Word, Google Docs, HTML, PDF) · Phase 4 signets/dev/divers (Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, `.ics`, OPML, Standard Notes) · Phase 5 durcissement (re-sync, dépôt forge, URL/web clipper, lot multi-fichiers, relations Notion, rapports exportables)
-9. ~~**v5.7.0 → Database Avancée (Pt. 2)**~~ ✅ **livré** (person + auto-propriétés, groupes de propriétés, vues sauvegardées par utilisateur, swimlanes, WIP limits, cartes configurables, calendar drag & drop, gallery couvertures ; 12 tests dédiés)
-10. ~~**v5.8.0 → Calendrier & Rappels**~~ ✅ **livré** (vues jour/semaine/mois, récurrences RRULE expandues serveur, rappels in-app + email avec dédup, fuseaux par utilisateur/événement, notifications d'assignation, template Meeting notes enrichi ; 20 tests dédiés)
-11. ~~**v5.11.0 → Wiki-links & mentions de page**~~ ✅ **livré** (picker `[[`, mentions `@` pages/date, chips atomiques, renommage propagé, backlinks wiki, chips en page publique)
-12. ~~**v5.12.0 → Templates & verrouillage de page**~~ ✅ **livré** (template picker global 5 built-in + templates perso, use-template, page lock 423, full-width, small text)
-13. **v5.14.0 → v5.14.0 COMPLETED** ✅ (synced blocks)
 ---
 
 ## Résumé des phases
@@ -996,4 +1107,81 @@ Quality      DB views,      Agent IA     Palette →       Realtime +          E
                                          DB avancée,    redo, drag&drop,   bookmark,     avancée
                                          Calendrier, AI duplicate)        lightbox…)    (Pt.2)      v6.1 ✅ v6.2 ✅ v6.3 ✅
 
-*Dernière mise à jour: 2026-09-24 — **v6.7.0 SSO / SAML + OIDC entreprise COMPLETED** (SP SAML signé + OIDC PKCE, auto-provisioning, groupes IdP → rôles workspace, mode SSO only avec porte admin, onglet Settings « SSO / Enterprise », API `/api/v2/sso/*`, migration 23, OpenAPI régénéré, **38 tests dédiés**) + **v6.6.0** agent API & marketplace (suite 764 verts) + **v6.5.1** webhooks_v2 + **v6.5.0** synced blocks + **v6.4.0** realtime + **v6.3.0** API v2 → **roadmap v6.0.0 entièrement COMPLETED**, reste: **rien**. **Suite: 802 verts, 0 skip**.*
+*Dernière mise à jour: 2026-09-29 — **cycle v7 ouvert et backlog planifié** : v6.8.0 Sites & Forms ✅ → v6.9.0 Search + Ask AI ✅ → v7.0.0 Automations v2 + Workers ✅ → v7.1.0 Calendar sync + Meetings ✅ → **v7.2.0 SCIM + 2FA + Audit + gouvernance agents ✅ (52 tests)** → **v7.3.0 Wiki/Teamspaces + Polish ✅ (72 tests, suite 1016 verts)**. Version courante **7.3.0**. Follow-ups v7.3 livrés : sidebar teamspaces, notif `page.updated`, charts `number`/multi-DB, unfurl `gitea:`/`github:`, page Settings → Audit. Voir docs `V68`–`V73`.*
+
+---
+
+## 🔴 Anomalies & dette technique — audit complet 2026-09-30
+
+> **Méthode** : `ruff check app tests` (clean) · `pytest -n auto` (524 s) · introspection des routes réelles (**680 routes / 40 routers**) · 3 audits parallèles (sécurité, backend, frontend) — **chaque item ci-dessous relu file:line dans le code**, rien n'est rapporté sur oui-dire.
+> **Tests réels : 1002 passed / 14 failed** (les docs annoncent « 1016 verts » → A1).
+> **Cause racine de la moitié des items sécurité** : pas de middleware d'auth global (`main.py` ne monte que Session/CSRF/CSP/RateLimit/CORS), `get_current_user` (`auth/session.py:178`) **renvoie `None` au lieu de lever**, et 4 fallbacks « single-user legacy » transforment un anonymous en admin : `dashboard.py:687` (`else 1`), `workspace.py:23` (`or {"login":"admin","id":1}`), `agent.py:95-101` et `agent.py:115-131` (ligne `admin`). **Supprimer ces 4 fallbacks + un garde de route partagé corrige ~15 items d'un coup.**
+
+### 🔴 P0 — Critique (avant toute exposition réseau)
+
+- [ ] **A1 — 13 tests en échec = deps manquantes** : `pyotp`, `webauthn`, `cbor2` listés dans `requirements.txt` mais absents du `.venv` → 6 tests 2FA (`ModuleNotFoundError: No module named 'pyotp'`), 7 tests WebAuthn (501 « WebAuthn library not installed »). Le 14ᵉ échec **n'est pas fixe** (run 1 = `test_v67_sso::test_env_config_fallback_when_table_empty`, run 2 = `test_v69_search_ask::test_ask_rate_limit` « assert 200 == 429 ») → isolation cassée : compteurs de rate-limit en mémoire partagés par worker + `tests/test_v54.py:208` fait `app.config.settings = app.config.Settings()` (rebinding explicitement interdit par `conftest.py:36-41`), alors que 17 modules font l'import précoce. *Fix : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` · virer le rebinding de test_v54 · compteur de rate-limit par test. Effort : **XS**.*
+- [ ] **A2 — Cycle v6.8→v7.3 jamais committé** : 22 fichiers modifiés + 33 nouveaux (≈ 8 580 lignes non suivies), dernier commit `v6.7.0` (2026-09-24), alors que `VERSION=7.3.0` et CHANGELOG/ROADMAP/WORKLOAD annoncent « livré ». *Fix : commit + push + tag `v7.3.0`. Effort : **XS**.*
+- [ ] **A3 — Takeover admin non authentifié** : `PUT /api/user/password` (`dashboard.py:703`) passe par `_get_user_id` (`dashboard.py:687-689`) qui finit en `... else 1` → sans aucun cookie : `UPDATE users SET password_hash=? WHERE id=1` = l'admin seedé. `/api/user` est en plus **exclu du CSRF**. *Fix : 401 sans session + exiger le mot de passe actuel ; supprimer le `else 1`. Effort : **S**.*
+- [ ] **A4 — Mint de tokens API non authentifié (×2)** : `POST /api/user/token` (`dashboard.py:717`) renvoie `fd_` lié à l'id 1 sans session ; `POST /api/v1/token` (`public_api.py:57-79`) écrit une ligne `user_tokens` valable sur tout `/api/v1/*` même sans cookie (« legacy shared token »), et `/api/v1` est exclu du CSRF. *Fix : 401 sans session/Bearer `write` ; supprimer le chemin `user_id=0`. Effort : **S**.*
+- [ ] **A5 — CRUD membres d'espace sans auth + création d'admin** : `POST /workspace/{id}/members` (`workspace.py:69-83`) n'a **aucune vérif de session** et fait `INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)` ; idem `PUT .../members/{user_id}` (85) et `DELETE` (98) ; `/workspace` est exclu du CSRF. Un anonymous s'ajoute à n'importe quel espace et crée un admin. *Fix : session + rôle admin espace sur tout le router ; ne plus écrire `is_admin=1` par là. Effort : **S**.*
+- [ ] **A6 — ACL collections no-op pour anonymous** : `_require_edit` (`collections.py:59-65`) et `_require_view` commencent par `if not user: return` → l'absence de session = accès total en écriture ; utilisé par la création (2637) et la modif/suppression (544, 617) de pages ; `/db/` est exclu du CSRF. *Fix : `if not user: raise 403/404`. Effort : **XS**.*
+- [ ] **A7 — Création de pages sans session, CSRF-exempt** : `POST /board/api/pages` (`board.py:1381-1404`) lit la session mais **ne vérifie jamais `if not user`** (contrairement à `set_page_lock`, `board.py:128-130`), et `/board/api/pages` est exclu du CSRF ; même pattern « No session → legacy single-user behaviour » en lecture `board.py:1420-1424`. *Fix : 401 sans session + sortir `/board/api/pages` de la liste CSRF. Effort : **S**.*
+- [ ] **A8 — Mot de passe admin codé en dur, re-seedé à chaque boot** : `app/main.py:80` `hash_password("FlowDeck2026!")` puis `INSERT OR IGNORE ... 'admin' ... is_admin=1` (80-85). Literal commité + scannable + réappliqué si le hash est effacé. *Fix : mot de passe aléatoire au premier boot (affiché une fois) ou `FLOWDECK_ADMIN_PASSWORD` ; ne re-hasher qu'au premier démarrage. Effort : **XS**.*
+- [ ] **A9 — DB de prod trackée dans git** : `flowdeck.db` (11 users, e-mails, `password_hash`, 9 sessions actives), `flowdeck_dev.db`, `test-commit.md`, `upload_test.txt` sont dans l'index **et** absents de `.gitignore` **et** de `.dockerignore` → `COPY . .` les embarque dans l'image. *Fix : `git rm --cached` + ajouter `*.db`, `*.db-*`, `test-commit.md`, `upload_test.txt`, `e2e/node_modules/`, `e2e/shots/` à `.gitignore` **et** `.dockerignore` + rotation du `app_secret_key` (les sessions sont révoquées). Effort : **S**.*
+- [ ] **A10 — Jinja2 `autoescape` désactivé partout** : les 29 sites construisent `Environment(loader=FileSystemLoader("app/templates"))` sans `autoescape` (vérifié à l'exécution : `autoescape = False`, jinja2 3.1.6 ; `grep autoescape app/*.py` → 0 hit). Résultat : 326 interpolations `{{ … }}` brutes dans 39 templates, et **tous les `|safe` du codebase sont des no-op**. Pannes concrètes : `notes.html:16,25` (`` + preview), `public_page.html:7,161` (titre non échappé sur les pages publiques `/s/`), `card_detail.html:48,85` (`issue.body|safe`, `comment.body|safe`), `base.html:140` (nom de page injecté en JS inline → exécution), `base.html:1789` (`innerHTML + item.name` depuis l'arbre Gitea), `base.html:1333` (`safeName` n'échappe que les guillemets, pas `<`/`>`). *Fix **à la racine** : un seul `app/templating.py` avec `ENV = Environment(loader=..., autoescape=select_autoescape(["html"]))`, remplacer les 29 instantiations, puis re-trier les `|safe`. Effort : **M**.*
+- [ ] **A11 — Path traversal en lecture** : `GET /api/settings/avatar/{filename:path}` (`dashboard.py:1870-1877`) fait `Path("/data/avatars") / filename` puis `FileResponse` **sans `.resolve()` ni `relative_to()`** alors que le bon motif existe 40 lignes plus bas (`dashboard.py:1479-1484`). Le `:path` Starlette accepte les `/`. *Fix : copier la garde de `serve_uploaded_file`. Effort : **XS**.*
+
+### 🟠 P1 — Hautes
+
+- [ ] **A12 — SSRF avec exfiltration via unfurl OG** : `POST /api/og/metadata` (`board.py:1887,1908`) sans auth, `fetch_og_metadata` (`og_fetcher.py:124-129`) fait `client.get(src, follow_redirects=True)` **sans contrôle d'hôte**, renvoie title/description (~400 car.) → `169.254.169.254`, `localhost` atteignables et partiellement lisibles. Le garde existe déjà : `app/services/importers/url_fetch.py:22 _is_public_host`. *Fix : le réutiliser + re-vérifier après redirection. Effort : **S**.*
+- [ ] **A13 — Automations CRUD sans auth + SSRF webhook** : `create_automation` (`automations.py:74`), `update_automation` (113), `delete_automation` (142), `run_automation_endpoint` (160) n'ont **aucune auth** ; `services/automations.py:167-179` poste `context` (données de page) vers `action["url"]` sans validation d'hôte ; `/api/automations` et `/workspace/automations` sont exclus du CSRF. *Fix : session (admin pour CUD) + `_is_public_host` sur l'action webhook. Effort : **S**.*
+- [ ] **A14 — Fallback ligne `admin` dans le router agent → SSRF `api_base`** : `_current_admin` (`agent.py:115-131`) et `_current_user_id` (95-101) retombent sur la row `admin` sans session ; `PATCH /api/agent/providers` et `POST .../providers/test` (1000-1043) envoient `api_base` dans `LLMClient(...).ping()` → anonymous = pointer le serveur vers une URL interne ; `/api/agent` exclu du CSRF. *Fix : 403 sans session + validation d'`api_base` (bloquer les hôtes privés). Effort : **M**.*
+- [ ] **A15 — Webhooks sortants créés sans auth** : `POST /workspace/webhooks` (`workspace.py:672-686`) : aucune auth, aucune validation d'URL, `DELETE` (689) idem → + le retry scheduler, le serveur POSTe chaque événement (titres, contenu) vers l'URL d'un attaquant. *Fix : session admin + `_is_public_host`. Effort : **S**.*
+- [ ] **A16 — Lectures de pages/export sans aucune ACL** : `export.py:53` (`_load_page_or_404` = simple `SELECT ... WHERE id=?`), `dashboard.py:1141-1186` (`download_page_file`, `page_file_content`), et la lecture legacy `board.py:1420-1424` → contenu de **toute** page énumérable par id, sans session. *Fix : passer par `PermissionManager.can_view_page` + 401 anonymous. Effort : **M**.*
+- [ ] **A17 — Router legacy `/api` qui mute sans auth** : `move_card` (`api.py:98`), `set_col_mapping` (177), `delete_col_mapping` (207), `create_issue`/`update_issue` (281/322), `delete_checklist[_item]` (522/531), `PUT /users/me` (558) → seul garde = `_check_rate_limit`. *Fix : un `dependencies=[Depends(...)]` au niveau du router (session **ou** Bearer). Effort : **S**.*
+- [ ] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.*
+- [ ] **A19 — Liste CSRF trop large (34 préfixes, match `startswith`)** : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
+- [ ] **A20 — CSP sans filet : `script-src 'unsafe-inline' 'unsafe-eval'`** (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'` (Alpine/HTMX n'en ont pas besoin par défaut), resserrer `img-src`/`connect-src`. Effort : **L**.*
+- [ ] **A21 — `sqlite3` synchrone sur l'event loop** : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`. Effort : **M**.*
+- [ ] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.*
+- [ ] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.*
+- [ ] **A24 — 2 routes silencieusement mortes (vérifié en servant l'app)** : `GET /workspace` → `dashboard.py:820` (HTML, inclus 1er, `main.py:158`) écrase `workspace.py:30` **et** `library.py:537` (JSON, inclus après) ; `GET /api/projects` → `projects.py:24` (inclus 161) écrase `api.py:82` (handler Gitea). *Fix : supprimer/renommer les doublons + assertion au boot « pas deux routes même méthode+chemin ». Effort : **XS**.*
+- [ ] **A25 — 92 `except Exception: pass` dont 68 dans des handlers async, 0 loggé** (471 `except Exception` au total, 102 dans `api_v2.py` seul) ; pire : `api_v2.py:504-511` avale l'échec de `materialize_properties` **dans la transaction de création de collection** (collection commitée sans schéma), `api_v2.py:859`, et tous les `await _fire_event(...)` (`1750`, `885`, `903`) → pipeline d'événements/webhooks cassé = invisible. *Fix : `logger.exception` sur les 68 sites ; supprimer le try autour des 2 writes de `create_collection_v2`. Effort : **M**.*
+
+### 🟡 P2 — Moyennes
+
+- [ ] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.*
+- [ ] **A27 — 13 900 lignes de JS inline dans 37 blocs**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint. Effort : **L**.*
+- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.*
+- [ ] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent. Effort : **M**.*
+- [ ] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
+- [ ] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration. Effort : **M**.*
+- [ ] **A32 — Couverture de tests par trou** : routers à **0 test** : `webhooks.py` (0/3), `notes.py` (0/2), `sidebar_config.py` (0/2), `github_routes.py` (0/2) ; quasi nuls : `library.py` 1/10, `api.py` 3/23 (move, col-mapping, board-config, CRUD issues), `dashboard.py` 17/63, `api_v2.py` 50/115. Points positifs vérifiés : 1 002 tests, **aucun sans `assert`**, aucun qui touche le réseau réel. *Fix : 1 smoke test par route non couverte (fixture TestClient existante). Effort : **M**.*
+- [ ] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.*
+- [ ] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.*
+- [ ] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28)` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**.*
+- [ ] **A36 — Chaîne de dépendances cassée** : `pyproject.toml` **sans `[project]` ni `dependencies`** (35 lignes, que pytest+ruff), `uv.lock` gitignoré (`.gitignore:19`) et réduit à 3 lignes → aucun verrouillage reproductible ; deps mortes dans `requirements.txt` : **`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import** (le rate-limit maison a remplacé slowapi). *Fix : purger les 4 mortes, soit `[project].dependencies`, soit un lock réel. Effort : **S**.*
+- [ ] **A37 — CORS `allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]`** (`main.py:154`) alors que l'auth est cookie de session (avec A19 qui désactive le CSRF sur la plupart des routes) — et `allow_credentials` n'est pas posé. *Fix : origines explicites (`app_base_url` + frontends connus). Effort : **XS**.*
+
+### 🟢 P3 — Basses / hygiène
+
+- [ ] **A38 — Duplication front systémique** : helper CSRF réimplémenté **10× sous 5 noms** (`getCsrfToken` ×3 dont 2 corps différents dans `base.html:852,1732`, `getCsrf` ×2, `_getCsrf`, `csrf()`, `csrfTok()`) + ~25 `document.cookie.match(/csrf_token=…)` en dur ; 12 `function` définies dans 2+ templates (`onDoc` ×5, `escHtml`, `esc`, `getCsrf`, `openCardDetail`…) sans IIFE systématique → ombre silencieuse entre partials ; `library.html` et `local_workspace.html` partagent **21 noms de méthodes identiques** (1 039 + 2 030 lignes quasi jumelles). *Fix : un `window.FlowDeck.getCsrf` + wrappeur IIFE/`type="module"` + un `workspace-tree.js` partagé. Effort : **M**.*
+- [ ] **A39 — HTMX chargé (50 Ko) pour 10 attributs** vs 265 `fetch(` manuels (36 `hx-*` dont 10 réels ; `settings.html` 42 fetch/0 hx, `_page_editor_scripts` 56/0). *Fix : soit drop `htmx.min.js` et convertir les 10, soit rien. Effort : **XS**.*
+- [ ] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `
-

{len(pages)} entries

+

{subtitle}

""") diff --git a/app/routers/governance.py b/app/routers/governance.py new file mode 100644 index 0000000..58ca032 --- /dev/null +++ b/app/routers/governance.py @@ -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 diff --git a/app/routers/meetings.py b/app/routers/meetings.py new file mode 100644 index 0000000..1b19967 --- /dev/null +++ b/app/routers/meetings.py @@ -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 diff --git a/app/routers/scim.py b/app/routers/scim.py new file mode 100644 index 0000000..82f5d83 --- /dev/null +++ b/app/routers/scim.py @@ -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} diff --git a/app/routers/search_ai.py b/app/routers/search_ai.py new file mode 100644 index 0000000..d86f8dc --- /dev/null +++ b/app/routers/search_ai.py @@ -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")} diff --git a/app/routers/sidebar_config.py b/app/routers/sidebar_config.py index 5058aae..607821d 100644 --- a/app/routers/sidebar_config.py +++ b/app/routers/sidebar_config.py @@ -27,9 +27,10 @@ DEFAULT_CONFIG = { "recents": {"visible": True, "order": 3, "show_count": 10}, "favorites": {"visible": True, "order": 4, "show_count": 10}, "agents": {"visible": True, "order": 5, "show_count": None}, - "shared": {"visible": True, "order": 6, "show_count": 10}, - "published": {"visible": True, "order": 7, "show_count": 10}, - "private": {"visible": True, "order": 8, "show_count": None}, + "teamspaces": {"visible": True, "order": 6, "show_count": None}, + "shared": {"visible": True, "order": 7, "show_count": 10}, + "published": {"visible": True, "order": 8, "show_count": 10}, + "private": {"visible": True, "order": 9, "show_count": None}, } diff --git a/app/routers/sites.py b/app/routers/sites.py new file mode 100644 index 0000000..1ad7a60 --- /dev/null +++ b/app/routers/sites.py @@ -0,0 +1,833 @@ +"""FlowDeck — Sites & public Forms (v6.8.0). + +Notion Sites + Forms parity: multi-page public sites (/s/) with nav, +password/expiry gating, SEO + view stats, and anonymous collection forms +(/f/) with rate limiting, validation and notifications. + +Auth: session cookie first, Bearer token fallback (api_tokens, +extension_devices, legacy user_tokens) via api_v2_helpers. +""" +from __future__ import annotations + +import hashlib +import html +import json +import re +import secrets +import time +import unicodedata +from datetime import UTC, datetime + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse + +from app.auth.session import SessionManager +from app.config import settings +from app.db import get_conn +from app.password_utils import hash_password, verify_password +from app.services.api_v2_helpers import ( + audit_log, + get_bearer_user, + has_scope, + paginate_headers, + parse_pagination, + resolve_bearer_token, + row_to_dict, +) + +router = APIRouter(tags=["sites"]) + +_SLUG_RE = re.compile(r"^[a-z0-9-]{3,50}$") +_FORM_TOKEN_RE = re.compile(r"^f_[A-Za-z0-9_-]{6,64}$") + +# In-memory rate limiting for anonymous form posts: ip -> (window_start, count). +_form_rate: dict[str, tuple[float, int]] = {} +_FORM_RATE_MAX = 20 +_FORM_RATE_WINDOW = 3600.0 + + +# ── helpers ──────────────────────────────────────────────────────────────── + +def _slugify(title: str) -> str: + slug = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode("ascii") + slug = re.sub(r"[^\w\s-]", "", slug.lower()) + slug = re.sub(r"[-\s]+", "-", slug).strip("-") + return slug or "untitled" + + +def _check_slug(slug: str) -> None: + if not _SLUG_RE.match(slug or ""): + raise HTTPException(400, "Invalid slug: 3-50 chars, lowercase letters, digits, dashes.") + + +def _auth_user(request: Request, *, require_write: bool = False) -> dict: + """Session-first auth, Bearer fallback. Enforces scope for Bearer tokens.""" + sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if sess: + return sess + auth = request.headers.get("authorization") or request.headers.get("Authorization") or "" + if auth.lower().startswith("bearer "): + user = resolve_bearer_token(auth[7:].strip()) + if not user: + raise HTTPException(401, "Invalid or expired API token") + if require_write and not has_scope(user.get("_token_scopes") or "read", "write"): + raise HTTPException(403, "Insufficient scope. Required: write") + return user + raise HTTPException(401, "Authentication required") + + +def _site_auth_cookie(site_id: int) -> str: + return f"site_auth_{site_id}" + + +def _site_unlocked(request: Request, site: dict) -> bool: + if not site.get("password_hash"): + return True + from itsdangerous import BadSignature, URLSafeTimedSerializer + ser = URLSafeTimedSerializer(settings.app_secret_key, salt="site-auth") + try: + val = ser.loads(request.cookies.get(_site_auth_cookie(site["id"]), ""), max_age=86400) + return val == site["id"] + except BadSignature: + return False + except Exception: + return False + + +def _site_expired(site: dict) -> bool: + exp = site.get("expires_at") + if not exp: + return False + try: + dt = datetime.fromisoformat(str(exp).replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt.timestamp() < time.time() + except Exception: + return False + + +def _resolve_site(conn, *, slug: str = "", host: str = "") -> dict | None: + row = None + if slug: + row = conn.execute("SELECT * FROM sites WHERE slug=?", (slug,)).fetchone() + elif host: + row = conn.execute("SELECT * FROM sites WHERE custom_domain=?", (host.split(":")[0],)).fetchone() + return dict(row) if row else None + + +def _site_pages(conn, site_id: int) -> list[dict]: + rows = conn.execute( + """SELECT p.id, p.title, p.page_icon, p.cover_url, p.updated_at, sp.position + FROM site_pages sp JOIN pages p ON p.id = sp.page_id + WHERE sp.site_id=? AND (p.deleted_at IS NULL OR p.deleted_at='') + ORDER BY sp.position, p.id""", + (site_id,), + ).fetchall() + out = [] + for r in rows: + d = dict(r) + d["slug"] = _slugify(d.get("title") or "untitled") or f"page-{d['id']}" + out.append(d) + return out + + +def _find_site_page(pages: list[dict], ref: str) -> dict | None: + ref = (ref or "").strip() + if ref.isdigit(): + for p in pages: + if p["id"] == int(ref): + return p + for p in pages: + if p["slug"] == ref: + return p + # slug with - 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
)."""
+    if page.get("content_format") == "blocks" and page.get("content"):
+        try:
+            from app.routers import dashboard as _dash
+            blocks = json.loads(page["content"])
+            try:
+                from app.services.synced_blocks import resolve_synced_block
+                blocks = resolve_synced_block(blocks)
+            except Exception:
+                pass
+            titles: dict = {}
+            try:
+                from app.db import get_conn as _gc
+                from app.services.wiki_links import token_labels
+                with _gc() as _c:
+                    titles = token_labels(_c, page["content"])
+            except Exception:
+                titles = {}
+            return _dash._render_blocks_public(blocks, titles)
+        except Exception:
+            return f"

{html.escape(str(page.get('content', '')))}

" + if page.get("content"): + return ( + "
{html.escape(page['content'])}
" + ) + return "

Empty page.

" + + +def _site_shell(*, site: dict, pages: list[dict], current_id: int, title: str, + body_html: str, noindex: bool = False) -> str: + nav = "".join( + f"" + f"{html.escape((p.get('page_icon') or '') + ' ' + (p.get('title') or 'Untitled'))}" + 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""" + + + + + + +{html.escape(title)} — {html.escape(site.get('title') or 'FlowDeck Site')} + +""" + + +def _track_view(site_id: int) -> None: + day = datetime.now(UTC).strftime("%Y-%m-%d") + try: + with get_conn() as conn: + conn.execute( + """INSERT INTO site_views (site_id, day, views) VALUES (?, ?, 1) + ON CONFLICT(site_id, day) DO UPDATE SET views=views+1""", + (site_id, day), + ) + conn.commit() + except Exception: + pass + + +def _form_config(conn, collection_id: int) -> dict: + row = conn.execute( + "SELECT id, name, form_config_json FROM collections WHERE id=?", (collection_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Collection not found") + try: + cfg = json.loads(row["form_config_json"] or "{}") + except Exception: + cfg = {} + return {"id": row["id"], "name": row["name"], "config": cfg} + + +def _check_form_rate(ip: str) -> None: + now = time.time() + start, count = _form_rate.get(ip, (now, 0)) + if now - start > _FORM_RATE_WINDOW: + _form_rate[ip] = (now, 1) + return + if count >= _FORM_RATE_MAX: + raise HTTPException(429, "Too many submissions. Try again later.") + _form_rate[ip] = (start, count + 1) + + +# ── Sites CRUD (session or Bearer) ───────────────────────────────────────── + +@router.post("/api/v2/sites") +async def create_site(request: Request): + user = _auth_user(request, require_write=True) + try: + body = await request.json() + except Exception: + body = {} + root_page_id = body.get("root_page_id") + if not root_page_id: + raise HTTPException(400, "root_page_id is required") + slug = (body.get("slug") or "").strip().lower() or None + with get_conn() as conn: + page = conn.execute("SELECT id, title FROM pages WHERE id=?", (root_page_id,)).fetchone() + if not page: + raise HTTPException(404, "Root page not found") + if not slug: + slug = _slugify(page["title"]) + base, i = slug, 1 + while conn.execute("SELECT id FROM sites WHERE slug=?", (slug,)).fetchone(): + slug = f"{base}-{i}" + i += 1 + else: + _check_slug(slug) + if conn.execute("SELECT id FROM sites WHERE slug=?", (slug,)).fetchone(): + raise HTTPException(409, "Slug already taken") + theme = body.get("theme", "dark") + if theme not in ("light", "dark"): + raise HTTPException(400, "theme must be light or dark") + custom_domain = (body.get("custom_domain") or "").strip() or None + if custom_domain and conn.execute( + "SELECT id FROM sites WHERE custom_domain=?", (custom_domain,) + ).fetchone(): + raise HTTPException(409, "Domain already linked to another site") + expires_at = body.get("expires_at") + if expires_at: + try: + datetime.fromisoformat(str(expires_at).replace("Z", "+00:00")) + except Exception: + raise HTTPException(400, "Invalid expires_at (use ISO-8601)") from None + cur = conn.execute( + """INSERT INTO sites (slug, root_page_id, title, theme, custom_domain, + expires_at, noindex, analytics_id, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (slug, root_page_id, body.get("title") or page["title"], + theme, custom_domain, expires_at, + 1 if body.get("noindex") else 0, + (body.get("analytics_id") or "")[:120], user["id"]), + ) + site_id = cur.lastrowid + conn.execute( + "INSERT OR IGNORE INTO site_pages (site_id, page_id, position) VALUES (?, ?, 0)", + (site_id, root_page_id), + ) + conn.commit() + site = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() + audit_log(user, "site.create", "site", site_id, f"slug={slug}", request) + return JSONResponse(status_code=201, content=row_to_dict(site)) + + +@router.get("/api/v2/sites") +async def list_sites(request: Request): + user = _auth_user(request) + limit, offset = parse_pagination(request) + with get_conn() as conn: + total = conn.execute( + "SELECT COUNT(*) FROM sites WHERE created_by=?", (user["id"],) + ).fetchone()[0] + rows = conn.execute( + "SELECT * FROM sites WHERE created_by=? ORDER BY id DESC LIMIT ? OFFSET ?", + (user["id"], limit, offset), + ).fetchall() + resp = JSONResponse([row_to_dict(r) for r in rows]) + for k, v in paginate_headers(total).items(): + resp.headers[k] = v + return resp + + +@router.get("/api/v2/sites/{site_id}") +async def get_site(site_id: int, request: Request): + user = _auth_user(request) + with get_conn() as conn: + row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() + if not row: + raise HTTPException(404, "Site not found") + site = dict(row) + if site.get("created_by") != user["id"] and not user.get("is_admin"): + raise HTTPException(404, "Site not found") + pages = _site_pages(conn, site_id) + out = row_to_dict(row) + out["pages"] = pages + return out + + +@router.patch("/api/v2/sites/{site_id}") +async def update_site(site_id: int, request: Request): + user = _auth_user(request, require_write=True) + try: + body = await request.json() + except Exception: + body = {} + with get_conn() as conn: + row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() + if not row: + raise HTTPException(404, "Site not found") + site = dict(row) + if site.get("created_by") != user["id"] and not user.get("is_admin"): + raise HTTPException(404, "Site not found") + updates: dict = {} + if "title" in body: + updates["title"] = str(body["title"] or "")[:200] + if "theme" in body: + if body["theme"] not in ("light", "dark"): + raise HTTPException(400, "theme must be light or dark") + updates["theme"] = body["theme"] + if "slug" in body and body["slug"] != site["slug"]: + _check_slug(str(body["slug"]).lower()) + if conn.execute( + "SELECT id FROM sites WHERE slug=? AND id!=?", (body["slug"].lower(), site_id) + ).fetchone(): + raise HTTPException(409, "Slug already taken") + updates["slug"] = str(body["slug"]).lower() + if "custom_domain" in body: + dom = (body["custom_domain"] or "").strip() or None + if dom and conn.execute( + "SELECT id FROM sites WHERE custom_domain=? AND id!=?", (dom, site_id) + ).fetchone(): + raise HTTPException(409, "Domain already linked to another site") + updates["custom_domain"] = dom + if "expires_at" in body: + if body["expires_at"]: + try: + datetime.fromisoformat(str(body["expires_at"]).replace("Z", "+00:00")) + except Exception: + raise HTTPException(400, "Invalid expires_at (use ISO-8601)") from None + updates["expires_at"] = body["expires_at"] + if "noindex" in body: + updates["noindex"] = 1 if body["noindex"] else 0 + if "analytics_id" in body: + updates["analytics_id"] = str(body["analytics_id"] or "")[:120] + if "password" in body: + updates["password_hash"] = hash_password(str(body["password"])) if body["password"] else "" + if updates: + updates["updated_at"] = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S") + sets = ", ".join(f"{k}=?" for k in updates) + conn.execute(f"UPDATE sites SET {sets} WHERE id=?", (*updates.values(), site_id)) + conn.commit() + site = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() + audit_log(user, "site.update", "site", site_id, ",".join(updates), request) + return row_to_dict(site) + + +@router.delete("/api/v2/sites/{site_id}") +async def delete_site(site_id: int, request: Request): + user = _auth_user(request, require_write=True) + with get_conn() as conn: + row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() + if not row: + raise HTTPException(404, "Site not found") + if row["created_by"] != user["id"] and not user.get("is_admin"): + raise HTTPException(404, "Site not found") + conn.execute("DELETE FROM sites WHERE id=?", (site_id,)) + conn.commit() + audit_log(user, "site.delete", "site", site_id, "", request) + return {"status": "deleted", "id": site_id} + + +@router.get("/api/v2/sites/{site_id}/pages") +async def list_site_pages(site_id: int, request: Request): + user = _auth_user(request) + with get_conn() as conn: + row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() + if not row or (row["created_by"] != user["id"] and not user.get("is_admin")): + raise HTTPException(404, "Site not found") + return {"site_id": site_id, "pages": _site_pages(conn, site_id)} + + +@router.post("/api/v2/sites/{site_id}/pages") +async def add_site_page(site_id: int, request: Request): + user = _auth_user(request, require_write=True) + try: + body = await request.json() + except Exception: + body = {} + page_id = body.get("page_id") + if not page_id: + raise HTTPException(400, "page_id is required") + with get_conn() as conn: + row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() + if not row or (row["created_by"] != user["id"] and not user.get("is_admin")): + raise HTTPException(404, "Site not found") + if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone(): + raise HTTPException(404, "Page not found") + pos = conn.execute( + "SELECT COALESCE(MAX(position), -1)+1 FROM site_pages WHERE site_id=?", (site_id,) + ).fetchone()[0] + conn.execute( + "INSERT OR IGNORE INTO site_pages (site_id, page_id, position) VALUES (?, ?, ?)", + (site_id, page_id, pos), + ) + conn.commit() + pages = _site_pages(conn, site_id) + audit_log(user, "site.page.add", "site", site_id, f"page={page_id}", request) + return {"site_id": site_id, "pages": pages} + + +@router.delete("/api/v2/sites/{site_id}/pages/{page_id}") +async def remove_site_page(site_id: int, page_id: int, request: Request): + user = _auth_user(request, require_write=True) + with get_conn() as conn: + row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() + if not row or (row["created_by"] != user["id"] and not user.get("is_admin")): + raise HTTPException(404, "Site not found") + if page_id == row["root_page_id"]: + raise HTTPException(400, "Cannot remove the root page") + conn.execute( + "DELETE FROM site_pages WHERE site_id=? AND page_id=?", (site_id, page_id) + ) + conn.commit() + audit_log(user, "site.page.remove", "site", site_id, f"page={page_id}", request) + return {"status": "removed", "site_id": site_id, "page_id": page_id} + + +@router.get("/api/v2/sites/{site_id}/stats") +async def site_stats(site_id: int, request: Request, days: int = 30): + user = _auth_user(request) + days = max(1, min(int(days or 30), 365)) + with get_conn() as conn: + row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() + if not row or (row["created_by"] != user["id"] and not user.get("is_admin")): + raise HTTPException(404, "Site not found") + rows = conn.execute( + "SELECT day, views FROM site_views WHERE site_id=? ORDER BY day DESC LIMIT ?", + (site_id, days), + ).fetchall() + total = conn.execute( + "SELECT COALESCE(SUM(views), 0) FROM site_views WHERE site_id=?", (site_id,) + ).fetchone()[0] + return {"site_id": site_id, "total_views": total, + "days": [{"day": r["day"], "views": r["views"]} for r in rows]} + + +# ── Public site rendering ────────────────────────────────────────────────── + +def _public_guard(site: dict, request: Request): + if _site_expired(site): + return HTMLResponse("

410 — Site expired.

", status_code=410) + if site.get("password_hash") and not _site_unlocked(request, site): + return HTMLResponse( + f""" +
+

🔒 {html.escape(site.get('title') or 'Protected site')}

+ +
""", + 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("

404 — Site not found.

", 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("

404 — Root page removed.

", status_code=404) + page = dict(page) + _track_view(site["id"]) + body = f"

{html.escape(page.get('title') or 'Untitled')}

" + _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"{base}/s/{slug}"] + [ + f"{base}/s/{slug}/{p['slug']}" for p in pages + ] + return PlainTextResponse( + "" + "" + f"{''.join(urls)}", + 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("

404 — Site not found.

", 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("

404 — Page not in this site.

", status_code=404) + page = conn.execute("SELECT * FROM pages WHERE id=?", (target["id"],)).fetchone() + if not page: + return HTMLResponse("

404 — Page removed.

", status_code=404) + page = dict(page) + _track_view(site["id"]) + body = f"

{html.escape(page.get('title') or 'Untitled')}

" + _render_page_html(page) + return _site_shell(site=site, pages=pages, current_id=page["id"], + title=page.get("title") or "Untitled", body_html=body) + + +@router.post("/s/{slug}/auth") +async def public_site_auth(request: Request, slug: str): + with get_conn() as conn: + site = _resolve_site(conn, slug=slug) + if not site: + return JSONResponse({"detail": "Site not found"}, status_code=404) + if not site.get("password_hash"): + return {"status": "public"} + ctype = request.headers.get("content-type", "") + password = "" + if "application/json" in ctype: + try: + password = (await request.json()).get("password", "") + except Exception: + password = "" + else: + try: + form = await request.form() + password = form.get("password", "") + except Exception: + password = "" + if not verify_password(password or "", site["password_hash"] or ""): + raise HTTPException(401, "Wrong password") + from itsdangerous import URLSafeTimedSerializer + ser = URLSafeTimedSerializer(settings.app_secret_key, salt="site-auth") + resp = JSONResponse({"status": "unlocked"}) + resp.set_cookie(_site_auth_cookie(site["id"]), ser.dumps(site["id"]), + httponly=True, samesite="lax", max_age=86400, path="/") + return resp + + +# ── Public Forms ─────────────────────────────────────────────────────────── + +@router.get("/api/v2/collections/{collection_id}/form") +async def get_form_config(collection_id: int, request: Request): + _auth_user(request) + with get_conn() as conn: + info = _form_config(conn, collection_id) + return {"collection_id": collection_id, "name": info["name"], "form": info["config"]} + + +@router.put("/api/v2/collections/{collection_id}/form") +async def put_form_config(collection_id: int, request: Request): + user = _auth_user(request, require_write=True) + try: + body = await request.json() + except Exception: + body = {} + with get_conn() as conn: + info = _form_config(conn, collection_id) + cfg = info["config"] if isinstance(info["config"], dict) else {} + if "enabled" in body: + cfg["enabled"] = bool(body["enabled"]) + for key in ("title", "success_message"): + if key in body: + cfg[key] = str(body[key] or "")[:300] + for key in ("fields", "required", "notify_user_ids"): + if key in body and isinstance(body[key], list): + cfg[key] = body[key][:50] + if "public_token" in body and body["public_token"]: + tok = str(body["public_token"]) + if not _FORM_TOKEN_RE.match(tok): + raise HTTPException(400, "Invalid public_token (f_ + 6-64 chars)") + cfg["public_token"] = tok + if cfg.get("enabled") and not cfg.get("public_token"): + cfg["public_token"] = "f_" + secrets.token_urlsafe(9) + conn.execute( + "UPDATE collections SET form_config_json=? WHERE id=?", + (json.dumps(cfg), collection_id), + ) + conn.commit() + audit_log(user, "form.config", "collection", collection_id, "", request) + return {"collection_id": collection_id, "form": cfg} + + +def _collection_props(conn, collection_id: int) -> list[dict]: + return [dict(r) for r in conn.execute( + "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", + (collection_id,)).fetchall()] + + +@router.get("/f/{token}", response_class=HTMLResponse) +async def public_form(request: Request, token: str): + embed = request.query_params.get("embed") == "1" + with get_conn() as conn: + row = conn.execute("SELECT * FROM collections").fetchone() + target = None + if _FORM_TOKEN_RE.match(token or ""): + for c in conn.execute("SELECT id, name, form_config_json FROM collections").fetchall(): + try: + cfg = json.loads(c["form_config_json"] or "{}") + except Exception: + continue + if cfg.get("enabled") and cfg.get("public_token") == token: + target = (c, cfg) + break + _ = row + if not target: + return HTMLResponse("

404 — Form not found.

", 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"" for o in opts) + inputs += (f"" + f"") + elif ptype == "checkbox": + inputs += (f"") + else: + inputs += (f"" + f"") + chrome = "" if embed else f"

{html.escape(cfg.get('title') or coll['name'])}

" + return HTMLResponse( + f""" + +{html.escape(cfg.get('title') or coll['name'])} + +{chrome} +
+ +{inputs}
""" + ) + + +@router.post("/f/{token}") +async def submit_form(request: Request, token: str): + ip = request.client.host if request.client else "unknown" + _check_form_rate(ip or "unknown") + ctype = request.headers.get("content-type", "") + data: dict = {} + if "application/json" in ctype: + try: + data = await request.json() + except Exception: + data = {} + else: + try: + form = await request.form() + data = dict(form) + except Exception: + data = {} + if data.get("__hp"): + raise HTTPException(400, "Spam detected") + with get_conn() as conn: + target = None + if _FORM_TOKEN_RE.match(token or ""): + for c in conn.execute("SELECT id, name, form_config_json FROM collections").fetchall(): + try: + cfg = json.loads(c["form_config_json"] or "{}") + except Exception: + continue + if cfg.get("enabled") and cfg.get("public_token") == token: + target = (c, cfg) + break + if not target: + # NOTE: return (not raise) — the global 404 handler redirects + # non-/api paths to /workspaces, which would turn this into a 200. + return JSONResponse({"detail": "Form not found"}, status_code=404) + coll, cfg = target + props = _collection_props(conn, coll["id"]) + by_name = {p["name"]: p for p in props} + fields = cfg.get("fields") or list(by_name)[:10] + required = set(cfg.get("required") or []) + values: dict = {} + for name in fields: + prop = by_name.get(name) + if not prop: + continue + raw = data.get(name, "") + if prop["prop_type"] == "checkbox": + raw = True if raw in (True, "on", "true", "1", "checked") else False + if name in required and (raw is None or raw == "" or raw is False): + raise HTTPException(400, f"Field required: {name}") + values[str(prop["id"])] = raw + # Validate via property_types.validate_property_rule + try: + from app.services.property_types import validate_property_rule + for name in fields: + prop = by_name.get(name) + if not prop: + continue + ok, _msg = validate_property_rule( + prop.get("prop_type", "text"), values.get(str(prop["id"])), + prop.get("validation_json") or prop.get("options_json") or "") + if not ok: + raise HTTPException(400, f"Invalid value for {name}: {_msg}") + except HTTPException: + raise + except Exception: + pass + title = str(data.get(by_name[fields[0]]["name"], "Form response") if fields else "Form response")[:200] + cur = conn.execute( + """INSERT INTO collection_pages (collection_id, title, property_values_json) + VALUES (?, ?, ?)""", + (coll["id"], title or "Form response", json.dumps(values)), + ) + row_id = cur.lastrowid + ip_hash = hashlib.sha256(f"{ip}|{datetime.now(UTC).strftime('%Y-%m-%d')}".encode()).hexdigest() + conn.execute( + "INSERT INTO form_responses (collection_id, row_id, ip_hash) VALUES (?, ?, ?)", + (coll["id"], row_id, ip_hash), + ) + conn.commit() + notify_ids = cfg.get("notify_user_ids") or [] + # Notify (never throws the submission) + try: + from app.services.notifications import create_notification + for uid in notify_ids[:20]: + try: + create_notification(int(uid), None, "form_response", + f"New response: {coll['name']}", + f"{title}", "collection", coll["id"], + f"/db/{coll['id']}") + except Exception: + continue + except Exception: + pass + try: + from app.services.automations import fire_event as _fire + await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id}) + except Exception: + pass + if "application/json" in ctype: + return {"status": "ok", "row_id": row_id, + "message": cfg.get("success_message") or "Merci !"} + return HTMLResponse( + f""" +

{html.escape(cfg.get('success_message') or 'Merci !')}

""" + ) + + +# 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"] diff --git a/app/routers/webauthn.py b/app/routers/webauthn.py new file mode 100644 index 0000000..37a8232 --- /dev/null +++ b/app/routers/webauthn.py @@ -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"] diff --git a/app/routers/wiki.py b/app/routers/wiki.py new file mode 100644 index 0000000..0c809e3 --- /dev/null +++ b/app/routers/wiki.py @@ -0,0 +1,537 @@ +"""FlowDeck — teamspaces, verified pages, wiki home, collab polish (v7.3.0). + +Routes under ``/api/v2/wiki`` plus the guest entry point ``/g/{token}``. +Design : ``docs/V73_Wiki_Teamspaces_Polish.md``. +""" +from __future__ import annotations + +import html + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse +from jinja2 import Environment, FileSystemLoader + +from app.auth.session import SessionManager +from app.db import get_conn +from app.services import wiki +from app.services.api_v2_helpers import audit_log +from app.services.notifications import create_notification + +router = APIRouter(tags=["wiki"]) + + +def _esc(value) -> str: + return html.escape(str(value)) + + +def _user(request: Request) -> dict: + sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not sess or not sess.get("id"): + raise HTTPException(401, "Authentication required") + with get_conn() as conn: + row = conn.execute("SELECT is_admin, is_active FROM users WHERE id=?", + (sess["id"],)).fetchone() + if not row or not row["is_active"]: + raise HTTPException(403, "Account disabled") + return sess + + +def _workspace_id(request: Request) -> int: + wid = request.query_params.get("workspace_id") + if not wid: + raise HTTPException(400, "workspace_id required") + try: + wid = int(wid) + except (TypeError, ValueError): + raise HTTPException(400, "invalid workspace_id") from None + with get_conn() as conn: + if not conn.execute("SELECT 1 FROM workspaces WHERE id=?", (wid,)).fetchone(): + raise HTTPException(404, "Workspace not found") + return wid + + +def _teamspace_or_404(teamspace_id: int, user_id: int) -> dict: + with get_conn() as conn: + row = conn.execute("SELECT * FROM teamspaces WHERE id=?", (teamspace_id,)).fetchone() + if not row: + raise HTTPException(404, "Teamspace not found") + if not wiki.can_read_teamspace(user_id, teamspace_id): + # private teamspace → 404 (not 403), same as restricted collections + raise HTTPException(404, "Teamspace not found") + return dict(row) + + +def _page_or_404(page_id: int) -> dict: + with get_conn() as conn: + row = conn.execute( + "SELECT id, title, workspace_id, teamspace_id, deleted_at FROM pages WHERE id=?", + (page_id,)).fetchone() + if not row or row["deleted_at"]: + raise HTTPException(404, "Page not found") + return dict(row) + + +def _is_admin(user: dict) -> bool: + with get_conn() as conn: + row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone() + return bool(row and row["is_admin"]) + + +def _can_verify(user: dict, page: dict) -> bool: + """Admin, or an editor/owner of the teamspace / workspace holding the page.""" + if _is_admin(user): + return True + if page.get("teamspace_id"): + return wiki.can_write_teamspace(user["id"], page["teamspace_id"]) + wid = page.get("workspace_id") + if not wid: + return False + with get_conn() as conn: + owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (wid,)).fetchone() + if owner and owner["owner_id"] == user["id"]: + return True + member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", + (wid, user["id"])).fetchone() + return bool(member and member["role"] in ("owner", "admin", "editor")) + + +# ── teamspaces ───────────────────────────────────────────────────────────── + +@router.get("/api/v2/wiki/teamspaces") +async def list_teamspaces(request: Request): + user = _user(request) + wid = request.query_params.get("workspace_id") + if wid: + try: + wid = int(wid) + except (TypeError, ValueError): + raise HTTPException(400, "invalid workspace_id") from None + else: + wid = None + return {"teamspaces": wiki.list_teamspaces(user["id"], wid)} + + +@router.get("/wiki/teamspaces/{teamspace_id}", response_class=HTMLResponse) +async def teamspace_page(teamspace_id: int, request: Request): + """Teamspace detail HTML page — sidebar entry point.""" + user = _user(request) + ts = _teamspace_or_404(teamspace_id, user["id"]) + ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id) + with get_conn() as conn: + ws = conn.execute("SELECT name FROM workspaces WHERE id=?", + (ts["workspace_id"],)).fetchone() + pages = wiki.teamspace_pages(teamspace_id) + collections = wiki.teamspace_collections(teamspace_id) + page_rows = "\n".join( + f'' + f'📄{_esc(p["title"] or "Untitled")}' + for p in pages) + coll_rows = "\n".join( + f'' + f'{_esc(c["icon"] or "🗄️")}{_esc(c["name"] or "Untitled")}' + for c in collections) + content_html = f""" +
+

+ {_esc(ts['name'])}{' 🔒 private' if ts['private'] else ''} +

+

{_esc(ts.get('description') or '')}

+
+ Workspace: {_esc((ws["name"]) if ws else '')} + ·Role: {_esc(ts['role'])} + ·{len(pages) + len(collections)} items +
+

Pages

+
+ {page_rows or '

No pages yet.

'} +
+

Databases

+
+ {coll_rows or '

No databases yet.

'} +
+
+""" + from app.routers.dashboard import _sidebar_data + env = Environment(loader=FileSystemLoader("app/templates")) + sidebar = _sidebar_data(request, []) + block_tpl = env.from_string( + '{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}' + ) + return block_tpl.render( + **sidebar, + request=request, + content_html=content_html, + page_title=ts["name"], + title_prefix="Teamspace", + page_icon="🏛️", + ) + + +@router.post("/api/v2/wiki/teamspaces") +async def create_teamspace(request: Request): + user = _user(request) + try: + body = await request.json() + except Exception: + body = {} + name = (body.get("name") or "").strip() + if not name or len(name) > 120: + raise HTTPException(400, "name required (max 120 chars)") + wid = int(body.get("workspace_id") or 0) + with get_conn() as conn: + ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (wid,)).fetchone() + if not ws: + raise HTTPException(404, "Workspace not found") + admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone() + member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", + (wid, user["id"])).fetchone() + allowed = (ws["owner_id"] == user["id"] or (admin and admin["is_admin"]) + or (member and member["role"] in ("admin", "editor", "owner"))) + if not allowed: + raise HTTPException(403, "Editor role required in the workspace") + try: + tsid = wiki.create_teamspace(wid, name, user["id"], + description=body.get("description") or "", + private=bool(body.get("private"))) + except ValueError as exc: + raise HTTPException(409, str(exc)) from None + audit_log(user, "teamspace.create", "teamspace", tsid, name, request) + return JSONResponse(status_code=201, content={"id": tsid, "name": name}) + + +@router.get("/api/v2/wiki/teamspaces/{teamspace_id}") +async def get_teamspace(teamspace_id: int, request: Request): + user = _user(request) + ts = _teamspace_or_404(teamspace_id, user["id"]) + ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id) + ts["member_count"] = len(wiki.teamspace_member_ids(teamspace_id)) + return ts + + +@router.get("/api/v2/wiki/teamspaces/{teamspace_id}/members") +async def list_members(teamspace_id: int, request: Request): + user = _user(request) + _teamspace_or_404(teamspace_id, user["id"]) + with get_conn() as conn: + rows = conn.execute( + """SELECT m.user_id, m.role, u.login, u.full_name FROM teamspace_members m + JOIN users u ON u.id = m.user_id WHERE m.teamspace_id=? ORDER BY u.login""", + (teamspace_id,)).fetchall() + return {"members": [dict(r) for r in rows]} + + +@router.put("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}") +async def set_member(teamspace_id: int, member_id: int, request: Request): + user = _user(request) + _teamspace_or_404(teamspace_id, user["id"]) + if not wiki.can_write_teamspace(user["id"], teamspace_id): + raise HTTPException(403, "Editor role required") + try: + body = await request.json() + except Exception: + body = {} + role = body.get("role") + if role not in wiki.TEAMSPACE_ROLES: + raise HTTPException(400, f"role must be one of {', '.join(wiki.TEAMSPACE_ROLES)}") + with get_conn() as conn: + if not conn.execute("SELECT 1 FROM users WHERE id=?", (member_id,)).fetchone(): + raise HTTPException(404, "User not found") + conn.execute( + """INSERT INTO teamspace_members (teamspace_id, user_id, role) VALUES (?,?,?) + ON CONFLICT(teamspace_id, user_id) DO UPDATE SET role=excluded.role""", + (teamspace_id, member_id, role)) + conn.commit() + audit_log(user, "teamspace.member.set", "teamspace", teamspace_id, + f"u{member_id}={role}", request) + return {"status": "ok", "user_id": member_id, "role": role} + + +@router.delete("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}") +async def remove_member(teamspace_id: int, member_id: int, request: Request): + user = _user(request) + _teamspace_or_404(teamspace_id, user["id"]) + if not wiki.can_write_teamspace(user["id"], teamspace_id): + raise HTTPException(403, "Editor role required") + with get_conn() as conn: + cur = conn.execute("DELETE FROM teamspace_members WHERE teamspace_id=? AND user_id=?", + (teamspace_id, member_id)) + conn.commit() + if not cur.rowcount: + raise HTTPException(404, "Not a member") + return {"status": "removed", "user_id": member_id} + + +# ── verified pages ───────────────────────────────────────────────────────── + +@router.get("/api/v2/wiki/pages/{page_id}/verification") +async def get_verification(page_id: int, request: Request): + _user(request) + _page_or_404(page_id) + return {"verification": wiki.verification(page_id)} + + +@router.post("/api/v2/wiki/pages/{page_id}/verify") +async def verify_page(page_id: int, request: Request): + user = _user(request) + page = _page_or_404(page_id) + if not _can_verify(user, page): + raise HTTPException(403, "Editor role required to verify a page") + try: + body = await request.json() + except Exception: + body = {} + out = wiki.verify_page(page_id, user["id"], + days=body.get("days") or wiki.VERIFICATION_DAYS_DEFAULT, + note=body.get("note") or "") + audit_log(user, "page.verify", "page", page_id, out.get("expires_at") or "", request) + return {"verification": out} + + +@router.delete("/api/v2/wiki/pages/{page_id}/verify") +async def unverify_page(page_id: int, request: Request): + user = _user(request) + _page_or_404(page_id) + if not wiki.unverify_page(page_id): + raise HTTPException(404, "Page is not verified") + audit_log(user, "page.unverify", "page", page_id, "", request) + return {"status": "unverified", "page_id": page_id} + + +@router.get("/api/v2/wiki/verified") +async def list_verified(request: Request): + """Verified (non-expired) pages of a workspace — the ✅ wiki index.""" + user = _user(request) + wid = _workspace_id(request) + with get_conn() as conn: + rows = conn.execute( + """SELECT p.id, p.title, p.page_icon, p.teamspace_id, + v.verified_at, v.expires_at, v.note, u.login + FROM page_verifications v + JOIN pages p ON p.id = v.page_id + LEFT JOIN users u ON u.id = v.verified_by + WHERE p.workspace_id=? AND p.deleted_at IS NULL""", + (wid,)).fetchall() + out = [] + for r in rows: + item = dict(r) + if wiki.is_expired(r): + continue # badge lapsed → not listed + if item["teamspace_id"] and not wiki.can_read_teamspace(user["id"], + item["teamspace_id"]): + continue # private teamspace → hidden + item["active"] = True + out.append(item) + return {"pages": out} + + +# ── follows ──────────────────────────────────────────────────────────────── + +@router.post("/api/v2/wiki/pages/{page_id}/follow") +async def follow_page(page_id: int, request: Request): + user = _user(request) + _page_or_404(page_id) + now = wiki.toggle_follow(page_id, user["id"]) + return {"page_id": page_id, "following": now} + + +@router.get("/api/v2/wiki/pages/{page_id}/followers") +async def list_followers(page_id: int, request: Request): + _user(request) + _page_or_404(page_id) + ids = wiki.followers(page_id) + if not ids: + return {"followers": []} + with get_conn() as conn: + rows = conn.execute( + f"SELECT id, login, full_name FROM users WHERE id IN ({','.join('?' * len(ids))})", + ids).fetchall() + return {"followers": [dict(r) for r in rows]} + + +# ── comment reactions ────────────────────────────────────────────────────── + +@router.post("/api/v2/wiki/comments/{comment_id}/reactions") +async def react(comment_id: int, request: Request): + user = _user(request) + try: + body = await request.json() + except Exception: + body = {} + emoji = (body.get("emoji") or "").strip() + if not emoji: + raise HTTPException(400, "emoji required") + try: + counts = wiki.toggle_reaction(comment_id, user["id"], emoji) + except LookupError: + raise HTTPException(404, "Comment not found") from None + return {"comment_id": comment_id, "reactions": counts} + + +@router.get("/api/v2/wiki/comments/{comment_id}/reactions") +async def list_reactions(comment_id: int, request: Request): + _user(request) + return {"comment_id": comment_id, "reactions": wiki.reactions(comment_id)} + + +# ── guest shares ─────────────────────────────────────────────────────────── + +@router.post("/api/v2/wiki/pages/{page_id}/guests") +async def create_guest(page_id: int, request: Request): + user = _user(request) + page = _page_or_404(page_id) + if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]): + raise HTTPException(403, "Editor role required to share") + try: + body = await request.json() + except Exception: + body = {} + try: + share = wiki.create_guest_share(page_id, body.get("email") or "", + body.get("role") or "viewer", + user["id"], days=body.get("days", 30)) + except ValueError as exc: + raise HTTPException(400, str(exc)) from None + audit_log(user, "page.guest_share", "page", page_id, share["email"], request) + return JSONResponse(status_code=201, content={ + "id": share["id"], "token": share["token"], "role": share["role"], + "expires_at": share["expires_at"], "url": f"/g/{share['token']}"}) + + +@router.get("/api/v2/wiki/pages/{page_id}/guests") +async def list_guests(page_id: int, request: Request): + _user(request) + _page_or_404(page_id) + with get_conn() as conn: + rows = conn.execute( + "SELECT id, email, role, expires_at, revoked, created_at FROM guest_shares" + " WHERE page_id=? ORDER BY id DESC", (page_id,)).fetchall() + return {"guests": [dict(r) for r in rows]} + + +@router.delete("/api/v2/wiki/guests/{share_id}") +async def revoke_guest(share_id: int, request: Request): + user = _user(request) + with get_conn() as conn: + if not conn.execute("SELECT 1 FROM guest_shares WHERE id=?", (share_id,)).fetchone(): + raise HTTPException(404, "Guest share not found") + conn.execute("UPDATE guest_shares SET revoked=1 WHERE id=?", (share_id,)) + conn.commit() + audit_log(user, "page.guest_revoke", "guest_share", share_id, "", request) + return {"status": "revoked", "id": share_id} + + +_GUEST_404 = """ + +Link unavailable — FlowDeck + +

This link is unavailable

+

It may have expired, been revoked, or never existed.
+Ask the person who shared it with you for a new link.

""" + + +@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""" + +{page['title']} — FlowDeck guest + +
You are viewing this page as a guest +({share['role']}{' — expires ' + str(share['expires_at']) if share['expires_at'] else ''}). +Editing is disabled.
+

{page['title']}

{page['content'] or ''}
""" + + +# ── 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()} diff --git a/app/routers/workers.py b/app/routers/workers.py new file mode 100644 index 0000000..92ee4f6 --- /dev/null +++ b/app/routers/workers.py @@ -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")} diff --git a/app/services/agent_engine.py b/app/services/agent_engine.py index e619e73..a80202b 100644 --- a/app/services/agent_engine.py +++ b/app/services/agent_engine.py @@ -18,9 +18,10 @@ import re from app.config import settings from app.db import get_conn +from app.services.agent_policies import check_tool, get_policy from app.services.context_builder import ContextBuilder from app.services.llm_client import LLMClient -from app.services.permission_manager import PermissionManager +from app.services.permission_manager import WRITE_TOOLS, PermissionManager from app.services.tool_registry import ToolRegistry logger = logging.getLogger(__name__) @@ -185,7 +186,10 @@ class AgentEngine: used_model = model or "" # peut être ajusté par un repli de modèle (404/410) try: - for _step in range(settings.agent_max_iterations or MAX_ITERATIONS): + # v7.2.0 — the workspace policy may cap iterations below the global max. + policy_max = get_policy(self.workspace_id).get("max_steps") or MAX_ITERATIONS + iterations = min(settings.agent_max_iterations or MAX_ITERATIONS, policy_max) + for _step in range(iterations): if self._tokens >= settings.agent_max_tokens_budget: yield self._event("error", {"message": "Budget de tokens dépassé"}) break @@ -235,8 +239,30 @@ class AgentEngine: tool, args = call["name"], call.get("arguments") or {} call_id = tool_specs[idx]["id"] denied = False + # v7.2.0 — workspace tool scope + human approval gate, checked + # *before* permissions so a scoped-out tool never reaches ACLs. + gov = check_tool(self.user_id, self.workspace_id, tool, + is_write=tool in WRITE_TOOLS, + conversation_id=conversation_id) + if not gov.get("allowed"): + detail = gov.get("reason") or "Refusé par la politique agent" + if gov.get("approval_id"): + detail = (f"Approbation requise (demande #{gov['approval_id']}) " + f"— action suspendue") + yield self._event("action", { + "tool": tool, "status": "approval_required" if gov.get("approval_id") + else "error", "detail": detail, + "approval_id": gov.get("approval_id")}) + self._log_action(conversation_id, tool, args, {}, "error", detail=detail) + messages.append({ + "role": "tool", "tool_call_id": call_id, + "content": json.dumps({"status": "error", "message": detail}, + ensure_ascii=False), + }) + denied = True try: - self.perms.assert_can(tool, args, self.workspace_id, approval_mode) + if not denied: + self.perms.assert_can(tool, args, self.workspace_id, approval_mode) except Exception as exc: # permission / approval guard detail = self._exc_detail(exc) yield self._event("action", {"tool": tool, "status": "error", "detail": detail}) diff --git a/app/services/agent_policies.py b/app/services/agent_policies.py new file mode 100644 index 0000000..afc8cd9 --- /dev/null +++ b/app/services/agent_policies.py @@ -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()) diff --git a/app/services/automations.py b/app/services/automations.py index b5fac53..43f30a9 100644 --- a/app/services/automations.py +++ b/app/services/automations.py @@ -23,6 +23,7 @@ from __future__ import annotations import asyncio import json import logging +import time from datetime import datetime, timedelta import httpx @@ -246,6 +247,43 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str: ) return f"notified user {user_id}" + if atype == "slack": + url = _secret_value(action.get("webhook_url") or action.get("url") or "") + if not url: + raise ValueError("slack action requires a webhook_url") + _, text = _maybe_convert_prediction( + action.get("text") or action.get("message") or "Automation fired", context) + return await _post_slack(url, text) + + if atype == "email": + to = action.get("to", "") + _, subject = _maybe_convert_prediction(action.get("subject", "FlowDeck automation"), context) + _, body = _maybe_convert_prediction(action.get("body", action.get("message", "")), context) + return await _send_email_action(to, subject, body, context) + + if atype == "forge_issue": + provider = (action.get("provider") or "gitea").lower() + owner = action.get("owner", "") + repo = action.get("repo", "") + if not owner or not repo: + raise ValueError("forge_issue requires owner + repo") + _, title = _maybe_convert_prediction(action.get("title", "Automation issue"), context) + _, body = _maybe_convert_prediction(action.get("body", ""), context) + return await _create_forge_issue( + provider, owner, repo, title, body, + labels=action.get("labels") or [], + user_id=context.get("created_by"), + ) + + if atype == "agent_trigger": + agent_id = action.get("agent_id") + if not agent_id: + raise ValueError("agent_trigger requires an agent_id") + _, message = _maybe_convert_prediction(action.get("message", ""), context) + return await _run_linked_agent( + int(agent_id), context.get("created_by") or 1, + context.get("workspace_id"), message, context) + raise ValueError(f"unknown action type: {atype!r}") @@ -260,6 +298,11 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict) if not auto["enabled"]: return {"status": "skipped", "detail": "automation disabled"} + # v7.0.0: chained steps take over when present (legacy path otherwise). + stepped = await _maybe_run_stepped(auto, trigger_source, context) + if stepped is not None: + return stepped + props = context.get("properties") before = context.get("before_properties") if not evaluate_conditions(auto["condition_json"], props, before): @@ -307,6 +350,15 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict) async def fire_event(event: str, payload: dict): """Dispatch an event to outbound webhooks and matching automations.""" + # v7.3.0: page.updated → in-app notification to followers (throttled). + if event == "page.updated": + try: + from app.services.wiki import notify_followers_of_page_update + notify_followers_of_page_update( + payload.get("page_id"), payload.get("actor_id"), + payload.get("title") or "") + except Exception: # noqa: BLE001 — notifications are best-effort + logger.debug("followers notification failed for page.updated") # Outbound webhooks (v2.1.0 machinery, previously called nowhere). try: from app.services.webhook_outbound import fire_event as fire_webhooks @@ -320,14 +372,29 @@ async def fire_event(event: str, payload: dict): WHERE trigger_type='event' AND event=? AND enabled=1""", (event,), ).fetchall() + stepped_ids: set[int] = set() + try: + with get_conn() as _c: + stepped_ids = {r[0] for r in _c.execute( + "SELECT DISTINCT automation_id FROM automation_steps").fetchall()} + except Exception: # noqa: BLE001 — table missing on very old DBs + pass for row in rows: auto = dict(row) + if auto["id"] in stepped_ids: + continue # v7.0.0: handled by fire_stepped_event below (no double run) if auto["collection_id"] and payload.get("collection_id") != auto["collection_id"]: continue context = dict(payload) context["event"] = event await run_automation(auto["id"], "event", context) + # v7.0.0: step-based automations (multi-trigger any/all, chains). + try: + await fire_stepped_event(event, payload) + except Exception: # noqa: BLE001 + logger.debug("stepped dispatch failed for %s", event) + # ═══════════ Cron scheduling (trigger_type='cron') ═══════════ @@ -409,6 +476,353 @@ async def automation_scheduler(): await run_automation(auto["id"], "cron", context) except Exception: # noqa: BLE001 logger.warning("Cron automation %s errored", auto["id"]) + # v7.0.0: workers on a cron schedule share the same 60s loop. + try: + from app.services.workers import run_due_workers + await run_due_workers() + except Exception: # noqa: BLE001 + logger.debug("worker cron iteration failed") except Exception: # noqa: BLE001 logger.warning("automation_scheduler iteration failed") await asyncio.sleep(60) + + +# ═══════════ v7.0.0 — multi-step automations (triggers/conditions/delay) ══ + +STEP_KINDS = ("trigger", "condition", "delay", "action") +STEP_ACTION_TYPES = ("webhook", "set_property", "create_page", "notify", + "slack", "email", "forge_issue", "agent_trigger") +ALL_MODE_WINDOW_S = 300.0 + +# mode=all bookkeeping (single-process): automation_id -> {event: timestamp}. +_ALL_PENDING: dict[int, dict[str, float]] = {} + + +def reset_all_pending() -> None: + """Test helper: clear the mode=all arrival window.""" + _ALL_PENDING.clear() + + +def _secret_value(stored: str | None) -> str: + """Decrypt a Fernet secret, falling back to raw plaintext (legacy/tests).""" + if not stored: + return "" + try: + from app.services.sso_provisioning import decrypt_secret + decrypted = decrypt_secret(stored) + if decrypted: + return decrypted + except Exception: # noqa: BLE001 + pass + if isinstance(stored, str) and not stored.startswith("gAAAAA"): + return stored + return "" + + +def validate_step(kind: str, config: dict) -> None: + """Validate a step payload. Raises ValueError with a human message.""" + from fastapi import HTTPException + if kind not in STEP_KINDS: + raise HTTPException(400, f"invalid kind: {kind!r} (want trigger|condition|delay|action)") + config = config or {} + if kind == "trigger": + if not config.get("event"): + raise HTTPException(400, "trigger step requires an event") + elif kind == "condition": + if config.get("op", "eq") not in COND_OPS: + raise HTTPException(400, f"invalid op: {config.get('op')!r}") + elif kind == "delay": + try: + seconds = int(config.get("seconds", 0)) + except (TypeError, ValueError): + raise HTTPException(400, "delay step requires integer seconds") from None + if seconds < 0 or seconds > 86400: + raise HTTPException(400, "delay seconds must be 0..86400") + elif kind == "action": + if config.get("type") not in STEP_ACTION_TYPES: + raise HTTPException(400, f"invalid action type: {config.get('type')!r}") + + +def get_steps(automation_id: int) -> list[dict]: + """Ordered steps of an automation (empty when legacy single-mode).""" + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM automation_steps WHERE automation_id=? ORDER BY position, id", + (automation_id,), + ).fetchall() + out = [] + for r in rows: + d = dict(r) + try: + d["config"] = json.loads(d.get("config_json") or "{}") + except (TypeError, json.JSONDecodeError): + d["config"] = {} + out.append(d) + return out + + +def _steps_by_kind(steps: list[dict]) -> dict[str, list[dict]]: + grouped: dict[str, list[dict]] = {"trigger": [], "condition": [], + "delay": [], "action": []} + for s in steps: + if s.get("kind") in grouped: + grouped[s["kind"]].append(s) + return grouped + + +def _step_trigger_matches(step_cfg: dict, event: str, payload: dict, + automation_collection_id: int | None) -> bool: + if step_cfg.get("event") != event: + return False + want_coll = step_cfg.get("collection_id") or automation_collection_id + if want_coll and payload.get("collection_id") != want_coll: + return False + return True + + +async def _run_with_steps(auto: dict, steps: list[dict], trigger_source: str, + context: dict) -> dict: + """Execute a chained automation. Records one run row with per-step detail.""" + grouped = _steps_by_kind(steps) + props = context.get("properties") + before = context.get("before_properties") + # Legacy single condition still applies on top of step conditions. + if not evaluate_conditions(auto.get("condition_json") or "[]", props, before): + _save_run(auto["id"], trigger_source, "skipped", "condition not met", + context.get("collection_id"), context.get("page_id")) + return {"status": "skipped", "detail": "condition not met"} + for cond in grouped["condition"]: + cfg = cond.get("config") or {} + if not match_condition_props(props, before, { + "property": cfg.get("property"), "op": cfg.get("op", "eq"), + "value": cfg.get("value")}): + _save_run(auto["id"], trigger_source, "skipped", + f"step condition not met: {cfg.get('property')}", + context.get("collection_id"), context.get("page_id")) + return {"status": "skipped", "detail": "step condition not met"} + + ctx = dict(context) + ctx["automation_name"] = auto["name"] + ctx["created_by"] = auto["created_by"] or ctx.get("created_by") + ordered = sorted(steps, key=lambda s: (s.get("position", 0), s.get("id", 0))) + results = [] + try: + for step in ordered: + kind = step.get("kind") + cfg = step.get("config") or {} + if kind in ("trigger", "condition"): + continue + if kind == "delay": + seconds = max(0, min(int(cfg.get("seconds", 0)), 300)) + if seconds: + await asyncio.sleep(seconds) + results.append(f"delay {cfg.get('seconds', 0)}s") + elif kind == "action": + summary = await _run_action({"type": cfg.get("type"), **cfg}, ctx, + trigger_source) + results.append(summary) + detail = "; ".join(results) or "no steps executed" + _save_run(auto["id"], trigger_source, "fired", detail, + ctx.get("collection_id"), ctx.get("page_id")) + try: + from app.services.webhook_outbound import fire_event as _fire_wh + await _fire_wh("automation.fired", { + "automation_id": auto["id"], "name": auto["name"], + "trigger": trigger_source, "collection_id": ctx.get("collection_id"), + "page_id": ctx.get("page_id"), "detail": detail}) + except Exception: # noqa: BLE001 + logger.debug("automation.fired webhook dispatch failed") + return {"status": "fired", "detail": detail} + except Exception as exc: # noqa: BLE001 + logger.warning("Automation %s (steps) failed: %s", auto["id"], exc) + _save_run(auto["id"], trigger_source, "error", str(exc), + ctx.get("collection_id"), ctx.get("page_id")) + return {"status": "error", "detail": str(exc)} + + +async def _maybe_run_stepped(auto: dict, trigger_source: str, context: dict) -> dict | None: + """Run via steps when the automation has any; None → use legacy path.""" + steps = get_steps(auto["id"]) + if not steps: + return None + return await _run_with_steps(auto, steps, trigger_source, context) + + +def _match_stepped_automations(event: str, payload: dict) -> list[tuple[dict, list[dict]]]: + """Automations (enabled) whose trigger steps match ``event`` + collection.""" + with get_conn() as conn: + rows = conn.execute( + """SELECT a.* FROM automations a + JOIN automation_steps s ON s.automation_id = a.id + WHERE a.enabled=1 AND s.kind='trigger' GROUP BY a.id""" + ).fetchall() + matched = [] + for row in rows: + auto = dict(row) + steps = get_steps(auto["id"]) + triggers = [s for s in steps if s.get("kind") == "trigger"] + if any(_step_trigger_matches(t.get("config") or {}, event, payload, + auto.get("collection_id")) for t in triggers): + matched.append((auto, triggers)) + return matched + + +async def fire_stepped_event(event: str, payload: dict) -> None: + """Dispatch ``event`` to step-based automations (mode any/all). + + Called from :func:`fire_event` after the legacy matcher. Unknown events + (not in the webhook catalogue) still work here — steps are independent + from outbound webhooks. + """ + now = time.time() + for auto, triggers in _match_stepped_automations(event, payload): + # Skip automations already handled by the legacy matcher to avoid + # double runs (legacy = trigger_type event + no steps). + if not get_steps(auto["id"]): + continue + mode = (auto.get("trigger_mode") or "any").lower() + if mode == "all": + pending = _ALL_PENDING.setdefault(auto["id"], {}) + pending[event] = now + # Expire arrivals outside the window. + for ev in [e for e, ts in pending.items() if now - ts > ALL_MODE_WINDOW_S]: + del pending[ev] + wanted = {t.get("config", {}).get("event") for t in triggers} + if not wanted <= set(pending): + continue + _ALL_PENDING.pop(auto["id"], None) + context = dict(payload) + context["event"] = event + await run_automation(auto["id"], "event", context) + + +# ── v7.0.0 action backends (module-level = monkeypatchable in tests) ─────── + +async def _post_slack(webhook_url: str, text: str) -> str: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post(webhook_url, json={"text": text}) + if resp.status_code >= 400: + raise RuntimeError(f"slack webhook returned HTTP {resp.status_code}") + return f"slack → ({resp.status_code})" + + +async def _send_email_action(to: str, subject: str, body: str, context: dict) -> str: + from app.services import mailer + address = (to or "").strip() + if address.startswith("user:"): + try: + uid = int(address.split(":", 1)[1]) + except ValueError: + raise ValueError(f"bad email target: {to!r}") from None + with get_conn() as conn: + row = conn.execute("SELECT email FROM users WHERE id=?", (uid,)).fetchone() + address = (row["email"] if row and row["email"] else "") + if not address: + raise ValueError(f"user {uid} has no email") + if not address: + address = None + with get_conn() as conn: + row = conn.execute("SELECT email FROM users WHERE id=?", + (context.get("created_by") or 1,)).fetchone() + if row and row["email"]: + address = row["email"] + if not address: + return "email skipped (no recipient)" + ok = mailer.send_email(address, subject or "FlowDeck automation", body or "") + return f"email → {address}" if ok else "email skipped (SMTP not configured)" + + +async def _create_forge_issue(provider: str, owner: str, repo: str, title: str, + body: str, labels: list | None = None, + user_id: int | None = None) -> str: + token = "" + if user_id: + with get_conn() as conn: + row = conn.execute( + "SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider=?", + (user_id, provider)).fetchone() + token = (row["access_token"] if row else "") or "" + if provider == "github": + if not token: + raise ValueError("github action needs a linked GitHub account (token)") + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + f"https://api.github.com/repos/{owner}/{repo}/issues", + headers={"Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json"}, + json={"title": title, "body": body, + "labels": labels or []} if labels else {"title": title, "body": body}, + ) + if resp.status_code >= 400: + raise RuntimeError(f"github returned HTTP {resp.status_code}") + return f"github issue #{resp.json().get('number')} in {owner}/{repo}" + # gitea (default) + from app.services.gitea_client import GiteaClient + gitea = GiteaClient(user_token=token or None) + issue = await gitea.create_issue(owner, repo, title, body) + return f"gitea issue #{issue.get('number')} in {owner}/{repo}" + + +async def _run_linked_agent(agent_id: int, user_id: int, workspace_id: int | None, + message: str, context: dict) -> str: + from app.services.agent_engine import AgentEngine + with get_conn() as conn: + agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() + if not agent: + raise ValueError(f"agent {agent_id} not found") + cur = conn.execute( + """INSERT INTO agent_conversations (agent_id, user_id, title, context_json) + VALUES (?,?,?,?)""", + (agent_id, user_id, + f"Automation: {context.get('automation_name', 'run')}", + json.dumps({"workspace_id": workspace_id})), + ) + conv_id = cur.lastrowid + conn.commit() + objective = ((agent["system_instructions"] or "").strip() + or f"Exécute l'agent « {agent['name']} ».") + if message: + objective = f"{objective}\n\n{message}" + engine = AgentEngine(user_id, workspace_id, agent["model"] or None) + final = "" + async for _ev in engine.run(conv_id, objective, model=agent["model"]): + pass + with get_conn() as conn: + row = conn.execute( + "SELECT content FROM agent_messages WHERE conversation_id=? AND role='assistant'" + " ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone() + final = (row["content"][:300] if row and row["content"] else "") + return f"agent « {agent['name']} » ran (conversation {conv_id})" + (f": {final}" if final else "") + + +# ── v7.0.0 native DB button ──────────────────────────────────────────────── + +async def press_button(collection_id: int, row_id: int, prop_ref: str | int, + user_id: int) -> dict: + """Run the automation linked to a ``button`` property cell.""" + with get_conn() as conn: + if isinstance(prop_ref, int) or str(prop_ref).isdigit(): + prop = conn.execute( + "SELECT * FROM collection_properties WHERE id=? AND collection_id=?", + (int(prop_ref), collection_id)).fetchone() + else: + prop = conn.execute( + "SELECT * FROM collection_properties WHERE collection_id=? AND name=?", + (collection_id, prop_ref)).fetchone() + if not prop: + raise ValueError("button property not found") + prop = dict(prop) + if prop.get("prop_type") != "button": + raise ValueError("property is not a button") + auto_id = prop.get("button_automation_id") + if not auto_id: + raise ValueError("button has no linked automation") + row = conn.execute( + "SELECT id FROM collection_pages WHERE id=? AND collection_id=?", + (row_id, collection_id)).fetchone() + if not row: + raise ValueError("row not found") + context = get_page_context(row_id, collection_id) + context["created_by"] = user_id + return await run_automation(auto_id, "button", context) + diff --git a/app/services/calendar_sync.py b/app/services/calendar_sync.py new file mode 100644 index 0000000..ed994fd --- /dev/null +++ b/app/services/calendar_sync.py @@ -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 = """ + + + + + +""" + + +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} diff --git a/app/services/export.py b/app/services/export.py index 13f0e1f..ea74438 100644 --- a/app/services/export.py +++ b/app/services/export.py @@ -639,6 +639,10 @@ def blocks_to_html(blocks: list) -> str: icon = b.get("icon") or "💡" bg = (b.get("style") or {}).get("bgColor", "#eef2ff") parts.append(f'
{_text(icon, escape=False)}
{c}
') + 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")) diff --git a/app/services/gitea_client.py b/app/services/gitea_client.py index 7e5f7b9..8cbe1b5 100644 --- a/app/services/gitea_client.py +++ b/app/services/gitea_client.py @@ -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) diff --git a/app/services/meetings.py b/app/services/meetings.py new file mode 100644 index 0000000..c84c9a3 --- /dev/null +++ b/app/services/meetings.py @@ -0,0 +1,112 @@ +"""FlowDeck — AI Meeting Notes v2 (v7.1.0). + +Upload audio → optional server transcription (``STT_COMMAND``, e.g. whisper) +→ AI summary (``AIWritingService.summarize``, offline-capable) → fires +``meeting.summarized`` so custom agents pick it up (Notion 07/2026 pattern). + +Without ``STT_COMMAND`` the server stores the audio and accepts a manual +``transcript`` (client-side transcription). Nothing here requires new pip +dependencies. See ``docs/V71_Calendar_Meetings.md``. +""" +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +from pathlib import Path + +from app.db import get_conn + +logger = logging.getLogger(__name__) + +AUDIO_EXTENSIONS = {"mp3", "wav", "m4a", "ogg", "flac", "aac"} +MAX_AUDIO_BYTES = 100 * 1024 * 1024 + + +class TranscriptionUnavailable(RuntimeError): + """Raised when no transcription backend is configured.""" + + +def meetings_dir() -> Path: + root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data")) + d = root / "uploads" / "meetings" + d.mkdir(parents=True, exist_ok=True) + return d + + +def save_transcript(page_id: int, transcript: str, language: str = "fr", + audio_path: str = "") -> int: + with get_conn() as conn: + if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone(): + raise ValueError("page not found") + cur = conn.execute( + """INSERT INTO meeting_transcripts (page_id, audio_path, transcript, language) + VALUES (?,?,?,?)""", + (page_id, audio_path, transcript or "", language or "fr")) + conn.commit() + return cur.lastrowid + + +def transcribe_audio(audio_path: str, language: str = "fr") -> str: + """Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text). + + Example: ``STT_COMMAND="whisper --language fr --output_format txt --output_dir /tmp"`` + (command must print or be adapted — stdout is preferred). Raises + :class:`TranscriptionUnavailable` when unconfigured. + """ + cmd_template = os.environ.get("STT_COMMAND", "").strip() + if not cmd_template: + raise TranscriptionUnavailable( + "no transcription backend (set STT_COMMAND or POST a manual transcript)") + if shutil.which(cmd_template.split()[0]) is None: + raise TranscriptionUnavailable(f"STT command not found: {cmd_template.split()[0]}") + try: + proc = subprocess.run(cmd_template.split() + [audio_path], # noqa: S603 — admin-configured + capture_output=True, text=True, timeout=600) + except subprocess.TimeoutExpired as exc: + raise TranscriptionUnavailable("transcription timed out") from exc + text = (proc.stdout or "").strip() + if proc.returncode != 0 or not text: + raise TranscriptionUnavailable( + f"transcription failed: {(proc.stderr or '')[:300]}") + return text + + +async def summarize_transcript(transcript_id: int, user_id: int | None = None) -> dict: + """Summarize a stored transcript + fire ``meeting.summarized``. + + Returns {transcript_id, summary, offline}. Emits the automation event so + custom agents (update tracker, post recap, file tickets) trigger. + """ + from app.services.ai_writing import AIWritingService + + with get_conn() as conn: + row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", + (transcript_id,)).fetchone() + if not row: + raise ValueError("transcript not found") + tr = dict(row) + if not (tr.get("transcript") or "").strip(): + raise ValueError("transcript is empty — transcribe first") + svc = AIWritingService(user_id=user_id) + res = await svc.run("summarize", context=tr["transcript"]) + summary = (res.get("text") or "").strip() if res.get("ok") else "" + if not summary: + raise RuntimeError(f"summarization failed: {res.get('error', 'unknown')}") + with get_conn() as conn: + conn.execute("UPDATE meeting_transcripts SET summary=? WHERE id=?", + (summary, transcript_id)) + page = conn.execute("SELECT id FROM pages WHERE id=?", (tr["page_id"],)).fetchone() + conn.commit() + try: + from app.services.automations import fire_event + await fire_event("meeting.summarized", { + "page_id": tr["page_id"] if page else 0, + "transcript_id": transcript_id, + "language": tr.get("language") or "fr", + }) + except Exception as exc: # noqa: BLE001 — summary stands even if dispatch fails + logger.debug("meeting.summarized dispatch failed: %s", exc) + return {"transcript_id": transcript_id, "summary": summary, + "offline": bool(res.get("offline"))} diff --git a/app/services/property_types.py b/app/services/property_types.py index e01fd52..4cd6785 100644 --- a/app/services/property_types.py +++ b/app/services/property_types.py @@ -96,6 +96,10 @@ PROPERTY_TYPES: dict[str, dict] = { "storage": "auto — {id, login}", "default": None, }, + "button": { + "storage": "none — runs linked automation (button_automation_id)", + "default": None, + }, } # CSV-friendly subset (no relation/rollup/formula) diff --git a/app/services/semantic_search.py b/app/services/semantic_search.py new file mode 100644 index 0000000..f77f420 --- /dev/null +++ b/app/services/semantic_search.py @@ -0,0 +1,535 @@ +"""FlowDeck — semantic (vector) search + Ask AI (v6.9.0). + +Hybrid retrieval = lexical (FTS5/LIKE via :mod:`app.services.search`) fused +with vector cosine similarity via Reciprocal Rank Fusion, then filtered +through :class:`PermissionManager` so unauthorized chunks never surface +(and never enter an LLM prompt). + +Vectors use a dependency-free **hashed TF** encoder (``hash-256``): token → +``md5 % 256`` with L2 normalization. Deterministic, offline-first, good +enough for recall on small workspaces; the ``embed_texts`` entry point is +pluggable should an LLM ``/embeddings`` provider be wired later. +See ``docs/V69_Search_Ask_AI.md``. +""" +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import math +import re +import struct +import time + +from app.db import get_conn + +logger = logging.getLogger(__name__) + +DIM = 256 +MODEL = "hash-256" +CHUNK_SIZE = 1200 +CHUNK_OVERLAP = 150 +MAX_CHUNKS_PER_RESOURCE = 50 +RRF_K = 60 + +_TOKEN_RE = re.compile(r"[\wÀ-ÿ]+", flags=re.UNICODE) + +# Ask cache: (question_hash, workspace_id, user_id) -> (expires_at, payload) +_ask_cache: dict[tuple[str, int | None, int], tuple[float, dict]] = {} +_ASK_CACHE_TTL = 600.0 + +# Ask rate limit: user_id -> (window_start, count) +_ask_rate: dict[int, tuple[float, int]] = {} +_ASK_RATE_MAX = 30 +_ASK_RATE_WINDOW = 60.0 + + +# ── text extraction & chunking ───────────────────────────────────────────── + +def _blocks_to_text(blocks) -> list[str]: + parts: list[str] = [] + + def _walk(items) -> None: + for b in items or []: + if not isinstance(b, dict): + continue + for key in ("content", "text", "title"): + val = b.get(key) + if isinstance(val, str) and val.strip(): + parts.append(val.strip()) + break + children = b.get("children") + if isinstance(children, list): + _walk(children) + + _walk(blocks if isinstance(blocks, list) else []) + return parts + + +def extract_page_text(content: str | None, content_format: str | None) -> str: + """Full searchable text of a ``pages`` row (all blocks, recursive).""" + if not content: + return "" + if (content_format or "blocks") == "blocks": + try: + blocks = json.loads(content) + return "\n".join(_blocks_to_text(blocks)) + except Exception: + return content + return content + + +def chunk_text(text: str, size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]: + """Split text into overlapping chunks (char-based, word-boundary aware).""" + text = (text or "").strip() + if not text: + return [] + if len(text) <= size: + return [text] + chunks: list[str] = [] + start = 0 + while start < len(text): + end = min(start + size, len(text)) + if end < len(text): + space = text.rfind(" ", start, end) + if space > start + size // 2: + end = space + chunks.append(text[start:end].strip()) + if end >= len(text): + break + start = max(end - overlap, start + 1) + if len(chunks) >= MAX_CHUNKS_PER_RESOURCE: + break + return [c for c in chunks if c] + + +# ── hashed-TF embeddings ─────────────────────────────────────────────────── + +def _tokens(text: str) -> list[str]: + return [t.lower() for t in _TOKEN_RE.findall(text or "") if t] + + +def embed_text(text: str, dim: int = DIM) -> bytes: + """Deterministic L2-normalized hashed-TF vector, struct-packed float32.""" + vec = [0.0] * dim + for tok in _tokens(text): + idx = int(hashlib.md5(tok.encode()).hexdigest(), 16) % dim + vec[idx] += 1.0 + norm = math.sqrt(sum(v * v for v in vec)) + if norm > 0: + vec = [v / norm for v in vec] + return struct.pack(f"<{dim}f", *vec) + + +def embed_texts(texts: list[str], dim: int = DIM) -> list[bytes]: + """Batch entry point (pluggable: LLM /embeddings can replace hashing).""" + return [embed_text(t, dim) for t in texts] + + +def cosine(a: bytes, b: bytes, dim: int = DIM) -> float: + """Cosine similarity of two packed normalized vectors (== dot product).""" + try: + va = struct.unpack(f"<{dim}f", a) + vb = struct.unpack(f"<{dim}f", b) + except struct.error: + return 0.0 + return sum(x * y for x, y in zip(va, vb, strict=True)) + + +# ── indexing ─────────────────────────────────────────────────────────────── + +def _resource_text(conn, resource_type: str, resource_id: int) -> str | None: + """Return indexable text, or None when the resource must not be indexed.""" + if resource_type == "page": + row = conn.execute( + "SELECT title, content, content_format FROM pages " + "WHERE id=? AND (deleted_at IS NULL OR deleted_at='') " + "AND COALESCE(search_excluded, 0)=0", + (resource_id,), + ).fetchone() + if not row: + return None + body = extract_page_text(row["content"], row["content_format"]) + return f"{row['title'] or ''}\n{body}".strip() + if resource_type == "collection": + row = conn.execute( + "SELECT name, description FROM collections WHERE id=?", (resource_id,) + ).fetchone() + if not row: + return None + return f"{row['name'] or ''}\n{row['description'] or ''}".strip() + return None + + +def index_resource(resource_type: str, resource_id: int) -> int: + """(Re)index one resource. Returns the number of chunks stored.""" + with get_conn() as conn: + text = _resource_text(conn, resource_type, resource_id) + conn.execute( + "DELETE FROM semantic_embeddings WHERE resource_type=? AND resource_id=?", + (resource_type, resource_id), + ) + n = 0 + if text: + for i, chunk in enumerate(chunk_text(text)[:MAX_CHUNKS_PER_RESOURCE]): + conn.execute( + """INSERT INTO semantic_embeddings + (resource_type, resource_id, chunk_id, chunk_text, embedding, model) + VALUES (?, ?, ?, ?, ?, ?)""", + (resource_type, resource_id, i, chunk, embed_text(chunk), MODEL), + ) + n += 1 + conn.execute( + """INSERT INTO semantic_index_state (resource_type, resource_id, indexed_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(resource_type, resource_id) + DO UPDATE SET indexed_at=CURRENT_TIMESTAMP""", + (resource_type, resource_id), + ) + conn.commit() + return n + + +def _stale_resources(conn, limit: int) -> list[tuple[str, int]]: + out: list[tuple[str, int]] = [] + rows = conn.execute( + """SELECT p.id, p.updated_at FROM pages p + LEFT JOIN semantic_index_state s + ON s.resource_type='page' AND s.resource_id=p.id + WHERE (p.deleted_at IS NULL OR p.deleted_at='') + AND COALESCE(p.search_excluded, 0)=0 + AND (s.indexed_at IS NULL OR p.updated_at > s.indexed_at) + ORDER BY p.updated_at DESC LIMIT ?""", + (limit,), + ).fetchall() + out += [("page", r["id"]) for r in rows] + if len(out) < limit: + rows = conn.execute( + """SELECT c.id, c.updated_at FROM collections c + LEFT JOIN semantic_index_state s + ON s.resource_type='collection' AND s.resource_id=c.id + WHERE s.indexed_at IS NULL OR c.updated_at > s.indexed_at + ORDER BY c.updated_at DESC LIMIT ?""", + (limit - len(out),), + ).fetchall() + out += [("collection", r["id"]) for r in rows] + return out + + +def purge_orphans() -> int: + """Drop vectors for deleted/excluded resources. Returns rows removed.""" + with get_conn() as conn: + cur = conn.execute( + """DELETE FROM semantic_embeddings + WHERE (resource_type='page' AND resource_id NOT IN ( + SELECT id FROM pages WHERE (deleted_at IS NULL OR deleted_at='') + AND COALESCE(search_excluded, 0)=0)) + OR (resource_type='collection' AND resource_id NOT IN ( + SELECT id FROM collections))""" + ) + conn.execute( + """DELETE FROM semantic_index_state + WHERE (resource_type='page' AND resource_id NOT IN ( + SELECT id FROM pages WHERE (deleted_at IS NULL OR deleted_at='') + AND COALESCE(search_excluded, 0)=0)) + OR (resource_type='collection' AND resource_id NOT IN ( + SELECT id FROM collections))""" + ) + conn.commit() + return cur.rowcount or 0 + + +def index_pending(limit: int = 50) -> dict: + """Index up to ``limit`` stale resources + purge orphans (scheduler job).""" + with get_conn() as conn: + stale = _stale_resources(conn, limit) + indexed = 0 + for rtype, rid in stale: + try: + index_resource(rtype, rid) + indexed += 1 + except Exception as exc: # never break the scheduler loop + logger.debug("semantic index failed for %s %s: %s", rtype, rid, exc) + purged = purge_orphans() + return {"checked": len(stale), "indexed": indexed, "purged": purged} + + +async def semantic_index_scheduler(interval_seconds: int = 300) -> None: + """Background task: incremental indexing (wired in app lifespan).""" + while True: + try: + await asyncio.to_thread(index_pending) + except Exception as exc: # noqa: BLE001 — scheduler must survive + logger.debug("semantic index scheduler: %s", exc) + await asyncio.sleep(interval_seconds) + + +# ── vector search ────────────────────────────────────────────────────────── + +def vector_search(query: str, *, limit: int = 20, + resource_types: tuple[str, ...] = ("page", "collection")) -> list[dict]: + """Brute-force cosine scan (fine at this scale). Returns ranked chunks.""" + q = (query or "").strip() + if not q: + return [] + qvec = embed_text(q) + with get_conn() as conn: + placeholders = ",".join("?" for _ in resource_types) + rows = conn.execute( + f"""SELECT resource_type, resource_id, chunk_id, chunk_text + FROM semantic_embeddings WHERE resource_type IN ({placeholders})""", + list(resource_types), + ).fetchall() + scored = [] + for r in rows: + row = conn.execute( + "SELECT embedding FROM semantic_embeddings " + "WHERE resource_type=? AND resource_id=? AND chunk_id=?", + (r["resource_type"], r["resource_id"], r["chunk_id"]), + ).fetchone() + s = cosine(qvec, row["embedding"]) if row else 0.0 + if s > 0: + scored.append({ + "resource_type": r["resource_type"], + "resource_id": r["resource_id"], + "chunk_id": r["chunk_id"], + "chunk_text": r["chunk_text"], + "score": s, + }) + scored.sort(key=lambda d: d["score"], reverse=True) + return scored[:limit] + + +# ── hybrid (lexical + vector, RRF) + ACL ─────────────────────────────────── + +def _rrf_fuse(ranked_lists: list[list[tuple[str, int]]], k: int = RRF_K) -> list[tuple[str, int, float]]: + scores: dict[tuple[str, int], float] = {} + for ranked in ranked_lists: + for rank, key in enumerate(ranked): + scores[key] = scores.get(key, 0.0) + 1.0 / (k + rank + 1) + fused = [(t, i, s) for (t, i), s in scores.items()] + fused.sort(key=lambda x: x[2], reverse=True) + return fused + + +def hybrid_search(query: str, user: dict, *, limit: int = 20, + workspace_id: int | None = None, + resource_types: tuple[str, ...] = ("page", "collection")) -> tuple[list[dict], int]: + """Lexical + vector fusion, workspace-scoped, ACL-filtered. + + Returns (results, total). Each result: {type, id, title, excerpt, url, score}. + """ + from app.services.permission_manager import PermissionManager + + q = (query or "").strip() + if not q: + return [], 0 + limit = max(1, min(int(limit or 20), 100)) + user_id = user.get("id") + pm = PermissionManager(user_id, bool(user.get("is_admin"))) + + # 1) lexical candidates (already workspace-membership scoped) + from app.services import search as search_service + lex = search_service.search(q, user_id, limit * 3) + lex_ranked: list[tuple[str, int]] = [] + lex_by_key: dict[tuple[str, int], dict] = {} + for item in (lex.get("pages") or []) + (lex.get("collections") or []): + key = (item["type"], item["id"]) + if key not in lex_by_key: + lex_by_key[key] = item + lex_ranked.append(key) + + # 2) vector candidates + vec = vector_search(q, limit=limit * 3, resource_types=resource_types) + vec_ranked = [(d["resource_type"], d["resource_id"]) for d in vec] + + # 3) fuse + fused = _rrf_fuse([lex_ranked, vec_ranked]) + + # 4) ACL + workspace filter, enrich + results: list[dict] = [] + with get_conn() as conn: + for rtype, rid, score in fused: + if rtype == "page": + if not pm.can_view_page(rid): + continue + row = conn.execute( + "SELECT id, title, content, content_format, workspace_id, " + "COALESCE(search_excluded, 0) AS excluded " + "FROM pages WHERE id=?", (rid,)).fetchone() + if not row or row["excluded"]: + continue + if workspace_id and row["workspace_id"] != workspace_id: + continue + excerpt = (lex_by_key.get((rtype, rid), {}).get("excerpt") + or extract_page_text(row["content"], row["content_format"])[:160]) + results.append({"type": "page", "id": rid, + "title": (row["title"] or "Untitled"), + "excerpt": excerpt, "url": f"/pages/{rid}", + "score": round(score, 5)}) + else: + if not pm.can_view_collection(rid): + continue + row = conn.execute( + "SELECT id, name, description, workspace_id FROM collections WHERE id=?", + (rid,)).fetchone() + if not row: + continue + if workspace_id and row["workspace_id"] != workspace_id: + continue + excerpt = (lex_by_key.get((rtype, rid), {}).get("subtitle") + or (row["description"] or "")[:160]) + results.append({"type": "collection", "id": rid, + "title": (row["name"] or "Untitled"), + "excerpt": excerpt, "url": f"/db/{rid}", + "score": round(score, 5)}) + if len(results) >= limit: + break + return results, len(results) + + +# ── Ask AI ───────────────────────────────────────────────────────────────── + +def _check_ask_rate(user_id: int) -> None: + from fastapi import HTTPException + now = time.time() + start, count = _ask_rate.get(user_id, (now, 0)) + if now - start > _ASK_RATE_WINDOW: + _ask_rate[user_id] = (now, 1) + return + if count >= _ASK_RATE_MAX: + raise HTTPException(429, "Too many questions. Slow down.") + _ask_rate[user_id] = (start, count + 1) + + +def _offline_answer(question: str, chunks: list[dict]) -> str: + """Extractive fallback: top sentences sharing query terms + citations.""" + qterms = {t.lower() for t in _tokens(question)} + picked: list[str] = [] + for ch in chunks[:8]: + for sent in re.split(r"(?<=[.!?])\s+", ch["chunk_text"] or ""): + words = {t.lower() for t in _tokens(sent)} + if qterms & words and len(sent.strip()) > 20: + picked.append((sent.strip(), ch)) + if len(picked) >= 4: + break + if len(picked) >= 4: + break + if not picked: + # No lexical overlap: still cite the top vector matches. + lines = [] + for ch in chunks[:3]: + snippet = (ch["chunk_text"] or "")[:200].replace("\n", " ") + lines.append(f"- {snippet} [[fdpage:{ch['resource_id']}]]" + if ch["resource_type"] == "page" else f"- {snippet}") + return ("Je n'ai pas trouvé de passage répondant directement, " + "mais voici les passages les plus proches :\n" + "\n".join(lines)) + lines = [] + for sent, ch in picked: + if ch["resource_type"] == "page": + lines.append(f"- {sent} [[fdpage:{ch['resource_id']}]]") + else: + lines.append(f"- {sent}") + return "Voici ce que j'ai trouvé dans votre workspace :\n" + "\n".join(lines) + + +def _resolve_citations(conn, chunks: list[dict]) -> list[dict]: + seen: list[dict] = [] + done: set[tuple[str, int]] = set() + for ch in chunks: + key = (ch["resource_type"], ch["resource_id"]) + if key in done: + continue + done.add(key) + if ch["resource_type"] == "page": + row = conn.execute("SELECT title FROM pages WHERE id=?", (ch["resource_id"],)).fetchone() + seen.append({"type": "page", "id": ch["resource_id"], + "title": (row["title"] if row else "Deleted page") or "Untitled"}) + else: + row = conn.execute("SELECT name FROM collections WHERE id=?", + (ch["resource_id"],)).fetchone() + seen.append({"type": "collection", "id": ch["resource_id"], + "title": (row["name"] if row else "Deleted") or "Untitled"}) + return seen + + +async def ask(question: str, user: dict, workspace_id: int | None = None) -> dict: + """RAG answer over the user's authorized chunks (LLM or offline fallback).""" + from app.services.permission_manager import PermissionManager + + q = (question or "").strip() + if not q: + from fastapi import HTTPException + raise HTTPException(400, "question is required") + _check_ask_rate(user.get("id") or 0) + cache_key = (hashlib.sha256(q.encode()).hexdigest(), workspace_id, user.get("id")) + now = time.time() + hit = _ask_cache.get(cache_key) + if hit and hit[0] > now: + out = dict(hit[1]) + out["cached"] = True + return out + + pm = PermissionManager(user.get("id"), bool(user.get("is_admin"))) + vec = vector_search(q, limit=24) + allowed = [] + with get_conn() as conn: + for ch in vec: + if ch["resource_type"] == "page": + row = conn.execute( + "SELECT workspace_id, COALESCE(search_excluded, 0) AS excluded " + "FROM pages WHERE id=?", (ch["resource_id"],)).fetchone() + if not row or row["excluded"] or not pm.can_view_page(ch["resource_id"]): + continue + if workspace_id and row["workspace_id"] != workspace_id: + continue + else: + row = conn.execute( + "SELECT workspace_id FROM collections WHERE id=?", + (ch["resource_id"],)).fetchone() + if not row or not pm.can_view_collection(ch["resource_id"]): + continue + if workspace_id and row["workspace_id"] != workspace_id: + continue + allowed.append(ch) + if len(allowed) >= 8: + break + + answer = "" + offline = True + if allowed: + try: + from app.services.llm_client import LLMClient + llm = LLMClient() + if await llm.is_available(): + ctx = "\n\n".join( + f"[doc {i+1} page_id={c['resource_id']}]\n{c['chunk_text'][:1500]}" + for i, c in enumerate(allowed) + ) + resp = await llm.complete([ + {"role": "system", + "content": "Réponds en français en citant les sources avec " + "[[fdpage:ID]] (ID = page_id indiqué). Concis."}, + {"role": "user", "content": f"Question : {q}\n\nContexte :\n{ctx}"}, + ]) + answer = (resp.text or "").strip() + offline = False + except Exception as exc: # noqa: BLE001 — fall back to extractive + logger.debug("ask LLM failed, offline fallback: %s", exc) + if not answer: + answer = ("Aucun contenu accessible ne correspond à votre question." + if not allowed else _offline_answer(q, allowed)) + + with get_conn() as conn: + citations = _resolve_citations(conn, allowed[:8]) + out = {"answer_markdown": answer, "citations": citations, + "offline": offline, "cached": False} + _ask_cache[cache_key] = (now + _ASK_CACHE_TTL, out) + return out + + +def reset_state() -> None: + """Test helper: clear ask cache + rate limiter.""" + _ask_cache.clear() + _ask_rate.clear() diff --git a/app/services/two_factor.py b/app/services/two_factor.py new file mode 100644 index 0000000..b8fcb6f --- /dev/null +++ b/app/services/two_factor.py @@ -0,0 +1,136 @@ +"""FlowDeck — TOTP 2FA + backup codes (v7.2.0). + +Secrets are Fernet-encrypted at rest (same construction as SSO secrets). +Login flow: ``POST /auth/local-login`` returns ``2fa_required`` + a short-lived +signed ``pending`` token; ``POST /auth/local-verify`` exchanges it for a +session. See ``docs/V72_Enterprise_SCIM_2FA.md``. +""" +from __future__ import annotations + +import hashlib +import json +import secrets + +from app.db import get_conn + +BACKUP_CODE_COUNT = 10 + + +def _fernet(): + import base64 + + from cryptography.fernet import Fernet + + from app.config import settings + key = hashlib.sha256((settings.app_secret_key or "flowdeck").encode()).digest() + return Fernet(base64.urlsafe_b64encode(key)) + + +def is_enabled(user_id: int) -> bool: + with get_conn() as conn: + row = conn.execute("SELECT totp_secret_enc FROM users WHERE id=?", + (user_id,)).fetchone() + if not row or not row["totp_secret_enc"]: + return False + try: + return bool(_fernet().decrypt(row["totp_secret_enc"].encode()).decode()) + except Exception: # noqa: BLE001 + return False + + +def setup_secret(user_id: int) -> dict: + """Create a new TOTP secret (not yet active until verified).""" + import pyotp + secret = pyotp.random_base32() + with get_conn() as conn: + row = conn.execute("SELECT login, email FROM users WHERE id=?", (user_id,)).fetchone() + label = (row["email"] or row["login"]) if row else f"user{user_id}" + uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name="FlowDeck") + return {"secret": secret, "otpauth_url": uri} + + +def activate_secret(user_id: int, secret: str, code: str) -> list[str]: + """Verify ``code`` against ``secret``; on success store + return backup codes.""" + import pyotp + if not pyotp.TOTP(secret).verify(code, valid_window=1): + raise ValueError("invalid code") + codes = [secrets.token_hex(4) for _ in range(BACKUP_CODE_COUNT)] + hashes = [hashlib.sha256(c.encode()).hexdigest() for c in codes] + with get_conn() as conn: + conn.execute("UPDATE users SET totp_secret_enc=?, totp_backup_hashes=? WHERE id=?", + (_fernet().encrypt(secret.encode()).decode(), + json.dumps(hashes), user_id)) + conn.commit() + return codes + + +def verify_code(user_id: int, code: str) -> bool: + """Check a TOTP code or consume a backup code.""" + code = (code or "").strip().replace(" ", "") + if not code: + return False + with get_conn() as conn: + row = conn.execute("SELECT totp_secret_enc, totp_backup_hashes FROM users WHERE id=?", + (user_id,)).fetchone() + if not row or not row["totp_secret_enc"]: + return False + try: + secret = _fernet().decrypt(row["totp_secret_enc"].encode()).decode() + except Exception: # noqa: BLE001 + return False + import pyotp + if secret and pyotp.TOTP(secret).verify(code, valid_window=1): + return True + # backup codes (single use) + try: + hashes = json.loads(row["totp_backup_hashes"] or "[]") + except (TypeError, json.JSONDecodeError): + hashes = [] + digest = hashlib.sha256(code.encode()).hexdigest() + if digest in hashes: + hashes.remove(digest) + with get_conn() as conn: + conn.execute("UPDATE users SET totp_backup_hashes=? WHERE id=?", + (json.dumps(hashes), user_id)) + conn.commit() + return True + return False + + +def disable(user_id: int) -> None: + with get_conn() as conn: + conn.execute("UPDATE users SET totp_secret_enc='', totp_backup_hashes='[]'" + " WHERE id=?", (user_id,)) + conn.commit() + + +def remaining_backup_codes(user_id: int) -> int: + with get_conn() as conn: + row = conn.execute("SELECT totp_backup_hashes FROM users WHERE id=?", + (user_id,)).fetchone() + try: + return len(json.loads(row["totp_backup_hashes"] or "[]")) if row else 0 + except (TypeError, json.JSONDecodeError): + return 0 + + +# ── pending 2FA challenge (signed, 5 min) ────────────────────────────────── + +def mint_pending(user_id: int) -> str: + from itsdangerous import URLSafeTimedSerializer + + from app.config import settings + ser = URLSafeTimedSerializer(settings.app_secret_key, salt="totp-pending") + return ser.dumps({"user_id": user_id}) + + +def redeem_pending(token: str, max_age: int = 300) -> int | None: + from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer + + from app.config import settings + ser = URLSafeTimedSerializer(settings.app_secret_key, salt="totp-pending") + try: + payload = ser.loads(token, max_age=max_age) + return int(payload.get("user_id", 0)) or None + except (BadSignature, SignatureExpired, ValueError): + return None diff --git a/app/services/wiki.py b/app/services/wiki.py new file mode 100644 index 0000000..efdd3e3 --- /dev/null +++ b/app/services/wiki.py @@ -0,0 +1,415 @@ +"""FlowDeck — teamspaces, verified pages, collab polish (v7.3.0). + +Design : ``docs/V73_Wiki_Teamspaces_Polish.md``. + +- ``teamspaces`` namespace pages + databases ; ``private=1`` → invisible aux + non-membres (404, comme une collection restricted). +- ``page_verifications`` : badge ✅ avec expiration (90 j par défaut). +- ``page_follows`` → notif ``page.updated`` ; ``comment_reactions`` ; + ``guest_shares`` (``/g/``) ; ``page_views`` (compteurs journaliers). +""" +from __future__ import annotations + +import datetime +import secrets + +from app.db import get_conn + +VERIFICATION_DAYS_DEFAULT = 90 +# Roles, strongest first. Mirrors collection roles. +TEAMSPACE_ROLES = ("owner", "editor", "commenter", "viewer") +_ROLE_RANK = {r: i for i, r in enumerate(reversed(TEAMSPACE_ROLES))} + + +def _utcnow() -> datetime.datetime: + return datetime.datetime.now(datetime.UTC) + + +def _iso(dt: datetime.datetime) -> str: + return dt.replace(microsecond=0).isoformat() + + +# ── teamspaces ───────────────────────────────────────────────────────────── + +def get_teamspace_role(user_id: int | None, teamspace_id: int) -> str | None: + """Explicit role, else workspace role, else ``None`` when unreachable.""" + if not user_id: + return None + with get_conn() as conn: + ts = conn.execute("SELECT workspace_id, private FROM teamspaces WHERE id=?", + (teamspace_id,)).fetchone() + if not ts: + return None + row = conn.execute("SELECT role FROM teamspace_members WHERE teamspace_id=? AND user_id=?", + (teamspace_id, user_id)).fetchone() + if row: + return row["role"] + admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone() + if admin and admin["is_admin"]: + return "owner" + owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", + (ts["workspace_id"],)).fetchone() + if owner and owner["owner_id"] == user_id: + return "owner" + if not ts["private"]: + # a public teamspace is still workspace-scoped: no workspace + # membership means no access (otherwise any logged-in account on + # the instance could read every public teamspace). + member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", + (ts["workspace_id"], user_id)).fetchone() + return member["role"] if member else None + return None + + +def can_read_teamspace(user_id: int | None, teamspace_id: int) -> bool: + return get_teamspace_role(user_id, teamspace_id) is not None + + +def can_write_teamspace(user_id: int | None, teamspace_id: int) -> bool: + role = get_teamspace_role(user_id, teamspace_id) + return role in ("owner", "editor") + + +def list_teamspaces(user_id: int, workspace_id: int | None = None) -> list[dict]: + """Teamspaces the user can see (private ones filtered out). + + ``workspace_id=None`` lists across every workspace (global sidebar). + """ + with get_conn() as conn: + if workspace_id is not None: + ws_member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", + (workspace_id, user_id)).fetchone() + if not ws_member: + owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", + (workspace_id,)).fetchone() + admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone() + if not owner or owner["owner_id"] != user_id: + if not (admin and admin["is_admin"]): + return [] + rows = conn.execute("SELECT * FROM teamspaces WHERE workspace_id=? ORDER BY name", + (workspace_id,)).fetchall() + else: + rows = conn.execute("SELECT * FROM teamspaces ORDER BY workspace_id, name").fetchall() + out = [] + for r in rows: + role = get_teamspace_role(user_id, r["id"]) + if role is None: + continue + counts = conn.execute( + """SELECT (SELECT COUNT(*) FROM pages WHERE teamspace_id=?) + + (SELECT COUNT(*) FROM collections WHERE teamspace_id=?) AS n""", + (r["id"], r["id"])).fetchone() + item = dict(r) + item["role"] = role + item["workspace_name"] = _workspace_name(conn, r["workspace_id"]) + item["item_count"] = counts["n"] + out.append(item) + return out + + +def _workspace_name(conn, workspace_id: int) -> str: + row = conn.execute("SELECT name FROM workspaces WHERE id=?", (workspace_id,)).fetchone() + return row["name"] if row else "" + + +def teamspace_pages(teamspace_id: int) -> list[dict]: + """Non-deleted pages belonging to a teamspace (title/URL order).""" + with get_conn() as conn: + return [dict(r) for r in conn.execute( + """SELECT id, title, workspace_id, teamspace_id + FROM pages WHERE teamspace_id=? AND deleted_at IS NULL + ORDER BY title""", (teamspace_id,)).fetchall()] + + +def teamspace_collections(teamspace_id: int) -> list[dict]: + """Databases belonging to a teamspace.""" + with get_conn() as conn: + return [dict(r) for r in conn.execute( + """SELECT id, name, icon FROM collections + WHERE teamspace_id=? ORDER BY name""", (teamspace_id,)).fetchall()] + + +def create_teamspace(workspace_id: int, name: str, user_id: int, + description: str = "", private: bool = False) -> int: + with get_conn() as conn: + try: + cur = conn.execute( + "INSERT INTO teamspaces (workspace_id, name, description, private, created_by)" + " VALUES (?,?,?,?,?)", + (workspace_id, name.strip(), description[:400], 1 if private else 0, user_id)) + except Exception as exc: # UNIQUE(workspace_id, name) + if "UNIQUE" in str(exc): + raise ValueError("A teamspace with this name already exists") from None + raise + conn.commit() + # the creator is owner + conn.execute("INSERT INTO teamspace_members (teamspace_id, user_id, role)" + " VALUES (?,?,'owner')", (cur.lastrowid, user_id)) + conn.commit() + return cur.lastrowid + + +def teamspace_member_ids(teamspace_id: int) -> list[int]: + with get_conn() as conn: + return [r["user_id"] for r in conn.execute( + "SELECT user_id FROM teamspace_members WHERE teamspace_id=?", + (teamspace_id,)).fetchall()] + + +# ── verified pages ───────────────────────────────────────────────────────── + +def is_expired(row) -> bool: + if not row or not row["expires_at"]: + return False + try: + return _utcnow() > datetime.datetime.fromisoformat(row["expires_at"]) + except ValueError: + return False + + +def verify_page(page_id: int, user_id: int, days: int = VERIFICATION_DAYS_DEFAULT, + note: str = "") -> dict: + days = max(1, min(int(days or VERIFICATION_DAYS_DEFAULT), 365)) + expires = _iso(_utcnow() + datetime.timedelta(days=days)) + with get_conn() as conn: + conn.execute( + """INSERT INTO page_verifications (page_id, verified_by, note, expires_at) + VALUES (?,?,?,?) + ON CONFLICT(page_id) DO UPDATE SET + verified_by=excluded.verified_by, note=excluded.note, + verified_at=CURRENT_TIMESTAMP, expires_at=excluded.expires_at""", + (page_id, user_id, note[:400], expires)) + conn.commit() + return verification(page_id) + + +def verification(page_id: int) -> dict | None: + with get_conn() as conn: + row = conn.execute( + """SELECT v.*, u.full_name, u.login FROM page_verifications v + LEFT JOIN users u ON u.id = v.verified_by WHERE v.page_id=?""", + (page_id,)).fetchone() + if not row: + return None + d = dict(row) + d["expired"] = is_expired(row) + d["active"] = not d["expired"] + return d + + +def unverify_page(page_id: int) -> bool: + with get_conn() as conn: + cur = conn.execute("DELETE FROM page_verifications WHERE page_id=?", (page_id,)) + conn.commit() + return bool(cur.rowcount) + + +def expiring_verifications(days: int = 7) -> list[dict]: + """Verifications expiring within ``days`` (drives the owner notification). + + ``pages`` has no owner column in FlowDeck, so the reminder targets the user + who performed the verification. + """ + horizon = _iso(_utcnow() + datetime.timedelta(days=days)) + with get_conn() as conn: + rows = conn.execute( + """SELECT v.*, p.title, v.verified_by AS owner_id FROM page_verifications v + JOIN pages p ON p.id = v.page_id + WHERE v.expires_at IS NOT NULL AND v.expires_at <= ?""", + (horizon,)).fetchall() + return [dict(r) for r in rows] + + +# ── follows ──────────────────────────────────────────────────────────────── + +def is_following(page_id: int, user_id: int) -> bool: + with get_conn() as conn: + return bool(conn.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?", + (page_id, user_id)).fetchone()) + + +def toggle_follow(page_id: int, user_id: int) -> bool: + """Returns the new state (True = now following).""" + with get_conn() as conn: + if conn.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?", + (page_id, user_id)).fetchone(): + conn.execute("DELETE FROM page_follows WHERE page_id=? AND user_id=?", + (page_id, user_id)) + conn.commit() + return False + conn.execute("INSERT OR IGNORE INTO page_follows (page_id, user_id) VALUES (?,?)", + (page_id, user_id)) + conn.commit() + return True + + +def ensure_follow(page_id: int, user_id: int, conn=None) -> bool: + """Follow unless already following. Returns True when newly followed.""" + if not user_id: + return False + + def _run(c): + if c.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?", + (page_id, user_id)).fetchone(): + return False + c.execute("INSERT OR IGNORE INTO page_follows (page_id, user_id) VALUES (?,?)", + (page_id, user_id)) + return True + + if conn is not None: + added = _run(conn) + conn.commit() + return added + with get_conn() as _c: + added = _run(_c) + _c.commit() + return added + + +def followers(page_id: int) -> list[int]: + with get_conn() as conn: + return [r["user_id"] for r in conn.execute( + "SELECT user_id FROM page_follows WHERE page_id=?", (page_id,)).fetchall()] + + +# Rate-limit window for ``page.updated`` notifications: the block editor +# autosaves every ~1.5 s; without it followers would be spammed per keystroke. +_UPDATE_NOTIF_WINDOW_MIN = 10 + + +def notify_followers_of_page_update(page_id: int, actor_id: int | None, + title: str = "") -> int: + """Create a ``page.updated`` notification for every follower (except the + actor), throttled to one per :data:`_UPDATE_NOTIF_WINDOW_MIN` minutes. + Returns the number of notifications created.""" + if not page_id: + return 0 + from app.services.notifications import create_notification + with get_conn() as conn: + followed = conn.execute("SELECT user_id FROM page_follows WHERE page_id=?", (page_id,)).fetchall() + if not followed: + return 0 + page = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone() + title = (title or (page["title"] if page else "") or "Untitled") + created = 0 + for f in followed: + uid = f["user_id"] + if uid == actor_id: + continue + recent = conn.execute( + """SELECT 1 FROM notifications + WHERE user_id=? AND resource_type='page' AND resource_id=? + AND ntype='page.updated' + AND created_at >= datetime('now', ?)""", + (uid, page_id, f"-{_UPDATE_NOTIF_WINDOW_MIN} minutes")).fetchone() + if recent: + continue + create_notification( + uid, actor_id, "page.updated", + title=f'"{title}" was updated', + message=f'Page "{title}" has been modified', + resource_type="page", resource_id=page_id, + url=f"/pages/{page_id}", conn=conn, commit=False, + ) + created += 1 + conn.commit() + return created + + +# ── comment reactions ────────────────────────────────────────────────────── + +def toggle_reaction(comment_id: int, user_id: int, emoji: str) -> dict: + """Add or remove ``emoji``; returns the aggregated counts for the comment.""" + emoji = (emoji or "").strip()[:16] + with get_conn() as conn: + if not conn.execute("SELECT 1 FROM comments WHERE id=?", (comment_id,)).fetchone(): + raise LookupError("Comment not found") + existing = conn.execute( + "SELECT id FROM comment_reactions WHERE comment_id=? AND user_id=? AND emoji=?", + (comment_id, user_id, emoji)).fetchone() + if existing: + conn.execute("DELETE FROM comment_reactions WHERE id=?", (existing["id"],)) + conn.commit() + else: + conn.execute("INSERT INTO comment_reactions (comment_id, user_id, emoji)" + " VALUES (?,?,?)", (comment_id, user_id, emoji)) + conn.commit() + return reactions(comment_id) + + +def reactions(comment_id: int) -> dict[str, dict]: + with get_conn() as conn: + rows = conn.execute( + """SELECT emoji, COUNT(*) AS n, + GROUP_CONCAT(user_id) AS users + FROM comment_reactions WHERE comment_id=? GROUP BY emoji ORDER BY emoji""", + (comment_id,)).fetchall() + return {r["emoji"]: {"count": r["n"], + "users": [int(u) for u in (r["users"] or "").split(",") if u]} + for r in rows} + + +# ── guest shares ─────────────────────────────────────────────────────────── + +def create_guest_share(page_id: int, email: str, role: str, created_by: int, + days: int | None = 30) -> dict: + if role not in ("viewer", "commenter"): + raise ValueError("role must be viewer or commenter") + token = f"g_{secrets.token_urlsafe(24)}" + expires = _iso(_utcnow() + datetime.timedelta(days=days)) if days else None + with get_conn() as conn: + cur = conn.execute( + """INSERT INTO guest_shares (page_id, email, token, role, created_by, expires_at) + VALUES (?,?,?,?,?,?)""", + (page_id, email[:200], token, role, created_by, expires)) + conn.commit() + return dict(conn.execute("SELECT * FROM guest_shares WHERE id=?", + (cur.lastrowid,)).fetchone()) + + +def resolve_guest_share(token: str) -> dict | None: + """Active share for ``token``, or ``None`` (unknown / revoked / expired).""" + with get_conn() as conn: + row = conn.execute("SELECT * FROM guest_shares WHERE token=?", (token,)).fetchone() + if not row or row["revoked"]: + return None + if row["expires_at"]: + try: + if _utcnow() > datetime.datetime.fromisoformat(row["expires_at"]): + return None + except ValueError: + pass + return dict(row) + + +# ── page views ───────────────────────────────────────────────────────────── + +def record_view(page_id: int, day: str | None = None) -> int: + day = day or _utcnow().strftime("%Y-%m-%d") + with get_conn() as conn: + conn.execute( + """INSERT INTO page_views (page_id, day, views) VALUES (?,?,1) + ON CONFLICT(page_id, day) DO UPDATE SET views = views + 1""", + (page_id, day)) + conn.commit() + row = conn.execute("SELECT views FROM page_views WHERE page_id=? AND day=?", + (page_id, day)).fetchone() + return row["views"] + + +def view_stats(page_id: int, days: int = 30) -> dict: + days = max(1, min(int(days or 30), 365)) + since = (_utcnow() - datetime.timedelta(days=days - 1)).strftime("%Y-%m-%d") + with get_conn() as conn: + rows = conn.execute( + "SELECT day, views FROM page_views WHERE page_id=? AND day>=? ORDER BY day", + (page_id, since)).fetchall() + series = {r["day"]: r["views"] for r in rows} + # fill the gap so charts have no holes + out, cursor = [], _utcnow() - datetime.timedelta(days=days - 1) + for _ in range(days): + key = cursor.strftime("%Y-%m-%d") + out.append({"day": key, "views": series.get(key, 0)}) + cursor += datetime.timedelta(days=1) + return {"page_id": page_id, "days": days, "total": sum(series.values()), + "series": out} diff --git a/app/services/wiki_blocks.py b/app/services/wiki_blocks.py new file mode 100644 index 0000000..a3ec6e5 --- /dev/null +++ b/app/services/wiki_blocks.py @@ -0,0 +1,196 @@ +"""FlowDeck — v7.3.0 blocks: mermaid, equation_inline, progress. + +Server-side rendering so HTML/PDF export embeds real content (the editor +already has Prism + KaTeX client-side). Design §2 of +``docs/V73_Wiki_Teamspaces_Polish.md``. + +- ``mermaid`` : SVG via ``mmdc`` when installed, else a ``
`` fallback
+                    that mermaid.js can still render in the browser.
+- ``equation_inline`` : KaTeX delimiters wrapped in a span (client auto-render);
+                    falls back to readable plaintext.
+- ``progress``    : ``{"rollup_ref": {collection_id, property_id}}`` → percent
+                    bar computed with :class:`RollupEngine`.
+"""
+from __future__ import annotations
+
+import html
+import re
+import shutil
+import subprocess
+import tempfile
+from pathlib import Path
+
+# ── mermaid ────────────────────────────────────────────────────────────────
+
+def mmdc_available() -> bool:
+    return shutil.which("mmdc") is not None
+
+
+def mermaid_to_svg(source: str, timeout: int = 20) -> str | None:
+    """Render mermaid source to an inline SVG, or ``None`` if unavailable.
+
+    Uses the local mermaid-cli (``mmdc``) when present; never raises — callers
+    degrade to the code fallback.
+    """
+    source = (source or "").strip()
+    if not source or not mmdc_available():
+        return None
+    with tempfile.TemporaryDirectory(prefix="fd_mermaid_") as tmp:
+        src = Path(tmp) / "diagram.mmd"
+        out = Path(tmp) / "diagram.svg"
+        src.write_text(source, encoding="utf-8")
+        try:
+            subprocess.run(
+                ["mmdc", "-i", str(src), "-o", str(out), "-b", "transparent"],
+                capture_output=True, timeout=timeout, check=True)
+        except (subprocess.SubprocessError, OSError):
+            return None
+        if not out.exists():
+            return None
+        svg = out.read_text(encoding="utf-8", errors="replace")
+    # strip the XML prolog / doctype so the SVG can be inlined
+    svg = re.sub(r"<\?xml.*?\?>", "", svg, flags=re.S).strip()
+    return svg or None
+
+
+def render_mermaid(source: str) -> str:
+    """Inline SVG when possible, else a mermaid-renderable code block."""
+    svg = mermaid_to_svg(source)
+    if svg:
+        return f'
{svg}
' + return (f'
'
+            f'{html.escape(source or "")}
') + + +# ── equations ────────────────────────────────────────────────────────────── + +# Only characters that are meaningful inside a math expression are kept. Note +# that `<`, `>` and `\` are NOT allowed: they would let the source close the +# KaTeX delimiter early or inject markup into the exported HTML. +_EQUATION_ALLOWED = re.compile(r"[^0-9A-Za-z\s+\-*/^_=!(){}\[\].|,';:]") + + +def sanitize_equation(source: str) -> str: + return _EQUATION_ALLOWED.sub("", source or "").strip() + + +def render_equation(source: str) -> str: + expr = sanitize_equation(source) + if not expr: + return "" + return (f'' + f'\\({html.escape(expr)}\\)') + + +# ── progress ─────────────────────────────────────────────────────────────── + +def _rows_values(collection_id: int) -> list[dict]: + import json as _json + + from app.db import get_conn + with get_conn() as conn: + rows = conn.execute( + "SELECT property_values_json FROM collection_pages WHERE collection_id=?", + (collection_id,)).fetchall() + out = [] + for r in rows: + try: + out.append(_json.loads(r["property_values_json"] or "{}")) + except ValueError: + out.append({}) + return out + + +_DONE_TRUE = (True, 1, "true", "done", "yes", "checked", "✓", "complete", "completed") + + +def _is_done(value) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + return value.strip().lower() in _DONE_TRUE + return False + + +def progress_value(rollup_ref: dict | None) -> dict: + """Resolve ``{collection_id, done_property_id | property_id, func}`` to a + 0..100 percent. + + Aggregates directly over ``collection_pages.property_values_json`` (the + relation-based :class:`RollupEngine` needs a relation chain + a source + page, which a page-embedded progress bar does not have). + """ + from app.db import get_conn + if not rollup_ref or not rollup_ref.get("collection_id"): + return {"percent": None, "done": None, "total": None} + cid = rollup_ref["collection_id"] + values = _rows_values(cid) + total = len(values) + + done_pid = rollup_ref.get("done_property_id") + if done_pid: + key = str(done_pid) + done = sum(1 for vals in values if _is_done(vals.get(key))) + if total: + return {"percent": round(done * 100 / total, 1), "done": done, "total": total} + return {"percent": 0.0, "done": 0, "total": 0} + + pid = rollup_ref.get("property_id") + if not pid or not total: + return {"percent": None, "done": None, "total": total} + key = str(pid) + with get_conn() as conn: + prop = conn.execute("SELECT prop_type FROM collection_properties WHERE id=? AND" + " collection_id=?", (pid, cid)).fetchone() + if not prop: + return {"percent": None, "done": None, "total": total} + func = (rollup_ref.get("func") or "count").lower() + if prop["prop_type"] in ("checkbox", "status", "select"): + done = sum(1 for vals in values if _is_done(vals.get(key))) + elif prop["prop_type"] in ("number", "formula", "rollup"): + nums = [] + for vals in values: + v = vals.get(key) + try: + nums.append(float(v)) + except (TypeError, ValueError): + continue + if not nums: + return {"percent": None, "done": None, "total": total} + done = {"sum": sum, "avg": lambda xs: sum(xs) / len(xs), + "max": max, "min": min}.get(func, len)(nums) + else: + done = sum(1 for vals in values if vals.get(key) not in (None, "", [], {})) + if not total: + return {"percent": 0.0, "done": done, "total": 0} + return {"percent": round(min(float(done) * 100 / total, 100), 1), + "done": done, "total": total} + + +def render_progress(block: dict) -> str: + stats = progress_value(block.get("rollup_ref") or block) + pct = stats["percent"] + label = block.get("label") or "Progress" + if pct is None: + return (f'
' + f'{html.escape(label)}: —
') + detail = f"{stats['done']}/{stats['total']}" if stats.get("total") else "" + return (f'
' + f'
{html.escape(label)}: {pct}%' + f'{(" (" + html.escape(detail) + ")") if detail else ""}
' + f'
') + + +def render_block(block: dict) -> str: + """Dispatch for the three v7.3 block types (used by export + preview API).""" + t = block.get("type") + if t == "mermaid": + return render_mermaid(block.get("content") or block.get("source") or "") + if t == "equation_inline": + return render_equation(block.get("content") or "") + if t == "progress": + return render_progress(block) + return "" diff --git a/app/services/workers.py b/app/services/workers.py new file mode 100644 index 0000000..978dbc0 --- /dev/null +++ b/app/services/workers.py @@ -0,0 +1,234 @@ +"""FlowDeck — Workers lite (v7.0.0). + +Custom Python snippets run on FlowDeck infrastructure: manual, on a cron +schedule, or shared across the team (fork). Parité Notion Workers (07/2026), +sans facturation : un budget journalier secondes/workspace fait office de +« credits dashboard ». + +Sandbox (documenté, best-effort single-process) : +- AST blacklist : ``import os/sys/subprocess/socket``, ``open()``, + ``exec/eval/compile``, attributs dunder. +- Pas de réseau, pas de FS ; builtins restreints (pas de ``__import__``). +- Timeout 30 s (thread + join), budget journalier ``daily_budget_s``. +- Seules API exposées : ``log()``, ``ctx`` (dict), ``result`` (dict out). + +Voir ``docs/V70_Automations_Workers.md``. +""" +from __future__ import annotations + +import ast +import asyncio +import io +import logging +import re +import time +from contextlib import redirect_stdout +from datetime import UTC, datetime + +from app.db import get_conn + +logger = logging.getLogger(__name__) + +RUN_TIMEOUT_S = 30 +MAX_CODE_CHARS = 20_000 +MAX_LOG_CHARS = 10_000 +_SLUG_RE = re.compile(r"^[a-z0-9-]{3,60}$") + +_FORBIDDEN_IMPORTS = {"os", "sys", "subprocess", "socket", "shutil", + "pathlib", "io", "asyncio", "threading", "multiprocessing"} +_FORBIDDEN_CALLS = {"open", "exec", "eval", "compile", "__import__"} + + +class WorkerRejected(ValueError): + """Raised when worker code violates the sandbox policy.""" + + +def validate_code(code: str) -> None: + """AST lint of worker code. Raises WorkerRejected on violation.""" + code = code or "" + if len(code) > MAX_CODE_CHARS: + raise WorkerRejected(f"code too long ({len(code)} > {MAX_CODE_CHARS})") + try: + tree = ast.parse(code) + except SyntaxError as exc: + raise WorkerRejected(f"syntax error: {exc}") from None + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + names = [a.name.split(".")[0] for a in node.names] + if getattr(node, "module", None): + names.append(str(node.module).split(".")[0]) + for name in names: + if name in _FORBIDDEN_IMPORTS: + raise WorkerRejected(f"import forbidden: {name}") + elif isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Name) and func.id in _FORBIDDEN_CALLS: + raise WorkerRejected(f"call forbidden: {func.id}()") + elif isinstance(node, ast.Attribute): + if isinstance(node.attr, str) and node.attr.startswith("__"): + raise WorkerRejected(f"dunder access forbidden: {node.attr}") + + +def _slugify(name: str) -> str: + import unicodedata + slug = unicodedata.normalize("NFKD", name or "").encode("ascii", "ignore").decode("ascii") + slug = re.sub(r"[^\w\s-]", "", slug.lower()) + return re.sub(r"[-\s]+", "-", slug).strip("-") or "worker" + + +def unique_slug(base: str, ignore_id: int | None = None) -> str: + slug, i = _slugify(base)[:60] or "worker", 1 + with get_conn() as conn: + while conn.execute( + "SELECT id FROM workers WHERE slug=? AND id != COALESCE(?, -1)", + (slug, ignore_id)).fetchone(): + i += 1 + slug = f"{_slugify(base)[:55]}-{i}" + return slug + + +_SAFE_BUILTINS = { + "abs": abs, "all": all, "any": any, "bool": bool, "dict": dict, + "enumerate": enumerate, "filter": filter, "float": float, "format": format, + "frozenset": frozenset, "int": int, "len": len, "list": list, "map": map, + "max": max, "min": min, "range": range, "reversed": reversed, "round": round, + "set": set, "sorted": sorted, "str": str, "sum": sum, "tuple": tuple, + "zip": zip, "print": print, "isinstance": isinstance, "type": type, +} + + +def _exec_code(code: str, ctx: dict) -> tuple[dict, str]: + """Run validated code in a thread. Returns (result_dict, logs).""" + logs: list[str] = [] + + def _log(*args) -> None: + logs.append(" ".join(str(a) for a in args)) + + namespace = {"__builtins__": dict(_SAFE_BUILTINS), + "log": _log, "ctx": dict(ctx or {}), "result": {}} + buf = io.StringIO() + with redirect_stdout(buf): + exec(compile(code, "", "exec"), namespace) # noqa: S102 — sandboxed + printed = buf.getvalue() + if printed: + logs.append(printed) + result = namespace.get("result") + return result if isinstance(result, dict) else {}, "\n".join(logs)[:MAX_LOG_CHARS] + + +def daily_usage_s(workspace_id: int | None) -> float: + """CPU seconds consumed today (UTC) by a workspace's workers.""" + day = datetime.now(UTC).strftime("%Y-%m-%d") + with get_conn() as conn: + row = conn.execute( + """SELECT COALESCE(SUM(wr.duration_ms), 0) FROM worker_runs wr + JOIN workers w ON w.id = wr.worker_id + WHERE date(wr.created_at) = date(?) + AND COALESCE(w.workspace_id, -1) = COALESCE(?, -1)""", + (day, workspace_id)).fetchone() + return (row[0] or 0) / 1000.0 + + +def _save_run(worker_id: int, status: str, logs: str, duration_ms: int) -> int: + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO worker_runs (worker_id, status, logs, duration_ms)" + " VALUES (?,?,?,?)", (worker_id, status, logs[:MAX_LOG_CHARS], duration_ms)) + conn.commit() + return cur.lastrowid + + +def run_worker(worker_id: int, ctx: dict | None = None) -> dict: + """Execute a worker synchronously (used by the router + cron loop). + + Returns {status, run_id, duration_ms}. Never raises for user-code errors + (they become ``error`` runs); raises only when the worker is missing or + over budget (caller maps to 404/429). + """ + from fastapi import HTTPException + with get_conn() as conn: + row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone() + if not row: + raise HTTPException(404, "Worker not found") + worker = dict(row) + validate_code(worker.get("code_py") or "") + used = daily_usage_s(worker.get("workspace_id")) + if used >= (worker.get("daily_budget_s") or 60): + _save_run(worker_id, "over_budget", f"daily budget exceeded ({used:.1f}s used)", 0) + raise HTTPException(429, "Worker daily budget exceeded") + + outcome: dict = {} + + def _target() -> None: + try: + result, logs = _exec_code(worker.get("code_py") or "", ctx or {}) + outcome["result"] = result + outcome["logs"] = logs + except Exception as exc: # noqa: BLE001 — user code, recorded + outcome["error"] = f"{type(exc).__name__}: {exc}" + + import threading + started = time.time() + thread = threading.Thread(target=_target, daemon=True) + thread.start() + thread.join(timeout=RUN_TIMEOUT_S) + duration_ms = int((time.time() - started) * 1000) + if thread.is_alive(): + run_id = _save_run(worker_id, "timeout", + f"exceeded {RUN_TIMEOUT_S}s timeout", duration_ms) + return {"status": "timeout", "run_id": run_id, "duration_ms": duration_ms} + if "error" in outcome: + run_id = _save_run(worker_id, "error", outcome["error"], duration_ms) + return {"status": "error", "run_id": run_id, + "duration_ms": duration_ms, "error": outcome["error"]} + run_id = _save_run(worker_id, "ok", outcome.get("logs", ""), duration_ms) + return {"status": "ok", "run_id": run_id, "duration_ms": duration_ms, + "result": outcome.get("result", {})} + + +async def run_due_workers() -> int: + """Fire workers whose ``schedule_cron`` is due (called from the 60s loop).""" + from app.services.automations import cron_due + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM workers WHERE schedule_cron IS NOT NULL AND schedule_cron != ''" + ).fetchall() + fired = 0 + for row in rows: + worker = dict(row) + with get_conn() as conn: + last = conn.execute( + "SELECT MAX(created_at) FROM worker_runs WHERE worker_id=?", + (worker["id"],)).fetchone()[0] + try: + if cron_due(worker["schedule_cron"] or "", last): + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, run_worker, worker["id"], {}) + fired += 1 + except Exception as exc: # noqa: BLE001 — one worker must not kill the loop + logger.debug("worker %s cron failed: %s", worker["id"], exc) + return fired + + +def fork_worker(worker_id: int, user_id: int) -> dict: + """Duplicate a shared worker for another user (Notion-style sharing).""" + with get_conn() as conn: + row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone() + if not row: + from fastapi import HTTPException + raise HTTPException(404, "Worker not found") + src = dict(row) + if not src.get("shared") and src.get("created_by") != user_id: + from fastapi import HTTPException + raise HTTPException(403, "Worker is not shared") + slug = unique_slug(f"{src['slug']}-fork") + with get_conn() as conn: + cur = conn.execute( + """INSERT INTO workers (slug, workspace_id, name, code_py, schedule_cron, + shared, daily_budget_s, created_by) + VALUES (?,?,?,?,?,?,?,?)""", + (slug, src["workspace_id"], f"{src['name']} (fork)", src["code_py"], + "", 0, src["daily_budget_s"], user_id)) + conn.commit() + new_id = cur.lastrowid + return {"id": new_id, "slug": slug, "status": "forked", "from": worker_id} diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index b522065..865e2ad 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -2393,7 +2393,7 @@ applyAIBlocks(text){ if(this.saving){ if(cb) cb(); return; } this.sync(); this.saving=true; - const blocksArr=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;}); + const blocksArr=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name','url','title','description','image','site_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;}); // v6.0.0 PWA: hors ligne (ou échec réseau) → file IndexedDB rejouée au retour du réseau const queueOffline=()=>{ if(!window.FlowOffline){this.saving=false;return;} diff --git a/app/templates/base.html b/app/templates/base.html index e84c01c..6a56579 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -404,6 +404,33 @@ + + +