Files
gestion_projets/main.go
Bertrand Benjamin 558d42f4cb Distribuer l'outil comme un binaire Go autonome
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 <noreply@anthropic.com>
2026-08-01 16:48:59 +02:00

403 lines
12 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"
"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})
}