Espaces personnels et favoris par utilisateur (stockage)
- DATA_DIR/.users/<id>/home : espace personnel, Store complet (historique, miniatures, corbeille, tags, partages), exposé sous le préfixe virtuel .perso. - DATA_DIR/.users/<id>/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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174sFqehj4uGqJHQZvWy1gn
This commit is contained in:
1 parent
c7a43ff12b
commit
781fc8f5f5
3 files changed
+944
-2
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/<id>/home espace personnel, un Store complet (historique,
|
||||
// miniatures, corbeille, tags, partages)
|
||||
// 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
|
||||
// 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/<lien> 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/<lien> 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/<lien>, 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
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user