Add storage package

This commit is contained in:
claude Bot committed 2026-10-07 11:50:07 +02:00
1 parent 9d5a444f1f
commit e2d0a01fd2
1 file changed
+423
+423
View File
@@ -0,0 +1,423 @@
// Package storage gère l'arborescence de schémas Excalidraw sur disque.
//
// Tous les chemins manipulés par l'API sont relatifs à la racine (DATA_DIR),
// au format "slash" (a/b/schema.excalidraw). Le package garantit qu'aucun
// chemin ne sort de la racine, y compris via un lien symbolique.
package storage
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
)
// Extension des fichiers de schéma gérés.
const Ext = ".excalidraw"
const (
trashDir = ".trash"
libraryFile = ".library.excalidrawlib"
)
var (
ErrNotFound = errors.New("introuvable")
ErrExists = errors.New("existe déjà")
ErrInvalidPath = errors.New("chemin invalide")
ErrConflict = errors.New("le fichier a été modifié entre-temps")
ErrNotEmpty = errors.New("dossier non vide")
ErrInvalidJSON = errors.New("contenu JSON invalide")
)
// Entry décrit un élément de l'arborescence.
type Entry struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
ModTime time.Time `json:"modTime"`
}
// Store est un dépôt de schémas enraciné dans un répertoire.
type Store struct {
root string
}
// New crée le répertoire racine si besoin et renvoie un Store.
func New(root string) (*Store, error) {
abs, err := filepath.Abs(root)
if err != nil {
return nil, err
}
if err := os.MkdirAll(abs, 0o755); err != nil {
return nil, err
}
// Résout un éventuel lien symbolique sur la racine elle-même pour que
// les comparaisons de préfixe soient fiables.
if abs, err = filepath.EvalSymlinks(abs); err != nil {
return nil, err
}
return &Store{root: abs}, nil
}
// Clean normalise un chemin relatif : "/a/../b/" -> "b", "" -> "".
// Les composants cachés (".trash", ".git"...) sont refusés.
func Clean(rel string) (string, error) {
if strings.ContainsRune(rel, 0) || strings.Contains(rel, `\`) {
return "", ErrInvalidPath
}
c := strings.TrimPrefix(path.Clean("/"+rel), "/")
if c == "" {
return "", nil
}
for _, part := range strings.Split(c, "/") {
if strings.HasPrefix(part, ".") {
return "", ErrInvalidPath
}
}
return c, nil
}
// resolve convertit un chemin relatif en chemin absolu sous la racine et
// vérifie que l'ancêtre existant le plus proche ne s'échappe pas via symlink.
func (s *Store) resolve(rel string) (string, string, error) {
c, err := Clean(rel)
if err != nil {
return "", "", err
}
abs := filepath.Join(s.root, filepath.FromSlash(c))
probe := abs
for {
real, err := filepath.EvalSymlinks(probe)
if err == nil {
if real != s.root && !strings.HasPrefix(real, s.root+string(os.PathSeparator)) {
return "", "", ErrInvalidPath
}
break
}
if !errors.Is(err, fs.ErrNotExist) {
return "", "", err
}
parent := filepath.Dir(probe)
if parent == probe {
return "", "", ErrInvalidPath
}
probe = parent
}
return c, abs, nil
}
func mapErr(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, fs.ErrNotExist):
return ErrNotFound
case errors.Is(err, fs.ErrExist):
return ErrExists
}
return err
}
func toEntry(rel string, fi fs.FileInfo) Entry {
return Entry{
Name: fi.Name(),
Path: rel,
IsDir: fi.IsDir(),
Size: fi.Size(),
ModTime: fi.ModTime().UTC(),
}
}
func visible(name string, isDir bool) bool {
if strings.HasPrefix(name, ".") {
return false
}
return isDir || strings.EqualFold(filepath.Ext(name), Ext)
}
// List renvoie le contenu d'un dossier : dossiers d'abord, puis schémas,
// triés par nom (insensible à la casse).
func (s *Store) List(rel string) ([]Entry, error) {
c, abs, err := s.resolve(rel)
if err != nil {
return nil, err
}
des, err := os.ReadDir(abs)
if err != nil {
return nil, mapErr(err)
}
out := make([]Entry, 0, len(des))
for _, de := range des {
if !visible(de.Name(), de.IsDir()) || de.Type()&fs.ModeSymlink != 0 {
continue
}
fi, err := de.Info()
if err != nil {
continue
}
out = append(out, toEntry(path.Join(c, de.Name()), fi))
}
sort.Slice(out, func(i, j int) bool {
if out[i].IsDir != out[j].IsDir {
return out[i].IsDir
}
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
return out, nil
}
// Search parcourt l'arborescence et renvoie les entrées dont le nom contient q.
func (s *Store) Search(q string, limit int) ([]Entry, error) {
q = strings.ToLower(strings.TrimSpace(q))
if q == "" {
return []Entry{}, nil
}
out := []Entry{}
errStop := errors.New("stop")
err := filepath.WalkDir(s.root, func(p string, d fs.DirEntry, err error) error {
if err != nil || p == s.root {
return nil
}
if strings.HasPrefix(d.Name(), ".") {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if !visible(d.Name(), d.IsDir()) || !strings.Contains(strings.ToLower(d.Name()), q) {
return nil
}
fi, err := d.Info()
if err != nil {
return nil
}
rel, _ := filepath.Rel(s.root, p)
out = append(out, toEntry(filepath.ToSlash(rel), fi))
if len(out) >= limit {
return errStop
}
return nil
})
if err != nil && !errors.Is(err, errStop) {
return nil, err
}
return out, nil
}
// ETag calcule l'empreinte d'un contenu (utilisée pour l'écriture optimiste).
func ETag(b []byte) string {
h := sha256.Sum256(b)
return `"` + hex.EncodeToString(h[:16]) + `"`
}
func requireSchemaPath(c string) error {
if c == "" || !strings.EqualFold(path.Ext(c), Ext) {
return fmt.Errorf("%w : extension %s attendue", ErrInvalidPath, Ext)
}
return nil
}
// Read renvoie le contenu d'un schéma et son ETag.
func (s *Store) Read(rel string) ([]byte, string, error) {
c, abs, err := s.resolve(rel)
if err != nil {
return nil, "", err
}
if err := requireSchemaPath(c); err != nil {
return nil, "", err
}
b, err := os.ReadFile(abs)
if err != nil {
return nil, "", mapErr(err)
}
return b, ETag(b), nil
}
// Write enregistre un schéma de façon atomique (fichier temporaire + rename).
// - ifMatch != "" : l'écriture échoue avec ErrConflict si le contenu actuel
// ne correspond pas à cet ETag (protection contre l'écrasement).
// - create == true : l'écriture échoue avec ErrExists si le fichier existe.
func (s *Store) Write(rel string, data []byte, ifMatch string, create bool) (Entry, string, error) {
c, abs, err := s.resolve(rel)
if err != nil {
return Entry{}, "", err
}
if err := requireSchemaPath(c); err != nil {
return Entry{}, "", err
}
if !json.Valid(data) {
return Entry{}, "", ErrInvalidJSON
}
cur, err := os.ReadFile(abs)
switch {
case err == nil && create:
return Entry{}, "", ErrExists
case err == nil && ifMatch != "" && ifMatch != "*" && ETag(cur) != ifMatch:
return Entry{}, "", ErrConflict
case err != nil && !errors.Is(err, fs.ErrNotExist):
return Entry{}, "", err
}
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return Entry{}, "", err
}
if err := atomicWrite(abs, data); err != nil {
return Entry{}, "", err
}
fi, err := os.Stat(abs)
if err != nil {
return Entry{}, "", err
}
return toEntry(c, fi), ETag(data), nil
}
func atomicWrite(abs string, data []byte) error {
tmp, err := os.CreateTemp(filepath.Dir(abs), ".tmp-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // no-op après un rename réussi
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
return err
}
return os.Rename(tmp.Name(), abs)
}
// Mkdir crée un dossier (et ses parents).
func (s *Store) Mkdir(rel string) (Entry, error) {
c, abs, err := s.resolve(rel)
if err != nil {
return Entry{}, err
}
if c == "" {
return Entry{}, ErrInvalidPath
}
if _, err := os.Stat(abs); err == nil {
return Entry{}, ErrExists
}
if err := os.MkdirAll(abs, 0o755); err != nil {
return Entry{}, mapErr(err)
}
fi, err := os.Stat(abs)
if err != nil {
return Entry{}, err
}
return toEntry(c, fi), nil
}
// Move renomme ou déplace un fichier ou un dossier. La destination ne doit
// pas exister ; un dossier ne peut pas être déplacé dans lui-même.
func (s *Store) Move(from, to string) (Entry, error) {
cf, af, err := s.resolve(from)
if err != nil {
return Entry{}, err
}
ct, at, err := s.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 ct == cf || strings.HasPrefix(ct, cf+"/") {
return Entry{}, fmt.Errorf("%w : déplacement d'un dossier dans lui-même", ErrInvalidPath)
}
} else 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)
}
fi, err = os.Stat(at)
if err != nil {
return Entry{}, err
}
return toEntry(ct, fi), nil
}
// Copy duplique un schéma (les dossiers ne sont pas copiés).
func (s *Store) Copy(from, to string) (Entry, error) {
data, _, err := s.Read(from)
if err != nil {
return Entry{}, err
}
e, _, err := s.Write(to, data, "", true)
return e, err
}
// Delete déplace un fichier ou un dossier dans la corbeille (.trash/), sans
// suppression physique. Un dossier non vide n'est supprimé que si recursive.
func (s *Store) Delete(rel string, recursive bool) error {
c, abs, err := s.resolve(rel)
if err != nil {
return err
}
if c == "" {
return ErrInvalidPath
}
fi, err := os.Lstat(abs)
if err != nil {
return mapErr(err)
}
if fi.IsDir() && !recursive {
des, err := os.ReadDir(abs)
if err != nil {
return err
}
if len(des) > 0 {
return ErrNotEmpty
}
}
trash := filepath.Join(s.root, trashDir, time.Now().UTC().Format("20060102T150405.000000000"))
if err := os.MkdirAll(trash, 0o755); err != nil {
return err
}
return os.Rename(abs, filepath.Join(trash, filepath.Base(abs)))
}
// ReadLibrary renvoie la bibliothèque Excalidraw partagée (ou nil si absente).
func (s *Store) ReadLibrary() ([]byte, error) {
b, err := os.ReadFile(filepath.Join(s.root, libraryFile))
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return b, err
}
// WriteLibrary enregistre la bibliothèque Excalidraw partagée.
func (s *Store) WriteLibrary(data []byte) error {
if !json.Valid(data) {
return ErrInvalidJSON
}
return atomicWrite(filepath.Join(s.root, libraryFile), data)
}