// 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 // Doit suivre VERSION_FORMAT dans js/model.js : le fichier créé au premier // lancement est immédiatement relu par le front, qui refuse une version plus // récente que la sienne — mais accepterait sans rien dire une version plus // ancienne, et la réécrirait au format courant. var donneesInitiales = []byte(`{"version": 4, "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}) }