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.
156 lines
6.1 KiB
JavaScript
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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
}
|