Serveur : surveillance des nouvelles versions d'Excalidraw (/api/update)

- Le serveur interroge le registre npm (@excalidraw/excalidraw, dist-tag
  latest) au démarrage puis toutes les 12 h ; GET /api/update.
  UPDATE_CHECK=false désactive, NPM_REGISTRY permet un miroir ; sans réseau,
  la dernière valeur connue est gardée.
- Dockerfile/compose : argument de build EXCALIDRAW_VERSION pour reconstruire
  l'image avec une autre version sans modifier le dépôt.
- README : style Excalidraw, import de dossiers, mise à jour d'Excalidraw.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EuvvuTv7AUc8ksDHv8VKx
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-07 15:45:17 +02:00
1 parent 9ecaab550a
commit 49a816bb12
8 files changed
+225 -7

No files matched your search

+3
View File
@@ -2,3 +2,6 @@ EXCALIDRAW_PORT=8080
AUTH_USER=
AUTH_PASSWORD=
MAX_BODY_MB=50
UPDATE_CHECK=true
UPDATE_CHECK_INTERVAL=12h
EXCALIDRAW_VERSION=
+7
View File
@@ -5,6 +5,13 @@ FROM node:22-alpine AS frontend
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
# Version d'Excalidraw imposée au build (vide : celle de package-lock.json).
# Ex. : EXCALIDRAW_VERSION=0.18.2 docker compose up -d --build
ARG EXCALIDRAW_VERSION=
RUN --mount=type=cache,target=/root/.npm \
if [ -n "$EXCALIDRAW_VERSION" ]; then \
npm install --no-audit --no-fund --save-exact "@excalidraw/excalidraw@$EXCALIDRAW_VERSION"; \
fi
COPY frontend/ ./
RUN npm run build
+20 -2
View File
@@ -39,24 +39,41 @@ docker compose up -d --build
| `DATA_DIR` | `/data` | Racine des schémas |
| `MAX_BODY_MB` | `50` | Taille max d'un schéma (images embarquées en base64 comprises) |
| `AUTH_USER` / `AUTH_PASSWORD` | vide | Active une authentification HTTP Basic si les deux sont définis |
| `UPDATE_CHECK` | `true` | Interroge `registry.npmjs.org` pour signaler une nouvelle version d'Excalidraw (`false` : désactivé) |
| `UPDATE_CHECK_INTERVAL` | `12h` | Intervalle entre deux vérifications (durée Go, minimum `1m`) |
| `NPM_REGISTRY` | `https://registry.npmjs.org` | Registre interrogé (miroir interne possible) |
| `EXCALIDRAW_VERSION` (build) | vide | Version d'Excalidraw imposée à la construction de l'image ; vide = celle de `package-lock.json` |
Bind mount au lieu du volume nommé : `- ./data:/data` puis `chown -R 65532:65532 ./data`.
## Utilisation
- **+ Schéma / + Dossier** : création dans le dossier courant.
- **Vignettes / liste** : bouton ▦ / ☰ à droite de la recherche (choix mémorisé par navigateur).
- **Vignettes / liste** : sélecteur à droite de la recherche (choix mémorisé par navigateur).
- **⋯** sur un élément : renommer, déplacer, dupliquer, télécharger, supprimer.
- **Sélection multiple** : case à cocher (au survol, toujours visible sur écran tactile), `Ctrl/Cmd+clic`, `Maj+clic` pour une plage, `Ctrl+A` pour tout. Tant qu'une sélection est en cours, un simple clic/tap ajoute ou retire un élément. La barre de sélection propose *Déplacer…* et la mise à la corbeille ; `Échap` annule, `Suppr` supprime.
- **Déplacer** : glisser-déposer (souris) d'un élément — ou de toute la sélection — sur un dossier, sur `..` ou sur le fil d'Ariane ; ou *Déplacer…* qui ouvre un sélecteur de dossier (navigation, création de dossier), utilisable au doigt. En cas de conflit de nom dans la destination, les autres éléments sont tout de même déplacés et les échecs listés.
- **Importer** : envoie un ou plusieurs `.excalidraw` locaux dans le dossier courant.
- **Importer** : envoie un ou plusieurs `.excalidraw` locaux dans le dossier courant. Le bouton voisin importe un **dossier complet**.
- **Glisser-déposer depuis le poste** : des fichiers ou des dossiers entiers déposés sur l'explorateur sont importés dans le dossier courant (ou dans le dossier survolé). L'arborescence est recréée, seuls les `.excalidraw` sont importés, les fichiers et dossiers cachés (`.git`, `.DS_Store`) sont ignorés et un schéma déjà présent n'est jamais écrasé. Un bilan s'affiche (détail au survol).
- **Sauvegarde** : automatique 1,5 s après la dernière modification, immédiate avec `Ctrl/Cmd+S`. Le statut (Modifié / Enregistrement… / Enregistré) est affiché dans la barre du haut.
- **Lien direct** : l'URL `http://hôte:8080/#/dossier/schema.excalidraw` ouvre le schéma.
- **Bibliothèque** Excalidraw : persistée côté serveur, partagée entre tous les schémas.
- **Largeur de l'explorateur** (bureau) : glisser la poignée entre l'explorateur et le canevas ; flèches ←/→ au clavier quand elle a le focus (Maj : pas plus grand), double-clic pour revenir à 330 px. Mémorisée par navigateur.
- **Style** : l'explorateur et la barre du haut reprennent les couleurs, la police, les rayons et les tailles de boutons d'Excalidraw (jetons `--ex-*` en tête de `styles.css`), en clair comme en sombre.
- **Mise à jour d'Excalidraw** : quand une version plus récente que celle embarquée est publiée sur npm, une icône apparaît à droite de la barre du haut ; un clic affiche les deux versions et la commande de reconstruction (voir *Mettre à jour Excalidraw*).
- **Mode clair/sombre** : bouton ☀/☾ à droite de la barre du haut ; s'applique à l'interface et au canevas Excalidraw, reste synchronisé avec le choix du menu d'Excalidraw. Mémorisé par navigateur.
- **Titre et logo** : bouton ✎ à droite de la barre (ou clic sur le titre). Le titre et le logo sont communs à tous les utilisateurs. Le logo (PNG, 5 Mo max) est recadré au centre, réduit à 64×64 px côté serveur et affiché dans un masque rond (34 px dans la barre, net sur écran HiDPI ; taille réglable via `--logo-size` dans `styles.css`).
### Mettre à jour Excalidraw
La version d'Excalidraw est figée au build (`frontend/package-lock.json`). Le serveur interroge le registre npm (`/api/update`, toutes les 12 h par défaut) et l'interface compare la dernière version publiée à celle embarquée. Pour reconstruire avec la nouvelle version :
```bash
EXCALIDRAW_VERSION=0.18.2 docker compose up -d --build # ou EXCALIDRAW_VERSION=… dans .env
```
Les versions `0.x` d'Excalidraw peuvent contenir des changements incompatibles : tester l'image avant de la mettre en service. Pour figer durablement la nouvelle version dans le dépôt : `cd frontend && npm install --save-exact @excalidraw/excalidraw@0.18.2`, puis commiter `package.json` et `package-lock.json`. Sans accès Internet, aucune icône n'est affichée.
### Miniatures
Rendues par le navigateur avec `exportToBlob` d'Excalidraw (PNG 320 px), puis stockées dans `DATA_DIR/.thumbs/` :
@@ -93,6 +110,7 @@ Tous les chemins sont relatifs à `DATA_DIR`, en notation `/`. Les composants co
| GET | `/api/logo?v=<version>` | | PNG 64×64 ; `404` sans logo |
| PUT | `/api/logo` | PNG (5 Mo, 8192×8192 px max) | réglages ; image recadrée et ré-encodée |
| DELETE | `/api/logo` | | réglages |
| GET | `/api/update` | | `{"enabled","latest","checkedAt"}` |
| GET | `/healthz` | | `ok` (hors authentification) |
`Entry` = `{"name","path","isDir","size","modTime"}`.
+21 -3
View File
@@ -18,6 +18,7 @@ import (
"github.com/vblogio/excalidraw-go/internal/api"
"github.com/vblogio/excalidraw-go/internal/storage"
"github.com/vblogio/excalidraw-go/internal/update"
"github.com/vblogio/excalidraw-go/web"
)
@@ -49,8 +50,27 @@ func main() {
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Surveillance des nouvelles versions d'Excalidraw (UPDATE_CHECK=false pour désactiver).
var checker *update.Checker
if b, err := strconv.ParseBool(env("UPDATE_CHECK", "true")); err != nil || b {
interval, err := time.ParseDuration(env("UPDATE_CHECK_INTERVAL", "12h"))
if err != nil || interval < time.Minute {
slog.Error("UPDATE_CHECK_INTERVAL invalide (ex. 12h, minimum 1m)")
os.Exit(1)
}
checker = &update.Checker{
Registry: env("NPM_REGISTRY", "https://registry.npmjs.org"),
Interval: interval,
Client: &http.Client{}, // respecte HTTPS_PROXY / NO_PROXY
}
go checker.Run(ctx)
}
mux := http.NewServeMux()
(&api.Handler{Store: store, MaxBody: maxMB << 20}).Register(mux)
(&api.Handler{Store: store, MaxBody: maxMB << 20, Update: checker}).Register(mux)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok"))
})
@@ -70,8 +90,6 @@ func main() {
IdleTimeout: 120 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
slog.Info("démarrage", "addr", addr, "data", dataDir)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+9 -1
View File
@@ -1,6 +1,11 @@
services:
excalidraw:
build: .
build:
context: .
args:
# Vide : version figée dans frontend/package-lock.json. Renseignée (dans .env
# ou en ligne de commande), l'image est reconstruite avec cette version.
EXCALIDRAW_VERSION: ${EXCALIDRAW_VERSION:-}
image: excalidraw-go:local
container_name: excalidraw-go
restart: unless-stopped
@@ -13,6 +18,9 @@ services:
AUTH_PASSWORD: ${AUTH_PASSWORD:-}
# Taille max d'un schéma (images embarquées en base64 comprises).
MAX_BODY_MB: ${MAX_BODY_MB:-50}
# Icône « mise à jour d'Excalidraw disponible » (interroge registry.npmjs.org).
UPDATE_CHECK: ${UPDATE_CHECK:-true}
UPDATE_CHECK_INTERVAL: ${UPDATE_CHECK_INTERVAL:-12h}
volumes:
# Volume nommé : les droits (uid 65532) sont hérités de l'image.
# Pour un bind mount (ex. ./data:/data), faire `chown -R 65532:65532 ./data`.
+7 -1
View File
@@ -13,6 +13,7 @@
// GET /api/library / PUT /api/library bibliothèque Excalidraw
// GET /api/settings / PUT {"title":"..."} personnalisation (titre, logo)
// GET /api/logo / PUT (image/png) / DELETE logo 64x64
// GET /api/update dernière version d'Excalidraw sur npm
package api
import (
@@ -23,12 +24,14 @@ import (
"net/http"
"github.com/vblogio/excalidraw-go/internal/storage"
"github.com/vblogio/excalidraw-go/internal/update"
)
// Handler regroupe les routes de l'API.
type Handler struct {
Store *storage.Store
MaxBody int64 // taille max d'un schéma (images embarquées comprises)
MaxBody int64 // taille max d'un schéma (images embarquées comprises)
Update *update.Checker // nil : vérification des mises à jour désactivée
}
// Register enregistre les routes sur mux (motifs Go 1.22+).
@@ -51,6 +54,9 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /api/logo", h.readLogo)
mux.HandleFunc("PUT /api/logo", h.writeLogo)
mux.HandleFunc("DELETE /api/logo", h.deleteLogo)
mux.HandleFunc("GET /api/update", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, h.Update.Status())
})
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusNotFound, "route inconnue")
})
+102
View File
@@ -0,0 +1,102 @@
// Package update surveille la dernière version publiée de @excalidraw/excalidraw
// sur le registre npm. Le frontend la compare à la version embarquée au build et
// signale qu'une reconstruction de l'image est possible.
package update
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sync"
"time"
)
// Package suivi sur le registre npm.
const Package = "@excalidraw/excalidraw"
// Status est la réponse de GET /api/update.
type Status struct {
Enabled bool `json:"enabled"`
Latest string `json:"latest,omitempty"` // vide tant que le registre n'a pas répondu
CheckedAt *time.Time `json:"checkedAt,omitempty"` // dernière interrogation réussie
}
// Checker interroge le registre au démarrage puis à intervalle régulier.
// Une erreur réseau est journalisée et la dernière valeur connue conservée :
// sans accès Internet, l'interface n'affiche simplement rien.
type Checker struct {
Registry string // ex. https://registry.npmjs.org
Interval time.Duration // ex. 12h
Client *http.Client
mu sync.RWMutex
status Status
}
// Run boucle jusqu'à l'annulation de ctx.
func (c *Checker) Run(ctx context.Context) {
for {
c.check(ctx)
select {
case <-ctx.Done():
return
case <-time.After(c.Interval):
}
}
}
func (c *Checker) check(ctx context.Context) {
v, err := c.fetch(ctx)
if err != nil {
if ctx.Err() == nil {
slog.Warn("vérification de mise à jour d'Excalidraw", "err", err)
}
return
}
now := time.Now().UTC()
c.mu.Lock()
c.status = Status{Enabled: true, Latest: v, CheckedAt: &now}
c.mu.Unlock()
}
func (c *Checker) fetch(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Registry+"/"+Package+"/latest", nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
res, err := c.Client.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return "", fmt.Errorf("registre npm : %s", res.Status)
}
var body struct {
Version string `json:"version"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
return "", fmt.Errorf("registre npm : %w", err)
}
if body.Version == "" {
return "", fmt.Errorf("registre npm : version absente")
}
return body.Version, nil
}
// Status renvoie le dernier état connu. Un Checker nil signifie « désactivé ».
func (c *Checker) Status() Status {
if c == nil {
return Status{}
}
c.mu.RLock()
defer c.mu.RUnlock()
s := c.status
s.Enabled = true
return s
}
+56
View File
@@ -0,0 +1,56 @@
package update
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestCheck(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/@excalidraw/excalidraw/latest" {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte(`{"name":"@excalidraw/excalidraw","version":"0.19.0"}`))
}))
defer srv.Close()
c := &Checker{Registry: srv.URL, Interval: time.Hour, Client: srv.Client()}
if s := c.Status(); !s.Enabled || s.Latest != "" {
t.Fatalf("avant vérification : %+v", s)
}
c.check(context.Background())
if s := c.Status(); s.Latest != "0.19.0" || s.CheckedAt == nil {
t.Fatalf("après vérification : %+v", s)
}
}
func TestCheckKeepsLastValueOnError(t *testing.T) {
ok := true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !ok {
http.Error(w, "panne", http.StatusBadGateway)
return
}
_, _ = w.Write([]byte(`{"version":"0.18.2"}`))
}))
defer srv.Close()
c := &Checker{Registry: srv.URL, Interval: time.Hour, Client: srv.Client()}
c.check(context.Background())
ok = false
c.check(context.Background())
if s := c.Status(); s.Latest != "0.18.2" {
t.Fatalf("valeur perdue après une erreur : %+v", s)
}
}
func TestNilCheckerIsDisabled(t *testing.T) {
var c *Checker
if s := c.Status(); s.Enabled {
t.Fatalf("checker nil : %+v", s)
}
}