Files
gestion_projets/main.go
Bertrand Benjamin faac28172e Sauvegarder une fois par séance plutôt qu'à chaque modification
L'interface enregistre après une seconde d'inactivité, et le serveur copiait
le planning avant chaque écriture : une après-midi d'édition produisait des
dizaines de fichiers quasi identiques.

Le problème n'est pas la place occupée mais ce que la rotation en faisait —
les cinquante emplacements se remplissaient en quelques heures et chassaient
les états anciens, les seuls qu'on cherche à retrouver. Un filet qui ne
remonte pas au-delà de la dernière demi-heure ne protège pas de la bêtise
qu'on découvre le lendemain.

La copie est désormais prise à l'ouverture, quand le planning est encore dans
l'état d'avant la séance. Deux garde-fous : au plus une par jour si le serveur
reste allumé longtemps, et rien du tout si le contenu n'a pas bougé depuis la
dernière copie. Les cinquante conservées couvrent maintenant des mois.

La copie et le remplacement partagent un état — la date de la dernière
sauvegarde — donc un verrou les sérialise, ce qui protège au passage du cas
où deux onglets enregistrent simultanément.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:08:26 +02:00

471 lines
15 KiB
Go

// 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"
"sync"
"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
// Intervalle minimal entre deux sauvegardes tant que le serveur tourne.
//
// L'interface enregistre après une seconde d'inactivité : copier le planning à
// chaque écriture produisait des dizaines de fichiers quasi identiques par
// séance, qui chassaient les états anciens et ne remontaient pas plus loin que
// la dernière demi-heure. Une sauvegarde est prise à l'ouverture — l'état
// d'avant la séance, celui qu'on veut retrouver — puis au plus une par jour si
// le serveur reste allumé longtemps. Voir docs/decisions.md, section 23.
const intervalleBackup = 24 * time.Hour
// 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
// Sérialise les écritures : la copie de sauvegarde et le remplacement du
// fichier forment un tout, que deux onglets ouverts ne doivent pas
// entrelacer.
mu sync.Mutex
dernierBackup time.Time
}
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)
// L'état d'avant la séance : c'est celui qu'on voudra retrouver si la
// séance tourne mal. Un échec ici n'empêche pas de travailler, mais il doit
// se voir — l'utilisateur croirait sinon avoir un filet qu'il n'a pas.
if err := srv.sauvegarderVersionPrecedente(true); err != nil {
fmt.Fprintf(os.Stderr, "Attention : sauvegarde d'ouverture impossible (%v)\n", err)
}
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')
s.mu.Lock()
err = s.sauvegarderVersionPrecedente(false)
if err == nil {
err = s.ecrireAtomiquement(indente.Bytes())
}
s.mu.Unlock()
if 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/, si la
// copie apporte quelque chose.
//
// Deux garde-fous évitent d'inonder le dossier : l'intervalle minimal, que
// « forcer » outrepasse à l'ouverture, et la comparaison au dernier état
// sauvegardé — rouvrir l'outil sans avoir rien modifié n'a pas à laisser de
// trace.
func (s *serveur) sauvegarderVersionPrecedente(forcer bool) error {
if !forcer && time.Since(s.dernierBackup) < intervalleBackup {
return nil
}
contenu, err := os.ReadFile(s.fichierDonnees)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if precedent, trouve := s.dernierEtatSauvegarde(); trouve && bytes.Equal(precedent, contenu) {
// Rien n'a bougé depuis la dernière copie. On note tout de même le
// passage, pour ne pas relire le dossier à chaque écriture.
s.dernierBackup = time.Now()
return nil
}
dossier := s.dossierBackups()
if err := os.MkdirAll(dossier, 0o755); err != nil {
return err
}
base := s.baseNom()
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
}
s.dernierBackup = time.Now()
return elaguerBackups(dossier, base)
}
// baseNom est le nom du planning sans son extension, préfixe commun à ses
// sauvegardes.
func (s *serveur) baseNom() string {
return strings.TrimSuffix(filepath.Base(s.fichierDonnees), filepath.Ext(s.fichierDonnees))
}
// dernierEtatSauvegarde renvoie le contenu de la sauvegarde la plus récente.
// L'horodatage est en tête et de longueur fixe : l'ordre lexicographique est
// l'ordre chronologique.
func (s *serveur) dernierEtatSauvegarde() ([]byte, bool) {
entrees, err := filepath.Glob(filepath.Join(s.dossierBackups(), s.baseNom()+"-*.json"))
if err != nil || len(entrees) == 0 {
return nil, false
}
sort.Strings(entrees)
contenu, err := os.ReadFile(entrees[len(entrees)-1])
if err != nil {
return nil, false
}
return contenu, true
}
// 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})
}