fix: A10 — autoescape Jinja2 activé partout via un Environment partagé
- `app/templating.py` : `ENV = Environment(loader=FileSystemLoader("app/templates"), autoescape=select_autoescape(["html"]))`
- les 29 instantiations `Environment(loader=FileSystemLoader("app/templates"))` (9 routers) remplacées par `env = ENV` — plus aucune interpolation `{{ … }}` servie crue, les `|safe` redeviennent efficaces
- re-tri des `|safe` : `card_detail.html` corps d'issue et commentaires échappés (XSS stocké), placeholder de description sorti du ternaire, `sidebar_config` passé en dict + `|tojson` (échappement `</script>` en contexte script)
- `|safe` conservé sur `right_actions` (HTML fabriqué dans les templates, fiable)
- `ruff check app tests` OK · suite **1016/1016 verts**
This commit is contained in:
@@ -1,5 +1,41 @@
|
|||||||
# Changelog - FlowDeck
|
# Changelog - FlowDeck
|
||||||
|
|
||||||
|
## v7.3.1 (2026-09-30) — Audit sécurité P0 : A1–A10
|
||||||
|
|
||||||
|
> Corrections du bloc critique de l'audit du 2026-09-30 (ROADMAP) : plus aucune
|
||||||
|
> route cookie-auth n'accepte un anonymous, et Jinja2 échappe enfin sa sortie.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A1/A2** — deps `pyotp`/`webauthn`/`cbor2` installées, rebinding de
|
||||||
|
`app.config.settings` supprimé dans `test_v54.py` (isolation rétablie) ;
|
||||||
|
cycle v6.8→v7.3 committé + tag `v7.3.0`
|
||||||
|
- **A3** — `PUT /api/user/password` : 401 sans session + `current_password`
|
||||||
|
exigé ; helper `_require_user_id()` sur profile/password/token/forge ; `/api/user`
|
||||||
|
sorti de la liste CSRF exemptée
|
||||||
|
- **A4** — `POST /api/v1/token` et `POST /api/user/token` : 401 sans session,
|
||||||
|
chemin legacy `user_id=0` supprimé
|
||||||
|
- **A5** — CRUD membres d'espace : session + rôle admin de l'espace (ou admin
|
||||||
|
global), placeholder user créé en `is_admin=0`
|
||||||
|
- **A6** — `_require_view` → 404 / `_require_edit` → 401 sans session (fin du
|
||||||
|
legacy single-user sur les collections)
|
||||||
|
- **A7** — création ET lecture de page → 401 sans session (`PermissionManager`),
|
||||||
|
`/board/api/pages` sorti du CSRF exempt (+ header manquant côté local workspace)
|
||||||
|
- **A8** — seed admin sans mot de passe codé en dur : aléatoire au premier boot
|
||||||
|
(loggé une fois) ou `FLOWDECK_ADMIN_PASSWORD`
|
||||||
|
- **A9** — `.db`/fichiers de test désindexés + `.gitignore`/`.dockerignore`,
|
||||||
|
rotation de `APP_SECRET_KEY`
|
||||||
|
- **A10** — `app/templating.py` : un seul `ENV` avec
|
||||||
|
`autoescape=select_autoescape(["html"])`, 29 instantiations remplacées ;
|
||||||
|
re-tri des `|safe` (corps d'issue + commentaires échappés, `sidebar_config`
|
||||||
|
en `|tojson`)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Client de test connecté par défaut (`_TestSessionAuth` : session + CSRF
|
||||||
|
injectés hors cookie jar) + helper `anon()` sur les 40 tests d'anonymat
|
||||||
|
- Suite complète : **1016 passed / 0 failed** · `ruff check app tests` OK
|
||||||
|
|
||||||
## v7.3.0 (2026-09-29) — Wiki / Teamspaces + Polish (dernière version du cycle v7)
|
## 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
|
> Connaissance vérifiée et finition collaborative : teamspaces, badge ✅ avec
|
||||||
|
|||||||
+2
-2
@@ -1128,7 +1128,7 @@ Quality DB views, Agent IA Palette → Realtime + E
|
|||||||
- [x] **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**.*
|
- [x] **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**.*
|
||||||
- [x] **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**.*
|
- [x] **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**.*
|
||||||
- [x] **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**.*
|
- [x] **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` (`<textarea>{{ content }}</textarea>` + 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**.*
|
- [x] **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` (`<textarea>{{ content }}</textarea>` + 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**.*
|
- [ ] **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
|
### 🟠 P1 — Hautes
|
||||||
@@ -1185,4 +1185,4 @@ Quality DB views, Agent IA Palette → Realtime + E
|
|||||||
→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20.
|
→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20.
|
||||||
|
|
||||||
*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
|
*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
|
||||||
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3`.**
|
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1.**
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# WORKLOAD — FlowDeck Notion Clone
|
# WORKLOAD — FlowDeck Notion Clone
|
||||||
|
|
||||||
> **Début**: 2026-07-08 | **Version**: v7.3.0 (cycle v7 — versions v6.8→v7.3 livrées) | **Statut**: EN COURS 🔄
|
> **Début**: 2026-07-08 | **Version**: v7.3.1 (audit sécurité P0 A1–A10) | **Statut**: EN COURS 🔄
|
||||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||||
|
|
||||||
## Avancement Global
|
## Avancement Global
|
||||||
|
|||||||
+1
-1
@@ -153,7 +153,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.3.0",
|
version="7.3.1",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
+2
-2
@@ -440,8 +440,8 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
|
|||||||
"comments": comments,
|
"comments": comments,
|
||||||
"checklists": checklists,
|
"checklists": checklists,
|
||||||
}
|
}
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
template = env.get_template("card_detail.html")
|
template = env.get_template("card_detail.html")
|
||||||
return HTMLResponse(template.render(**ctx))
|
return HTMLResponse(template.render(**ctx))
|
||||||
|
|
||||||
|
|||||||
+11
-11
@@ -804,7 +804,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
|||||||
"has_active_workspace": has_active_workspace,
|
"has_active_workspace": has_active_workspace,
|
||||||
"app_version": _get_app_version(),
|
"app_version": _get_app_version(),
|
||||||
"local_workspaces": _local_workspaces_for_user(user),
|
"local_workspaces": _local_workspaces_for_user(user),
|
||||||
"sidebar_config": json.dumps(get_sidebar_config_sync(uid))}
|
"sidebar_config": get_sidebar_config_sync(uid)}
|
||||||
|
|
||||||
|
|
||||||
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
||||||
@@ -908,8 +908,8 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
|||||||
@router.get("/library", response_class=HTMLResponse)
|
@router.get("/library", response_class=HTMLResponse)
|
||||||
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||||
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
sidebar = _sidebar_data(request, owner, repo)
|
sidebar = _sidebar_data(request, owner, repo)
|
||||||
# Load all pages for the workspace from DB
|
# Load all pages for the workspace from DB
|
||||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||||
@@ -1084,8 +1084,8 @@ async def permanent_delete(request: Request, page_id: int):
|
|||||||
|
|
||||||
@router.get("/trash", response_class=HTMLResponse)
|
@router.get("/trash", response_class=HTMLResponse)
|
||||||
async def trash_page(request: Request):
|
async def trash_page(request: Request):
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
template = env.get_template("trash.html")
|
template = env.get_template("trash.html")
|
||||||
return template.render(**_sidebar_data(request))
|
return template.render(**_sidebar_data(request))
|
||||||
|
|
||||||
@@ -1213,8 +1213,8 @@ async def get_page_synced_refs(request: Request, page_id: int):
|
|||||||
|
|
||||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||||
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
sidebar = _sidebar_data(request, owner, repo)
|
sidebar = _sidebar_data(request, owner, repo)
|
||||||
template = env.get_template("board.html")
|
template = env.get_template("board.html")
|
||||||
return template.render(request=request, owner=owner, repo=repo, groups=[],
|
return template.render(request=request, owner=owner, repo=repo, groups=[],
|
||||||
@@ -1240,8 +1240,8 @@ async def board_view(
|
|||||||
logger.error("Board view error: %s", e)
|
logger.error("Board view error: %s", e)
|
||||||
cards = []
|
cards = []
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
|
|
||||||
# Dynamic groups from Gitea labels (fallback to hardcoded)
|
# Dynamic groups from Gitea labels (fallback to hardcoded)
|
||||||
group_names = ["Design", "Engineering", "No Team"]
|
group_names = ["Design", "Engineering", "No Team"]
|
||||||
@@ -2047,8 +2047,8 @@ async def view_page(request: Request, page_id: int):
|
|||||||
"""Render a page as HTML, or a file viewer for uploaded files.
|
"""Render a page as HTML, or a file viewer for uploaded files.
|
||||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||||
embed = request.query_params.get("embed") == "1"
|
embed = request.query_params.get("embed") == "1"
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
# v6.0.0: granular page permissions — hide restricted pages (404).
|
# v6.0.0: granular page permissions — hide restricted pages (404).
|
||||||
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
|
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
|
||||||
|
|||||||
+32
-36
@@ -252,10 +252,9 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
|||||||
@router.get("/trash", response_class=HTMLResponse)
|
@router.get("/trash", response_class=HTMLResponse)
|
||||||
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||||
"""Trash page — scoped to workspace if owner/repo provided."""
|
"""Trash page — scoped to workspace if owner/repo provided."""
|
||||||
from jinja2 import Environment, FileSystemLoader
|
|
||||||
|
|
||||||
from app.routers.board import _sidebar_data as board_sidebar
|
from app.routers.board import _sidebar_data as board_sidebar
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
from app.templating import ENV
|
||||||
|
env = ENV
|
||||||
sidebar = board_sidebar(request, owner, repo)
|
sidebar = board_sidebar(request, owner, repo)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||||
@@ -283,10 +282,9 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
|||||||
|
|
||||||
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
||||||
"""
|
"""
|
||||||
from jinja2 import Environment, FileSystemLoader
|
|
||||||
|
|
||||||
from app.routers.board import _sidebar_data as board_sidebar
|
from app.routers.board import _sidebar_data as board_sidebar
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
from app.templating import ENV
|
||||||
|
env = ENV
|
||||||
sidebar = board_sidebar(request, owner, repo)
|
sidebar = board_sidebar(request, owner, repo)
|
||||||
# Pass active workspace for breadcrumb nav menu
|
# Pass active workspace for breadcrumb nav menu
|
||||||
ws_key_ws = f"{owner}/{repo}" if owner and repo else ""
|
ws_key_ws = f"{owner}/{repo}" if owner and repo else ""
|
||||||
@@ -312,10 +310,9 @@ async def view_page_root(request: Request, page_id: int):
|
|||||||
"""Render a Markdown page at root level with workspace context — or file viewer.
|
"""Render a Markdown page at root level with workspace context — or file viewer.
|
||||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||||
embed = request.query_params.get("embed") == "1"
|
embed = request.query_params.get("embed") == "1"
|
||||||
from jinja2 import Environment, FileSystemLoader
|
|
||||||
|
|
||||||
from app.routers.board import _sidebar_data as board_sidebar
|
from app.routers.board import _sidebar_data as board_sidebar
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
from app.templating import ENV
|
||||||
|
env = ENV
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
@@ -437,8 +434,8 @@ async def view_page_root(request: Request, page_id: int):
|
|||||||
@router.get("/accounts", response_class=HTMLResponse)
|
@router.get("/accounts", response_class=HTMLResponse)
|
||||||
async def accounts_page(request: Request):
|
async def accounts_page(request: Request):
|
||||||
"""Account management panel."""
|
"""Account management panel."""
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
sidebar = _sidebar_data(request, [])
|
sidebar = _sidebar_data(request, [])
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -451,8 +448,8 @@ async def accounts_page(request: Request):
|
|||||||
@router.get("/help", response_class=HTMLResponse)
|
@router.get("/help", response_class=HTMLResponse)
|
||||||
async def help_page(request: Request):
|
async def help_page(request: Request):
|
||||||
"""Comprehensive help & documentation page."""
|
"""Comprehensive help & documentation page."""
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
sidebar = _sidebar_data(request, [])
|
sidebar = _sidebar_data(request, [])
|
||||||
# Render via a block-based template so content_html lands in {% block content %}
|
# Render via a block-based template so content_html lands in {% block content %}
|
||||||
block_tpl = env.from_string(
|
block_tpl = env.from_string(
|
||||||
@@ -656,8 +653,8 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&o
|
|||||||
@router.get("/accounts/settings", response_class=HTMLResponse)
|
@router.get("/accounts/settings", response_class=HTMLResponse)
|
||||||
async def settings_page(request: Request):
|
async def settings_page(request: Request):
|
||||||
"""User settings page — profile, forges, tokens."""
|
"""User settings page — profile, forges, tokens."""
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
sidebar = _sidebar_data(request, [])
|
sidebar = _sidebar_data(request, [])
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
if not user:
|
if not user:
|
||||||
@@ -769,14 +766,14 @@ async def dashboard(
|
|||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
if count == 0:
|
if count == 0:
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
template = env.get_template("landing.html")
|
template = env.get_template("landing.html")
|
||||||
return template.render()
|
return template.render()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
template = env.get_template("landing.html")
|
template = env.get_template("landing.html")
|
||||||
return template.render()
|
return template.render()
|
||||||
|
|
||||||
@@ -821,8 +818,8 @@ async def dashboard(
|
|||||||
logger.error("Dashboard error: %s", e)
|
logger.error("Dashboard error: %s", e)
|
||||||
repos = []
|
repos = []
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
sidebar = _sidebar_data(request, repos)
|
sidebar = _sidebar_data(request, repos)
|
||||||
template = env.get_template("dashboard.html")
|
template = env.get_template("dashboard.html")
|
||||||
return template.render(request=request, repos=repos, search=search,
|
return template.render(request=request, repos=repos, search=search,
|
||||||
@@ -834,8 +831,8 @@ async def dashboard(
|
|||||||
@router.get("/workspace", response_class=HTMLResponse)
|
@router.get("/workspace", response_class=HTMLResponse)
|
||||||
async def workspace_page(request: Request):
|
async def workspace_page(request: Request):
|
||||||
"""Unified workspace showing all projects."""
|
"""Unified workspace showing all projects."""
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
sidebar = _sidebar_data(request, [])
|
sidebar = _sidebar_data(request, [])
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
if not user:
|
if not user:
|
||||||
@@ -853,8 +850,8 @@ async def gitea_workspace_page(request: Request):
|
|||||||
"""Gitea workspace — browse repo files."""
|
"""Gitea workspace — browse repo files."""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
sidebar = _sidebar_data(request, [])
|
sidebar = _sidebar_data(request, [])
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
if not user:
|
if not user:
|
||||||
@@ -1030,8 +1027,8 @@ async def local_workspace_page(request: Request, folder: int = None):
|
|||||||
|
|
||||||
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
||||||
"""
|
"""
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
sidebar = _sidebar_data(request, [])
|
sidebar = _sidebar_data(request, [])
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
if not user:
|
if not user:
|
||||||
@@ -1735,8 +1732,8 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
|
|||||||
@router.get("/workspaces", response_class=HTMLResponse)
|
@router.get("/workspaces", response_class=HTMLResponse)
|
||||||
async def workspaces_page(request: Request):
|
async def workspaces_page(request: Request):
|
||||||
"""Workspaces list page."""
|
"""Workspaces list page."""
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
sidebar = _sidebar_data(request, [], include_workspace=False)
|
sidebar = _sidebar_data(request, [], include_workspace=False)
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
if not user:
|
if not user:
|
||||||
@@ -1838,8 +1835,8 @@ async def select_workspace(request: Request, ws_id: int):
|
|||||||
@router.get("/settings", response_class=HTMLResponse)
|
@router.get("/settings", response_class=HTMLResponse)
|
||||||
async def app_settings_page(request: Request):
|
async def app_settings_page(request: Request):
|
||||||
"""Settings & configuration page."""
|
"""Settings & configuration page."""
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
sidebar = _sidebar_data(request, [])
|
sidebar = _sidebar_data(request, [])
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
if not user:
|
if not user:
|
||||||
@@ -2156,9 +2153,8 @@ async def sidebar_workspace_tree(request: Request):
|
|||||||
Called by appState().refreshSidebarTree() after CRUD operations
|
Called by appState().refreshSidebarTree() after CRUD operations
|
||||||
in the main content area to keep the sidebar in sync.
|
in the main content area to keep the sidebar in sync.
|
||||||
"""
|
"""
|
||||||
from jinja2 import Environment, FileSystemLoader
|
|
||||||
|
|
||||||
from app.routers.board import _load_workspace_pages
|
from app.routers.board import _load_workspace_pages
|
||||||
|
from app.templating import ENV
|
||||||
|
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
if not user:
|
if not user:
|
||||||
@@ -2194,7 +2190,7 @@ async def sidebar_workspace_tree(request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Render the tree using the extracted macro
|
# Render the tree using the extracted macro
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
template = env.from_string(
|
template = env.from_string(
|
||||||
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
|
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
|
||||||
"{{ render_workspace_tree(pages) }}"
|
"{{ render_workspace_tree(pages) }}"
|
||||||
@@ -2215,7 +2211,7 @@ async def sidebar_workspace_tree(request: Request):
|
|||||||
@router.get("/p/{slug}", response_class=HTMLResponse)
|
@router.get("/p/{slug}", response_class=HTMLResponse)
|
||||||
async def public_published_page(request: Request, slug: str):
|
async def public_published_page(request: Request, slug: str):
|
||||||
"""Serve a published page at /p/<slug> — no auth required."""
|
"""Serve a published page at /p/<slug> — no auth required."""
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
@@ -2235,7 +2231,7 @@ h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
|
|||||||
)
|
)
|
||||||
|
|
||||||
page = dict(row)
|
page = dict(row)
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
|
|
||||||
# Convert blocks to HTML for rendering
|
# Convert blocks to HTML for rendering
|
||||||
content_html = ""
|
content_html = ""
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ async def import_page(request: Request):
|
|||||||
user = _current_user(request)
|
user = _current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
|
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
return HTMLResponse(content=env.get_template("import.html").render(user=user))
|
return HTMLResponse(content=env.get_template("import.html").render(user=user))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import logging
|
|||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from jinja2 import Environment, FileSystemLoader
|
|
||||||
|
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
from app.templating import ENV
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
|
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
|
||||||
@@ -102,7 +102,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
|
|||||||
</div>"""
|
</div>"""
|
||||||
|
|
||||||
from app.routers.dashboard import _sidebar_data
|
from app.routers.dashboard import _sidebar_data
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
sidebar = _sidebar_data(request, [])
|
sidebar = _sidebar_data(request, [])
|
||||||
block_tpl = env.from_string(
|
block_tpl = env.from_string(
|
||||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||||
|
|||||||
@@ -21,10 +21,9 @@ async def get_notes(request: Request, owner: str, repo: str):
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
content = row["content"] if row else ""
|
content = row["content"] if row else ""
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
|
||||||
|
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
from app.templating import ENV
|
||||||
|
env = ENV
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
template = env.get_template("notes.html")
|
template = env.get_template("notes.html")
|
||||||
return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
|
return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
|
||||||
@@ -45,10 +44,9 @@ async def save_notes(request: Request, owner: str, repo: str):
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
|
||||||
|
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
from app.templating import ENV
|
||||||
|
env = ENV
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
template = env.get_template("notes.html")
|
template = env.get_template("notes.html")
|
||||||
return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
|
return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ async def onboarding_page(request: Request):
|
|||||||
if ws_count > 0:
|
if ws_count > 0:
|
||||||
return RedirectResponse("/workspaces", status_code=302)
|
return RedirectResponse("/workspaces", status_code=302)
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from app.templating import ENV
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
template = env.get_template("welcome.html")
|
template = env.get_template("welcome.html")
|
||||||
return HTMLResponse(content=template.render(
|
return HTMLResponse(content=template.render(
|
||||||
user=user,
|
user=user,
|
||||||
|
|||||||
@@ -228,11 +228,10 @@ async def revoke_extension_device(device_id: int, request: Request):
|
|||||||
|
|
||||||
@router.get("/extensions", response_class=HTMLResponse)
|
@router.get("/extensions", response_class=HTMLResponse)
|
||||||
async def extensions_page(request: Request):
|
async def extensions_page(request: Request):
|
||||||
from jinja2 import Environment, FileSystemLoader
|
|
||||||
|
|
||||||
from app.routers.dashboard import _sidebar_data
|
from app.routers.dashboard import _sidebar_data
|
||||||
|
from app.templating import ENV
|
||||||
|
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
try:
|
try:
|
||||||
sidebar = _sidebar_data(request, [])
|
sidebar = _sidebar_data(request, [])
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+2
-2
@@ -9,13 +9,13 @@ import html
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from jinja2 import Environment, FileSystemLoader
|
|
||||||
|
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
from app.services import wiki
|
from app.services import wiki
|
||||||
from app.services.api_v2_helpers import audit_log
|
from app.services.api_v2_helpers import audit_log
|
||||||
from app.services.notifications import create_notification
|
from app.services.notifications import create_notification
|
||||||
|
from app.templating import ENV
|
||||||
|
|
||||||
router = APIRouter(tags=["wiki"])
|
router = APIRouter(tags=["wiki"])
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ async def teamspace_page(teamspace_id: int, request: Request):
|
|||||||
.ts-row:hover{{background:var(--bg-hover);}}
|
.ts-row:hover{{background:var(--bg-hover);}}
|
||||||
</style>"""
|
</style>"""
|
||||||
from app.routers.dashboard import _sidebar_data
|
from app.routers.dashboard import _sidebar_data
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = ENV
|
||||||
sidebar = _sidebar_data(request, [])
|
sidebar = _sidebar_data(request, [])
|
||||||
block_tpl = env.from_string(
|
block_tpl = env.from_string(
|
||||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||||
|
|||||||
@@ -1370,7 +1370,7 @@
|
|||||||
loadSidebarConfig() {
|
loadSidebarConfig() {
|
||||||
var self = this;
|
var self = this;
|
||||||
try {
|
try {
|
||||||
var raw = {{ sidebar_config|default('{}')|safe }};
|
var raw = {{ sidebar_config|default({})|tojson }};
|
||||||
if (raw && raw.config) {
|
if (raw && raw.config) {
|
||||||
self.sidebarConfig = raw.config;
|
self.sidebarConfig = raw.config;
|
||||||
} else if (typeof raw === 'object') {
|
} else if (typeof raw === 'object') {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
<div style="margin-bottom:16px; padding:12px; background:var(--bg-secondary); border-radius:6px; min-height:60px;"
|
<div style="margin-bottom:16px; padding:12px; background:var(--bg-secondary); border-radius:6px; min-height:60px;"
|
||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
@blur="updateField('body', $event.target.innerHTML)">
|
@blur="updateField('body', $event.target.innerHTML)">
|
||||||
{{ issue.body|safe if issue.body else '<span style="color:var(--text-dim);">Add description...</span>' }}
|
{% if issue.body %}{{ issue.body }}{% else %}<span style="color:var(--text-dim);">Add description...</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Checklists -->
|
<!-- Checklists -->
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
<span class="text-dim" style="font-weight:400;">· {{ comment.created_at[:10] }}</span>
|
<span class="text-dim" style="font-weight:400;">· {{ comment.created_at[:10] }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:13px; color:var(--text-primary); line-height:1.5;">
|
<div style="font-size:13px; color:var(--text-primary); line-height:1.5;">
|
||||||
{{ comment.body|safe }}
|
{{ comment.body }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Environment Jinja2 partagé (A10 : autoescape activé partout).
|
||||||
|
|
||||||
|
Une seule instance au lieu de 29 `Environment(loader=FileSystemLoader(...))`
|
||||||
|
sans autoescape — 326 interpolations `{{ … }}` étaient servies crues et tous
|
||||||
|
les `|safe` du codebase étaient des no-op.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
||||||
|
ENV = Environment(
|
||||||
|
loader=FileSystemLoader("app/templates"),
|
||||||
|
autoescape=select_autoescape(["html"]),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user