Glisser la barre résumé décale toutes les phases du même nombre de jours, écarts conservés : un projet engagé glisse souvent, et le faire phase par phase les déformait. Restent en place ce qui est terminé — on ne réécrit pas le passé — et les phases à date imposée, nouveau booléen `fixed` pour ce qui est délégué ou tenu du dehors. D'où le format en version 6 : sans l'incrément, un binaire antérieur effacerait ces dates imposées à la première sauvegarde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
475 lines
15 KiB
Go
475 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
|
|
|
|
// 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": 6, "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})
|
|
}
|