From 781fc8f5f5f23541725e05cc64acecf8278918af Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 8 Oct 2026 10:24:55 +0200 Subject: [PATCH] Espaces personnels et favoris par utilisateur (stockage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DATA_DIR/.users//home : espace personnel, Store complet (historique, miniatures, corbeille, tags, partages), exposé sous le préfixe virtuel .perso. - DATA_DIR/.users//favoris : un lien symbolique relatif par favori, exposé sous .favoris. Les liens suivent renommages et déplacements (y compris entre espaces) pour tous les utilisateurs et disparaissent à la suppression. - Identifiant : email OIDC normalisé (UserID), utilisateur « local » sinon. - Reprise unique des favoris de .meta.json à la création d'un espace. - Les espaces personnels signent leurs liens de partage avec la clé du Store principal ; Tout révoquer couvre aussi les espaces personnels. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0174sFqehj4uGqJHQZvWy1gn --- internal/storage/share.go | 14 +- internal/storage/spaces.go | 926 ++++++++++++++++++++++++++++++++++++ internal/storage/storage.go | 6 + 3 files changed, 944 insertions(+), 2 deletions(-) create mode 100644 internal/storage/spaces.go diff --git a/internal/storage/share.go b/internal/storage/share.go index c5a284d..37f16c1 100644 --- a/internal/storage/share.go +++ b/internal/storage/share.go @@ -63,6 +63,9 @@ func (s *Store) SetShareSecret(secret string) { // key renvoie la clé de signature, créée au besoin ; appelé sous sharesMu. func (s *Store) key() ([]byte, error) { + if s.keyFrom != nil { + return s.keyFrom.lockedKey() + } if s.shareKey != nil { return s.shareKey, nil } @@ -79,6 +82,13 @@ func (s *Store) key() ([]byte, error) { return s.newKey() } +// lockedKey renvoie la clé de signature pour un espace personnel. +func (s *Store) lockedKey() ([]byte, error) { + s.sharesMu.Lock() + defer s.sharesMu.Unlock() + return s.key() +} + func (s *Store) newKey() ([]byte, error) { k := make([]byte, 32) if _, err := rand.Read(k); err != nil { @@ -230,11 +240,11 @@ func (s *Store) RevokeShare(id string) error { } // RevokeAllShares supprime tous les liens et change la clé de signature -// (sauf si elle est imposée par SHARE_SECRET). +// (sauf si elle est imposée par SHARE_SECRET ou reprise du Store principal). func (s *Store) RevokeAllShares() error { return s.editShares(func(m map[string]shareDisk) (bool, error) { clear(m) - if !s.fixedKey { + if !s.fixedKey && s.keyFrom == nil { if _, err := s.newKey(); err != nil { return false, err } diff --git a/internal/storage/spaces.go b/internal/storage/spaces.go new file mode 100644 index 0000000..b04e8a6 --- /dev/null +++ b/internal/storage/spaces.go @@ -0,0 +1,926 @@ +package storage + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// Espaces personnels et favoris par utilisateur : +// +// DATA_DIR/.users//home espace personnel, un Store complet (historique, +// miniatures, corbeille, tags, partages) +// 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é. + +const ( + // PersoPrefix est la racine virtuelle de l'espace personnel. + PersoPrefix = ".perso" + // FavPrefix est le dossier virtuel des favoris. + FavPrefix = ".favoris" + + usersDir = ".users" + homeName = "home" + favName = "favoris" + seededFile = ".seeded" // favoris de .meta.json déjà repris pour cet utilisateur +) + +// LocalUser est l'utilisateur unique hors OIDC. +const LocalUser = "local" + +// UserID transforme un identifiant (email, sub) en nom de dossier : minuscules, +// caractères hors [a-z0-9._@+-] remplacés par « _ », jamais caché. +func UserID(raw string) string { + s := strings.ToLower(strings.TrimSpace(raw)) + var b strings.Builder + for _, r := range s { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || strings.ContainsRune("._@+-", r) { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + id := b.String() + if strings.HasPrefix(id, ".") { + id = "_" + id[1:] + } + if id == "" { + return LocalUser + } + if len(id) > 80 { + h := sha256.Sum256([]byte(s)) + id = id[:60] + "-" + hex.EncodeToString(h[:8]) + } + return id +} + +// Spaces réunit l'arborescence commune et les espaces des utilisateurs. +type Spaces struct { + Main *Store + + mu sync.Mutex + homes map[string]*Store + + favMu sync.Mutex // dossiers de favoris (tous utilisateurs) +} + +// NewSpaces crée le gestionnaire d'espaces autour du Store principal. +func NewSpaces(main *Store) *Spaces { + return &Spaces{Main: main, homes: map[string]*Store{}} +} + +func (sp *Spaces) usersRoot() string { return filepath.Join(sp.Main.root, usersDir) } +func (sp *Spaces) userDir(id string) string { return filepath.Join(sp.usersRoot(), id) } +func (sp *Spaces) favDir(id string) string { return filepath.Join(sp.userDir(id), favName) } + +// home renvoie l'espace personnel de id, créé au premier accès. +func (sp *Spaces) home(id string) (*Store, error) { + if id == "" || UserID(id) != id { + return nil, fmt.Errorf("%w : utilisateur %q", ErrInvalidPath, id) + } + sp.mu.Lock() + defer sp.mu.Unlock() + if st := sp.homes[id]; st != nil { + return st, nil + } + if err := os.MkdirAll(sp.favDir(id), 0o755); err != nil { + return nil, err + } + st, err := New(filepath.Join(sp.userDir(id), homeName)) + if err != nil { + return nil, err + } + st.History = sp.Main.History + st.keyFrom = sp.Main + sp.homes[id] = st + sp.seedFavorites(id) + return st, nil +} + +// seedFavorites reprend une fois les favoris de .meta.json (avant les espaces +// personnels, ils étaient communs à tous). +func (sp *Spaces) seedFavorites(id string) { + mark := filepath.Join(sp.userDir(id), seededFile) + if _, err := os.Stat(mark); err == nil { + return + } + var paths []string + for p, m := range sp.Main.metaSnapshot() { + if m.Favorite && requireSchemaPath(p) == nil { + paths = append(paths, p) + } + } + sort.Strings(paths) + sp.favMu.Lock() + for _, p := range paths { + if _, abs, err := sp.Main.schema(p); err == nil { + if fi, err := os.Lstat(abs); err == nil && fi.Mode().IsRegular() { + _ = sp.addLink(id, abs) + } + } + } + sp.favMu.Unlock() + _ = os.WriteFile(mark, []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644) +} + +// For renvoie la vue de l'utilisateur id. +func (sp *Spaces) For(id string) (*Space, error) { + h, err := sp.home(id) + if err != nil { + return nil, err + } + return &Space{sp: sp, id: id, home: h}, nil +} + +// users liste les utilisateurs qui ont un espace. +func (sp *Spaces) users() []string { + des, _ := os.ReadDir(sp.usersRoot()) + var out []string + for _, de := range des { + if de.IsDir() && UserID(de.Name()) == de.Name() { + out = append(out, de.Name()) + } + } + return out +} + +// ReadShared résout un lien de partage, de l'arborescence commune ou d'un +// espace personnel. +func (sp *Spaces) ReadShared(token string) (Share, []byte, error) { + sh, data, err := sp.Main.ReadShared(token) + if !errors.Is(err, ErrNotFound) { + return sh, data, err + } + for _, id := range sp.users() { + st, herr := sp.home(id) + if herr != nil { + continue + } + if sh, data, herr := st.ReadShared(token); !errors.Is(herr, ErrNotFound) { + return sh, data, herr + } + } + return Share{}, nil, err +} + +// --- Liens des favoris ------------------------------------------------------- + +type favLink struct { + name string // nom du lien dans le dossier des favoris + target string // chemin absolu visé +} + +// links lit les liens du dossier des favoris de id ; appelé sous favMu. +func (sp *Spaces) links(id string) []favLink { + dir := sp.favDir(id) + des, _ := os.ReadDir(dir) + var out []favLink + for _, de := range des { + if de.Type()&fs.ModeSymlink == 0 { + continue + } + t, err := os.Readlink(filepath.Join(dir, de.Name())) + if err != nil { + continue + } + if !filepath.IsAbs(t) { + t = filepath.Join(dir, t) + } + out = append(out, favLink{name: de.Name(), target: filepath.Clean(t)}) + } + return out +} + +// visibleTo indique si l'utilisateur id peut viser abs : arborescence commune +// ou son propre espace personnel. +func (sp *Spaces) visibleTo(id, abs string) bool { + sep := string(os.PathSeparator) + if strings.HasPrefix(abs, sp.usersRoot()+sep) { + return strings.HasPrefix(abs, filepath.Join(sp.userDir(id), homeName)+sep) + } + return strings.HasPrefix(abs, sp.Main.root+sep) +} + +// addLink crée un lien vers abs (nom du schéma, suffixé en cas de doublon) ; +// appelé sous favMu. +func (sp *Spaces) addLink(id, abs string) error { + for _, l := range sp.links(id) { + if l.target == abs { + return nil + } + } + dir := sp.favDir(id) + rel, err := filepath.Rel(dir, abs) + if err != nil { + return err + } + base := filepath.Base(abs) + stem, ext := strings.TrimSuffix(base, filepath.Ext(base)), filepath.Ext(base) + name := base + for i := 2; ; i++ { + if _, err := os.Lstat(filepath.Join(dir, name)); errors.Is(err, fs.ErrNotExist) { + break + } + name = fmt.Sprintf("%s (%d)%s", stem, i, ext) + } + return os.Symlink(rel, filepath.Join(dir, name)) +} + +// relink fait suivre aux favoris de tous les utilisateurs un élément déplacé +// de from vers to (chemins absolus, fichier ou dossier). Un favori qui +// deviendrait invisible pour son utilisateur (schéma passé dans l'espace +// personnel d'un autre) est retiré. +func (sp *Spaces) relink(from, to string) { + sp.favMu.Lock() + defer sp.favMu.Unlock() + for _, id := range sp.users() { + dir := sp.favDir(id) + for _, l := range sp.links(id) { + if l.target != from && !strings.HasPrefix(l.target, from+string(os.PathSeparator)) { + continue + } + nt := to + l.target[len(from):] + lp := filepath.Join(dir, l.name) + _ = os.Remove(lp) + if sp.visibleTo(id, nt) { + if rel, err := filepath.Rel(dir, nt); err == nil { + _ = os.Symlink(rel, lp) + } + } + } + } +} + +// unlink retire, chez tous les utilisateurs, les favoris visant abs ou son contenu. +func (sp *Spaces) unlink(abs string) { + sp.favMu.Lock() + defer sp.favMu.Unlock() + for _, id := range sp.users() { + for _, l := range sp.links(id) { + if l.target == abs || strings.HasPrefix(l.target, abs+string(os.PathSeparator)) { + _ = os.Remove(filepath.Join(sp.favDir(id), l.name)) + } + } + } +} + +// --- Vue d'un utilisateur ------------------------------------------------------ + +// Space est la vue d'un utilisateur : arborescence commune, espace personnel +// (préfixe .perso) et favoris (.favoris). Ses méthodes prennent et renvoient +// des chemins virtuels. +type Space struct { + sp *Spaces + id string + home *Store +} + +// ID renvoie l'identifiant de l'utilisateur. +func (u *Space) ID() string { return u.id } + +func cleanVirtual(p string) string { return strings.TrimPrefix(path.Clean("/"+p), "/") } + +func favLinkName(p string) (string, bool) { + name, ok := strings.CutPrefix(cleanVirtual(p), FavPrefix+"/") + return name, ok && name != "" && !strings.Contains(name, "/") +} + +// route traduit un chemin virtuel en Store et chemin relatif à ce Store. +func (u *Space) route(p string) (*Store, string, error) { + c := cleanVirtual(p) + switch { + case c == PersoPrefix: + return u.home, "", nil + case strings.HasPrefix(c, PersoPrefix+"/"): + 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) + } + return u.sp.Main, p, nil +} + +// virt traduit un chemin relatif à st en chemin virtuel. +func (u *Space) virt(st *Store, rel string) string { + if st == u.sp.Main { + return rel + } + if rel == "" { + return PersoPrefix + } + return PersoPrefix + "/" + rel +} + +// fromAbs traduit un chemin absolu en chemin virtuel, s'il est visible. +func (u *Space) fromAbs(abs string) (string, bool) { + for _, st := range []*Store{u.home, u.sp.Main} { + rel, err := filepath.Rel(st.root, abs) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") { + continue + } + c, err := Clean(filepath.ToSlash(rel)) + if err != nil || c == "" { + return "", false // .users, .trash, .history... + } + return u.virt(st, c), true + } + return "", false +} + +func absOf(st *Store, c string) string { return filepath.Join(st.root, filepath.FromSlash(c)) } + +func (u *Space) favSet() map[string]bool { + u.sp.favMu.Lock() + links := u.sp.links(u.id) + u.sp.favMu.Unlock() + out := make(map[string]bool, len(links)) + for _, l := range links { + if p, ok := u.fromAbs(l.target); ok { + out[p] = true + } + } + return out +} + +func (u *Space) entry(st *Store, e Entry, favs map[string]bool) Entry { + e.Path = u.virt(st, e.Path) + e.Favorite = favs[e.Path] + return e +} + +// Favorites liste les favoris de l'utilisateur ; Path est le chemin du schéma +// d'origine (ou .favoris/ si l'original a disparu). +func (u *Space) Favorites() ([]Entry, error) { + u.sp.favMu.Lock() + links := u.sp.links(u.id) + u.sp.favMu.Unlock() + meta := map[*Store]map[string]itemMeta{u.sp.Main: u.sp.Main.metaSnapshot(), u.home: u.home.metaSnapshot()} + out := make([]Entry, 0, len(links)) + for _, l := range links { + e := Entry{Name: l.name, Path: FavPrefix + "/" + l.name, Link: l.name, Broken: true} + if p, ok := u.fromAbs(l.target); ok { + if fi, err := os.Stat(l.target); err == nil && fi.Mode().IsRegular() { + st, rel, _ := u.route(p) + e = toEntry(rel, fi) + decorate(&e, meta[st]) + e.Path, e.Link, e.Favorite = p, l.name, true + } + } + out = append(out, e) + } + sort.Slice(out, func(i, j int) bool { return fold(out[i].Name) < fold(out[j].Name) }) + return out, nil +} + +// SetFavorite ajoute ou retire des schémas des favoris. Un chemin +// .favoris/ désigne directement un lien (favori dont l'original a disparu). +func (u *Space) SetFavorite(paths []string, favorite bool) error { + var targets []string + var names []string + for _, p := range paths { + if name, ok := favLinkName(p); ok { + if !favorite { + names = append(names, name) + } + continue + } + st, rel, err := u.route(p) + if err != nil { + return err + } + _, abs, err := st.schema(rel) + if err != nil { + return fmt.Errorf("%w (seuls les schémas peuvent être favoris)", err) + } + fi, err := os.Lstat(abs) + if err != nil { + return mapErr(err) + } + if !fi.Mode().IsRegular() { + return fmt.Errorf("%w : %s n'est pas un schéma", ErrInvalidPath, p) + } + targets = append(targets, abs) + } + u.sp.favMu.Lock() + defer u.sp.favMu.Unlock() + dir := u.sp.favDir(u.id) + for _, name := range names { + if fi, err := os.Lstat(filepath.Join(dir, name)); err == nil && fi.Mode()&fs.ModeSymlink != 0 { + _ = os.Remove(filepath.Join(dir, name)) + } + } + for _, abs := range targets { + if favorite { + if err := u.sp.addLink(u.id, abs); err != nil { + return err + } + continue + } + for _, l := range u.sp.links(u.id) { + if l.target == abs { + _ = os.Remove(filepath.Join(dir, l.name)) + } + } + } + return nil +} + +// --- Opérations sur les schémas, en chemins virtuels ----------------------------- + +func isRoot(rel string) bool { + c, err := Clean(rel) + return err == nil && c == "" +} + +// List renvoie le contenu d'un dossier. La racine commune commence par les +// dossiers virtuels Favoris et Personnel. +func (u *Space) List(p string) ([]Entry, error) { + if cleanVirtual(p) == FavPrefix { + return u.Favorites() + } + st, rel, err := u.route(p) + if err != nil { + return nil, err + } + es, err := st.List(rel) + if err != nil { + return nil, err + } + favs := u.favSet() + out := make([]Entry, 0, len(es)+2) + 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"}) + } + for _, e := range es { + out = append(out, u.entry(st, e, favs)) + } + return out, nil +} + +// Search cherche dans l'espace personnel puis l'arborescence commune. +func (u *Space) Search(q string, f Filter, limit int) ([]Hit, error) { + favs := u.favSet() + inner := Filter{Tag: f.Tag} + tag := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(f.Tag), "#")) + if f.Favorite && strings.TrimSpace(q) == "" && tag == "" { + es, err := u.Favorites() + out := []Hit{} + for _, e := range es { + if !e.Broken && len(out) < limit { + out = append(out, Hit{Entry: e}) + } + } + return out, err + } + lim := limit + if f.Favorite { + lim = 1 << 30 // filtrés ensuite + } + var all []Hit + for _, st := range []*Store{u.home, u.sp.Main} { + hs, err := st.Search(q, inner, lim) + if err != nil { + return nil, err + } + for _, h := range hs { + h.Entry = u.entry(st, h.Entry, favs) + if !f.Favorite || h.Favorite { + all = append(all, h) + } + } + } + // Correspondances sur le nom (sans extrait) d'abord, comme Store.Search. + sort.SliceStable(all, func(i, j int) bool { return all[i].Snippet == "" && all[j].Snippet != "" }) + if len(all) > limit { + all = all[:limit] + } + if all == nil { + all = []Hit{} + } + return all, nil +} + +// Tags réunit les tags de l'arborescence commune et de l'espace personnel. +func (u *Space) Tags() ([]TagCount, error) { + idx := map[string]int{} + out := []TagCount{} + for _, st := range []*Store{u.sp.Main, u.home} { + ts, err := st.Tags() + if err != nil { + return nil, err + } + for _, t := range ts { + if i, ok := idx[fold(t.Name)]; ok { + out[i].Count += t.Count + } else { + idx[fold(t.Name)] = len(out) + out = append(out, t) + } + } + } + sort.Slice(out, func(i, j int) bool { return fold(out[i].Name) < fold(out[j].Name) }) + return out, nil +} + +// EditTags ajoute et retire des tags (voir Store.EditTags). +func (u *Space) EditTags(paths, add, remove []string) error { + groups := map[*Store][]string{} + for _, p := range paths { + st, rel, err := u.route(p) + if err != nil { + return err + } + groups[st] = append(groups[st], rel) + } + for st, rels := range groups { + if err := st.EditTags(rels, add, remove); err != nil { + return err + } + } + return nil +} + +func (u *Space) Read(p string) ([]byte, string, error) { + st, rel, err := u.route(p) + if err != nil { + return nil, "", err + } + return st.Read(rel) +} + +func (u *Space) Write(p string, data []byte, ifMatch string, create bool) (Entry, string, error) { + st, rel, err := u.route(p) + if err != nil { + return Entry{}, "", err + } + e, etag, err := st.Write(rel, data, ifMatch, create) + return u.entry(st, e, nil), etag, err +} + +func (u *Space) Mkdir(p string) (Entry, error) { + st, rel, err := u.route(p) + if err != nil { + return Entry{}, err + } + e, err := st.Mkdir(rel) + return u.entry(st, e, nil), err +} + +// Move renomme ou déplace un élément, y compris entre l'arborescence commune +// et l'espace personnel. Les favoris suivent. +func (u *Space) Move(from, to string) (Entry, error) { + sf, rf, err := u.route(from) + if err != nil { + return Entry{}, err + } + st, rt, err := u.route(to) + if err != nil { + return Entry{}, err + } + if sf != st { + e, err := u.sp.moveAcross(sf, rf, st, rt) + return u.entry(st, e, nil), err + } + cf, _ := Clean(rf) + e, err := sf.Move(rf, rt) + if err != nil { + return Entry{}, err + } + u.sp.relink(absOf(sf, cf), absOf(sf, e.Path)) + return u.entry(st, e, nil), nil +} + +// moveAcross déplace un élément d'un Store à un autre (même volume) avec ses +// miniatures, son historique, ses tags et ses liens de partage. +func (sp *Spaces) moveAcross(src *Store, from string, dst *Store, to string) (Entry, error) { + cf, af, err := src.resolve(from) + if err != nil { + return Entry{}, err + } + ct, at, err := dst.resolve(to) + if err != nil { + return Entry{}, err + } + if cf == "" || ct == "" { + return Entry{}, ErrInvalidPath + } + fi, err := os.Lstat(af) + if err != nil { + return Entry{}, mapErr(err) + } + if !fi.IsDir() { + if err := requireSchemaPath(ct); err != nil { + return Entry{}, err + } + } + if _, err := os.Lstat(at); err == nil { + return Entry{}, ErrExists + } + if err := os.MkdirAll(filepath.Dir(at), 0o755); err != nil { + return Entry{}, err + } + if err := os.Rename(af, at); err != nil { + return Entry{}, mapErr(err) + } + moveAside(src.thumbAbs(cf, fi.IsDir()), dst.thumbAbs(ct, fi.IsDir())) + moveAside(src.historyAbs(cf), dst.historyAbs(ct)) + dst.putMeta(ct, src.takeMeta(cf)) + dst.putShares(ct, src.takeShares(cf)) + sp.relink(af, at) + if fi, err = os.Stat(at); err != nil { + return Entry{}, err + } + return toEntry(ct, fi), nil +} + +// moveAside déplace src vers dst s'il existe (miniatures, historique). +func moveAside(src, dst string) { + 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) + } +} + +// MoveMany déplace plusieurs éléments dans le dossier dir (voir Store.MoveMany). +func (u *Space) MoveMany(paths []string, dir string) ([]MoveResult, error) { + sd, rd, err := u.route(dir) + if err != nil { + return nil, err + } + cd, ad, err := sd.resolve(rd) + 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) + } + vd := u.virt(sd, cd) + out := make([]MoveResult, 0, len(paths)) + for _, p := range paths { + r := MoveResult{From: p} + sf, rf, err := u.route(p) + var c string + if err == nil { + c, err = Clean(rf) + } + switch { + case err != nil || c == "": + r.err = ErrInvalidPath + case sf == sd && strings.TrimPrefix(path.Dir("/"+c), "/") == cd: + r.From = u.virt(sf, c) + r.To = r.From // déjà dans le dossier cible + default: + r.From = u.virt(sf, c) + var e Entry + if e, r.err = u.Move(r.From, path.Join(vd, 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, éventuellement vers l'autre espace. +func (u *Space) Copy(from, to string) (Entry, error) { + sf, rf, err := u.route(from) + if err != nil { + return Entry{}, err + } + st, rt, err := u.route(to) + if err != nil { + return Entry{}, err + } + if sf == st { + e, err := sf.Copy(rf, rt) + return u.entry(st, e, nil), err + } + data, _, err := sf.Read(rf) + if err != nil { + return Entry{}, err + } + e, _, err := st.Write(rt, data, "", true) + return u.entry(st, e, nil), err +} + +// Delete met un élément à la corbeille de son espace ; les favoris qui le +// visent disparaissent. Sur .favoris/, seul le lien est retiré. +func (u *Space) Delete(p string, recursive bool) error { + if _, ok := favLinkName(p); ok { + return u.SetFavorite([]string{p}, false) + } + st, rel, err := u.route(p) + if err != nil { + return err + } + _, abs, err := st.resolve(rel) + if err != nil { + return err + } + if err := st.Delete(rel, recursive); err != nil { + return err + } + u.sp.unlink(abs) + return nil +} + +func (u *Space) ReadThumb(p string) ([]byte, time.Time, error) { + st, rel, err := u.route(p) + if err != nil { + return nil, time.Time{}, err + } + return st.ReadThumb(rel) +} + +func (u *Space) WriteThumb(p string, png []byte) error { + st, rel, err := u.route(p) + if err != nil { + return err + } + return st.WriteThumb(rel, png) +} + +func (u *Space) Versions(p string) ([]Version, error) { + st, rel, err := u.route(p) + if err != nil { + return nil, err + } + return st.Versions(rel) +} + +func (u *Space) ReadVersion(p, id string) ([]byte, error) { + st, rel, err := u.route(p) + if err != nil { + return nil, err + } + return st.ReadVersion(rel, id) +} + +func (u *Space) Snapshot(p string) (Version, error) { + st, rel, err := u.route(p) + if err != nil { + return Version{}, err + } + return st.Snapshot(rel) +} + +func (u *Space) Restore(p, id string) (Entry, string, error) { + st, rel, err := u.route(p) + if err != nil { + return Entry{}, "", err + } + e, etag, err := st.Restore(rel, id) + return u.entry(st, e, nil), etag, err +} + +// Shares liste les liens d'un schéma, ou tous ceux visibles (arborescence +// commune et espace personnel) si p est vide. +func (u *Space) Shares(p string) ([]Share, error) { + stores := []*Store{u.sp.Main, u.home} + rel := "" + if p != "" { + st, r, err := u.route(p) + if err != nil { + return nil, err + } + stores, rel = []*Store{st}, r + } + out := []Share{} + for _, st := range stores { + l, err := st.Shares(rel) + if err != nil { + return nil, err + } + for _, sh := range l { + sh.Path = u.virt(st, sh.Path) + out = append(out, sh) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) }) + return out, nil +} + +func (u *Space) CreateShare(p string, ttl time.Duration) (Share, error) { + st, rel, err := u.route(p) + if err != nil { + return Share{}, err + } + sh, err := st.CreateShare(rel, ttl) + sh.Path = u.virt(st, sh.Path) + return sh, err +} + +// RevokeShare supprime un lien de l'arborescence commune ou de l'espace personnel. +func (u *Space) RevokeShare(id string) error { + err := u.sp.Main.RevokeShare(id) + if errors.Is(err, ErrNotFound) { + err = u.home.RevokeShare(id) + } + return err +} + +// RevokeAllShares révoque tous les liens, espaces personnels compris. +func (u *Space) RevokeAllShares() error { + if err := u.sp.Main.RevokeAllShares(); err != nil { + return err + } + for _, id := range u.sp.users() { + if st, err := u.sp.home(id); err == nil { + if err := st.RevokeAllShares(); err != nil { + return err + } + } + } + return nil +} + +// --- Transferts de métadonnées entre Stores ---------------------------------------- + +// takeMeta retire et renvoie les métadonnées de c et de son contenu (clés +// relatives à c). +func (s *Store) takeMeta(c string) map[string]itemMeta { + s.metaMu.Lock() + defer s.metaMu.Unlock() + if s.loadMeta() != nil { + return nil + } + out := map[string]itemMeta{} + for k, m := range s.meta { + if under(k, c) { + out[k[len(c):]] = m + delete(s.meta, k) + } + } + if len(out) > 0 { + _ = s.saveMeta() + } + return out +} + +func (s *Store) putMeta(c string, m map[string]itemMeta) { + if len(m) == 0 { + return + } + s.metaMu.Lock() + defer s.metaMu.Unlock() + if s.loadMeta() != nil { + return + } + for suffix, v := range m { + s.meta[c+suffix] = v + } + _ = s.saveMeta() +} + +func (s *Store) takeShares(c string) map[string]shareDisk { + out := map[string]shareDisk{} + _ = s.editShares(func(m map[string]shareDisk) (bool, error) { + for id, d := range m { + if under(d.Path, c) { + d.Path = d.Path[len(c):] // suffixe relatif à c + out[id] = d + delete(m, id) + } + } + return len(out) > 0, nil + }) + return out +} + +func (s *Store) putShares(c string, add map[string]shareDisk) { + if len(add) == 0 { + return + } + _ = s.editShares(func(m map[string]shareDisk) (bool, error) { + for id, d := range add { + d.Path = c + d.Path + m[id] = d + } + return true, nil + }) +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index de8df34..3499c1d 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -50,6 +50,11 @@ type Entry struct { Tags []string `json:"tags,omitempty"` Favorite bool `json:"favorite,omitempty"` + + // Dossiers virtuels et favoris (spaces.go). + 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 } // Store est un dépôt de schémas enraciné dans un répertoire. @@ -68,6 +73,7 @@ type Store struct { sharesMu sync.Mutex // liens de partage (share.go) shareKey []byte // clé HMAC ; nil : pas encore chargée fixedKey bool // clé imposée par SHARE_SECRET + keyFrom *Store // espace personnel : clé de signature du Store principal } // New crée le répertoire racine si besoin et renvoie un Store.