// Transport d'ÉCRITURE CalDAV : pose le statut mairie sur l'événement Nextcloud // sans jamais deviner l'URL de la ressource ni écraser le travail d'un·e autre. // // Séquence mesurée sur Framaspace (cf. docs/RECHERCHE.md) : // GET /csrftoken → REPORT (calendar-query par UID) → PUT If-Match. // Le REPORT rend href + ETag + calendar-data : on écrit dans la ressource que le // serveur a désignée, avec l'ETag qu'il vient de donner. // // IMPUR : `fetchImpl` et `extraireReponsesImpl` sont injectés (obligatoires), // même couture que agenda-mairie.js — la boucle est testée, le DOMParser non. // Le module rend un *type* d'erreur, jamais un message. import { URL_COLLECTION } from "./nextcloud.js"; import { appliquerAuxCategories } from "./ics-categories.js"; const URL_JETON = new URL("/csrftoken", URL_COLLECTION).href; const NS_DAV = "DAV:"; const NS_CALDAV = "urn:ietf:params:xml:ns:caldav"; export async function ecrireStatut({ fetchImpl, extraireReponsesImpl, uid, transformer }) { const jeton = await recupererJetonCsrf(fetchImpl); const report = await appeler(fetchImpl, URL_COLLECTION, { method: "REPORT", credentials: "include", headers: enTetes(jeton, { "Content-Type": "application/xml; charset=utf-8", Depth: "1", }), body: requeteParUid(uid), }); if (!report.ok) return report; const reponses = extraireReponsesImpl(report.corps); // Deviner est précisément ce que cette approche refuse : 0 ou plusieurs // candidates → on n'écrit nulle part. if (reponses.length === 0) return { ok: false, erreur: "introuvable" }; if (new Set(reponses.map((r) => r.href)).size > 1) return { ok: false, erreur: "ambigu" }; const { href, etag, ics } = reponses[0]; const reecriture = appliquerAuxCategories(ics, uid, transformer); if (!reecriture.ok) return reecriture; // L'href du multistatus est un chemin : résolu contre la collection. On refuse // toute cible qui sortirait de CETTE collection : le serveur est de confiance, // mais on n'écrit jamais dans une ressource qu'on n'a pas choisie. const cible = resoudre(href); if (!cible) return { ok: false, erreur: "ambigu" }; const put = await appeler(fetchImpl, cible, { method: "PUT", credentials: "include", headers: enTetes(jeton, { "Content-Type": "text/calendar; charset=utf-8", "If-Match": etag, }), body: reecriture.ics, }); if (!put.ok) return put; return { ok: true, categories: reecriture.categories }; } // Extraction DOM du multistatus (IMPUR : DOMParser, absent de Bun → non testé // unitairement, comme extraireCartes). Sélection PAR NAMESPACE : le préfixe est // libre côté serveur. Toute réponse incomplète est ignorée, jamais d'exception. export function extraireReponses(xml) { const doc = new DOMParser().parseFromString(xml, "application/xml"); // DOMParser ne lève JAMAIS : il rend un document . Sans cette // trace, une réponse qui n'est pas du multistatus (page de login HTML, // portail captif) se lirait « événement introuvable » — message faux. if (doc.getElementsByTagName("parsererror").length > 0) { console.error("Écho du Huit : réponse CalDAV illisible (pas du XML).", xml.slice(0, 300)); return []; } const reponses = []; for (const reponse of doc.getElementsByTagNameNS(NS_DAV, "response")) { const href = reponse.getElementsByTagNameNS(NS_DAV, "href")[0]?.textContent; const etag = reponse.getElementsByTagNameNS(NS_DAV, "getetag")[0]?.textContent; const ics = reponse.getElementsByTagNameNS(NS_CALDAV, "calendar-data")[0]?.textContent; // `trim()` : un `calendar-data` réduit à des blancs est *truthy* mais ne // contient aucun événement — le laisser passer, c'est écrire dans le vide. if (!href?.trim() || !etag?.trim() || !ics?.trim()) continue; reponses.push({ href, etag, ics }); } return reponses; } // Échec NON FATAL : le serveur tranchera. Mesuré : DAV accepte l'écriture même // sans jeton valide, mais on l'envoie quand il est disponible. async function recupererJetonCsrf(fetchImpl) { try { const reponse = await fetchImpl(URL_JETON, { credentials: "include" }); if (!reponse.ok) return null; return JSON.parse(await reponse.text()).token ?? null; } catch { return null; } } // `new URL` LÈVE sur un href absolu malformé : ici comme ailleurs, une réponse // serveur bizarre doit produire un refus, jamais une exception. function resoudre(href) { try { const cible = new URL(href, URL_COLLECTION).href; return cible.startsWith(URL_COLLECTION) ? cible : null; } catch { return null; } } function enTetes(jeton, entetes) { return jeton ? { ...entetes, requesttoken: jeton } : entetes; } async function appeler(fetchImpl, url, options) { let reponse; let corps; try { reponse = await fetchImpl(url, options); // La lecture du corps est DANS le try : une connexion qui tombe pendant le // téléchargement fait rejeter `text()`, pas `fetch`. corps = await reponse.text(); } catch { return { ok: false, erreur: "reseau" }; } if (reponse.ok) return { ok: true, corps }; return { ok: false, erreur: typeErreur(reponse.status, corps) }; } function typeErreur(status, corps) { if (status === 412) return "conflit"; if (status === 403) return "lecture-seule"; if (status === 404) return "introuvable"; if (status === 401) return /csrf/i.test(corps) ? "csrf" : "non-connecte"; return "serveur"; } // Les UID du flux sont d'origines diverses : échappement systématique avant // interpolation dans le XML. function requeteParUid(uid) { return ` ${echapperXml(uid)} `; } function echapperXml(texte) { return texte.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); }