Files
excalicloud/internal/storage/storage.go
T
claude BotandClaude Opus 5.5 50d40cd929 Partage en lecture seule par lien signé
- Lien /s/<jeton> : jeton = id aléatoire 128 bits + HMAC-SHA256 tronqué par la
  clé du serveur (SHARE_SECRET, sinon DATA_DIR/.share-secret générée en 0600).
  Valide si la signature est correcte, le lien présent dans .shares.json et non
  expiré (1, 7, 30 jours ou sans expiration).
- API : GET/POST/DELETE /api/shares, POST /api/shares/revoke-all (renouvelle la
  clé), GET /api/public/{jeton} hors authentification (nom, titre, logo, scène).
- Auth Basic : /s/, /api/public/, /healthz et les fichiers statiques du build
  sont publics ; le reste de l'API et l'index restent protégés. Le serveur
  statique ne liste plus les répertoires. X-Robots-Tag: noindex sur les liens.
- Les liens suivent renommages et déplacements, disparaissent à la suppression.
- Interface : dialogue Partager (barre du haut, menu de l'explorateur), section
  Liens de partage dans Paramètres, vue publique Excalidraw en lecture seule
  avec téléchargement et bascule clair/sombre.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H6J7vKwXx7uopxKhWfFJpm
2026-10-07 18:10:09 +02:00

478 lines
12 KiB
Go

// 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 (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// Extension des fichiers de schéma gérés.
const Ext = ".excalidraw"
const (
trashDir = ".trash"
thumbsDir = ".thumbs"
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"`
Tags []string `json:"tags,omitempty"`
Favorite bool `json:"favorite,omitempty"`
}
// Store est un dépôt de schémas enraciné dans un répertoire.
type Store struct {
root string
// History règle l'historique des versions (DefaultHistory par défaut).
History HistoryPolicy
settingsMu sync.Mutex // sérialise les lectures-modifications de .settings.json
index textIndex // texte des schémas pour la recherche (search.go)
metaMu sync.Mutex // tags et favoris (meta.go)
meta map[string]itemMeta // nil : pas encore chargé
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
}
// 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, History: DefaultHistory}, 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))
meta := s.metaSnapshot()
for _, de := range des {
if !visible(de.Name(), de.IsDir()) || de.Type()&fs.ModeSymlink != 0 {
continue
}
fi, err := de.Info()
if err != nil {
continue
}
e := toEntry(path.Join(c, de.Name()), fi)
decorate(&e, meta)
out = append(out, e)
}
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
}
// 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
case err == nil && !bytes.Equal(cur, data):
if fi, err := os.Stat(abs); err == nil {
s.autoSnapshot(c, cur, fi.ModTime())
}
}
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)
}
s.moveThumb(cf, ct, fi.IsDir())
s.moveHistory(cf, ct)
s.moveMeta(cf, ct)
s.moveShares(cf, ct)
fi, err = os.Stat(at)
if err != nil {
return Entry{}, err
}
return toEntry(ct, fi), nil
}
// MoveResult est le résultat d'un déplacement unitaire dans MoveMany.
type MoveResult struct {
From string `json:"from"`
To string `json:"to,omitempty"`
Error string `json:"error,omitempty"`
err error
}
// Err renvoie l'erreur du déplacement (nil si réussi).
func (r MoveResult) Err() error { return r.err }
// MoveMany déplace plusieurs fichiers ou dossiers dans le dossier dir en
// conservant leur nom. Chaque élément est traité indépendamment : un échec
// (destination existante, dossier déplacé dans lui-même...) n'interrompt pas
// les autres. Les éléments déjà présents dans dir sont ignorés.
func (s *Store) MoveMany(paths []string, dir string) ([]MoveResult, error) {
cd, ad, err := s.resolve(dir)
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)
}
out := make([]MoveResult, 0, len(paths))
for _, p := range paths {
r := MoveResult{From: p}
if c, err := Clean(p); err != nil || c == "" {
r.err = ErrInvalidPath
} else if strings.TrimPrefix(path.Dir("/"+c), "/") == cd {
r.From, r.To = c, c // déjà dans le dossier cible
} else {
r.From = c
var e Entry
if e, r.err = s.Move(c, path.Join(cd, 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 (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)
if err == nil {
s.copyThumb(from, e.Path)
if cf, err := Clean(from); err == nil {
s.copyMeta(cf, e.Path)
}
}
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
}
if err := os.Rename(abs, filepath.Join(trash, filepath.Base(abs))); err != nil {
return err
}
s.removeThumb(c, fi.IsDir())
s.trashHistory(c, trash)
s.removeMeta(c)
s.removeShares(c)
return nil
}
// 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)
}