From 558d42f4cb259c40e838f59104c102545a69469f Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Sat, 1 Aug 2026 16:48:59 +0200 Subject: [PATCH] Distribuer l'outil comme un binaire Go autonome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serve.py supposait Python installé et une commande tapée dans un terminal depuis le dossier du dépôt. L'objectif ici : poser l'exécutable et son planning dans un dossier synchronisé, et les retrouver sur n'importe quelle machine sans rien installer. Le binaire embarque l'interface, expose les mêmes routes sur le même format, et cherche son projets.json à côté de lui. Compilé avec CGO_ENABLED=0, il ne dépend d'aucune bibliothèque système, pas même de la glibc ; build.sh produit les cinq plateformes depuis une seule machine. Le front est inchangé : les modules ES sont embarqués et servis tels quels, et --dev les sert depuis le disque pour développer sans recompiler. Deux ajustements dictés par le double-clic, où aucun drapeau ne peut être passé : le port se cherche à partir de 8000 s'il est occupé, et le fichier de données est résolu depuis l'exécutable et non depuis le répertoire courant. La décision 3 est rectifiée au passage : elle affirmait à tort que la File System Access API ne fonctionne pas depuis file://. Vérification faite, c'est CORS sur les modules ES qui ferme cette voie, pas la sécurité de l'API. serve.py est supprimé, le binaire l'ayant remplacé en usage réel. Co-Authored-By: Claude Opus 5 --- .gitignore | 12 +- README.md | 84 +++++++--- build.sh | 41 +++++ docs/decisions.md | 71 +++++++- go.mod | 3 + js/storage.js | 4 +- main.go | 402 ++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 352 ++++++++++++++++++++++++++++++++++++++++ serve.py | 230 -------------------------- 9 files changed, 940 insertions(+), 259 deletions(-) create mode 100755 build.sh create mode 100644 go.mod create mode 100644 main.go create mode 100644 main_test.go delete mode 100644 serve.py diff --git a/.gitignore b/.gitignore index 1b8e5a5..7cf3f7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,24 @@ # Ce dépôt ne contient que le code : le planning de travail reste local. # data/exemple.json est versionné, lui, comme jeu de démonstration. data/projets.json +# Le binaire cherche ses données à côté de lui : à la racine, donc, quand on +# le lance depuis le dépôt. +/projets.json -# Sauvegardes horodatées écrites par serve.py à chaque enregistrement. +# Sauvegardes horodatées écrites par le serveur à chaque enregistrement. # Régénérables, et jusqu'à 50 fichiers en rotation : uniquement du bruit ici. data/backups/ +/backups/ # Fichier temporaire de l'écriture atomique du serveur. Normalement renommé # aussitôt, il ne subsiste qu'en cas d'arrêt brutal en cours d'écriture. data/*.tmp +/*.json.tmp + +# Binaires compilés, régénérables par ./build.sh +dist/ # Réglages locaux de Claude Code, propres à la machine. .claude/settings.local.json -__pycache__/ -*.py[cod] .DS_Store diff --git a/README.md b/README.md index 6e09d0f..fd26def 100644 --- a/README.md +++ b/README.md @@ -9,26 +9,63 @@ checklists. Une phase représente plusieurs semaines ou plusieurs mois de travai ## Démarrer +Récupérer le binaire de sa plateforme, le poser dans un dossier avec son planning, et le +lancer — par un double-clic ou en ligne de commande : + ```sh -python3 serve.py +./frise ``` -Puis ouvrir . Aucune installation, aucune dépendance, aucune étape de -compilation — ni côté Python (bibliothèque standard uniquement) ni côté navigateur (JavaScript -vanilla en modules ES natifs). +Le navigateur s'ouvre tout seul. **Rien à installer** : l'interface est embarquée dans +l'exécutable, qui ne dépend d'aucune bibliothèque système — pas même de la glibc. Il tourne tel +quel sur n'importe quelle distribution, et sur une machine sans Python ni Node. + +Par défaut, le planning est le fichier `projets.json` **posé à côté de l'exécutable**. C'est ce +qui permet de déposer les deux dans un dossier synchronisé (Dropbox, Nextcloud, Syncthing) et de +retrouver son outil et ses données sur n'importe quelle machine. + +``` +📁 Mon dossier synchronisé + ├── frise ← l'exécutable + └── projets.json ← le planning +``` Options : ```sh -python3 serve.py --port 9000 # changer le port -python3 serve.py --data data/exemple.json # ouvrir un autre fichier de données +./frise --port 9000 # imposer un port (sinon : le premier libre à partir de 8000) +./frise --data ~/plannings/2027.json # ouvrir un autre fichier +./frise --no-browser # ne pas ouvrir le navigateur ``` -Au premier lancement, le planning est vide. Pour découvrir l'outil sur des données -réalistes : +Au premier lancement, le planning est vide et aucun fichier n'est créé tant qu'on n'a rien +saisi. Pour découvrir l'outil sur des données réalistes, copier `data/exemple.json` en +`projets.json` à côté de l'exécutable. + +### Compiler soi-même ```sh -cp data/exemple.json data/projets.json +./build.sh # les cinq plateformes, dans dist/ +./build.sh local # seulement la machine courante +``` + +Il faut [Go](https://go.dev) 1.22 ou plus récent, et rien d'autre : aucune dépendance externe, +aucun `vendor/`. Les binaires des trois systèmes se produisent depuis une seule machine, sans +intégration continue ni SDK tiers. + +### Développer + +```sh +go run . --dev +``` + +Le mode `--dev` sert `index.html`, `css/` et `js/` **depuis le disque** plutôt que depuis le +binaire : un simple rafraîchissement suffit à voir ses modifications, sans recompiler. Il lit +alors `data/projets.json`, comme avant. + +```sh +go test ./... # le serveur +node --test tests/ # le modèle métier ``` ## Prise en main @@ -131,14 +168,20 @@ Tout tient dans un fichier JSON, `data/projets.json`, décrit dans parlants (`site-web`, `cadrage`) plutôt que des UUID : il reste éditable à la main et lisible dans un diff git. -À chaque sauvegarde, le serveur copie la version précédente dans `data/backups/`, horodatée. +À chaque sauvegarde, le serveur copie la version précédente dans un dossier `backups/` voisin du +planning, horodatée. -Le planning n'est **pas versionné** : `.gitignore` exclut `data/projets.json`, ce dépôt ne contenant +Le planning n'est **pas versionné** : `.gitignore` exclut `projets.json`, ce dépôt ne contenant que le code. Seul `data/exemple.json` y figure, comme jeu de démonstration. -**Pour synchroniser plusieurs machines**, placer le dossier du projet dans un espace synchronisé -(Syncthing, Drive). L'option `--data` ne permet pas de pointer ailleurs : le serveur refuse un -fichier situé hors de son propre dossier. +**Pour synchroniser plusieurs machines**, déposer l'exécutable et son `projets.json` dans un +espace synchronisé. L'option `--data` accepte n'importe quel chemin, le planning peut donc aussi +vivre ailleurs que le binaire. + +Deux frictions propres à ce mode de distribution, qui touchent tout exécutable et pas seulement +celui-ci : le bit d'exécution n'est pas toujours préservé par la synchronisation (`chmod +x frise` +une fois par machine), et un binaire non signé déclenche un avertissement au premier lancement — +SmartScreen sous Windows, Gatekeeper sous macOS. L'outil ne gère pas les conflits — si deux machines modifient le planning en même temps, la dernière écriture l'emporte. Les sauvegardes horodatées de `data/backups/` sont le filet de sécurité. @@ -146,18 +189,21 @@ L'outil ne gère pas les conflits — si deux machines modifient le planning en ## Tests ```sh -node --test +node --test # la logique métier +go test ./... # le serveur ``` -Couvre la logique métier : validation des dates, bornes d'un projet, calcul de dérive, génération des -identifiants, normalisation et filtrage des tags. Aucune dépendance, le lanceur de tests intégré à -Node suffit. +Côté navigateur : validation des dates, bornes d'un projet, calcul de dérive, génération des +identifiants, normalisation et filtrage des tags. Côté serveur : lecture et écriture du planning, +refus des corps invalides, rotation des sauvegardes, écriture atomique, résolution du chemin de +données. Aucune dépendance de part ni d'autre, les lanceurs intégrés à Node et à Go suffisent. ## Organisation du code | Fichier | Rôle | |---|---| -| `serve.py` | Sert les fichiers statiques, expose `GET`/`PUT` sur `/api/data`, écrit les sauvegardes | +| `main.go` | Sert l'interface embarquée, expose `GET`/`PUT` sur `/api/data`, écrit les sauvegardes | +| `build.sh` | Compile les binaires des cinq plateformes dans `dist/` | | `js/model.js` | Données et règles métier : CRUD, validation, bornes, référence, dérive, tags | | `js/storage.js` | Dialogue avec le serveur, sauvegarde debouncée, indicateur d'état | | `js/timeline.js` | Rendu de la frise : échelle, couloirs, barres, jalons, pli/dépli | diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..ebe7ac3 --- /dev/null +++ b/build.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Compile le binaire pour les plateformes usuelles, dans dist/. +# +# Aucune dépendance : CGO_ENABLED=0 produit un exécutable statique, qui ne +# réclame ni glibc ni runtime sur la machine où il atterrit. C'est ce qui +# permet de déposer le binaire dans un dossier synchronisé et de le lancer +# tel quel, quelle que soit la distribution. +# +# ./build.sh toutes les plateformes +# ./build.sh local seulement la machine courante, pour tester vite +set -eu + +cd "$(dirname "$0")" +mkdir -p dist + +# -s -w retirent la table des symboles et les infos de debug : environ 30 % +# de moins à synchroniser à chaque version. +DRAPEAUX='-s -w' + +construire() { + os=$1 + arch=$2 + suffixe=${3:-} + sortie="dist/frise-$os-$arch$suffixe" + CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -ldflags="$DRAPEAUX" -o "$sortie" . + printf '%-28s %s\n' "$sortie" "$(du -h "$sortie" | cut -f1)" +} + +go test ./... + +if [ "${1:-}" = local ]; then + CGO_ENABLED=0 go build -ldflags="$DRAPEAUX" -o dist/frise . + printf '%-28s %s\n' dist/frise "$(du -h dist/frise | cut -f1)" + exit 0 +fi + +construire linux amd64 +construire linux arm64 +construire windows amd64 .exe +construire darwin amd64 # Mac Intel +construire darwin arm64 # Apple Silicon diff --git a/docs/decisions.md b/docs/decisions.md index 1221f60..9c8dbc4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -44,11 +44,20 @@ décision 4). La première approche envisagée était d'écrire directement dans le fichier depuis le navigateur, via `showOpenFilePicker` / `createWritable`, pour se passer de tout back-end. -Sauf que cette API exige un **contexte sécurisé** : elle ne fonctionne pas depuis `file://`. Il -fallait donc de toute façon lancer un serveur statique. Le « zéro back-end » était perdu d'avance, -tout en payant trois prix : limitation aux navigateurs Chromium, re-autorisation du sélecteur de -fichier à chaque session, et maintien d'un chemin de repli export/import pour Firefox et Safari, -doublé d'un miroir `localStorage`. +> **Rectification (1ᵉʳ août 2026).** Le paragraphe suivant affirmait que l'API « ne fonctionne pas +> depuis `file://` ». C'est faux, et la vérification sur Chromium 150 le montre : `file://` **est** +> un contexte sécurisé (`isSecureContext === true`), `showSaveFilePicker` y ouvre bien son +> dialogue, et `localStorage` comme IndexedDB y persistent d'une session à l'autre — un handle +> peut donc y être mémorisé. Ce qui bloque réellement en `file://`, c'est autre chose : les +> **modules ES** et `fetch` y sont interdits par CORS (origine `null`), donc c'est la décision 10 +> qui ferme cette voie, pas la sécurité de l'API. La conclusion ci-dessous — lancer un serveur — +> reste la bonne, mais pour ce motif-là. Voir décision 22. + +Cette API exige un **contexte sécurisé**, et l'on avait cru qu'elle ne fonctionnait pas depuis +`file://`. Il fallait donc de toute façon lancer un serveur statique. Le « zéro back-end » était +perdu d'avance, tout en payant trois prix : limitation aux navigateurs Chromium, re-autorisation du +sélecteur de fichier à chaque session, et maintien d'un chemin de repli export/import pour Firefox +et Safari, doublé d'un miroir `localStorage`. Puisqu'une commande doit être lancée dans tous les cas, autant qu'elle serve à quelque chose. Un serveur de la bibliothèque standard, `GET` et `PUT` sur `/api/data`, tient en une centaine de lignes @@ -132,6 +141,13 @@ compilation obsolète. Node n'est utilisé que pour lancer les tests, via son lanceur intégré (`node --test`), sans aucune dépendance. +**Portée après la décision 22.** Le serveur se compile désormais, mais le front, lui, ne se +compile toujours pas : aucun bundler, aucune transformation, aucun `node_modules`. Les fichiers de +`js/` sont embarqués tels quels dans le binaire et servis tels quels au navigateur — c'est bien la +ligne qu'on lit dans l'éditeur qui s'exécute, et `--dev` les sert directement depuis le disque. +Ce qui était visé ici est donc intact ; ce qui change, c'est seulement la façon dont le serveur est +distribué. + ## 11. Un en-tête à trois bandes, la semaine pour grain le plus fin L'en-tête affichait une seule rangée de graduations dont le contenu changeait selon le @@ -439,3 +455,48 @@ même si l'on ferme le panneau sans rien saisir. Il porte alors le nom « Nouveau projet », se voit sur la frise, et se supprime d'un clic depuis son propre panneau — ce qui reste moins coûteux que d'imposer un dialogue à tout le monde à chaque création. + +## 22. Un binaire Go plutôt qu'un script Python + +`serve.py` supposait Python installé et une commande tapée dans un terminal, depuis le dossier du +dépôt. C'est une friction quotidienne pour un outil qu'on veut ouvrir d'un geste, et un obstacle +net sous Windows, où Python est absent par défaut. + +L'objectif visé : **poser l'outil et son planning dans un dossier synchronisé, et les retrouver +sur n'importe quelle machine**. Trois voies menaient là. + +**Un `index.html` autonome en `file://`.** Séduisante — zéro exécutable, longévité maximale — mais +elle impose de renoncer aux modules ES, bloqués par CORS depuis `file://` (voir la rectification en +décision 3), donc de concaténer les sources en un fichier unique. Et surtout elle enferme dans +Chromium : Firefox et Safari n'implémentent pas la File System Access API. + +**Tauri.** Accès direct au disque, sans dialogue ni permission, et le code du navigateur reste +intact. Mais un webview système à installer sous Linux (WebKitGTK), un rendu à vérifier sur trois +moteurs différents, une chaîne Rust et une intégration continue multi-OS pour produire les +binaires. C'est contradictoire avec la décision 10 : une chaîne Tauri de 2026 ne se recompilera pas +aussi sûrement dans dix ans qu'un fichier HTML s'ouvrira. + +**Un binaire Go qui contient le serveur et l'interface**, retenu. Il conserve exactement +l'architecture éprouvée — mêmes routes, même fichier JSON, mêmes sauvegardes horodatées, front +inchangé — en supprimant la seule vraie friction, l'installation. `CGO_ENABLED=0` produit un +exécutable statique de 6 Mo qui ne dépend de rien, pas même de la glibc, et les cinq plateformes se +compilent depuis une seule machine sans intégration continue. + +Face à `file://`, il garde **tous** les navigateurs et les modules ES. Face à Tauri, il n'exige +aucun webview et divise l'outillage par dix. Ce qu'il concède : l'interface s'ouvre dans un onglet +plutôt que dans une fenêtre d'application, et il faut un binaire par plateforme. + +Deux choix de détail méritent d'être notés. + +Le port n'est plus fixe : lancé par un double-clic, l'utilisateur n'a aucun moyen de passer +`--port` si 8000 est occupé. Le serveur essaie donc les dix premiers ports libres à partir de 8000. +Un `--port` explicite, lui, n'est jamais contourné — c'est un choix de l'utilisateur. + +Le fichier de données est cherché **à côté de l'exécutable**, pas dans le répertoire courant, qui +est imprévisible lors d'un double-clic. Le mode `--dev` fait exception : il sert l'interface depuis +le disque et lit `data/projets.json`, pour qu'un rafraîchissement suffise à voir ses modifications +sans recompiler. + +`serve.py` a été supprimé une fois le binaire validé en usage réel. Il n'y avait rien à migrer : le +format du fichier et les routes sont identiques, et son historique reste dans git si le besoin de +le relire se présentait. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4e9077d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module frise + +go 1.22 diff --git a/js/storage.js b/js/storage.js index 77062fd..7d31e6d 100644 --- a/js/storage.js +++ b/js/storage.js @@ -89,8 +89,8 @@ export function creerStockage(surEtat = () => {}) { reponse = await fetch(URL_DONNEES, { headers: { Accept: 'application/json' } }); } catch { throw new Error( - "Serveur injoignable. L'application doit être ouverte via « python3 serve.py », " + - 'pas en ouvrant index.html directement.' + "Serveur injoignable. L'application doit être ouverte en lançant l'exécutable " + + '« frise », pas en ouvrant index.html directement.' ); } diff --git a/main.go b/main.go new file mode 100644 index 0000000..ee47891 --- /dev/null +++ b/main.go @@ -0,0 +1,402 @@ +// Serveur local de la frise multi-projets, distribué comme exécutable unique. +// +// GET /api/data -> renvoie le JSON du planning +// PUT /api/data -> le remplace, après copie horodatée dans backups/ +// +// L'interface (index.html, css/, js/) est embarquée dans le binaire. Il n'y a +// donc rien à installer et rien à poser à côté, sinon le fichier de données — +// ce qui permet de déposer l'exécutable et son planning dans un dossier +// synchronisé et de retrouver les deux sur n'importe quelle machine. +// +// Ce n'est volontairement pas un back-end : aucune logique métier ici, le +// serveur lit et écrit un fichier. Les règles sont dans js/model.js. +// Voir docs/decisions.md, sections 3 et 22. +package main + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "sort" + "strings" + "syscall" + "time" +) + +//go:embed index.html css js +var interfaceEmbarquee embed.FS + +// Au-delà, on refuse la requête : un planning macro pèse quelques kilo-octets, +// un corps de cette taille signale forcément une erreur. +const tailleMax = 5 << 20 + +// Nombre de sauvegardes conservées par fichier de données. Les plus anciennes +// sont supprimées pour que backups/ ne grossisse pas indéfiniment. +const backupsConserves = 50 + +// Premier port tenté. En cas d'occupation on essaie les suivants : lancé par +// un double-clic, l'utilisateur n'a aucun moyen de passer --port. +const portInitial = 8000 +const portsTentes = 10 + +var donneesInitiales = []byte(`{"version": 3, "projects": []}`) + +type serveur struct { + fichierDonnees string + interfaceWeb fs.FS +} + +func main() { + port := flag.Int("port", 0, "port d'écoute (défaut : le premier libre à partir de 8000)") + data := flag.String("data", "", "fichier de données (défaut : projets.json à côté de l'exécutable)") + dev := flag.Bool("dev", false, "servir l'interface depuis le disque au lieu du binaire") + sansNavigateur := flag.Bool("no-browser", false, "ne pas ouvrir le navigateur au démarrage") + flag.Parse() + + fichier, err := resoudreFichierDonnees(*data, *dev) + if err != nil { + echouer(err) + } + + srv := &serveur{fichierDonnees: fichier} + if *dev { + // Le répertoire courant, pour recharger le JS et le CSS d'un simple + // rafraîchissement sans recompiler. + srv.interfaceWeb = os.DirFS(".") + } else { + srv.interfaceWeb = interfaceEmbarquee + } + + ecouteur, err := ecouter(*port) + if err != nil { + echouer(err) + } + + url := fmt.Sprintf("http://localhost:%d", ecouteur.Addr().(*net.TCPAddr).Port) + fmt.Printf("Frise multi-projets -> %s\n", url) + fmt.Printf("Données : %s\n", fichier) + fmt.Println("Ctrl+C pour arrêter.") + fmt.Println() + + if !*sansNavigateur { + ouvrirNavigateur(url) + } + + if err := servir(ecouteur, srv.routes()); err != nil { + echouer(err) + } + fmt.Println("Arrêt.") +} + +// --- démarrage ------------------------------------------------------------ + +// resoudreFichierDonnees applique la règle du chemin par défaut : à côté de +// l'exécutable en usage normal, dans data/ quand on développe depuis le dépôt. +func resoudreFichierDonnees(demande string, dev bool) (string, error) { + if demande != "" { + return filepath.Abs(demande) + } + if dev { + return filepath.Abs(filepath.Join("data", "projets.json")) + } + + executable, err := os.Executable() + if err != nil { + return "", fmt.Errorf("impossible de localiser l'exécutable : %w", err) + } + // Un lien symbolique placé dans le PATH ne doit pas faire chercher les + // données à côté du lien, mais à côté du binaire réel. + if resolu, err := filepath.EvalSymlinks(executable); err == nil { + executable = resolu + } + return filepath.Join(filepath.Dir(executable), "projets.json"), nil +} + +// ecouter ouvre le port demandé, ou cherche le premier libre si aucun n'est +// imposé. Un port explicite qui échoue est une erreur : l'utilisateur l'a +// choisi, on ne le contourne pas silencieusement. +func ecouter(port int) (net.Listener, error) { + if port != 0 { + ecouteur, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return nil, fmt.Errorf("impossible d'écouter sur le port %d : %w", port, err) + } + return ecouteur, nil + } + + for essai := 0; essai < portsTentes; essai++ { + ecouteur, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", portInitial+essai)) + if err == nil { + return ecouteur, nil + } + } + return nil, fmt.Errorf( + "aucun port libre entre %d et %d. Un autre exemplaire tourne peut-être déjà ; "+ + "sinon, préciser --port", portInitial, portInitial+portsTentes-1) +} + +// servir écoute jusqu'à Ctrl+C, puis laisse les écritures en cours se terminer. +func servir(ecouteur net.Listener, routes http.Handler) error { + ctx, arreter := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer arreter() + + httpSrv := &http.Server{Handler: routes} + fini := make(chan error, 1) + go func() { fini <- httpSrv.Serve(ecouteur) }() + + select { + case err := <-fini: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + case <-ctx.Done(): + fmt.Println() + extinction, annuler := context.WithTimeout(context.Background(), 5*time.Second) + defer annuler() + return httpSrv.Shutdown(extinction) + } +} + +func ouvrirNavigateur(url string) { + var commande *exec.Cmd + switch runtime.GOOS { + case "windows": + commande = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + case "darwin": + commande = exec.Command("open", url) + default: + commande = exec.Command("xdg-open", url) + } + // Un échec n'est pas bloquant : l'URL est déjà affichée, l'utilisateur peut + // la coller lui-même. C'est le cas sur une machine sans xdg-utils. + _ = commande.Start() +} + +func echouer(err error) { + fmt.Fprintln(os.Stderr, "Erreur :", err) + os.Exit(1) +} + +// --- routes --------------------------------------------------------------- + +func (s *serveur) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/api/data", s.donnees) + mux.Handle("/", http.FileServerFS(s.interfaceWeb)) + return sansCache(mux) +} + +// sansCache s'applique à toutes les routes, et depuis un seul endroit. +// +// Filtrer sur l'extension ne suffirait pas : la page d'accueil est servie sur +// « / », donc elle passerait au travers et le navigateur resservirait un +// index.html périmé après chaque modification. +func sansCache(suivant http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + suivant.ServeHTTP(w, r) + }) +} + +func (s *serveur) donnees(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.lireDonnees(w) + case http.MethodPut: + s.ecrireDonnees(w, r) + default: + w.Header().Set("Allow", "GET, PUT") + erreur(w, http.StatusMethodNotAllowed, "Méthode non autorisée sur /api/data.") + } +} + +func (s *serveur) lireDonnees(w http.ResponseWriter) { + contenu, err := os.ReadFile(s.fichierDonnees) + if errors.Is(err, fs.ErrNotExist) { + // Premier lancement : on répond un planning vide sans rien écrire sur + // le disque. Le fichier naîtra à la première sauvegarde. + envoyer(w, http.StatusOK, donneesInitiales) + return + } + if err != nil { + erreur(w, http.StatusInternalServerError, fmt.Sprintf("Lecture impossible : %v", err)) + return + } + + if !json.Valid(contenu) { + // On refuse de servir un fichier corrompu : l'application ne doit + // jamais démarrer sur des données douteuses. + erreur(w, http.StatusInternalServerError, fmt.Sprintf( + "%s n'est pas un JSON valide. Le corriger à la main ou repartir d'une "+ + "sauvegarde de %s.", filepath.Base(s.fichierDonnees), s.dossierBackups())) + return + } + + envoyer(w, http.StatusOK, contenu) +} + +func (s *serveur) ecrireDonnees(w http.ResponseWriter, r *http.Request) { + if r.ContentLength > tailleMax { + erreur(w, http.StatusRequestEntityTooLarge, "Corps de requête trop volumineux.") + return + } + + corps, err := io.ReadAll(http.MaxBytesReader(w, r.Body, tailleMax)) + if err != nil { + erreur(w, http.StatusRequestEntityTooLarge, "Corps de requête trop volumineux.") + return + } + if len(corps) == 0 { + erreur(w, http.StatusBadRequest, "Corps de requête vide.") + return + } + + // On valide avant d'écrire : mieux vaut refuser une sauvegarde que + // remplacer un planning correct par du JSON cassé. + var racine map[string]json.RawMessage + if err := json.Unmarshal(corps, &racine); err != nil { + erreur(w, http.StatusBadRequest, fmt.Sprintf("JSON invalide : %v", err)) + return + } + if _, ok := racine["projects"]; !ok { + erreur(w, http.StatusBadRequest, "Le JSON doit être un objet contenant « projects ».") + return + } + + // json.Indent plutôt qu'un ré-encodage : il préserve l'ordre des clés + // et les accents littéraux, pour que le fichier reste lisible et que + // deux versions successives se comparent proprement. + var indente bytes.Buffer + if err := json.Indent(&indente, corps, "", " "); err != nil { + erreur(w, http.StatusBadRequest, fmt.Sprintf("JSON invalide : %v", err)) + return + } + indente.WriteByte('\n') + + if err := s.sauvegarderVersionPrecedente(); err != nil { + erreur(w, http.StatusInternalServerError, fmt.Sprintf("Écriture impossible : %v", err)) + return + } + if err := s.ecrireAtomiquement(indente.Bytes()); err != nil { + erreur(w, http.StatusInternalServerError, fmt.Sprintf("Écriture impossible : %v", err)) + return + } + + repondreJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "savedAt": time.Now().UTC().Format(time.RFC3339), + }) +} + +// --- écriture sur disque -------------------------------------------------- + +func (s *serveur) dossierBackups() string { + return filepath.Join(filepath.Dir(s.fichierDonnees), "backups") +} + +// sauvegarderVersionPrecedente copie le fichier actuel dans backups/ avant de +// l'écraser. +func (s *serveur) sauvegarderVersionPrecedente() error { + contenu, err := os.ReadFile(s.fichierDonnees) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + + dossier := s.dossierBackups() + if err := os.MkdirAll(dossier, 0o755); err != nil { + return err + } + + base := strings.TrimSuffix(filepath.Base(s.fichierDonnees), filepath.Ext(s.fichierDonnees)) + marqueur := time.Now().Format("20060102-150405") + cible := filepath.Join(dossier, fmt.Sprintf("%s-%s.json", base, marqueur)) + + // Deux sauvegardes dans la même seconde : on suffixe pour ne pas écraser + // la précédente. + for compteur := 1; existe(cible); compteur++ { + cible = filepath.Join(dossier, fmt.Sprintf("%s-%s-%d.json", base, marqueur, compteur)) + } + + if err := os.WriteFile(cible, contenu, 0o644); err != nil { + return err + } + return elaguerBackups(dossier, base) +} + +// ecrireAtomiquement écrit dans un fichier temporaire puis remplace, pour +// qu'une coupure en cours d'écriture ne laisse jamais un planning tronqué. +func (s *serveur) ecrireAtomiquement(contenu []byte) error { + if err := os.MkdirAll(filepath.Dir(s.fichierDonnees), 0o755); err != nil { + return err + } + // Le temporaire doit partager le dossier de la cible : un rename n'est + // atomique qu'à l'intérieur d'un même système de fichiers. + temporaire := s.fichierDonnees + ".tmp" + if err := os.WriteFile(temporaire, contenu, 0o644); err != nil { + return err + } + if err := os.Rename(temporaire, s.fichierDonnees); err != nil { + os.Remove(temporaire) + return err + } + return nil +} + +func elaguerBackups(dossier, base string) error { + entrees, err := filepath.Glob(filepath.Join(dossier, base+"-*.json")) + if err != nil { + return err + } + // L'horodatage est en tête et de longueur fixe : l'ordre lexicographique + // est l'ordre chronologique. + sort.Strings(entrees) + for i := 0; i < len(entrees)-backupsConserves; i++ { + if err := os.Remove(entrees[i]); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + } + return nil +} + +func existe(chemin string) bool { + _, err := os.Stat(chemin) + return err == nil +} + +// --- réponses ------------------------------------------------------------- + +func envoyer(w http.ResponseWriter, statut int, corps []byte) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(statut) + _, _ = w.Write(corps) +} + +func repondreJSON(w http.ResponseWriter, statut int, charge any) { + corps, err := json.Marshal(charge) + if err != nil { + http.Error(w, "encodage impossible", http.StatusInternalServerError) + return + } + envoyer(w, statut, corps) +} + +func erreur(w http.ResponseWriter, statut int, message string) { + repondreJSON(w, statut, map[string]string{"error": message}) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..0f474d9 --- /dev/null +++ b/main_test.go @@ -0,0 +1,352 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" +) + +// serveurDeTest monte un serveur sur un dossier temporaire. Le fichier de +// données n'existe pas encore : c'est l'état d'un premier lancement. +func serveurDeTest(t *testing.T) (*serveur, http.Handler) { + t.Helper() + dossier := t.TempDir() + s := &serveur{ + fichierDonnees: filepath.Join(dossier, "projets.json"), + interfaceWeb: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("

