fix: A43 TERMINÉ + A38 phase 1 — CSRF rendu côté serveur, helper unique (v7.33.0)
FlowDeck CI / lint (push) Successful in 1m57s
FlowDeck CI / test (push) Successful in 15m20s
FlowDeck CI / docker (push) Canceled after 0s

A43 (les 4 items sont clos) :
- `__CSRF_PLACEHOLDER__` supprimé : ContextVar CSRF_TOKEN posée par
  CSRFMiddleware avant call_next (même mécanique que le nonce CSP),
  global `{{ csrf_token() }}` dans templating, base.html rend
  `{"X-CSRF-Token":{{ csrf_token()|tojson }}}` — vide si cookie absent,
  `htmx:configRequest` re-lit le cookie à chaque appel → plus jamais de
  jeton factice servi.
- Palette : `fetch('/api/search…')` SANS header (GET ∈ SAFE_METHODS →
  CSRF inapplicable) — le JSON.parse du body-attr par frappe disparaît.
- utcnow déprécié = 0 dans app/**.py ; health loggé (A25) + booléens db/
  gitea = raison ; probe réseau = voulu (test de connectivité).

A38 phase 1 (CSRF unifié) :
- `window.getCsrf()` unique dans le <head> de base.html.
- 76 lectures brutes du cookie → getCsrf() dans 13 fichiers (47 formes
  `(…||[])[1]||''`, 25 déclarations `const X = match(…)` avec conversion
  de leurs usages `X?X[1]:''` → `X` (noms collectés PAR FICHIER, jamais
  de règle globale), 4 formes espacées).
- Définitions dupliquées supprimées : card_detail.html (multi-lignes),
  database_table.js (1 ligne dans IIFE → chute sur le global).
- Les 3 variantes de base.html (IIFE getCsrf + 2 getCsrfToken) →
  `return getCsrf()`.
- welcome.html garde sa lecture locale (page autonome sans base) ;
  reste exactement 1 raw dans base.html (la déf head) + 2 dans welcome.

Test : test_csrf_server_rendered_no_placeholder (pas de placeholder +
token du hx-headers == cookie csrf_token).

suite **1092/1092** · ruff OK · node --check vert · docs à jour
This commit is contained in:
2026-10-02 08:45:27 -04:00
parent 0bc74ad728
commit 770fdc2b68
25 changed files with 190 additions and 119 deletions
+16 -16
View File
@@ -300,7 +300,7 @@ window._wsInitData = (function() {
var self = this;
// Soft-delete all selected items
for (var i=0; i<ids.length; i++) {
await fetch('/api/local-workspace/items/' + ids[i], {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
await fetch('/api/local-workspace/items/' + ids[i], {headers: {'X-CSRF-Token': getCsrf()}, method: 'DELETE' });
}
this.clearSelection();
this._reloadAfterAction();
@@ -643,7 +643,7 @@ window._wsInitData = (function() {
var r = await fetch('/board/api/pages/' + node.id + '/icon', {
method: 'POST',
headers: {'Content-Type': 'application/json',
'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''},
'X-CSRF-Token': getCsrf()},
body: JSON.stringify({icon: icon})
});
if (!r.ok) throw new Error('icon update failed');
@@ -725,7 +725,7 @@ window._wsInitData = (function() {
color = color || (store && store.newTagColor) || '#787774';
try {
var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', {
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({name: tagName, color: color})
});
if (r.ok) {
@@ -862,7 +862,7 @@ window._wsInitData = (function() {
if (!newName) return;
try {
var r = await fetch('/api/local-workspace/items/' + id, {
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({name: newName})
});
if (r.ok) {
@@ -1042,7 +1042,7 @@ window._wsInitData = (function() {
this.renamingId = null;
if (!n || n === node.name) return;
var r = await fetch('/api/local-workspace/items/' + node.id, {
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({name: n})
});
if (r.ok) {
@@ -1096,7 +1096,7 @@ window._wsInitData = (function() {
var targetId = this.currentFolder > 0 ? this.currentFolder : null;
this.clipboard.forEach(function(id) {
fetch('/api/local-workspace/items/' + id + '/move', {
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({parent_id: targetId})
}).then(function(r) { if (r.ok) self._reloadAfterAction(); });
});
@@ -1107,7 +1107,7 @@ window._wsInitData = (function() {
// ── Duplicate ──
async duplicateItem(node) {
var r = await fetch('/api/local-workspace/items', {
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({name: node.name + ' (copy)', type: node.type || 'page',
parent_id: this.currentFolder > 0 ? this.currentFolder : null})
});
@@ -1135,7 +1135,7 @@ window._wsInitData = (function() {
this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
this.undoVisible = true;
// Delete via API
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, {headers: {'X-CSRF-Token': getCsrf()}, method:'DELETE' });
if (!r.ok) { this.undoVisible = false; return; }
// Reload from API for reliability (sidebar refresh handled by _reloadAfterAction)
this._reloadAfterAction();
@@ -1159,7 +1159,7 @@ window._wsInitData = (function() {
self._reloadAfterAction();
return;
}
fetch('/api/local-workspace/items/' + ids[i] + '/restore', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST' })
fetch('/api/local-workspace/items/' + ids[i] + '/restore', {headers: {'X-CSRF-Token': getCsrf()}, method: 'POST' })
.finally(function() { restoreOne(i + 1); });
}
restoreOne(0);
@@ -1455,7 +1455,7 @@ window._wsInitData = (function() {
if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
var r = await fetch('/api/local-workspace/items', {
method:'POST',
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body:JSON.stringify(body)
});
if (r.ok) {
@@ -1539,7 +1539,7 @@ window._wsInitData = (function() {
if (!n||!this.target) return;
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {
method:'PUT',
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body:JSON.stringify({name:n})
});
if (r.ok) { this._reloadAfterAction(); }
@@ -1552,7 +1552,7 @@ window._wsInitData = (function() {
async doDelete() {
if (!this.target) return;
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {headers: {'X-CSRF-Token': getCsrf()}, method:'DELETE' });
if (r.ok) { this._reloadAfterAction(); }
},
@@ -1739,7 +1739,7 @@ window._wsInitData = (function() {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/move', {
method: 'PUT',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (r.ok) {
@@ -1834,7 +1834,7 @@ window._wsInitData = (function() {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags', {
method: 'POST',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
body: JSON.stringify({name: tagName})
});
if (r.ok) {
@@ -1853,7 +1853,7 @@ window._wsInitData = (function() {
async removeTag(itemId, tagId) {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {headers: {'X-CSRF-Token': getCsrf()},
method: 'DELETE'
});
if (r.ok) {
@@ -1999,7 +1999,7 @@ window._wsInitData = (function() {
try {
await fetch('/api/local-workspace/items/' + ids[i] + '/move', {
method: 'PUT',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({ parent_id: parentId || null })
});
} catch { /* volontaire */ }