- 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
927 lines
23 KiB
Go
927 lines
23 KiB
Go
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
|
|
})
|
|
}
|