frise

")}}, + } + return s, s.routes() +} + +func appeler(t *testing.T, routes http.Handler, methode, cible, corps string) *httptest.ResponseRecorder { + t.Helper() + var lecteur *strings.Reader + if corps == "" { + lecteur = strings.NewReader("") + } else { + lecteur = strings.NewReader(corps) + } + req := httptest.NewRequest(methode, cible, lecteur) + rec := httptest.NewRecorder() + routes.ServeHTTP(rec, req) + return rec +} + +func ecrireFichier(t *testing.T, chemin, contenu string) { + t.Helper() + if err := os.WriteFile(chemin, []byte(contenu), 0o644); err != nil { + t.Fatalf("préparation du fichier : %v", err) + } +} + +func lireFichier(t *testing.T, chemin string) string { + t.Helper() + contenu, err := os.ReadFile(chemin) + if err != nil { + t.Fatalf("lecture du fichier : %v", err) + } + return string(contenu) +} + +// --- lecture -------------------------------------------------------------- + +func TestGetSansFichierRenvoiePlanningVide(t *testing.T) { + s, routes := serveurDeTest(t) + + rec := appeler(t, routes, http.MethodGet, "/api/data", "") + + if rec.Code != http.StatusOK { + t.Fatalf("statut = %d, attendu 200", rec.Code) + } + + var planning struct { + Version int `json:"version"` + Projects []any `json:"projects"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &planning); err != nil { + t.Fatalf("réponse illisible : %v", err) + } + // Le format courant est la version 3 (js/model.js, VERSION_FORMAT). + if planning.Version != 3 { + t.Errorf("version = %d, attendu 3", planning.Version) + } + if len(planning.Projects) != 0 { + t.Errorf("projects = %v, attendu vide", planning.Projects) + } + + // Rien ne doit avoir été écrit : le fichier naît à la première sauvegarde. + if existe(s.fichierDonnees) { + t.Error("le fichier de données a été créé alors qu'aucune écriture n'a eu lieu") + } +} + +func TestGetRenvoieLeFichierTelQuel(t *testing.T) { + s, routes := serveurDeTest(t) + contenu := "{\n \"version\": 3,\n \"projects\": [],\n \"note\": \"éàü\"\n}\n" + ecrireFichier(t, s.fichierDonnees, contenu) + + rec := appeler(t, routes, http.MethodGet, "/api/data", "") + + if rec.Code != http.StatusOK { + t.Fatalf("statut = %d, attendu 200", rec.Code) + } + if rec.Body.String() != contenu { + t.Errorf("corps = %q, attendu %q", rec.Body.String(), contenu) + } +} + +func TestGetRefuseUnFichierCorrompu(t *testing.T) { + s, routes := serveurDeTest(t) + ecrireFichier(t, s.fichierDonnees, "{ceci n'est pas du JSON") + + rec := appeler(t, routes, http.MethodGet, "/api/data", "") + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("statut = %d, attendu 500", rec.Code) + } + // Le message doit orienter vers la réparation, pas seulement constater. + if !strings.Contains(rec.Body.String(), "backups") { + t.Errorf("le message n'oriente pas vers les sauvegardes : %s", rec.Body.String()) + } +} + +// --- écriture ------------------------------------------------------------- + +func TestPutEcritLeFichierIndente(t *testing.T) { + s, routes := serveurDeTest(t) + + rec := appeler(t, routes, http.MethodPut, "/api/data", + `{"version":3,"projects":[{"id":"a","name":"Réunion"}]}`) + + if rec.Code != http.StatusOK { + t.Fatalf("statut = %d, attendu 200 (%s)", rec.Code, rec.Body.String()) + } + + ecrit := lireFichier(t, s.fichierDonnees) + if !strings.HasSuffix(ecrit, "}\n") { + t.Errorf("le fichier ne se termine pas par une nouvelle ligne : %q", ecrit) + } + if !strings.Contains(ecrit, "\n \"version\": 3") { + t.Errorf("le fichier n'est pas indenté de deux espaces : %s", ecrit) + } + // L'ordre des clés du client est conservé : « version » avant « projects ». + if strings.Index(ecrit, "version") > strings.Index(ecrit, "projects") { + t.Errorf("l'ordre des clés n'a pas été préservé : %s", ecrit) + } + // Les accents restent littéraux, pour que le fichier se lise à l'œil. + if !strings.Contains(ecrit, "Réunion") { + t.Errorf("les accents ont été échappés : %s", ecrit) + } +} + +func TestPutRefuseUnJSONInvalideSansToucherAuFichier(t *testing.T) { + s, routes := serveurDeTest(t) + origine := "{\n \"version\": 3,\n \"projects\": []\n}\n" + ecrireFichier(t, s.fichierDonnees, origine) + + rec := appeler(t, routes, http.MethodPut, "/api/data", "{cassé") + + if rec.Code != http.StatusBadRequest { + t.Fatalf("statut = %d, attendu 400", rec.Code) + } + if lireFichier(t, s.fichierDonnees) != origine { + t.Error("le fichier a été modifié alors que la requête était invalide") + } + if existe(s.dossierBackups()) { + t.Error("une sauvegarde a été créée pour une requête refusée") + } +} + +func TestPutRefuseUnObjetSansProjects(t *testing.T) { + _, routes := serveurDeTest(t) + + rec := appeler(t, routes, http.MethodPut, "/api/data", `{"version":3}`) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("statut = %d, attendu 400", rec.Code) + } +} + +func TestPutRefuseUnTableauALaRacine(t *testing.T) { + _, routes := serveurDeTest(t) + + rec := appeler(t, routes, http.MethodPut, "/api/data", `[1,2,3]`) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("statut = %d, attendu 400", rec.Code) + } +} + +func TestPutRefuseUnCorpsVide(t *testing.T) { + _, routes := serveurDeTest(t) + + rec := appeler(t, routes, http.MethodPut, "/api/data", "") + + if rec.Code != http.StatusBadRequest { + t.Fatalf("statut = %d, attendu 400", rec.Code) + } +} + +func TestPutRefuseUnCorpsTropVolumineux(t *testing.T) { + _, routes := serveurDeTest(t) + + req := httptest.NewRequest(http.MethodPut, "/api/data", strings.NewReader(`{"projects":[]}`)) + req.ContentLength = tailleMax + 1 + rec := httptest.NewRecorder() + routes.ServeHTTP(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("statut = %d, attendu 413", rec.Code) + } +} + +// --- sauvegardes ---------------------------------------------------------- + +func TestPutSauvegardeLaVersionPrecedente(t *testing.T) { + s, routes := serveurDeTest(t) + origine := "{\n \"version\": 3,\n \"projects\": [],\n \"marqueur\": \"ancien\"\n}\n" + ecrireFichier(t, s.fichierDonnees, origine) + + rec := appeler(t, routes, http.MethodPut, "/api/data", `{"version":3,"projects":[]}`) + if rec.Code != http.StatusOK { + t.Fatalf("statut = %d, attendu 200", rec.Code) + } + + sauvegardes, err := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json")) + if err != nil || len(sauvegardes) != 1 { + t.Fatalf("sauvegardes = %v (err %v), attendu une seule", sauvegardes, err) + } + if lireFichier(t, sauvegardes[0]) != origine { + t.Error("la sauvegarde ne contient pas la version précédente") + } +} + +func TestElaguerBackupsNeGardeQueLesPlusRecents(t *testing.T) { + dossier := t.TempDir() + // Horodatages croissants : l'ordre lexicographique doit être l'ordre + // chronologique, c'est sur quoi repose l'élagage. + for i := 0; i < backupsConserves+5; i++ { + nom := fmt.Sprintf("projets-20260801-%06d.json", i) + ecrireFichier(t, filepath.Join(dossier, nom), "{}") + } + + if err := elaguerBackups(dossier, "projets"); err != nil { + t.Fatalf("élagage : %v", err) + } + + restants, _ := filepath.Glob(filepath.Join(dossier, "projets-*.json")) + if len(restants) != backupsConserves { + t.Fatalf("restants = %d, attendu %d", len(restants), backupsConserves) + } + // Les cinq plus anciens doivent avoir disparu, pas d'autres. + if strings.Contains(strings.Join(restants, " "), "-000004.json") { + t.Error("un fichier ancien a survécu à l'élagage") + } + if !strings.Contains(strings.Join(restants, " "), "-000005.json") { + t.Error("un fichier qui devait être conservé a été supprimé") + } +} + +func TestSauvegardesMultiplesDansLaMemeSeconde(t *testing.T) { + s, routes := serveurDeTest(t) + ecrireFichier(t, s.fichierDonnees, `{"projects":[]}`) + + for i := 0; i < 3; i++ { + rec := appeler(t, routes, http.MethodPut, "/api/data", + fmt.Sprintf(`{"version":3,"projects":[],"n":%d}`, i)) + if rec.Code != http.StatusOK { + t.Fatalf("écriture %d : statut %d", i, rec.Code) + } + } + + sauvegardes, _ := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json")) + if len(sauvegardes) != 3 { + t.Errorf("sauvegardes = %d, attendu 3 — des copies se sont écrasées", len(sauvegardes)) + } +} + +func TestEcritureAtomiqueNeLaissePasDeTemporaire(t *testing.T) { + s, routes := serveurDeTest(t) + + appeler(t, routes, http.MethodPut, "/api/data", `{"version":3,"projects":[]}`) + + if existe(s.fichierDonnees + ".tmp") { + t.Error("le fichier temporaire subsiste après l'écriture") + } +} + +// --- routes --------------------------------------------------------------- + +func TestMethodeNonAutoriseeSurApiData(t *testing.T) { + _, routes := serveurDeTest(t) + + rec := appeler(t, routes, http.MethodDelete, "/api/data", "") + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("statut = %d, attendu 405", rec.Code) + } +} + +func TestInterfaceServieDepuisLeBinaire(t *testing.T) { + _, routes := serveurDeTest(t) + + rec := appeler(t, routes, http.MethodGet, "/", "") + + if rec.Code != http.StatusOK { + t.Fatalf("statut = %d, attendu 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "frise") { + t.Errorf("index.html non servi : %s", rec.Body.String()) + } +} + +func TestAucuneReponseNEstMiseEnCache(t *testing.T) { + s, routes := serveurDeTest(t) + ecrireFichier(t, s.fichierDonnees, `{"projects":[]}`) + + for _, cible := range []string{"/", "/api/data"} { + rec := appeler(t, routes, http.MethodGet, cible, "") + if rec.Header().Get("Cache-Control") != "no-store" { + t.Errorf("%s : Cache-Control = %q, attendu no-store", + cible, rec.Header().Get("Cache-Control")) + } + } +} + +// --- chemin des données --------------------------------------------------- + +func TestFichierDonneesParDefautEstACoteDeLExecutable(t *testing.T) { + chemin, err := resoudreFichierDonnees("", false) + if err != nil { + t.Fatalf("résolution : %v", err) + } + + executable, _ := os.Executable() + attendu := filepath.Join(filepath.Dir(executable), "projets.json") + // Le binaire de test peut être un lien ; on compare après résolution. + if resolu, err := filepath.EvalSymlinks(executable); err == nil { + attendu = filepath.Join(filepath.Dir(resolu), "projets.json") + } + if chemin != attendu { + t.Errorf("chemin = %q, attendu %q", chemin, attendu) + } +} + +func TestOptionDataLEmporteSurLeDefaut(t *testing.T) { + chemin, err := resoudreFichierDonnees(filepath.Join(t.TempDir(), "ailleurs.json"), false) + if err != nil { + t.Fatalf("résolution : %v", err) + } + if filepath.Base(chemin) != "ailleurs.json" { + t.Errorf("chemin = %q, attendu un fichier ailleurs.json", chemin) + } + if !filepath.IsAbs(chemin) { + t.Errorf("chemin = %q, attendu un chemin absolu", chemin) + } +} diff --git a/serve.py b/serve.py deleted file mode 100644 index d9d57c7..0000000 --- a/serve.py +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env python3 -"""Micro-serveur pour la frise multi-projets. - -Sert les fichiers statiques du dossier courant et expose le fichier de données : - - GET /api/data -> renvoie le JSON du planning - PUT /api/data -> le remplace, apres copie horodatee dans data/backups/ - -Bibliotheque standard uniquement, aucune dependance a installer. - -Ce n'est volontairement pas un back-end : il n'y a ici aucune logique metier, le -serveur ne fait que lire et ecrire un fichier. Voir docs/decisions.md, section 3. -""" - -import argparse -import json -import shutil -import sys -from datetime import datetime, timezone -from functools import partial -from http import HTTPStatus -from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path - -RACINE = Path(__file__).resolve().parent - -# Au-dela, on refuse la requete : un planning macro pese quelques kilo-octets, -# un corps de cette taille signale forcement une erreur. -TAILLE_MAX = 5 * 1024 * 1024 - -# Nombre de sauvegardes conservees par fichier de donnees. Les plus anciennes -# sont supprimees pour que data/backups/ ne grossisse pas indefiniment. -BACKUPS_CONSERVES = 50 - -DONNEES_INITIALES = {"version": 2, "projects": []} - - -class Handler(SimpleHTTPRequestHandler): - """Fichiers statiques, plus deux routes pour le fichier de donnees.""" - - def __init__(self, *args, fichier_donnees: Path, **kwargs): - self.fichier_donnees = fichier_donnees - super().__init__(*args, directory=str(RACINE), **kwargs) - - # --- routes ----------------------------------------------------------- - - def do_GET(self): - if self.path.split("?")[0] == "/api/data": - self._lire_donnees() - return - super().do_GET() - - def do_PUT(self): - if self.path.split("?")[0] == "/api/data": - self._ecrire_donnees() - return - self._erreur(HTTPStatus.NOT_FOUND, "Route inconnue.") - - # --- implementation des routes ---------------------------------------- - - def _lire_donnees(self): - if not self.fichier_donnees.exists(): - # Premier lancement : on repond un planning vide sans rien ecrire - # sur le disque. Le fichier naitra a la premiere sauvegarde. - self._json(HTTPStatus.OK, DONNEES_INITIALES) - return - - try: - contenu = self.fichier_donnees.read_text(encoding="utf-8") - json.loads(contenu) # on refuse de servir un fichier corrompu - except json.JSONDecodeError as err: - self._erreur( - HTTPStatus.INTERNAL_SERVER_ERROR, - f"{self.fichier_donnees.name} n'est pas un JSON valide : {err}. " - "Le corriger a la main ou repartir d'une sauvegarde de data/backups/.", - ) - return - except OSError as err: - self._erreur(HTTPStatus.INTERNAL_SERVER_ERROR, f"Lecture impossible : {err}") - return - - self._envoyer(HTTPStatus.OK, contenu.encode("utf-8"), "application/json") - - def _ecrire_donnees(self): - try: - taille = int(self.headers.get("Content-Length", 0)) - except ValueError: - self._erreur(HTTPStatus.BAD_REQUEST, "En-tete Content-Length invalide.") - return - - if taille <= 0: - self._erreur(HTTPStatus.BAD_REQUEST, "Corps de requete vide.") - return - if taille > TAILLE_MAX: - self._erreur(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Corps de requete trop volumineux.") - return - - corps = self.rfile.read(taille) - - # On valide avant d'ecrire : mieux vaut refuser une sauvegarde que - # remplacer un planning correct par du JSON casse. - try: - donnees = json.loads(corps.decode("utf-8")) - except (json.JSONDecodeError, UnicodeDecodeError) as err: - self._erreur(HTTPStatus.BAD_REQUEST, f"JSON invalide : {err}") - return - - if not isinstance(donnees, dict) or "projects" not in donnees: - self._erreur(HTTPStatus.BAD_REQUEST, "Le JSON doit etre un objet contenant 'projects'.") - return - - try: - self._sauvegarder_version_precedente() - self._ecrire_atomiquement(donnees) - except OSError as err: - self._erreur(HTTPStatus.INTERNAL_SERVER_ERROR, f"Ecriture impossible : {err}") - return - - self._json(HTTPStatus.OK, {"ok": True, "savedAt": _horodatage_iso()}) - - # --- ecriture sur disque ---------------------------------------------- - - def _sauvegarder_version_precedente(self): - """Copie le fichier actuel dans data/backups/ avant de l'ecraser.""" - if not self.fichier_donnees.exists(): - return - - dossier = self.fichier_donnees.parent / "backups" - dossier.mkdir(parents=True, exist_ok=True) - - marqueur = datetime.now().strftime("%Y%m%d-%H%M%S") - cible = dossier / f"{self.fichier_donnees.stem}-{marqueur}.json" - - # Deux sauvegardes dans la meme seconde : on suffixe pour ne pas - # ecraser la precedente. - compteur = 1 - while cible.exists(): - cible = dossier / f"{self.fichier_donnees.stem}-{marqueur}-{compteur}.json" - compteur += 1 - - shutil.copy2(self.fichier_donnees, cible) - _elaguer_backups(dossier, self.fichier_donnees.stem) - - def _ecrire_atomiquement(self, donnees): - """Ecrit dans un fichier temporaire puis remplace, pour qu'une coupure - en cours d'ecriture ne laisse jamais un planning tronque.""" - self.fichier_donnees.parent.mkdir(parents=True, exist_ok=True) - temporaire = self.fichier_donnees.with_suffix(".json.tmp") - texte = json.dumps(donnees, indent=2, ensure_ascii=False) + "\n" - temporaire.write_text(texte, encoding="utf-8") - temporaire.replace(self.fichier_donnees) - - # --- helpers de reponse ----------------------------------------------- - - def _json(self, statut, charge): - self._envoyer(statut, json.dumps(charge, ensure_ascii=False).encode("utf-8"), - "application/json") - - def _erreur(self, statut, message): - self._json(statut, {"error": message}) - - def _envoyer(self, statut, corps, type_contenu): - self.send_response(statut) - self.send_header("Content-Type", f"{type_contenu}; charset=utf-8") - self.send_header("Content-Length", str(len(corps))) - self.end_headers() # ajoute Cache-Control: no-store - self.wfile.write(corps) - - def end_headers(self): - # Aucun cache, sur aucune route, et emis ici seulement pour n'avoir - # qu'un seul endroit responsable de cet en-tete. - # - # Filtrer sur l'extension ne suffirait pas : la page d'accueil est - # servie sur « / », donc elle passerait au travers et Chromium - # resservirait un index.html perime apres chaque modification. - self.send_header("Cache-Control", "no-store") - super().end_headers() - - def log_message(self, format, *args): - # Une ligne par requete suffit ; on retire l'adresse, toujours locale. - sys.stderr.write(f" {format % args}\n") - - -def _elaguer_backups(dossier: Path, prefixe: str): - fichiers = sorted(dossier.glob(f"{prefixe}-*.json")) - for vieux in fichiers[:-BACKUPS_CONSERVES]: - vieux.unlink(missing_ok=True) - - -def _horodatage_iso(): - return datetime.now(timezone.utc).isoformat(timespec="seconds") - - -def main(): - analyseur = argparse.ArgumentParser(description="Serveur local de la frise multi-projets.") - analyseur.add_argument("--port", type=int, default=8000, help="port d'ecoute (defaut : 8000)") - analyseur.add_argument("--data", default="data/projets.json", - help="fichier de donnees (defaut : data/projets.json)") - arguments = analyseur.parse_args() - - fichier = Path(arguments.data) - if not fichier.is_absolute(): - fichier = RACINE / fichier - fichier = fichier.resolve() - - if RACINE not in fichier.parents: - analyseur.error("--data doit designer un fichier situe dans le dossier du projet.") - - handler = partial(Handler, fichier_donnees=fichier) - - try: - serveur = ThreadingHTTPServer(("127.0.0.1", arguments.port), handler) - except OSError as err: - print(f"Impossible d'ecouter sur le port {arguments.port} : {err}", file=sys.stderr) - print("Un autre serveur tourne peut-etre deja. Essayer --port 8001.", file=sys.stderr) - raise SystemExit(1) - - print(f"Frise multi-projets -> http://localhost:{arguments.port}") - print(f"Donnees : {fichier.relative_to(RACINE)}") - print("Ctrl+C pour arreter.\n") - - try: - serveur.serve_forever() - except KeyboardInterrupt: - print("\nArret.") - serveur.shutdown() - - -if __name__ == "__main__": - main()