From 66eb1c08de6b390baf5b1189855785ca9cce9fb5 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 7 Oct 2026 12:45:21 +0200 Subject: [PATCH] =?UTF-8?q?API=20:=20d=C3=A9placement=20multiple=20(/api/m?= =?UTF-8?q?ove-many)=20et=20miniatures=20(/api/thumb)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CZ78TqR7fgtRx3rvpNUbAL --- internal/api/api.go | 56 ++++++++++++++++ internal/storage/storage.go | 60 ++++++++++++++++- internal/storage/storage_test.go | 94 +++++++++++++++++++++++++++ internal/storage/thumbs.go | 108 +++++++++++++++++++++++++++++++ 4 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 internal/storage/thumbs.go diff --git a/internal/api/api.go b/internal/api/api.go index 5c36d83..51fd628 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -6,8 +6,10 @@ // 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/move-many {"paths":[...],"dir":"d"} déplacement multiple // POST /api/copy {"from":"x","to":"y"} duplication d'un schéma // DELETE /api/entry?path=x[&recursive=true] mise à la corbeille +// GET /api/thumb?path=x / PUT (image/png) miniature d'un schéma // GET /api/library / PUT /api/library bibliothèque Excalidraw package api @@ -35,8 +37,11 @@ func (h *Handler) Register(mux *http.ServeMux) { 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/move-many", h.moveMany) mux.HandleFunc("POST /api/copy", h.copy) mux.HandleFunc("DELETE /api/entry", h.delete) + mux.HandleFunc("GET /api/thumb", h.readThumb) + mux.HandleFunc("PUT /api/thumb", h.writeThumb) mux.HandleFunc("GET /api/library", h.readLibrary) mux.HandleFunc("PUT /api/library", h.writeLibrary) mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { @@ -175,6 +180,57 @@ func (h *Handler) move(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, entry) } +type moveManyReq struct { + Paths []string `json:"paths"` + Dir string `json:"dir"` +} + +// moveMany renvoie toujours 200 avec le détail par élément : le client +// affiche les échecs partiels (ex. nom déjà pris dans la destination). +func (h *Handler) moveMany(w http.ResponseWriter, r *http.Request) { + var req moveManyReq + if !h.decode(w, r, &req) { + return + } + if len(req.Paths) == 0 || len(req.Paths) > 1000 { + writeErr(w, http.StatusBadRequest, "entre 1 et 1000 chemins attendus") + return + } + res, err := h.Store.MoveMany(req.Paths, req.Dir) + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, res) +} + +func (h *Handler) readThumb(w http.ResponseWriter, r *http.Request) { + data, mod, err := h.Store.ReadThumb(r.URL.Query().Get("path")) + if err != nil { + // Pas de mise en cache d'une absence : la miniature va être générée. + w.Header().Set("Cache-Control", "no-store") + fail(w, err) + return + } + // L'URL porte la date du schéma (?v=...) : une nouvelle version change l'URL. + w.Header().Set("Cache-Control", "private, max-age=86400") + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Last-Modified", mod.UTC().Format(http.TimeFormat)) + _, _ = w.Write(data) +} + +func (h *Handler) writeThumb(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, storage.MaxThumbSize)) + if err == nil { + err = h.Store.WriteThumb(r.URL.Query().Get("path"), data) + } + if err != nil { + fail(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *Handler) copy(w http.ResponseWriter, r *http.Request) { var req moveReq if !h.decode(w, r, &req) { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index f57f089..c25ed99 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -25,6 +25,7 @@ const Ext = ".excalidraw" const ( trashDir = ".trash" + thumbsDir = ".thumbs" libraryFile = ".library.excalidrawlib" ) @@ -358,6 +359,7 @@ func (s *Store) Move(from, to string) (Entry, error) { if err := os.Rename(af, at); err != nil { return Entry{}, mapErr(err) } + s.moveThumb(cf, ct, fi.IsDir()) fi, err = os.Stat(at) if err != nil { return Entry{}, err @@ -365,6 +367,55 @@ func (s *Store) Move(from, to string) (Entry, error) { return toEntry(ct, fi), nil } +// MoveResult est le résultat d'un déplacement unitaire dans MoveMany. +type MoveResult struct { + From string `json:"from"` + To string `json:"to,omitempty"` + Error string `json:"error,omitempty"` + err error +} + +// Err renvoie l'erreur du déplacement (nil si réussi). +func (r MoveResult) Err() error { return r.err } + +// MoveMany déplace plusieurs fichiers ou dossiers dans le dossier dir en +// conservant leur nom. Chaque élément est traité indépendamment : un échec +// (destination existante, dossier déplacé dans lui-même...) n'interrompt pas +// les autres. Les éléments déjà présents dans dir sont ignorés. +func (s *Store) MoveMany(paths []string, dir string) ([]MoveResult, error) { + cd, ad, err := s.resolve(dir) + if err != nil { + return nil, err + } + fi, err := os.Stat(ad) + if err != nil { + return nil, mapErr(err) + } + if !fi.IsDir() { + return nil, fmt.Errorf("%w : la destination n'est pas un dossier", ErrInvalidPath) + } + out := make([]MoveResult, 0, len(paths)) + for _, p := range paths { + r := MoveResult{From: p} + if c, err := Clean(p); err != nil || c == "" { + r.err = ErrInvalidPath + } else if strings.TrimPrefix(path.Dir("/"+c), "/") == cd { + r.From, r.To = c, c // déjà dans le dossier cible + } else { + r.From = c + var e Entry + if e, r.err = s.Move(c, path.Join(cd, path.Base(c))); r.err == nil { + r.To = e.Path + } + } + if r.err != nil { + r.Error = r.err.Error() + } + out = append(out, r) + } + return out, nil +} + // Copy duplique un schéma (les dossiers ne sont pas copiés). func (s *Store) Copy(from, to string) (Entry, error) { data, _, err := s.Read(from) @@ -372,6 +423,9 @@ func (s *Store) Copy(from, to string) (Entry, error) { return Entry{}, err } e, _, err := s.Write(to, data, "", true) + if err == nil { + s.copyThumb(from, e.Path) + } return e, err } @@ -402,7 +456,11 @@ func (s *Store) Delete(rel string, recursive bool) error { if err := os.MkdirAll(trash, 0o755); err != nil { return err } - return os.Rename(abs, filepath.Join(trash, filepath.Base(abs))) + if err := os.Rename(abs, filepath.Join(trash, filepath.Base(abs))); err != nil { + return err + } + s.removeThumb(c, fi.IsDir()) + return nil } // ReadLibrary renvoie la bibliothèque Excalidraw partagée (ou nil si absente). diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index d714424..7b119ca 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "testing" + "time" ) const doc = `{"type":"excalidraw","version":2,"elements":[]}` @@ -99,3 +100,96 @@ func TestSymlinkEscape(t *testing.T) { t.Fatalf("échappement par symlink non bloqué : %v", err) } } + +func TestMoveMany(t *testing.T) { + s := newStore(t) + for _, p := range []string{"a.excalidraw", "b.excalidraw", "src/c.excalidraw", "dst/b.excalidraw"} { + if _, _, err := s.Write(p, []byte(doc), "", true); err != nil { + t.Fatal(err) + } + } + if _, err := s.Mkdir("src/sub"); err != nil { + t.Fatal(err) + } + if _, err := s.MoveMany([]string{"a.excalidraw"}, "a.excalidraw"); !errors.Is(err, ErrInvalidPath) { + t.Fatalf("destination fichier acceptée : %v", err) + } + res, err := s.MoveMany([]string{"a.excalidraw", "b.excalidraw", "src/c.excalidraw", "src/sub", "dst/b.excalidraw", "dst", "nope.excalidraw"}, "dst") + if err != nil { + t.Fatal(err) + } + want := []struct { + to string + err error + }{ + {"dst/a.excalidraw", nil}, + {"", ErrExists}, + {"dst/c.excalidraw", nil}, + {"dst/sub", nil}, + {"dst/b.excalidraw", nil}, // déjà dans dst : ignoré + {"", ErrInvalidPath}, // dossier dans lui-même + {"", ErrNotFound}, + } + for i, w := range want { + if res[i].To != w.to || !errors.Is(res[i].Err(), w.err) { + t.Errorf("%s : to=%q err=%v ; attendu %q, %v", res[i].From, res[i].To, res[i].Err(), w.to, w.err) + } + } + if _, _, err := s.Read("b.excalidraw"); err != nil { + t.Fatalf("b déplacé malgré le conflit : %v", err) + } + // Vers la racine. + res, err = s.MoveMany([]string{"dst/sub", "b.excalidraw"}, "") + if err != nil || res[0].To != "sub" || res[0].Err() != nil || res[1].To != "b.excalidraw" { + t.Fatalf("vers la racine : %+v, %v", res, err) + } +} + +var png = []byte("\x89PNG\r\n\x1a\nfake") + +func TestThumbs(t *testing.T) { + s := newStore(t) + if err := s.WriteThumb("d/a.excalidraw", png); !errors.Is(err, ErrNotFound) { + t.Fatalf("miniature d'un schéma absent : %v", err) + } + if _, _, err := s.Write("d/a.excalidraw", []byte(doc), "", true); err != nil { + t.Fatal(err) + } + if err := s.WriteThumb("d/a.excalidraw", []byte("")); !errors.Is(err, ErrInvalidPath) { + t.Fatalf("non-PNG accepté : %v", err) + } + if _, _, err := s.ReadThumb("d/a.excalidraw"); !errors.Is(err, ErrNotFound) { + t.Fatalf("miniature inexistante : %v", err) + } + if err := s.WriteThumb("d/a.excalidraw", png); err != nil { + t.Fatal(err) + } + if b, _, err := s.ReadThumb("d/a.excalidraw"); err != nil || string(b) != string(png) { + t.Fatalf("lecture : %q, %v", b, err) + } + // Suit le déplacement du dossier, la copie, puis la suppression. + if _, err := s.Move("d", "e"); err != nil { + t.Fatal(err) + } + if _, _, err := s.ReadThumb("e/a.excalidraw"); err != nil { + t.Fatalf("après déplacement : %v", err) + } + if _, err := s.Copy("e/a.excalidraw", "e/b.excalidraw"); err != nil { + t.Fatal(err) + } + if _, _, err := s.ReadThumb("e/b.excalidraw"); err != nil { + t.Fatalf("après copie : %v", err) + } + if err := s.Delete("e/a.excalidraw", false); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(s.root, thumbsDir, "e", "a.excalidraw.png")); !os.IsNotExist(err) { + t.Fatalf("miniature non supprimée : %v", err) + } + // Périmée après modification du schéma. + old := time.Now().Add(-time.Hour) + _ = os.Chtimes(filepath.Join(s.root, thumbsDir, "e", "b.excalidraw.png"), old, old) + if _, _, err := s.ReadThumb("e/b.excalidraw"); !errors.Is(err, ErrNotFound) { + t.Fatalf("miniature périmée servie : %v", err) + } +} diff --git a/internal/storage/thumbs.go b/internal/storage/thumbs.go new file mode 100644 index 0000000..9fed564 --- /dev/null +++ b/internal/storage/thumbs.go @@ -0,0 +1,108 @@ +package storage + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "time" +) + +// Miniatures PNG des schémas, générées par le navigateur et rangées dans +// .thumbs/ en miroir de l'arborescence (a/b.excalidraw -> .thumbs/a/b.excalidraw.png). +// Une miniature plus ancienne que son schéma est considérée comme périmée. + +// MaxThumbSize borne la taille d'une miniature acceptée. +const MaxThumbSize = 1 << 20 + +var pngMagic = []byte("\x89PNG\r\n\x1a\n") + +func (s *Store) thumbAbs(c string, isDir bool) string { + p := filepath.Join(s.root, thumbsDir, filepath.FromSlash(c)) + if isDir { + return p + } + return p + ".png" +} + +// ReadThumb renvoie la miniature d'un schéma et sa date, ou ErrNotFound si +// elle est absente ou périmée. +func (s *Store) ReadThumb(rel string) ([]byte, time.Time, error) { + c, abs, err := s.resolve(rel) + if err != nil { + return nil, time.Time{}, err + } + if err := requireSchemaPath(c); err != nil { + return nil, time.Time{}, err + } + src, err := os.Stat(abs) + if err != nil { + return nil, time.Time{}, mapErr(err) + } + ta := s.thumbAbs(c, false) + ti, err := os.Stat(ta) + if err != nil { + return nil, time.Time{}, mapErr(err) + } + if ti.ModTime().Before(src.ModTime()) { + return nil, time.Time{}, fmt.Errorf("%w : miniature périmée", ErrNotFound) + } + b, err := os.ReadFile(ta) + return b, ti.ModTime(), mapErr(err) +} + +// WriteThumb enregistre la miniature PNG d'un schéma existant. +func (s *Store) WriteThumb(rel string, png []byte) error { + c, abs, err := s.resolve(rel) + if err != nil { + return err + } + if err := requireSchemaPath(c); err != nil { + return err + } + if !bytes.HasPrefix(png, pngMagic) || len(png) > MaxThumbSize { + return fmt.Errorf("%w : PNG de %d Ko max attendu", ErrInvalidPath, MaxThumbSize>>10) + } + if _, err := os.Stat(abs); err != nil { + return mapErr(err) + } + ta := s.thumbAbs(c, false) + if err := os.MkdirAll(filepath.Dir(ta), 0o755); err != nil { + return err + } + return atomicWrite(ta, png) +} + +// Les opérations suivantes accompagnent Move/Copy/Delete. Elles sont +// « au mieux » : une miniature manquante sera simplement régénérée. + +func (s *Store) moveThumb(from, to string, isDir bool) { + src, dst := s.thumbAbs(from, isDir), s.thumbAbs(to, isDir) + if _, err := os.Stat(src); err != nil { + return + } + _ = os.RemoveAll(dst) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err == nil { + _ = os.Rename(src, dst) + } +} + +func (s *Store) copyThumb(from, to string) { + cf, err1 := Clean(from) + ct, err2 := Clean(to) + if err1 != nil || err2 != nil { + return + } + b, err := os.ReadFile(s.thumbAbs(cf, false)) + if err != nil { + return + } + dst := s.thumbAbs(ct, false) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err == nil { + _ = atomicWrite(dst, b) // écrite après la copie du schéma : donc à jour + } +} + +func (s *Store) removeThumb(c string, isDir bool) { + _ = os.RemoveAll(s.thumbAbs(c, isDir)) +}