diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ee7610 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# excalidraw-go + +Excalidraw auto-hébergé avec un **gestionnaire de fichiers intégré** : arborescence de dossiers, création, renommage, déplacement (glisser-déposer ou menu), duplication, suppression (corbeille), recherche, import/export de fichiers `.excalidraw`, sauvegarde automatique côté serveur. Interface web unique, responsive (l'explorateur devient un tiroir sur mobile). + +- **Backend** : Go 1.24, bibliothèque standard uniquement (aucune dépendance externe), API REST/JSON, stockage en fichiers `.excalidraw` sur un volume. +- **Frontend** : React 19 + composant officiel [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/excalidraw) 0.18.1 (licence MIT), build Vite **embarqué dans le binaire Go** (`embed`). +- **Image** : multi-stage, finale en `distroless/static:nonroot` (~53 Mo), un seul conteneur, aucun accès Internet requis à l'exécution (polices Excalidraw servies localement). + +## Architecture + +``` +Navigateur ──HTTP──▶ conteneur excalidraw-go (:8080, uid 65532, rootfs read-only) + ├─ / SPA React + Excalidraw (embarquée dans le binaire) + ├─ /api/... API fichiers (Go net/http) + └─ /healthz sonde + │ + ▼ + volume /data + ├─ projets/réseau/sdn.excalidraw ← fichiers JSON Excalidraw standards + ├─ .library.excalidrawlib ← bibliothèque partagée + └─ .trash//... ← éléments supprimés +``` + +Les schémas restent des fichiers `.excalidraw` standards : ils s'ouvrent tels quels dans excalidraw.com, VS Code (extension Excalidraw) ou Obsidian, et se sauvegardent/versionnent comme n'importe quel répertoire (rsync, restic, git...). + +## Démarrage + +```bash +cp .env.example .env # optionnel : port, authentification Basic +docker compose up -d --build +# http://localhost:8080 +``` + +| Variable | Défaut | Rôle | +|-----------------|----------|-------------------------------------------------------------------| +| `LISTEN_ADDR` | `:8080` | Adresse d'écoute | +| `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 | + +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. +- **⋯** sur une ligne : renommer, déplacer, dupliquer, télécharger, supprimer. +- **Glisser-déposer** (souris) d'un élément sur un dossier, sur `..` ou sur le fil d'Ariane pour le déplacer. Sur écran tactile : menu ⋯ → *Déplacer…* +- **Importer** : envoie un ou plusieurs `.excalidraw` locaux dans le dossier courant. +- **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. + +### Concurrence + +Chaque lecture renvoie un `ETag` (SHA-256 tronqué du contenu) ; chaque sauvegarde l'envoie en `If-Match`. Si le fichier a été modifié ailleurs (autre onglet, autre poste), le serveur répond `412` et l'interface propose d'écraser ou de recharger. Il n'y a pas d'édition collaborative temps réel (voir *Évolutions*). + +## API + +Tous les chemins sont relatifs à `DATA_DIR`, en notation `/`. Les composants commençant par `.` sont refusés, et les liens symboliques sortant de la racine sont bloqués. + +| Méthode | Route | Corps / paramètres | Réponse | +|---|---|---|---| +| GET | `/api/list?path=dir` | | `[Entry]` (dossiers puis schémas) | +| GET | `/api/search?q=texte` | | `[Entry]` (200 max) | +| GET | `/api/file?path=x.excalidraw` | | JSON du schéma + `ETag` | +| PUT | `/api/file?path=x.excalidraw` | JSON ; `If-Match: ` ou `If-None-Match: *` (création) | `Entry` + `ETag` ; `409` existe, `412` conflit | +| POST | `/api/folder` | `{"path":"a/b"}` | `Entry` | +| POST | `/api/move` | `{"from":"a.excalidraw","to":"d/b.excalidraw"}` | `Entry` (renommage / déplacement, fichier ou dossier) | +| POST | `/api/copy` | `{"from":"…","to":"…"}` | `Entry` | +| DELETE | `/api/entry?path=x[&recursive=true]` | | `204` (déplacé dans `.trash/`) | +| GET/PUT | `/api/library` | bibliothèque `excalidrawlib` | | +| GET | `/healthz` | | `ok` (hors authentification) | + +`Entry` = `{"name","path","isDir","size","modTime"}`. + +Exemple d'automatisation (génération de schémas par script) : + +```bash +curl -X PUT 'http://localhost:8080/api/file?path=auto/inventaire.excalidraw' \ + -H 'If-None-Match: *' --data-binary @inventaire.excalidraw +``` + +## Développement + +```bash +# Terminal 1 : API +go run ./cmd/server # DATA_DIR=./data par défaut +# Terminal 2 : frontend avec rechargement à chaud (proxy /api -> :8080) +cd frontend && npm install && npm run dev + +# Tests et build complet hors Docker +go test ./... +cd frontend && npm run build && cd .. && go build -o server ./cmd/server +``` + +Arborescence : + +``` +cmd/server/main.go serveur HTTP, SPA embarquée, auth Basic, healthcheck +internal/storage/ accès disque : sécurité des chemins, écriture atomique, ETag, corbeille +internal/api/ routes REST +web/embed.go embarque web/dist (produit par le build Vite) +frontend/src/App.tsx éditeur Excalidraw, sauvegarde auto, conflits, bibliothèque +frontend/src/Explorer.tsx gestionnaire de fichiers +``` + +## Sécurité + +- Conteneur non-root, rootfs en lecture seule, `cap_drop: ALL`, `no-new-privileges`. +- L'authentification Basic intégrée suffit pour un usage local ; en exposition réseau, placer le service derrière un reverse proxy TLS (Traefik, Caddy, nginx) avec SSO (Authelia, oauth2-proxy, Authentik) et laisser `AUTH_*` vides. +- Écritures atomiques (fichier temporaire + `fsync` + `rename`) : pas de schéma tronqué en cas d'arrêt brutal. +- Aucune suppression physique : la purge de `.trash/` est à la charge de l'exploitant (ex. `find /data/.trash -mindepth 1 -maxdepth 1 -mtime +30 -exec rm -rf {} +`). + +## Limites et évolutions possibles + +- **Collaboration temps réel** : nécessiterait le serveur [excalidraw-room](https://github.com/excalidraw/excalidraw-room) (WebSocket) et la logique de réconciliation (`reconcileElements`) côté client. +- **Miniatures** dans l'explorateur : générables côté client via `exportToSvg` et stockées à côté du fichier. +- **Historique de versions** : commit git automatique dans `DATA_DIR` à chaque sauvegarde, ou snapshots du volume (ZFS/Btrfs). +- **Multi-utilisateurs avec espaces privés** : identité transmise par le reverse proxy (`X-Forwarded-User`) et racine par utilisateur. +- Les schémas avec beaucoup d'images embarquées sont volumineux (base64 dans le JSON) ; ajuster `MAX_BODY_MB`. diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..5c36d83 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,224 @@ +// Package api expose le Store en REST/JSON. +// +// GET /api/list?path=dir contenu d'un dossier +// GET /api/search?q=texte recherche par nom +// GET /api/file?path=a/b.excalidraw lecture (ETag en en-tête) +// PUT /api/file?path=a/b.excalidraw écriture (If-Match / If-None-Match: *) +// POST /api/folder {"path":"a/b"} création de dossier +// POST /api/move {"from":"x","to":"y"} renommage / déplacement +// POST /api/copy {"from":"x","to":"y"} duplication d'un schéma +// DELETE /api/entry?path=x[&recursive=true] mise à la corbeille +// GET /api/library / PUT /api/library bibliothèque Excalidraw +package api + +import ( + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + + "github.com/vblogio/excalidraw-go/internal/storage" +) + +// 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) +} + +// Register enregistre les routes sur mux (motifs Go 1.22+). +func (h *Handler) Register(mux *http.ServeMux) { + mux.HandleFunc("GET /api/list", h.list) + mux.HandleFunc("GET /api/search", h.search) + mux.HandleFunc("GET /api/file", h.readFile) + mux.HandleFunc("PUT /api/file", h.writeFile) + mux.HandleFunc("POST /api/folder", h.mkdir) + mux.HandleFunc("POST /api/move", h.move) + mux.HandleFunc("POST /api/copy", h.copy) + mux.HandleFunc("DELETE /api/entry", h.delete) + mux.HandleFunc("GET /api/library", h.readLibrary) + mux.HandleFunc("PUT /api/library", h.writeLibrary) + mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { + writeErr(w, http.StatusNotFound, "route inconnue") + }) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +// fail traduit une erreur du Store en code HTTP. +func fail(w http.ResponseWriter, err error) { + var tooBig *http.MaxBytesError + switch { + case errors.Is(err, storage.ErrNotFound): + writeErr(w, http.StatusNotFound, err.Error()) + case errors.Is(err, storage.ErrExists), errors.Is(err, storage.ErrNotEmpty): + writeErr(w, http.StatusConflict, err.Error()) + case errors.Is(err, storage.ErrConflict): + writeErr(w, http.StatusPreconditionFailed, err.Error()) + case errors.Is(err, storage.ErrInvalidPath), errors.Is(err, storage.ErrInvalidJSON): + writeErr(w, http.StatusBadRequest, err.Error()) + case errors.As(err, &tooBig): + writeErr(w, http.StatusRequestEntityTooLarge, "contenu trop volumineux") + default: + slog.Error("erreur interne", "err", err) + writeErr(w, http.StatusInternalServerError, "erreur interne") + } +} + +func (h *Handler) readBody(w http.ResponseWriter, r *http.Request) ([]byte, error) { + return io.ReadAll(http.MaxBytesReader(w, r.Body, h.MaxBody)) +} + +func (h *Handler) decode(w http.ResponseWriter, r *http.Request, v any) bool { + b, err := h.readBody(w, r) + if err == nil { + err = json.Unmarshal(b, v) + } + if err != nil { + writeErr(w, http.StatusBadRequest, "requête JSON invalide") + return false + } + return true +} + +func (h *Handler) list(w http.ResponseWriter, r *http.Request) { + entries, err := h.Store.List(r.URL.Query().Get("path")) + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, entries) +} + +func (h *Handler) search(w http.ResponseWriter, r *http.Request) { + entries, err := h.Store.Search(r.URL.Query().Get("q"), 200) + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, entries) +} + +func (h *Handler) readFile(w http.ResponseWriter, r *http.Request) { + data, etag, err := h.Store.Read(r.URL.Query().Get("path")) + if err != nil { + fail(w, err) + return + } + w.Header().Set("ETag", etag) + w.Header().Set("Cache-Control", "no-cache") + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) +} + +func (h *Handler) writeFile(w http.ResponseWriter, r *http.Request) { + data, err := h.readBody(w, r) + if err != nil { + fail(w, err) + return + } + create := r.Header.Get("If-None-Match") == "*" + entry, etag, err := h.Store.Write(r.URL.Query().Get("path"), data, r.Header.Get("If-Match"), create) + if err != nil { + fail(w, err) + return + } + w.Header().Set("ETag", etag) + writeJSON(w, http.StatusOK, entry) +} + +type pathReq struct { + Path string `json:"path"` +} + +type moveReq struct { + From string `json:"from"` + To string `json:"to"` +} + +func (h *Handler) mkdir(w http.ResponseWriter, r *http.Request) { + var req pathReq + if !h.decode(w, r, &req) { + return + } + entry, err := h.Store.Mkdir(req.Path) + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusCreated, entry) +} + +func (h *Handler) move(w http.ResponseWriter, r *http.Request) { + var req moveReq + if !h.decode(w, r, &req) { + return + } + entry, err := h.Store.Move(req.From, req.To) + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, entry) +} + +func (h *Handler) copy(w http.ResponseWriter, r *http.Request) { + var req moveReq + if !h.decode(w, r, &req) { + return + } + entry, err := h.Store.Copy(req.From, req.To) + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusCreated, entry) +} + +func (h *Handler) delete(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if err := h.Store.Delete(q.Get("path"), q.Get("recursive") == "true"); err != nil { + fail(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) readLibrary(w http.ResponseWriter, r *http.Request) { + data, err := h.Store.ReadLibrary() + if err != nil { + fail(w, err) + return + } + if data == nil { + data = []byte(`{"type":"excalidrawlib","version":2,"libraryItems":[]}`) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(data) +} + +func (h *Handler) writeLibrary(w http.ResponseWriter, r *http.Request) { + data, err := h.readBody(w, r) + if err == nil { + err = h.Store.WriteLibrary(data) + } + if err != nil { + fail(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go new file mode 100644 index 0000000..d714424 --- /dev/null +++ b/internal/storage/storage_test.go @@ -0,0 +1,101 @@ +package storage + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +const doc = `{"type":"excalidraw","version":2,"elements":[]}` + +func newStore(t *testing.T) *Store { + t.Helper() + s, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestClean(t *testing.T) { + ok := map[string]string{"": "", "/": "", "a/b/../c": "a/c", "/../../x": "x"} + for in, want := range ok { + if got, err := Clean(in); err != nil || got != want { + t.Errorf("Clean(%q) = %q, %v ; attendu %q", in, got, err, want) + } + } + for _, in := range []string{".trash/x", "a/.git", `a\b`} { + if _, err := Clean(in); !errors.Is(err, ErrInvalidPath) { + t.Errorf("Clean(%q) aurait dû échouer", in) + } + } +} + +func TestWriteReadConflict(t *testing.T) { + s := newStore(t) + if _, _, err := s.Write("x.txt", []byte(doc), "", false); !errors.Is(err, ErrInvalidPath) { + t.Fatalf("extension non vérifiée : %v", err) + } + if _, _, err := s.Write("a/b.excalidraw", []byte("{"), "", false); !errors.Is(err, ErrInvalidJSON) { + t.Fatalf("JSON non vérifié : %v", err) + } + _, etag, err := s.Write("a/b.excalidraw", []byte(doc), "", true) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.Write("a/b.excalidraw", []byte(doc), "", true); !errors.Is(err, ErrExists) { + t.Fatalf("création en double : %v", err) + } + if _, _, err := s.Write("a/b.excalidraw", []byte(`{"v":1}`), etag, false); err != nil { + t.Fatal(err) + } + if _, _, err := s.Write("a/b.excalidraw", []byte(`{"v":2}`), etag, false); !errors.Is(err, ErrConflict) { + t.Fatalf("conflit non détecté : %v", err) + } + b, _, err := s.Read("a/b.excalidraw") + if err != nil || string(b) != `{"v":1}` { + t.Fatalf("lecture : %s, %v", b, err) + } +} + +func TestListMoveDelete(t *testing.T) { + s := newStore(t) + s.Write("z.excalidraw", []byte(doc), "", false) + s.Mkdir("dir") + os.WriteFile(filepath.Join(s.root, "ignore.png"), nil, 0o644) + + l, err := s.List("") + if err != nil || len(l) != 2 || !l[0].IsDir || l[1].Name != "z.excalidraw" { + t.Fatalf("List = %+v, %v", l, err) + } + if _, err := s.Move("z.excalidraw", "dir/y.excalidraw"); err != nil { + t.Fatal(err) + } + if _, err := s.Move("dir", "dir/sub"); !errors.Is(err, ErrInvalidPath) { + t.Fatalf("déplacement dans soi-même : %v", err) + } + if res, _ := s.Search("Y.EXCAL", 10); len(res) != 1 || res[0].Path != "dir/y.excalidraw" { + t.Fatalf("Search = %+v", res) + } + if err := s.Delete("dir", false); !errors.Is(err, ErrNotEmpty) { + t.Fatalf("suppression non récursive : %v", err) + } + if err := s.Delete("dir", true); err != nil { + t.Fatal(err) + } + if l, _ := s.List(""); len(l) != 0 { + t.Fatalf("racine non vide après suppression : %+v", l) + } +} + +func TestSymlinkEscape(t *testing.T) { + s := newStore(t) + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(s.root, "evil")); err != nil { + t.Skip(err) + } + if _, _, err := s.Write("evil/x.excalidraw", []byte(doc), "", false); !errors.Is(err, ErrInvalidPath) { + t.Fatalf("échappement par symlink non bloqué : %v", err) + } +}