Files
echo-du-huit/extension/caldav.js
T
Pierre Martin 058229dce3 feat: write the mairie status back to Nextcloud over CalDAV
REPORT by UID gives href + ETag + calendar-data, then PUT with If-Match: the
resource URL is never guessed and a concurrent edit yields 412 instead of an
overwrite. URL_COLLECTION is derived from URL_CALENDRIER.
2026-08-13 16:50:16 +02:00

156 lines
6.1 KiB
JavaScript

// 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 <parsererror>. 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 `<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="${NS_DAV}" xmlns:c="${NS_CALDAV}">
<d:prop><d:getetag/><c:calendar-data/></d:prop>
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT">
<c:prop-filter name="UID"><c:text-match collation="i;octet">${echapperXml(uid)}</c:text-match></c:prop-filter>
</c:comp-filter></c:comp-filter></c:filter>
</c:calendar-query>`;
}
function echapperXml(texte) {
return texte.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}