Poubelle par utilisateur (backend) : dossier virtuel .poubelle, restauration, suppression définitive, purge automatique (trashDays)
This commit is contained in:
1 parent
8c29eb35e7
commit
8a1417055b
9 files changed
+627
-30
No files matched your search
+29
-4
@@ -12,7 +12,9 @@
|
|||||||
// POST /api/move {"from":"x","to":"y"} renommage / déplacement
|
// POST /api/move {"from":"x","to":"y"} renommage / déplacement
|
||||||
// POST /api/move-many {"paths":[...],"dir":"d"} déplacement multiple
|
// POST /api/move-many {"paths":[...],"dir":"d"} déplacement multiple
|
||||||
// POST /api/copy {"from":"x","to":"y"} duplication d'un schéma
|
// 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/thumb?path=x / PUT (image/png) miniature d'un schéma
|
||||||
// GET /api/history?path=x versions 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
|
// 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
|
// POST /api/shares/revoke-all révocation de tous les liens
|
||||||
// GET /api/public/{jeton} schéma partagé (sans authentification)
|
// GET /api/public/{jeton} schéma partagé (sans authentification)
|
||||||
// GET /api/library / PUT /api/library bibliothèque Excalidraw
|
// 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/logo / PUT (image/png) / DELETE logo 64x64
|
||||||
// GET /api/update dernière version d'Excalidraw sur npm
|
// GET /api/update dernière version d'Excalidraw sur npm
|
||||||
//
|
//
|
||||||
// Les chemins sont vus par l'utilisateur connecté (storage.Space) : l'arborescence
|
// Les chemins sont vus par l'utilisateur connecté (storage.Space) : l'arborescence
|
||||||
// commune, plus ".perso/..." (son espace personnel) et ".favoris" (ses favoris,
|
// commune, plus ".perso/..." (son espace personnel), ".favoris" (ses favoris,
|
||||||
// liens symboliques vers les originaux ; DELETE .favoris/x retire le lien).
|
// 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
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -87,6 +91,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("POST /api/move-many", h.moveMany)
|
mux.HandleFunc("POST /api/move-many", h.moveMany)
|
||||||
mux.HandleFunc("POST /api/copy", h.copy)
|
mux.HandleFunc("POST /api/copy", h.copy)
|
||||||
mux.HandleFunc("DELETE /api/entry", h.delete)
|
mux.HandleFunc("DELETE /api/entry", h.delete)
|
||||||
|
mux.HandleFunc("POST /api/trash/restore", h.restoreTrash)
|
||||||
mux.HandleFunc("GET /api/thumb", h.readThumb)
|
mux.HandleFunc("GET /api/thumb", h.readThumb)
|
||||||
mux.HandleFunc("PUT /api/thumb", h.writeThumb)
|
mux.HandleFunc("PUT /api/thumb", h.writeThumb)
|
||||||
mux.HandleFunc("GET /api/history", h.history)
|
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)
|
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) {
|
func (h *Handler) readThumb(w http.ResponseWriter, r *http.Request) {
|
||||||
u, ok := h.space(w, r)
|
u, ok := h.space(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -650,6 +667,7 @@ func (h *Handler) readSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
type settingsReq struct {
|
type settingsReq struct {
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Channel *string `json:"channel"`
|
Channel *string `json:"channel"`
|
||||||
|
TrashDays *int `json:"trashDays"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) writeSettings(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if err == nil && req.Channel != nil {
|
||||||
st, err = h.Store.WriteChannel(*req.Channel)
|
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 {
|
if err != nil {
|
||||||
fail(w, err)
|
fail(w, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func TestFavoritesPerUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var root []storage.Entry
|
var root []storage.Entry
|
||||||
call(t, srv, "bob@x.fr", "GET", "/api/list?path=", "", &root)
|
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)
|
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 {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,7 +137,7 @@ func TestWriteReadListSearch(t *testing.T) {
|
|||||||
for _, e := range l["entries"].([]any) {
|
for _, e := range l["entries"].([]any) {
|
||||||
paths = append(paths, e.(map[string]any)["path"].(string))
|
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)
|
t.Fatalf("liste = %v", paths)
|
||||||
}
|
}
|
||||||
s, _ := callTool(t, h, "search_diagrams", map[string]any{"query": "172.27.207"})
|
s, _ := callTool(t, h, "search_diagrams", map[string]any{"query": "172.27.207"})
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import (
|
|||||||
"github.com/vblogio/excalidraw-go/internal/update"
|
"github.com/vblogio/excalidraw-go/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Paramètres de l'interface (titre, logo, canal d'Excalidraw), partagés par
|
// Paramètres de l'interface (titre, logo, canal d'Excalidraw) et rétention de
|
||||||
// tous les navigateurs : .settings.json et .logo.png à la racine.
|
// la corbeille, partagés par tous les navigateurs : .settings.json et .logo.png à la racine.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
settingsFile = ".settings.json"
|
settingsFile = ".settings.json"
|
||||||
@@ -31,6 +31,8 @@ const (
|
|||||||
MaxLogoUpload = 5 << 20
|
MaxLogoUpload = 5 << 20
|
||||||
// MaxTitleLen borne la longueur du titre (en caractères).
|
// MaxTitleLen borne la longueur du titre (en caractères).
|
||||||
MaxTitleLen = 80
|
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
|
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
|
// Settings est l'état exposé au client. Logo vaut "" sans logo, sinon une
|
||||||
// version (date) à ajouter à l'URL pour invalider le cache navigateur.
|
// version (date) à ajouter à l'URL pour invalider le cache navigateur.
|
||||||
// Channel est le canal npm d'Excalidraw (latest ou next) dont l'interface
|
// 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 {
|
type Settings struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Logo string `json:"logo"`
|
Logo string `json:"logo"`
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
|
TrashDays int `json:"trashDays"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type settingsDisk struct {
|
type settingsDisk struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Channel string `json:"channel,omitempty"`
|
Channel string `json:"channel,omitempty"`
|
||||||
|
TrashDays int `json:"trashDays,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) readSettingsDisk() (settingsDisk, error) {
|
func (s *Store) readSettingsDisk() (settingsDisk, error) {
|
||||||
@@ -97,7 +102,7 @@ func (s *Store) ReadSettings() (Settings, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Settings{}, err
|
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) {
|
if !update.ValidChannel(st.Channel) {
|
||||||
st.Channel = DefaultChannel
|
st.Channel = DefaultChannel
|
||||||
}
|
}
|
||||||
@@ -115,6 +120,14 @@ func (s *Store) WriteChannel(channel string) (Settings, error) {
|
|||||||
return s.updateSettings(func(d *settingsDisk) { d.Channel = channel })
|
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).
|
// WriteTitle enregistre le titre affiché ("" = titre par défaut).
|
||||||
func (s *Store) WriteTitle(title string) (Settings, error) {
|
func (s *Store) WriteTitle(title string) (Settings, error) {
|
||||||
title = strings.TrimSpace(title)
|
title = strings.TrimSpace(title)
|
||||||
|
|||||||
+136
-9
@@ -22,16 +22,20 @@ import (
|
|||||||
// DATA_DIR/.users/<id>/favoris liens symboliques relatifs vers des schémas
|
// DATA_DIR/.users/<id>/favoris liens symboliques relatifs vers des schémas
|
||||||
//
|
//
|
||||||
// Côté API, l'arborescence commune garde ses chemins. L'espace personnel est
|
// 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
|
// exposé sous le préfixe virtuel ".perso", les favoris sous ".favoris" et la
|
||||||
// préfixe caché ne peut pas entrer en conflit avec un dossier réel. Un favori
|
// poubelle sous ".poubelle" : un préfixe caché ne peut pas entrer en conflit
|
||||||
// pointe vers le schéma d'origine ; il suit ses renommages et déplacements et
|
// avec un dossier réel. Un favori pointe vers le schéma d'origine ; il suit ses
|
||||||
// disparaît quand le schéma est supprimé.
|
// 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-<id>",
|
||||||
|
// visibles de tous) et de l'espace personnel (".poubelle/p-<id>").
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// PersoPrefix est la racine virtuelle de l'espace personnel.
|
// PersoPrefix est la racine virtuelle de l'espace personnel.
|
||||||
PersoPrefix = ".perso"
|
PersoPrefix = ".perso"
|
||||||
// FavPrefix est le dossier virtuel des favoris.
|
// FavPrefix est le dossier virtuel des favoris.
|
||||||
FavPrefix = ".favoris"
|
FavPrefix = ".favoris"
|
||||||
|
// TrashPrefix est le dossier virtuel de la poubelle.
|
||||||
|
TrashPrefix = ".poubelle"
|
||||||
|
|
||||||
usersDir = ".users"
|
usersDir = ".users"
|
||||||
homeName = "home"
|
homeName = "home"
|
||||||
@@ -76,6 +80,9 @@ type Spaces struct {
|
|||||||
homes map[string]*Store
|
homes map[string]*Store
|
||||||
|
|
||||||
favMu sync.Mutex // dossiers de favoris (tous utilisateurs)
|
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.
|
// 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
|
return u.home, c[len(PersoPrefix)+1:], nil
|
||||||
case c == FavPrefix || strings.HasPrefix(c, FavPrefix+"/"):
|
case c == FavPrefix || strings.HasPrefix(c, FavPrefix+"/"):
|
||||||
return nil, "", fmt.Errorf("%w : le dossier Favoris ne contient que des liens", ErrInvalidPath)
|
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
|
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
|
// 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) {
|
func (u *Space) List(p string) ([]Entry, error) {
|
||||||
if cleanVirtual(p) == FavPrefix {
|
u.sp.autoPurge()
|
||||||
|
switch cleanVirtual(p) {
|
||||||
|
case FavPrefix:
|
||||||
return u.Favorites()
|
return u.Favorites()
|
||||||
|
case TrashPrefix:
|
||||||
|
return u.Trash()
|
||||||
}
|
}
|
||||||
st, rel, err := u.route(p)
|
st, rel, err := u.route(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -464,7 +477,9 @@ func (u *Space) List(p string) ([]Entry, error) {
|
|||||||
if st == u.sp.Main && isRoot(rel) {
|
if st == u.sp.Main && isRoot(rel) {
|
||||||
out = append(out,
|
out = append(out,
|
||||||
Entry{Name: "Favoris", Path: FavPrefix, IsDir: true, Special: "favoris"},
|
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 {
|
for _, e := range es {
|
||||||
out = append(out, u.entry(st, e, favs))
|
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
|
// Delete met un élément à la corbeille de son espace ; les favoris qui le
|
||||||
// visent disparaissent. Sur .favoris/<lien>, seul le lien est retiré.
|
// visent disparaissent. Sur .favoris/<lien>, 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 {
|
func (u *Space) Delete(p string, recursive bool) error {
|
||||||
if _, ok := favLinkName(p); ok {
|
if _, ok := favLinkName(p); ok {
|
||||||
return u.SetFavorite([]string{p}, false)
|
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)
|
st, rel, err := u.route(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -760,7 +782,7 @@ func (u *Space) Delete(p string, recursive bool) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := st.Delete(rel, recursive); err != nil {
|
if err := st.deleteAs(rel, recursive, u.id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u.sp.unlink(abs)
|
u.sp.unlink(abs)
|
||||||
@@ -942,3 +964,108 @@ func (s *Store) putShares(c string, add map[string]shareDisk) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Poubelle ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
// trashItem traduit .poubelle/c-<id> ou .poubelle/p-<id> 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...)
|
||||||
|
}
|
||||||
@@ -77,7 +77,8 @@ func TestSpacesListAndPrivacy(t *testing.T) {
|
|||||||
write(t, a, "commun.excalidraw")
|
write(t, a, "commun.excalidraw")
|
||||||
|
|
||||||
l, err := a.List("")
|
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)
|
t.Fatalf("racine = %+v, %v", l, err)
|
||||||
}
|
}
|
||||||
if l, _ := a.List(".perso"); len(l) != 1 || l[0].Path != ".perso/secret.excalidraw" {
|
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 {
|
if _, err := a.Move(".perso/p.excalidraw", "q.excalidraw"); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("tags après déplacement : %+v", l)
|
||||||
}
|
}
|
||||||
if vs, err := a.Versions("q.excalidraw"); err != nil || len(vs) != 1 {
|
if vs, err := a.Versions("q.excalidraw"); err != nil || len(vs) != 1 {
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ type Entry struct {
|
|||||||
Special string `json:"special,omitempty"` // "favoris" ou "perso" : entrée de la racine
|
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
|
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
|
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.
|
// 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
|
return e, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete déplace un fichier ou un dossier dans la corbeille (.trash/), sans
|
// Delete déplace un fichier ou un dossier dans la corbeille (.trash/, voir
|
||||||
// suppression physique. Un dossier non vide n'est supprimé que si recursive.
|
// trash.go), sans suppression physique. Un dossier non vide n'est supprimé que
|
||||||
|
// si recursive.
|
||||||
func (s *Store) Delete(rel string, recursive bool) error {
|
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)
|
c, abs, err := s.resolve(rel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -452,16 +463,10 @@ func (s *Store) Delete(rel string, recursive bool) error {
|
|||||||
return ErrNotEmpty
|
return ErrNotEmpty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
trash := filepath.Join(s.root, trashDir, time.Now().UTC().Format("20060102T150405.000000000"))
|
if err := s.trash(c, abs, by); err != nil {
|
||||||
if err := os.MkdirAll(trash, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.Rename(abs, filepath.Join(trash, filepath.Base(abs))); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.removeThumb(c, fi.IsDir())
|
s.removeThumb(c, fi.IsDir())
|
||||||
s.trashHistory(c, trash)
|
|
||||||
s.removeMeta(c)
|
|
||||||
s.removeShares(c)
|
s.removeShares(c)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/<id>/, avec
|
||||||
|
//
|
||||||
|
// .trash/<id>/<nom> l'élément lui-même
|
||||||
|
// .trash/<id>/.history/<nom> son historique de versions
|
||||||
|
// .trash/<id>/.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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user