fix: logout servi « hors ligne » par le SW + drag&drop upload en 403 CSRF (v7.45.1)
FlowDeck CI / docker (push) Successful in 1m51s
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / test (push) Successful in 15m11s

- SW : le fetch event de navigation arrive en redirect:'manual' → toute 302
  du serveur (logout → /auth/login) se lisait opaqueredirect (status 0) →
  « bad status » → page hors ligne. networkFirst rejoue la requête en
  redirect:'follow' et sert une 302 synthétique vers l'URL finale (Chromium
  refuse une response 'redirected' servie à une navigation → ERR_FAILED).
  timeoutFetch annule désormais réellement (AbortController branchée).
- Local workspace : _doUpload (upload/upload-folder) et toggleFavorite
  n'envoyaient pas X-CSRF-Token → 403 systématique depuis A19 (derniers
  appels mutants du front, balayage complet refait).
- Porte : e2e/regression_logout_dnd.spec.js (2 tests verts, joués sur
  l'image rebuildée) · pytest 1094 passed · ruff OK · OpenAPI 7.45.1.
This commit is contained in:
2026-10-02 17:41:07 -04:00
parent 6d7af3fb64
commit c20aeaada1
9 changed files with 157 additions and 12 deletions
+2 -2
View File
@@ -628,7 +628,7 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
if (!node) return;
try {
var method = node.favorited ? 'DELETE' : 'POST';
var r = await fetch('/board/api/favorites/' + node.id, { method: method });
var r = await fetch('/board/api/favorites/' + node.id, { method: method, headers: {'X-CSRF-Token': getCsrf()} });
if (r.ok) {
node.favorited = !node.favorited;
if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
@@ -1763,7 +1763,7 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
var url = folderHandled ? '/api/local-workspace/upload-folder' : '/api/local-workspace/upload';
try {
var r = await fetch(url, { method: 'POST', body: formData });
var r = await fetch(url, { method: 'POST', headers: {'X-CSRF-Token': getCsrf()}, body: formData });
this.uploadProgress = 80;
var d = await r.json();
if (r.ok) {
+18 -5
View File
@@ -156,9 +156,22 @@ async function cacheFirst(request, { cacheName = CACHE_NAME } = {}) {
async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell = false, timeoutMs = 4000 } = {}) {
const cache = await caches.open(cacheName);
try {
const res = await timeoutFetch(request, timeoutMs);
let res = await timeoutFetch(request, timeoutMs);
// Les navigations interceptées arrivent en redirect:'manual' : une 302 du
// serveur se lit opaqueredirect (status 0) → on rejoue la requête avec suivi
// explicite. Sans ça, tout logout / redirection rendait la page hors ligne.
if (res.type === 'opaqueredirect') {
// Navigation en redirect:'manual' : on suit la redirection à la main.
res = await timeoutFetch(new Request(request, { redirect: 'follow' }), timeoutMs);
if (res && res.ok && res.redirected) {
// Chromium refuse une response 'redirected' servie à une navigation
// (ERR_FAILED) → on émet une vraie redirection vers l'URL finale.
return new Response(null, { status: 302, headers: { Location: res.url } });
}
}
if (res && res.ok) {
cache.put(request, res.clone());
// Ne pas mettre en cache une réponse de redirection sous l'URL d'origine.
if (!res.redirected) cache.put(request, res.clone());
return res;
}
throw new Error('bad status');
@@ -176,12 +189,12 @@ async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell =
function timeoutFetch(request, ms) {
return new Promise((resolve, reject) => {
const ctrl = new AbortController();
const timer = setTimeout(() => {
const controller = new AbortController();
controller.abort();
ctrl.abort();
reject(new Error('timeout'));
}, ms);
fetch(request).then(
fetch(request, { signal: ctrl.signal }).then(
(res) => { clearTimeout(timer); resolve(res); },
(err) => { clearTimeout(timer); reject(err); }
);