Explorateur : sélection et déplacement multiples, vignettes des schémas #2
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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("<svg/>")); !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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user