/**
* Planification — Extension navigateur EasyVista (Canton de Vaud / DGNSI)
*
* Service worker (Manifest V3) : récupération session EV, fetch planning XML,
* fetch fiches détaillées, gestion cache.
*
* Copyright (c) 2026 FroSteel
* Licensed under the MIT License — see LICENSE file in the project root.
*
* @author FroSteel
* @repository https://gitea.netaplaid.ch/FroSteel/Planification
*
* Développé par FroSteel (FRS) — DGNSI, Canton de Vaud. Licence MIT.
*/
// background.js — Service worker (Manifest V3) — v4
//
// v2026.5.46 — fiabilisation de la détection de session (faux
// « déconnecté ») : le cookie PHPSESSID suffit sans onglet EV ouvert
// (permission cookies), contre-vérification session_time avant de classer
// un statut HTTP en « session expirée », cache session réactivé (200 ms).
//
// Rôles :
// 1. Au clic sur l'icône : ouvrir le viewer
// 2. Répondre aux messages du viewer :
// - getSession : trouve l'onglet EasyVista ouvert, renvoie {phpsessid, origin}
// - fetchPlanning : fetch le XML du planning pour une date (1 requête = tout)
// - fetchXhr2 : fetch un texte d'action détaillé (utilisé en lazy-load au survol)
// - fetchFiche : fetch une fiche individuelle (HTML) pour statut + commentaire tech
// 3. Nettoyer les vieux caches (>7 jours)
// (v4.2 : l'auto-refresh 12h/15h a été retiré)
//
// v4 : suppression de fetchTimeline (pu utilisé). Le calendar_block contient
// directement ref/contact/lieu/catégorie dans ses attributs attr1/attr2/attr3,
// donc on n'a plus besoin ni de xhr2 en masse, ni de l'API timeline.
// ============================================================================
// v2026.5.38 : Observabilité — logger unifié + handlers globaux SW
// ============================================================================
// Le service worker MV3 est endormi/relancé fréquemment, donc important d'avoir
// un format de log compact et reproductible. Les handlers `error` et
// `unhandledrejection` sur `self` capturent ce qui passe à travers les
// try/catch (ex: une promise oubliée dans un setTimeout).
// ============================================================================
// Clef chrome.storage.local pour le mode debug — toggle depuis le panel admin
// du viewer. Le viewer envoie un message {type:"setDebugLogs"} qu'on traite
// plus bas pour sync, et on lit aussi au boot.
const DEBUG_LOGS_KEY = "debug_logs";
const LOG = (() => {
let _version = "?";
try {
if (chrome && chrome.runtime && chrome.runtime.getManifest) {
_version = chrome.runtime.getManifest().version || "?";
}
} catch (e) {}
let _debug = false;
// Lecture initiale (asynchrone, mais on s'en fout : on commence muet
// et qd la valeur arrive on update). Le SW peut être tué/relancé donc
// on relit à chaque démarrage.
try {
chrome.storage.local.get([DEBUG_LOGS_KEY]).then(obj => {
_debug = !!obj[DEBUG_LOGS_KEY];
}).catch(() => {});
} catch (e) {}
const _stamp = () => new Date().toISOString().substring(11, 23);
const _format = (level, prefix, msg, ctx) => {
const head = `[${_stamp()}][v${_version}][bg/${prefix}][${level}]`;
if (ctx !== undefined) return [head, msg, ctx];
return [head, msg];
};
return {
info: (prefix, msg, ctx) => {
if (!_debug) return;
console.log(..._format("INFO", prefix, msg, ctx));
},
warn: (prefix, msg, ctx) => console.warn (..._format("WARN", prefix, msg, ctx)),
error:(prefix, msg, ctx) => console.error(..._format("ERROR", prefix, msg, ctx)),
exception: (prefix, msg, err, extra) => {
const ctx = {
name: err && err.name,
message: err && err.message,
stack: err && err.stack,
extra: extra
};
console.error(..._format("ERROR", prefix, msg, ctx));
},
setDebug: (on) => {
_debug = !!on;
try { chrome.storage.local.set({ [DEBUG_LOGS_KEY]: _debug }); } catch (e) {}
console.log(..._format("INFO", "logger", `mode debug = ${_debug ? "ON" : "OFF"}`));
},
isDebug: () => _debug,
version: () => _version
};
})();
// Si le viewer toggle pendant qu'on est endormi/relancé, on capte le
// changement chrome.storage et on update le flag local.
chrome.storage.onChanged.addListener((changes, area) => {
if (area === "local" && changes[DEBUG_LOGS_KEY]) {
const newVal = !!changes[DEBUG_LOGS_KEY].newValue;
LOG.setDebug(newVal);
}
});
self.addEventListener("error", (event) => {
LOG.exception("global", "uncaught error in service worker",
event.error || new Error(event.message || "unknown"),
{ filename: event.filename, lineno: event.lineno, colno: event.colno });
});
self.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
LOG.exception("global", "unhandled promise rejection in service worker",
reason instanceof Error ? reason : new Error(String(reason)));
});
LOG.info("boot", "service worker démarré", { version: LOG.version() });
// ============================================================================
// v2026.5.41 : Configuration runtime — lue depuis admin_config (chrome.storage.local).
//
// Les domaines EV et le group_id ont des défauts (filet de sécurité 1er install).
// La liste de techniciens, elle, n'a AUCUN défaut : tant que l'utilisateur n'a
// rien coché dans Paramètres → Équipe, l'extension ne fetche aucun planning et
// invite à configurer.
//
// chrome.storage.local survit aux mises à jour d'extension → la sélection de
// l'utilisateur est conservée d'une version à l'autre.
// ============================================================================
const DEFAULT_EV_ORIGINS = [
"https://itsma.etat-de-vaud.ch", // interne DGNSI
"https://itsma.vd.ch" // externe Internet
];
const DEFAULT_GROUP_ID = "191"; // SI-CSS
/**
* Lit admin_config depuis chrome.storage.local. Retourne {} si absent ou
* en cas d'erreur.
*/
async function getAdminConfig() {
try {
const stored = await chrome.storage.local.get("admin_config");
return stored.admin_config || {};
} catch (e) {
LOG.warn("config", "getAdminConfig failed, using defaults", e);
return {};
}
}
/** Origines EV à surveiller, depuis admin_config ou défaut. */
async function getEvOrigins() {
const cfg = await getAdminConfig();
const o = Array.isArray(cfg.evOrigins) ? cfg.evOrigins.filter(Boolean) : [];
return o.length >= 1 ? o : DEFAULT_EV_ORIGINS;
}
/** Group ID effectif (défaut SI-CSS). */
async function getGroupId() {
const cfg = await getAdminConfig();
return cfg.groupId || DEFAULT_GROUP_ID;
}
/**
* Support IDs effectifs (CSV des clés de cfg.team).
* Retourne "" si aucun tech sélectionné — l'appelant doit alors signaler
* à l'utilisateur d'aller configurer son équipe.
*/
async function getSupportIds() {
const cfg = await getAdminConfig();
const ids = Object.keys(cfg.team || {}).filter(Boolean);
return ids.join(",");
}
/**
* Plage horaire d'affichage (heures pleines).
* Lue depuis admin_config (Paramètres → Apparence → Heures de la journée),
* défaut 8h-18h. Utilisée pour les paramètres day_start_hour / day_end_hour /
* begin_hour / end_hour des requêtes EV. Le viewer utilise déjà ces mêmes
* valeurs pour dessiner la timeline (cf. _initDayBoundsFromConfig dans viewer.js).
*/
async function getDayBounds() {
const cfg = await getAdminConfig();
const start = (typeof cfg.dayStart === "number" && cfg.dayStart >= 0 && cfg.dayStart <= 23) ? cfg.dayStart : 8;
const end = (typeof cfg.dayEnd === "number" && cfg.dayEnd > start && cfg.dayEnd <= 24) ? cfg.dayEnd : 18;
return { start, end };
}
// ============================================================================
// Clic sur l'icône → ouvrir le viewer
// ============================================================================
// ============================================================================
// badge "!" clignotant sur l'icône de l'extension quand la session
// EasyVista va expirer. Permet à l'utilisateur de voir l'alerte même si
// l'onglet planning n'est pas au premier plan.
// ============================================================================
let _expirationBlinkInterval = null;
let _expirationBlinkTimeout = null;
function _setExpirationBadge(active, durationMs) {
// Stop l'état précédent
if (_expirationBlinkInterval) {
clearInterval(_expirationBlinkInterval);
_expirationBlinkInterval = null;
}
if (_expirationBlinkTimeout) {
clearTimeout(_expirationBlinkTimeout);
_expirationBlinkTimeout = null;
}
try { chrome.action.setBadgeText({ text: "" }); } catch (e) {}
if (!active) return;
// Couleur de fond rouge pour le "!"
try { chrome.action.setBadgeBackgroundColor({ color: "#d6443d" }); } catch (e) {}
let on = true;
const tick = () => {
try {
chrome.action.setBadgeText({ text: on ? "!" : "" });
} catch (e) {}
on = !on;
};
tick();
_expirationBlinkInterval = setInterval(tick, 700);
if (durationMs && durationMs > 0) {
_expirationBlinkTimeout = setTimeout(() => {
_setExpirationBadge(false);
}, durationMs);
}
}
chrome.action.onClicked.addListener(async () => {
const viewerUrl = chrome.runtime.getURL("viewer.html");
// Si le viewer est déjà ouvert, on focus cet onglet plutôt que d'en ouvrir un autre
const existing = await chrome.tabs.query({ url: viewerUrl + "*" });
if (existing.length > 0) {
await chrome.tabs.update(existing[0].id, { active: true });
await chrome.windows.update(existing[0].windowId, { focused: true });
} else {
await chrome.tabs.create({ url: viewerUrl });
}
});
// ============================================================================
// Trouver l'onglet EasyVista actif et en extraire le PHPSESSID
// ============================================================================
/**
* Trouve l'onglet EasyVista ouvert et récupère phpsessid + origin.
*
* v2026.5.45 (issue #4) : si la permission optionnelle "cookies" est
* accordée, on lit le PHPSESSID directement depuis le cookie HttpOnly du
* domaine EZV — toujours à jour, immune au PHPSESSID périmé qu'on trouve
* dans l'URL des onglets historiques après un relog. Sinon, fallback sur
* l'ancienne logique URL (compat sans la permission).
*
* @author FroSteel
*/
async function _hasCookiesPermission() {
return new Promise(resolve => {
try {
chrome.permissions.contains({ permissions: ["cookies"] }, granted => {
resolve(!!granted);
});
} catch (e) {
resolve(false);
}
});
}
async function _readPhpsessidFromCookie(origin) {
if (!chrome.cookies) return null;
return new Promise(resolve => {
try {
chrome.cookies.get({ url: origin, name: "PHPSESSID" }, c => {
if (!c || !c.value) { resolve(null); return; }
resolve({
value: c.value,
expirationDate: c.expirationDate || null
});
});
} catch (e) {
resolve(null);
}
});
}
// ─── findEasyVistaSession ───────────────────────────────────────────────────
//
// Stratégie unifiée : pour chaque origine EV configurée, on cherche le
// PHPSESSID dans cet ordre :
// 1. cookie HttpOnly (autoritatif, donne aussi expirationDate)
// 2. fallback URL d'un onglet ouvert (?PHPSESSID=…)
// On retourne la première origine qui a un onglet ouvert ET un PHPSESSID.
//
// Anti-rafale : la fonction est appelée par ~16 endroits dans le service
// worker, parfois en parallèle. On cache le résultat 200 ms pour éviter
// le bombardement de chrome.cookies.get + chrome.tabs.query, et de logs
// dupliqués. Le cache est invalidé immédiatement quand un événement
// cookies.onChanged tombe (relog, expiration).
//
// Logs : une seule ligne `LOG.warn` par CHANGEMENT d'état (transition
// cookie↔url, nouveau PHPSESSID, ou perte de session). Pas de log
// répétitif par appel — utiliser `LOG.info` pour les détails internes.
let _sessionCache = null;
let _sessionCacheTs = 0;
// v2026.5.46 — cache anti-rafale RÉACTIVÉ (200 ms). Le soupçon qui
// l'avait fait désactiver (« page de login ~8 Ko au fetch fiche ») venait du
// PHPSESSID périmé lu dans l'URL des onglets historiques — résolu depuis par
// la lecture du cookie (issue #4). 200 ms de fenêtre stale sont inoffensifs
// et évitent le bombardement tabs.query+cookies.get pendant la phase A.
const _SESSION_CACHE_TTL_MS = 200;
let _lastSessionSignature = null;
function _invalidateSessionCache() {
_sessionCache = null;
_sessionCacheTs = 0;
}
async function _findEasyVistaSessionRaw() {
const origins = await getEvOrigins();
const hasCookies = await _hasCookiesPermission();
// v2026.5.46 — avec la permission cookies, le cookie PHPSESSID
// HttpOnly SUFFIT : les fetch partent avec credentials:include, un onglet
// EV ouvert n'est PAS nécessaire. Avant, fermer l'onglet EV faisait
// perdre la session à l'extension (bannière « déconnecté » à tort) alors
// que la session serveur vivait toujours. On PRÉFÈRE toujours une origine
// avec onglet ouvert (fraîcheur SSO), mais on retombe sur « cookie seul ».
let cookieOnly = null;
for (const origin of origins) {
const tabs = await chrome.tabs.query({ url: origin + "/*" });
let phpsessid = null;
let expirationDate = null;
let source = null;
if (hasCookies) {
const c = await _readPhpsessidFromCookie(origin);
if (c && c.value) {
phpsessid = c.value;
expirationDate = c.expirationDate;
source = "cookie";
}
}
if (!tabs.length) {
if (phpsessid && !cookieOnly) {
cookieOnly = { phpsessid, origin, tabId: null, source: "cookie", expirationDate };
}
continue;
}
if (!phpsessid) {
for (const tab of tabs) {
const m = (tab.url || "").match(/[?&]PHPSESSID=([a-zA-Z0-9]+)/);
if (m) {
phpsessid = m[1];
source = "url";
break;
}
}
}
if (phpsessid) {
return { phpsessid, origin, tabId: tabs[0].id, source, expirationDate };
}
}
return cookieOnly;
}
async function findEasyVistaSession() {
const now = Date.now();
if (_sessionCache !== null && now - _sessionCacheTs < _SESSION_CACHE_TTL_MS) {
return _sessionCache;
}
if (_sessionCache === null && _sessionCacheTs > 0 && now - _sessionCacheTs < _SESSION_CACHE_TTL_MS) {
// Cache négatif (pas de session trouvée à l'instant) — même TTL.
return null;
}
const result = await _findEasyVistaSessionRaw();
_sessionCache = result;
_sessionCacheTs = now;
// Log uniquement si l'état change (source, origine, ou PHPSESSID).
const sig = result
? `${result.source}|${result.origin}|${result.phpsessid.slice(0, 8)}`
: "none";
if (sig !== _lastSessionSignature) {
if (result) {
LOG.warn("session",
`PHPSESSID via ${result.source.toUpperCase()} (${result.phpsessid.slice(0, 8)}…) sur ${result.origin}`,
{ source: result.source, origin: result.origin,
expirationDate: result.expirationDate || null });
} else {
LOG.warn("session", "Aucun PHPSESSID disponible (pas d'onglet EZV ouvert ou cookie absent)");
}
_lastSessionSignature = sig;
}
return result;
}
// ============================================================================
// Fetch helpers (s'exécutent dans le contexte du service worker,
// les cookies du domaine sont automatiquement inclus via credentials: include)
// ============================================================================
/**
* Fetch du XML retourné par planning_xhr.php?div=calendar_block.
* Contient les interventions de nos 8 techs pour la date donnée (~40 ko).
*
* Ce n'est PAS le HTML de la page Planning — le serveur ne rend pas les données
* dans le HTML, elles arrivent via cet endpoint AJAX.
*
* @author FroSteel
*/
async function fetchPlanningXml(origin, phpsessid, unixDate) {
// v2026.5.41 : groupId vient de admin_config (défaut SI-CSS).
// techIds vient de admin_config — si vide, on lève une erreur claire pour
// que le viewer affiche "Aucun technicien sélectionné" plutôt qu'un planning vide.
const groupId = await getGroupId();
const techIds = await getSupportIds();
if (!techIds) {
const err = new Error("no_team_configured");
err.kind = "no_team_configured";
throw err;
}
// v2026.5.41 : heures synchronisées avec admin_config (Paramètres → Apparence).
// EV utilise day_end_hour exclusif (la plage rendue va jusqu'à end-1:59),
// donc on envoie end+1 pour que la dernière heure pleine soit incluse.
const { start, end } = await getDayBounds();
const url =
`${origin}/planning_xhr.php` +
`?PHPSESSID=${encodeURIComponent(phpsessid)}` +
`&div=calendar_block` +
`&mode=day` +
`&group_id=${groupId}` +
`&event_name=HelpDesk_PlanningItem` +
`&sql_param=${techIds}` +
`&unix_date=${unixDate}` +
`&start_date_label=Date` +
`&end_date_label=Date` +
`&click_here_label=Ici` +
`&mail_title=mail` +
`&day_start_hour=${start}` +
`&day_end_hour=${end + 1}`;
// v2026.5.38 : on retire les logs verbose à chaque fetch (URL/status/taille).
// En cas de souci, le throw plus bas porte assez d'info pour debug.
const r = await evFetch(url, origin);
if (!r.ok) {
// v4.2 : classifier l'erreur HTTP pour que le viewer affiche le bon
// écran (session expirée vs EV inaccessible).
const err = new Error("HTTP " + r.status);
err.kind = await classifyHttpStatusVerified(r.status, origin, phpsessid);
err.status = r.status;
throw err;
}
return await r.text();
}
/**
* v5.0.9 : wrapper autour de fetch() qui ajoute systématiquement les
* headers de sécurité attendus par EasyVista (Referer, Sec-Fetch-Site,
* X-Requested-With). Sans ces headers, EV renvoie soit un
* (protection CSRF ou session expirée)
* 3. URL de logout : index.php?...&logout=1
* 4. Redirection vers le portail SSO : portail.etat-de-vaud.ch/sso/
* 5. Réponse JSON avec "isLogged": false
*/
function looksLikeLoginPage(text) {
const t = (text || "").substring(0, 3000);
if (!t) return false;
// Pattern 1 : page de login EV classique
if (/customer_login|my\.policy/i.test(t)) return true;
// Pattern 2 : script de redirection (< 500 chars = probablement juste ça)
if (t.length < 500 && /