From 8a1417055bd15b814c03ff70c5f352a49ce77a9e Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 15 Oct 2026 09:59:22 +0200 Subject: [PATCH] =?UTF-8?q?Poubelle=20par=20utilisateur=20(backend)=20:=20?= =?UTF-8?q?dossier=20virtuel=20.poubelle,=20restauration,=20suppression=20?= =?UTF-8?q?d=C3=A9finitive,=20purge=20automatique=20(trashDays)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/api.go | 37 ++++- internal/api/api_test.go | 40 ++++- internal/mcp/mcp_test.go | 2 +- internal/storage/settings.go | 31 ++-- internal/storage/spaces.go | 145 ++++++++++++++++-- internal/storage/spaces_test.go | 5 +- internal/storage/storage.go | 23 +-- internal/storage/trash.go | 260 ++++++++++++++++++++++++++++++++ internal/storage/trash_test.go | 128 ++++++++++++++++ 9 files changed, 634 insertions(+), 37 deletions(-) create mode 100644 internal/storage/trash.go create mode 100644 internal/storage/trash_test.go diff --git a/internal/api/api.go b/internal/api/api.go index 34b2c36..16e153b 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -12,7 +12,9 @@ // 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 +// DELETE /api/entry?path=x[&recursive=true] mise à la poubelle (suppression définitive +// dans .poubelle ; path=.poubelle : la vide) +// POST /api/trash/restore {"paths":[...]} restauration depuis la poubelle // GET /api/thumb?path=x / PUT (image/png) miniature d'un schéma // GET /api/history?path=x versions d'un schéma // GET /api/history/version?path=x&id=v contenu d'une version @@ -24,13 +26,15 @@ // POST /api/shares/revoke-all révocation de tous les liens // GET /api/public/{jeton} schéma partagé (sans authentification) // GET /api/library / PUT /api/library bibliothèque Excalidraw -// GET /api/settings / PUT {"title","channel"} paramètres (titre, logo, canal) +// GET /api/settings / PUT {"title","channel","trashDays"} paramètres (titre, logo, canal, +// purge de la poubelle après N jours, 0 : jamais) // GET /api/logo / PUT (image/png) / DELETE logo 64x64 // GET /api/update dernière version d'Excalidraw sur npm // // Les chemins sont vus par l'utilisateur connecté (storage.Space) : l'arborescence -// commune, plus ".perso/..." (son espace personnel) et ".favoris" (ses favoris, -// liens symboliques vers les originaux ; DELETE .favoris/x retire le lien). +// commune, plus ".perso/..." (son espace personnel), ".favoris" (ses favoris, +// liens symboliques vers les originaux ; DELETE .favoris/x retire le lien) et +// ".poubelle" (éléments supprimés, de l'arborescence commune et de son espace). package api import ( @@ -87,6 +91,7 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("POST /api/move-many", h.moveMany) mux.HandleFunc("POST /api/copy", h.copy) mux.HandleFunc("DELETE /api/entry", h.delete) + mux.HandleFunc("POST /api/trash/restore", h.restoreTrash) mux.HandleFunc("GET /api/thumb", h.readThumb) mux.HandleFunc("PUT /api/thumb", h.writeThumb) mux.HandleFunc("GET /api/history", h.history) @@ -381,6 +386,18 @@ func (h *Handler) moveMany(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, res) } +func (h *Handler) restoreTrash(w http.ResponseWriter, r *http.Request) { + u, ok := h.space(w, r) + if !ok { + return + } + req, ok := h.decodeMeta(w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, u.RestoreTrash(req.Paths)) +} + func (h *Handler) readThumb(w http.ResponseWriter, r *http.Request) { u, ok := h.space(w, r) if !ok { @@ -648,8 +665,9 @@ func (h *Handler) readSettings(w http.ResponseWriter, r *http.Request) { // settingsReq : seuls les champs présents sont modifiés. type settingsReq struct { - Title *string `json:"title"` - Channel *string `json:"channel"` + Title *string `json:"title"` + Channel *string `json:"channel"` + TrashDays *int `json:"trashDays"` } func (h *Handler) writeSettings(w http.ResponseWriter, r *http.Request) { @@ -664,6 +682,13 @@ func (h *Handler) writeSettings(w http.ResponseWriter, r *http.Request) { if err == nil && req.Channel != nil { st, err = h.Store.WriteChannel(*req.Channel) } + if err == nil && req.TrashDays != nil { + if st, err = h.Store.WriteTrashDays(*req.TrashDays); err == nil { + if perr := h.Spaces.PurgeTrash(); perr != nil { + slog.Warn("poubelle : purge incomplète", "err", perr) + } + } + } if err != nil { fail(w, err) return diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 223e44a..637d346 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -67,7 +67,7 @@ func TestFavoritesPerUser(t *testing.T) { } var root []storage.Entry call(t, srv, "bob@x.fr", "GET", "/api/list?path=", "", &root) - if len(root) != 3 || root[0].Special != "favoris" || root[2].Favorite { + if len(root) != 4 || root[0].Special != "favoris" || root[2].Special != "poubelle" || root[3].Favorite { t.Fatalf("racine de bob = %+v", root) } if c := call(t, srv, "bob@x.fr", "GET", "/api/file?path=.perso/mine.excalidraw", "", nil); c != 404 { @@ -87,3 +87,41 @@ func TestFavoritesPerUser(t *testing.T) { t.Fatalf("écriture dans Favoris : %d", c) } } + +func TestTrash(t *testing.T) { + srv := newServer(t) + call(t, srv, "alice@x.fr", "PUT", "/api/file?path=d/a.excalidraw", doc, nil) + if c := call(t, srv, "alice@x.fr", "DELETE", "/api/entry?path=d/a.excalidraw", "", nil); c != 204 { + t.Fatalf("suppression : %d", c) + } + var items []storage.Entry + call(t, srv, "bob@x.fr", "GET", "/api/list?path=.poubelle", "", &items) + if len(items) != 1 || items[0].Origin != "d/a.excalidraw" || items[0].By != "alice@x.fr" { + t.Fatalf("poubelle vue par bob = %+v", items) + } + var res []storage.MoveResult + if c := call(t, srv, "bob@x.fr", "POST", "/api/trash/restore", `{"paths":["`+items[0].Path+`"]}`, &res); c != 200 || + len(res) != 1 || res[0].To != "d/a.excalidraw" { + t.Fatalf("restauration : %d %+v", c, res) + } + if c := call(t, srv, "alice@x.fr", "GET", "/api/file?path=d/a.excalidraw", "", nil); c != 200 { + t.Fatalf("lecture après restauration : %d", c) + } + + // Réglage de la purge, puis vidage. + var st storage.Settings + if c := call(t, srv, "alice@x.fr", "PUT", "/api/settings", `{"trashDays":7}`, &st); c != 200 || st.TrashDays != 7 { + t.Fatalf("trashDays : %d %+v", c, st) + } + if c := call(t, srv, "alice@x.fr", "PUT", "/api/settings", `{"trashDays":-3}`, nil); c != 400 { + t.Fatalf("trashDays négatif : %d", c) + } + call(t, srv, "alice@x.fr", "DELETE", "/api/entry?path=d/a.excalidraw", "", nil) + if c := call(t, srv, "alice@x.fr", "DELETE", "/api/entry?path=.poubelle", "", nil); c != 204 { + t.Fatalf("vidage : %d", c) + } + call(t, srv, "alice@x.fr", "GET", "/api/list?path=.poubelle", "", &items) + if len(items) != 0 { + t.Fatalf("poubelle après vidage = %+v", items) + } +} diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index d8f363b..adfccdf 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -137,7 +137,7 @@ func TestWriteReadListSearch(t *testing.T) { for _, e := range l["entries"].([]any) { paths = append(paths, e.(map[string]any)["path"].(string)) } - if strings.Join(paths, ",") != ".favoris,.perso,réseau,réseau/app trois tiers.excalidraw" { + if strings.Join(paths, ",") != ".favoris,.perso,.poubelle,réseau,réseau/app trois tiers.excalidraw" { t.Fatalf("liste = %v", paths) } s, _ := callTool(t, h, "search_diagrams", map[string]any{"query": "172.27.207"}) diff --git a/internal/storage/settings.go b/internal/storage/settings.go index 805b928..31b4e43 100644 --- a/internal/storage/settings.go +++ b/internal/storage/settings.go @@ -18,8 +18,8 @@ import ( "github.com/vblogio/excalidraw-go/internal/update" ) -// Paramètres de l'interface (titre, logo, canal d'Excalidraw), partagés par -// tous les navigateurs : .settings.json et .logo.png à la racine. +// Paramètres de l'interface (titre, logo, canal d'Excalidraw) et rétention de +// la corbeille, partagés par tous les navigateurs : .settings.json et .logo.png à la racine. const ( settingsFile = ".settings.json" @@ -31,6 +31,8 @@ const ( MaxLogoUpload = 5 << 20 // MaxTitleLen borne la longueur du titre (en caractères). MaxTitleLen = 80 + // MaxTrashDays borne la durée de conservation de la corbeille (en jours). + MaxTrashDays = 3650 maxLogoSide = 8192 // protège contre les « bombes » de décompression ) @@ -44,16 +46,19 @@ const DefaultChannel = "latest" // Settings est l'état exposé au client. Logo vaut "" sans logo, sinon une // version (date) à ajouter à l'URL pour invalider le cache navigateur. // Channel est le canal npm d'Excalidraw (latest ou next) dont l'interface -// signale les nouvelles versions. +// signale les nouvelles versions. TrashDays est la durée de conservation des +// éléments de la corbeille (0 : jusqu'à ce qu'on la vide). type Settings struct { - Title string `json:"title"` - Logo string `json:"logo"` - Channel string `json:"channel"` + Title string `json:"title"` + Logo string `json:"logo"` + Channel string `json:"channel"` + TrashDays int `json:"trashDays"` } type settingsDisk struct { - Title string `json:"title"` - Channel string `json:"channel,omitempty"` + Title string `json:"title"` + Channel string `json:"channel,omitempty"` + TrashDays int `json:"trashDays,omitempty"` } func (s *Store) readSettingsDisk() (settingsDisk, error) { @@ -97,7 +102,7 @@ func (s *Store) ReadSettings() (Settings, error) { if err != nil { return Settings{}, err } - st := Settings{Title: d.Title, Channel: d.Channel} + st := Settings{Title: d.Title, Channel: d.Channel, TrashDays: d.TrashDays} if !update.ValidChannel(st.Channel) { st.Channel = DefaultChannel } @@ -115,6 +120,14 @@ func (s *Store) WriteChannel(channel string) (Settings, error) { return s.updateSettings(func(d *settingsDisk) { d.Channel = channel }) } +// WriteTrashDays règle la purge automatique de la corbeille (0 : désactivée). +func (s *Store) WriteTrashDays(days int) (Settings, error) { + if days < 0 || days > MaxTrashDays { + return Settings{}, fmt.Errorf("%w : durée de 0 à %d jours", ErrInvalidPath, MaxTrashDays) + } + return s.updateSettings(func(d *settingsDisk) { d.TrashDays = days }) +} + // WriteTitle enregistre le titre affiché ("" = titre par défaut). func (s *Store) WriteTitle(title string) (Settings, error) { title = strings.TrimSpace(title) diff --git a/internal/storage/spaces.go b/internal/storage/spaces.go index 6b4be92..aeb8e48 100644 --- a/internal/storage/spaces.go +++ b/internal/storage/spaces.go @@ -22,16 +22,20 @@ import ( // DATA_DIR/.users//favoris liens symboliques relatifs vers des schémas // // Côté API, l'arborescence commune garde ses chemins. L'espace personnel est -// exposé sous le préfixe virtuel ".perso" et les favoris sous ".favoris" : un -// préfixe caché ne peut pas entrer en conflit avec un dossier réel. Un favori -// pointe vers le schéma d'origine ; il suit ses renommages et déplacements et -// disparaît quand le schéma est supprimé. +// exposé sous le préfixe virtuel ".perso", les favoris sous ".favoris" et la +// poubelle sous ".poubelle" : un préfixe caché ne peut pas entrer en conflit +// avec un dossier réel. Un favori pointe vers le schéma d'origine ; il suit ses +// renommages et déplacements et disparaît quand le schéma est supprimé. La +// poubelle réunit les corbeilles de l'arborescence commune (".poubelle/c-", +// visibles de tous) et de l'espace personnel (".poubelle/p-"). const ( // PersoPrefix est la racine virtuelle de l'espace personnel. PersoPrefix = ".perso" // FavPrefix est le dossier virtuel des favoris. FavPrefix = ".favoris" + // TrashPrefix est le dossier virtuel de la poubelle. + TrashPrefix = ".poubelle" usersDir = ".users" homeName = "home" @@ -76,6 +80,9 @@ type Spaces struct { homes map[string]*Store favMu sync.Mutex // dossiers de favoris (tous utilisateurs) + + purgeMu sync.Mutex + lastPurge time.Time // dernière purge automatique de la corbeille } // NewSpaces crée le gestionnaire d'espaces autour du Store principal. @@ -309,6 +316,8 @@ func (u *Space) route(p string) (*Store, string, error) { return u.home, c[len(PersoPrefix)+1:], nil case c == FavPrefix || strings.HasPrefix(c, FavPrefix+"/"): return nil, "", fmt.Errorf("%w : le dossier Favoris ne contient que des liens", ErrInvalidPath) + case c == TrashPrefix || strings.HasPrefix(c, TrashPrefix+"/"): + return nil, "", fmt.Errorf("%w : élément de la poubelle (à restaurer d'abord)", ErrInvalidPath) } return u.sp.Main, p, nil } @@ -446,10 +455,14 @@ func isRoot(rel string) bool { } // List renvoie le contenu d'un dossier. La racine commune commence par les -// dossiers virtuels Favoris et Personnel. +// dossiers virtuels Favoris, Personnel et Poubelle. func (u *Space) List(p string) ([]Entry, error) { - if cleanVirtual(p) == FavPrefix { + u.sp.autoPurge() + switch cleanVirtual(p) { + case FavPrefix: return u.Favorites() + case TrashPrefix: + return u.Trash() } st, rel, err := u.route(p) if err != nil { @@ -464,7 +477,9 @@ func (u *Space) List(p string) ([]Entry, error) { if st == u.sp.Main && isRoot(rel) { out = append(out, Entry{Name: "Favoris", Path: FavPrefix, IsDir: true, Special: "favoris"}, - Entry{Name: "Personnel", Path: PersoPrefix, IsDir: true, Special: "perso"}) + Entry{Name: "Personnel", Path: PersoPrefix, IsDir: true, Special: "perso"}, + Entry{Name: "Poubelle", Path: TrashPrefix, IsDir: true, Special: "poubelle", + Count: u.sp.Main.TrashCount() + u.home.TrashCount()}) } for _, e := range es { out = append(out, u.entry(st, e, favs)) @@ -747,11 +762,18 @@ func (u *Space) Copy(from, to string) (Entry, error) { } // Delete met un élément à la corbeille de son espace ; les favoris qui le -// visent disparaissent. Sur .favoris/, seul le lien est retiré. +// visent disparaissent. Sur .favoris/, seul le lien est retiré. Dans la +// poubelle, l'élément est supprimé définitivement (.poubelle seul : la vide). func (u *Space) Delete(p string, recursive bool) error { if _, ok := favLinkName(p); ok { return u.SetFavorite([]string{p}, false) } + if cleanVirtual(p) == TrashPrefix { + return u.EmptyTrash() + } + if st, id, ok := u.trashItem(p); ok { + return st.DropTrash(id) + } st, rel, err := u.route(p) if err != nil { return err @@ -760,7 +782,7 @@ func (u *Space) Delete(p string, recursive bool) error { if err != nil { return err } - if err := st.Delete(rel, recursive); err != nil { + if err := st.deleteAs(rel, recursive, u.id); err != nil { return err } u.sp.unlink(abs) @@ -942,3 +964,108 @@ func (s *Store) putShares(c string, add map[string]shareDisk) { return true, nil }) } + +// --- Poubelle --------------------------------------------------------------------- + +// trashItem traduit .poubelle/c- ou .poubelle/p- en Store et identifiant. +func (u *Space) trashItem(p string) (*Store, string, bool) { + key, ok := strings.CutPrefix(cleanVirtual(p), TrashPrefix+"/") + if !ok { + return nil, "", false + } + switch { + case strings.HasPrefix(key, "c-"): + return u.sp.Main, key[2:], true + case strings.HasPrefix(key, "p-"): + return u.home, key[2:], true + } + return nil, "", false +} + +// Trash liste la poubelle : suppressions de l'arborescence commune et de +// l'espace personnel, les plus récentes en premier. ModTime est la date de +// suppression, Origin le chemin d'origine. +func (u *Space) Trash() ([]Entry, error) { + out := []Entry{} + for _, st := range []*Store{u.sp.Main, u.home} { + items, err := st.TrashItems() + if err != nil { + return nil, err + } + prefix := "c-" + if st == u.home { + prefix = "p-" + } + for _, it := range items { + out = append(out, Entry{ + Name: it.Name, + Path: TrashPrefix + "/" + prefix + it.ID, + IsDir: it.IsDir, + Size: it.Size, + ModTime: it.Deleted, + Origin: u.virt(st, it.Path), + By: it.By, + }) + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].ModTime.After(out[j].ModTime) }) + return out, nil +} + +// RestoreTrash remet des éléments de la poubelle à leur emplacement d'origine ; +// To est le chemin restauré (renommé si la place était prise). +func (u *Space) RestoreTrash(paths []string) []MoveResult { + out := make([]MoveResult, 0, len(paths)) + for _, p := range paths { + r := MoveResult{From: p} + st, id, ok := u.trashItem(p) + if !ok { + r.Error = ErrInvalidPath.Error() + } else if e, err := st.RestoreTrash(id); err != nil { + r.Error = err.Error() + } else { + r.To = u.virt(st, e.Path) + } + out = append(out, r) + } + return out +} + +// EmptyTrash vide la poubelle (corbeille commune comprise). +func (u *Space) EmptyTrash() error { + return errors.Join(u.sp.Main.EmptyTrash(), u.home.EmptyTrash()) +} + +// purgeEvery espace les purges automatiques déclenchées par List. +const purgeEvery = 10 * time.Minute + +func (sp *Spaces) autoPurge() { + sp.purgeMu.Lock() + due := time.Since(sp.lastPurge) >= purgeEvery + if due { + sp.lastPurge = time.Now() + } + sp.purgeMu.Unlock() + if due { + _ = sp.PurgeTrash() + } +} + +// PurgeTrash supprime définitivement, dans toutes les corbeilles, les éléments +// plus anciens que Settings.TrashDays (rien si 0). +func (sp *Spaces) PurgeTrash() error { + st, err := sp.Main.ReadSettings() + if err != nil || st.TrashDays <= 0 { + return err + } + maxAge := time.Duration(st.TrashDays) * 24 * time.Hour + errs := []error{sp.Main.PurgeTrashOlder(maxAge)} + for _, id := range sp.users() { + h, err := sp.home(id) + if err == nil { + err = h.PurgeTrashOlder(maxAge) + } + errs = append(errs, err) + } + return errors.Join(errs...) +} diff --git a/internal/storage/spaces_test.go b/internal/storage/spaces_test.go index 74642eb..a536112 100644 --- a/internal/storage/spaces_test.go +++ b/internal/storage/spaces_test.go @@ -77,7 +77,8 @@ func TestSpacesListAndPrivacy(t *testing.T) { write(t, a, "commun.excalidraw") l, err := a.List("") - if err != nil || len(l) != 3 || l[0].Special != "favoris" || l[1].Special != "perso" || l[2].Path != "commun.excalidraw" { + if err != nil || len(l) != 4 || l[0].Special != "favoris" || l[1].Special != "perso" || l[2].Special != "poubelle" || + l[3].Path != "commun.excalidraw" { t.Fatalf("racine = %+v, %v", l, err) } if l, _ := a.List(".perso"); len(l) != 1 || l[0].Path != ".perso/secret.excalidraw" { @@ -242,7 +243,7 @@ func TestSpacesSharesAndCrossMove(t *testing.T) { if _, err := a.Move(".perso/p.excalidraw", "q.excalidraw"); err != nil { t.Fatal(err) } - if l, _ := a.List(""); len(l) != 3 || len(l[2].Tags) != 1 { + if l, _ := a.List(""); len(l) != 4 || len(l[3].Tags) != 1 { t.Fatalf("tags après déplacement : %+v", l) } if vs, err := a.Versions("q.excalidraw"); err != nil || len(vs) != 1 { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 4465d31..9514d24 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -56,6 +56,11 @@ type Entry struct { Special string `json:"special,omitempty"` // "favoris" ou "perso" : entrée de la racine Link string `json:"link,omitempty"` // nom du lien dans le dossier Favoris Broken bool `json:"broken,omitempty"` // favori dont le schéma d'origine a disparu + + // Poubelle (trash.go, spaces.go). + Count int `json:"count,omitempty"` // entrée « poubelle » de la racine : nombre d'éléments + Origin string `json:"origin,omitempty"` // élément de la poubelle : chemin d'origine + By string `json:"by,omitempty"` // élément de la poubelle : supprimé par } // Store est un dépôt de schémas enraciné dans un répertoire. @@ -429,9 +434,15 @@ func (s *Store) Copy(from, to string) (Entry, error) { return e, err } -// Delete déplace un fichier ou un dossier dans la corbeille (.trash/), sans -// suppression physique. Un dossier non vide n'est supprimé que si recursive. +// Delete déplace un fichier ou un dossier dans la corbeille (.trash/, voir +// trash.go), sans suppression physique. Un dossier non vide n'est supprimé que +// si recursive. func (s *Store) Delete(rel string, recursive bool) error { + return s.deleteAs(rel, recursive, "") +} + +// deleteAs est Delete en notant l'auteur de la suppression (by). +func (s *Store) deleteAs(rel string, recursive bool, by string) error { c, abs, err := s.resolve(rel) if err != nil { return err @@ -452,16 +463,10 @@ func (s *Store) Delete(rel string, recursive bool) error { return ErrNotEmpty } } - trash := filepath.Join(s.root, trashDir, time.Now().UTC().Format("20060102T150405.000000000")) - if err := os.MkdirAll(trash, 0o755); err != nil { - return err - } - if err := os.Rename(abs, filepath.Join(trash, filepath.Base(abs))); err != nil { + if err := s.trash(c, abs, by); err != nil { return err } s.removeThumb(c, fi.IsDir()) - s.trashHistory(c, trash) - s.removeMeta(c) s.removeShares(c) return nil } diff --git a/internal/storage/trash.go b/internal/storage/trash.go new file mode 100644 index 0000000..c57723d --- /dev/null +++ b/internal/storage/trash.go @@ -0,0 +1,260 @@ +package storage + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// Corbeille : Delete range chaque élément supprimé dans .trash//, avec +// +// .trash// l'élément lui-même +// .trash//.history/ son historique de versions +// .trash//.info.json chemin d'origine, date, auteur, tags et icônes +// +// L'élément peut être restauré à son emplacement d'origine ou supprimé +// définitivement. Les éléments plus anciens que Settings.TrashDays (0 : jamais) +// sont purgés automatiquement (Spaces.PurgeTrash). + +const trashInfoFile = ".info.json" + +const trashLayout = "20060102T150405.000000000" + +var trashID = regexp.MustCompile(`^\d{8}T\d{6}\.\d{9}$`) + +type trashInfo struct { + Path string `json:"path"` + Deleted time.Time `json:"deleted"` + By string `json:"by,omitempty"` + Meta map[string]itemMeta `json:"meta,omitempty"` // clés relatives à Path +} + +// TrashItem décrit un élément de la corbeille. Path est son chemin d'origine. +type TrashItem struct { + ID string + Name string + Path string + IsDir bool + Size int64 + Deleted time.Time + By string +} + +func (s *Store) trashAbs(id string) string { return filepath.Join(s.root, trashDir, id) } + +// trash range l'élément c (chemin absolu abs) dans une nouvelle entrée de +// corbeille, avec son historique et ses métadonnées. +func (s *Store) trash(c, abs, by string) error { + now := time.Now().UTC() + id := now.Format(trashLayout) + dir := s.trashAbs(id) + for i := 1; ; i++ { // deux suppressions dans la même nanoseconde + if _, err := os.Lstat(dir); errors.Is(err, fs.ErrNotExist) { + break + } + id = now.Add(time.Duration(i)).Format(trashLayout) + dir = s.trashAbs(id) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + if err := os.Rename(abs, filepath.Join(dir, filepath.Base(abs))); err != nil { + _ = os.Remove(dir) + return err + } + info := trashInfo{Path: c, Deleted: now, By: by, Meta: s.takeMeta(c)} + if b, err := json.Marshal(info); err == nil { + _ = atomicWrite(filepath.Join(dir, trashInfoFile), b) + } + s.trashHistory(c, dir) + return nil +} + +// trashEntry lit une entrée de la corbeille : nom de l'élément et informations +// (déduites du nom et de l'identifiant pour les entrées d'avant .info.json). +func (s *Store) trashEntry(id string) (string, trashInfo, error) { + if !trashID.MatchString(id) { + return "", trashInfo{}, fmt.Errorf("%w : élément de corbeille %q", ErrInvalidPath, id) + } + dir := s.trashAbs(id) + des, err := os.ReadDir(dir) + if err != nil { + return "", trashInfo{}, mapErr(err) + } + name := "" + for _, de := range des { + if !strings.HasPrefix(de.Name(), ".") { + name = de.Name() + break + } + } + if name == "" { + return "", trashInfo{}, ErrNotFound + } + var info trashInfo + if b, err := os.ReadFile(filepath.Join(dir, trashInfoFile)); err == nil { + _ = json.Unmarshal(b, &info) + } + if c, err := Clean(info.Path); err != nil || c == "" || path.Base(c) != name { + info.Path = name // emplacement inconnu : restauration à la racine + } + if info.Deleted.IsZero() { + info.Deleted, _ = time.Parse(trashLayout, id) + } + return name, info, nil +} + +// TrashItems liste la corbeille, les suppressions les plus récentes en premier. +func (s *Store) TrashItems() ([]TrashItem, error) { + des, err := os.ReadDir(filepath.Join(s.root, trashDir)) + if errors.Is(err, fs.ErrNotExist) { + return []TrashItem{}, nil + } + if err != nil { + return nil, err + } + out := []TrashItem{} + for _, de := range des { + if !de.IsDir() || !trashID.MatchString(de.Name()) { + continue + } + name, info, err := s.trashEntry(de.Name()) + if err != nil { + continue + } + fi, err := os.Lstat(filepath.Join(s.trashAbs(de.Name()), name)) + if err != nil { + continue + } + it := TrashItem{ID: de.Name(), Name: name, Path: info.Path, IsDir: fi.IsDir(), Deleted: info.Deleted, By: info.By} + if !it.IsDir { + it.Size = fi.Size() + } + out = append(out, it) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + return out, nil +} + +// TrashCount renvoie le nombre d'éléments dans la corbeille. +func (s *Store) TrashCount() int { + des, _ := os.ReadDir(filepath.Join(s.root, trashDir)) + n := 0 + for _, de := range des { + if de.IsDir() && trashID.MatchString(de.Name()) { + n++ + } + } + return n +} + +// freeName renvoie c s'il est libre, sinon « nom (restauré) », « nom (restauré 2) »... +func (s *Store) freeName(c string, isDir bool) string { + if _, err := os.Lstat(absOf(s, c)); errors.Is(err, fs.ErrNotExist) { + return c + } + dir, base, ext := path.Dir(c), path.Base(c), "" + if !isDir { + ext = path.Ext(base) + base = strings.TrimSuffix(base, ext) + } + for i := 1; ; i++ { + suffix := " (restauré)" + if i > 1 { + suffix = fmt.Sprintf(" (restauré %d)", i) + } + n := base + suffix + ext + if dir != "." { + n = dir + "/" + n + } + if _, err := os.Lstat(absOf(s, n)); errors.Is(err, fs.ErrNotExist) { + return n + } + } +} + +// RestoreTrash remet un élément à son emplacement d'origine (dossiers parents +// recréés au besoin), sous un autre nom si la place est prise. +func (s *Store) RestoreTrash(id string) (Entry, error) { + name, info, err := s.trashEntry(id) + if err != nil { + return Entry{}, err + } + dir := s.trashAbs(id) + src := filepath.Join(dir, name) + fi, err := os.Lstat(src) + if err != nil { + return Entry{}, mapErr(err) + } + c := s.freeName(info.Path, fi.IsDir()) + _, abs, err := s.resolve(c) + if err != nil { + return Entry{}, err + } + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return Entry{}, err + } + if err := os.Rename(src, abs); err != nil { + return Entry{}, mapErr(err) + } + moveAside(filepath.Join(dir, historyDir, name), s.historyAbs(c)) + s.putMeta(c, info.Meta) + _ = os.RemoveAll(dir) + if fi, err = os.Stat(abs); err != nil { + return Entry{}, err + } + e := toEntry(c, fi) + decorate(&e, s.metaSnapshot()) + return e, nil +} + +// DropTrash supprime définitivement un élément de la corbeille. +func (s *Store) DropTrash(id string) error { + if _, _, err := s.trashEntry(id); err != nil { + return err + } + return os.RemoveAll(s.trashAbs(id)) +} + +// EmptyTrash vide la corbeille. +func (s *Store) EmptyTrash() error { + return s.purgeTrash(func(string) bool { return true }) +} + +// PurgeTrashOlder supprime définitivement les éléments supprimés il y a plus de maxAge. +func (s *Store) PurgeTrashOlder(maxAge time.Duration) error { + limit := time.Now().Add(-maxAge) + return s.purgeTrash(func(id string) bool { + t, err := time.Parse(trashLayout, id) + return err == nil && t.Before(limit) + }) +} + +func (s *Store) purgeTrash(drop func(id string) bool) error { + root := filepath.Join(s.root, trashDir) + des, err := os.ReadDir(root) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var first error + for _, de := range des { + if !de.IsDir() || !trashID.MatchString(de.Name()) || !drop(de.Name()) { + continue + } + if err := os.RemoveAll(filepath.Join(root, de.Name())); err != nil && first == nil { + first = err + } + } + return first +} diff --git a/internal/storage/trash_test.go b/internal/storage/trash_test.go new file mode 100644 index 0000000..ecd5f72 --- /dev/null +++ b/internal/storage/trash_test.go @@ -0,0 +1,128 @@ +package storage + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestTrash(t *testing.T) { + main := newStore(t) + sp := NewSpaces(main) + u, err := sp.For("alice") + if err != nil { + t.Fatal(err) + } + main.Write("d/a.excalidraw", []byte(doc), "", true) + main.Write("d/b.excalidraw", []byte(doc), "", true) + u.Write(".perso/p.excalidraw", []byte(doc), "", true) + if err := u.EditTags([]string{"d/a.excalidraw"}, []string{"NSX"}, nil); err != nil { + t.Fatal(err) + } + + for _, p := range []string{"d/a.excalidraw", ".perso/p.excalidraw"} { + if err := u.Delete(p, false); err != nil { + t.Fatal(err) + } + } + if err := u.Delete("d", true); err != nil { + t.Fatal(err) + } + root, _ := u.List("") + if len(root) < 3 || root[2].Special != "poubelle" || root[2].Count != 3 { + t.Fatalf("racine = %+v", root) + } + items, err := u.List(TrashPrefix) + if err != nil || len(items) != 3 { + t.Fatalf("poubelle = %+v, %v", items, err) + } + // Plus récent en premier : le dossier d, puis p, puis a. + if items[0].Name != "d" || !items[0].IsDir || items[0].Origin != "d" || items[0].By != "alice" || + items[1].Origin != ".perso/p.excalidraw" || items[2].Origin != "d/a.excalidraw" { + t.Fatalf("poubelle = %+v", items) + } + // Les éléments de la poubelle ne s'ouvrent pas. + if _, _, err := u.Read(items[2].Path); err == nil { + t.Fatal("lecture dans la poubelle acceptée") + } + + // a revient dans d (recréé), avec son tag. + res := u.RestoreTrash([]string{items[2].Path}) + if res[0].Error != "" || res[0].To != "d/a.excalidraw" { + t.Fatalf("restauration = %+v", res) + } + if l, _ := u.List("d"); len(l) != 1 || len(l[0].Tags) != 1 { + t.Fatalf("d = %+v", l) + } + // Le dossier d revient sous un autre nom, la place étant prise. + res = u.RestoreTrash([]string{items[0].Path}) + if res[0].Error != "" || res[0].To != "d (restauré)" { + t.Fatalf("restauration = %+v", res) + } + if l, _ := u.List("d (restauré)"); len(l) != 1 || l[0].Name != "b.excalidraw" { + t.Fatalf("d (restauré) = %+v", l) + } + + // Suppression définitive, puis poubelle vide. + if err := u.Delete(items[1].Path, false); err != nil { + t.Fatal(err) + } + if l, _ := u.Trash(); len(l) != 0 { + t.Fatalf("poubelle = %+v", l) + } + if err := u.Delete(TrashPrefix+"/c-x", false); !errors.Is(err, ErrInvalidPath) { + t.Fatalf("élément inconnu : %v", err) + } + + // Vider la poubelle. + u.Delete("d/a.excalidraw", false) + if err := u.Delete(TrashPrefix, true); err != nil { + t.Fatal(err) + } + if main.TrashCount() != 0 { + t.Fatal("poubelle non vidée") + } +} + +func TestTrashPurge(t *testing.T) { + main := newStore(t) + sp := NewSpaces(main) + u, _ := sp.For("bob") + main.Write("old.excalidraw", []byte(doc), "", true) + main.Write("new.excalidraw", []byte(doc), "", true) + u.Write(".perso/old.excalidraw", []byte(doc), "", true) + u.Delete("old.excalidraw", false) + u.Delete(".perso/old.excalidraw", false) + // Vieillit les deux entrées de 10 jours. + for _, st := range []*Store{main, u.home} { + items, _ := st.TrashItems() + old := time.Now().UTC().Add(-240 * time.Hour).Format(trashLayout) + if err := os.Rename(st.trashAbs(items[0].ID), st.trashAbs(old)); err != nil { + t.Fatal(err) + } + } + u.Delete("new.excalidraw", false) + + // Sans durée de conservation, rien n'est purgé. + if err := sp.PurgeTrash(); err != nil || main.TrashCount() != 2 || u.home.TrashCount() != 1 { + t.Fatalf("purge sans durée : %v", err) + } + if _, err := main.WriteTrashDays(-1); err == nil { + t.Fatal("durée négative acceptée") + } + if st, err := main.WriteTrashDays(7); err != nil || st.TrashDays != 7 { + t.Fatalf("WriteTrashDays = %+v, %v", st, err) + } + if err := sp.PurgeTrash(); err != nil { + t.Fatal(err) + } + if main.TrashCount() != 1 || u.home.TrashCount() != 0 { + t.Fatalf("après purge : %d / %d", main.TrashCount(), u.home.TrashCount()) + } + // L'historique de l'élément purgé disparaît avec lui. + if des, _ := os.ReadDir(filepath.Join(main.root, trashDir)); len(des) != 1 { + t.Fatalf("corbeille = %v", des) + } +}