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
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-07 18:10:09 +02:00
1 parent c1ea1e84a7
commit 50d40cd929
18 files changed
+1076 -12

No files matched your search

+310
View File
@@ -0,0 +1,310 @@
package storage
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Partage en lecture seule : un lien /s/<jeton> donne accès à un schéma sans
// authentification. Le jeton est <id>.<signature> : id aléatoire (128 bits)
// et HMAC-SHA256 de l'id par la clé du serveur. Un lien est valide si sa
// signature est correcte, si son id figure dans .shares.json (révocation
// lien par lien) et s'il n'a pas expiré. Changer de clé (RevokeAllShares)
// invalide tous les liens d'un coup.
//
// Clé : SHARE_SECRET si défini, sinon .share-secret (générée au premier
// partage, 0600). Le lien suit le schéma s'il est renommé ou déplacé et
// disparaît s'il est supprimé.
const (
sharesFile = ".shares.json"
secretFile = ".share-secret"
shareIDLen = 16 // octets aléatoires
shareSigLen = 16 // octets de HMAC conservés
)
// Share décrit un lien de partage.
type Share struct {
ID string `json:"id"`
Token string `json:"token"`
Path string `json:"path"`
Created time.Time `json:"created"`
Expires *time.Time `json:"expires,omitempty"`
}
type shareDisk struct {
Path string `json:"path"`
Created time.Time `json:"created"`
Expires *time.Time `json:"expires,omitempty"`
}
var b64 = base64.RawURLEncoding
// SetShareSecret impose la clé de signature (variable SHARE_SECRET).
func (s *Store) SetShareSecret(secret string) {
s.sharesMu.Lock()
defer s.sharesMu.Unlock()
s.shareKey = []byte(secret)
s.fixedKey = true
}
// key renvoie la clé de signature, créée au besoin ; appelé sous sharesMu.
func (s *Store) key() ([]byte, error) {
if s.shareKey != nil {
return s.shareKey, nil
}
p := filepath.Join(s.root, secretFile)
b, err := os.ReadFile(p)
if err == nil {
if k, err := hex.DecodeString(strings.TrimSpace(string(b))); err == nil && len(k) >= 32 {
s.shareKey = k
return k, nil
}
} else if !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
return s.newKey()
}
func (s *Store) newKey() ([]byte, error) {
k := make([]byte, 32)
if _, err := rand.Read(k); err != nil {
return nil, err
}
p := filepath.Join(s.root, secretFile)
if err := atomicWrite(p, []byte(hex.EncodeToString(k)+"\n")); err != nil {
return nil, err
}
_ = os.Chmod(p, 0o600)
s.shareKey = k
return k, nil
}
func sign(key []byte, id string) string {
m := hmac.New(sha256.New, key)
m.Write([]byte("excalidraw-share:" + id))
return b64.EncodeToString(m.Sum(nil)[:shareSigLen])
}
func (s *Store) readShares() (map[string]shareDisk, error) {
out := map[string]shareDisk{}
b, err := os.ReadFile(filepath.Join(s.root, sharesFile))
switch {
case errors.Is(err, fs.ErrNotExist):
return out, nil
case err != nil:
return nil, err
}
var d struct {
Links map[string]shareDisk `json:"links"`
}
if err := json.Unmarshal(b, &d); err != nil {
return nil, fmt.Errorf("%s : %w", sharesFile, err)
}
if d.Links != nil {
out = d.Links
}
return out, nil
}
func (s *Store) writeShares(m map[string]shareDisk) error {
b, err := json.Marshal(map[string]any{"links": m})
if err != nil {
return err
}
p := filepath.Join(s.root, sharesFile)
if err := atomicWrite(p, b); err != nil {
return err
}
return os.Chmod(p, 0o600)
}
// editShares applique edit à la liste des liens sous verrou ; edit renvoie
// false si rien n'a changé (pas d'écriture).
func (s *Store) editShares(edit func(map[string]shareDisk) (bool, error)) error {
s.sharesMu.Lock()
defer s.sharesMu.Unlock()
m, err := s.readShares()
if err != nil {
return err
}
changed, err := edit(m)
if err != nil || !changed {
return err
}
return s.writeShares(m)
}
func (s *Store) toShare(key []byte, id string, d shareDisk) Share {
return Share{ID: id, Token: id + "." + sign(key, id), Path: d.Path, Created: d.Created, Expires: d.Expires}
}
// CreateShare crée un lien de partage d'un schéma ; ttl = 0 : sans expiration.
func (s *Store) CreateShare(rel string, ttl time.Duration) (Share, error) {
c, abs, err := s.schema(rel)
if err != nil {
return Share{}, err
}
if _, err := os.Stat(abs); err != nil {
return Share{}, mapErr(err)
}
if ttl < 0 {
return Share{}, fmt.Errorf("%w : durée négative", ErrInvalidPath)
}
raw := make([]byte, shareIDLen)
if _, err := rand.Read(raw); err != nil {
return Share{}, err
}
id := b64.EncodeToString(raw)
d := shareDisk{Path: c, Created: time.Now().UTC().Truncate(time.Second)}
if ttl > 0 {
e := d.Created.Add(ttl)
d.Expires = &e
}
var out Share
err = s.editShares(func(m map[string]shareDisk) (bool, error) {
key, err := s.key()
if err != nil {
return false, err
}
m[id] = d
out = s.toShare(key, id, d)
return true, nil
})
return out, err
}
// Shares liste les liens (d'un schéma, ou tous si rel est vide), les plus
// récents en premier. Les liens expirés sont purgés au passage.
func (s *Store) Shares(rel string) ([]Share, error) {
c := ""
if rel != "" {
var err error
if c, _, err = s.schema(rel); err != nil {
return nil, err
}
}
out := []Share{}
err := s.editShares(func(m map[string]shareDisk) (bool, error) {
key, err := s.key()
if err != nil {
return false, err
}
purged := false
for id, d := range m {
if d.Expires != nil && time.Now().After(*d.Expires) {
delete(m, id)
purged = true
} else if c == "" || d.Path == c {
out = append(out, s.toShare(key, id, d))
}
}
return purged, nil
})
sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) })
return out, err
}
// RevokeShare supprime un lien.
func (s *Store) RevokeShare(id string) error {
return s.editShares(func(m map[string]shareDisk) (bool, error) {
if _, ok := m[id]; !ok {
return false, ErrNotFound
}
delete(m, id)
return true, nil
})
}
// RevokeAllShares supprime tous les liens et change la clé de signature
// (sauf si elle est imposée par SHARE_SECRET).
func (s *Store) RevokeAllShares() error {
return s.editShares(func(m map[string]shareDisk) (bool, error) {
clear(m)
if !s.fixedKey {
if _, err := s.newKey(); err != nil {
return false, err
}
}
return true, nil
})
}
// ResolveShare vérifie un jeton et renvoie le lien. Toute anomalie (jeton
// mal formé, signature fausse, lien révoqué ou expiré) donne ErrNotFound.
func (s *Store) ResolveShare(token string) (Share, error) {
id, sig, ok := strings.Cut(token, ".")
if !ok || len(id) != b64.EncodedLen(shareIDLen) || len(sig) != b64.EncodedLen(shareSigLen) {
return Share{}, ErrNotFound
}
s.sharesMu.Lock()
defer s.sharesMu.Unlock()
key, err := s.key()
if err != nil {
return Share{}, err
}
if subtle.ConstantTimeCompare([]byte(sig), []byte(sign(key, id))) != 1 {
return Share{}, ErrNotFound
}
m, err := s.readShares()
if err != nil {
return Share{}, err
}
d, ok := m[id]
if !ok || (d.Expires != nil && time.Now().After(*d.Expires)) {
return Share{}, ErrNotFound
}
return s.toShare(key, id, d), nil
}
// ReadShared renvoie le lien et le contenu du schéma partagé.
func (s *Store) ReadShared(token string) (Share, []byte, error) {
sh, err := s.ResolveShare(token)
if err != nil {
return Share{}, nil, err
}
b, _, err := s.Read(sh.Path)
return sh, b, err
}
// Les opérations suivantes accompagnent Move/Delete.
func (s *Store) moveShares(from, to string) {
_ = s.editShares(func(m map[string]shareDisk) (bool, error) {
changed := false
for id, d := range m {
if under(d.Path, from) {
d.Path = to + d.Path[len(from):]
m[id] = d
changed = true
}
}
return changed, nil
})
}
func (s *Store) removeShares(c string) {
_ = s.editShares(func(m map[string]shareDisk) (bool, error) {
changed := false
for id, d := range m {
if under(d.Path, c) {
delete(m, id)
changed = true
}
}
return changed, nil
})
}
+107
View File
@@ -0,0 +1,107 @@
package storage
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func TestShares(t *testing.T) {
s := newStore(t)
s.Write("d/a.excalidraw", []byte(doc), "", true)
if _, err := s.CreateShare("d/absent.excalidraw", 0); !errors.Is(err, ErrNotFound) {
t.Fatalf("schéma absent : %v", err)
}
if _, err := s.CreateShare("d", 0); !errors.Is(err, ErrInvalidPath) {
t.Fatalf("dossier : %v", err)
}
sh, err := s.CreateShare("d/a.excalidraw", 0)
if err != nil {
t.Fatal(err)
}
if fi, _ := os.Stat(filepath.Join(s.root, secretFile)); fi == nil || fi.Mode().Perm() != 0o600 {
t.Fatalf("clé : %v", fi)
}
got, data, err := s.ReadShared(sh.Token)
if err != nil || got.Path != "d/a.excalidraw" || string(data) != doc {
t.Fatalf("ReadShared = %+v, %s, %v", got, data, err)
}
// Jetons altérés ou mal formés.
bad := []string{"", "x", sh.ID, sh.ID + ".", sh.Token[:len(sh.Token)-1] + "A", "A" + sh.Token[1:]}
for _, tok := range bad {
if tok == sh.Token {
continue
}
if _, err := s.ResolveShare(tok); !errors.Is(err, ErrNotFound) {
t.Errorf("jeton %q accepté : %v", tok, err)
}
}
// La clé survit au redémarrage ; le lien suit le schéma déplacé.
s2, _ := New(s.root)
s2.Move("d", "e")
if got, err := s2.ResolveShare(sh.Token); err != nil || got.Path != "e/a.excalidraw" {
t.Fatalf("après déplacement : %+v, %v", got, err)
}
if l, _ := s2.Shares("e/a.excalidraw"); len(l) != 1 || l[0].Token != sh.Token {
t.Fatalf("Shares = %+v", l)
}
// Révocation unitaire.
if err := s2.RevokeShare(sh.ID); err != nil {
t.Fatal(err)
}
if _, err := s2.ResolveShare(sh.Token); !errors.Is(err, ErrNotFound) {
t.Fatalf("lien révoqué accepté : %v", err)
}
// Expiration : le lien expiré est refusé puis purgé de la liste.
exp, _ := s2.CreateShare("e/a.excalidraw", time.Millisecond)
time.Sleep(5 * time.Millisecond)
if _, err := s2.ResolveShare(exp.Token); !errors.Is(err, ErrNotFound) {
t.Fatalf("lien expiré accepté : %v", err)
}
if l, _ := s2.Shares(""); len(l) != 0 {
t.Fatalf("lien expiré listé : %+v", l)
}
// Tout révoquer change la clé : un lien recréé avec un ancien id ne passe plus.
keep, _ := s2.CreateShare("e/a.excalidraw", 0)
if err := s2.RevokeAllShares(); err != nil {
t.Fatal(err)
}
if _, err := s2.ResolveShare(keep.Token); !errors.Is(err, ErrNotFound) {
t.Fatalf("après révocation globale : %v", err)
}
// Suppression du schéma : ses liens disparaissent.
del, _ := s2.CreateShare("e/a.excalidraw", 0)
s2.Delete("e", true)
if _, err := s2.ResolveShare(del.Token); !errors.Is(err, ErrNotFound) {
t.Fatalf("schéma supprimé : %v", err)
}
}
func TestShareFixedSecret(t *testing.T) {
s := newStore(t)
s.SetShareSecret("une-clé-imposée-assez-longue")
s.Write("a.excalidraw", []byte(doc), "", true)
sh, _ := s.CreateShare("a.excalidraw", 0)
if _, err := os.Stat(filepath.Join(s.root, secretFile)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("clé écrite sur disque malgré SHARE_SECRET : %v", err)
}
s2, _ := New(s.root)
s2.SetShareSecret("une-clé-imposée-assez-longue")
if _, err := s2.ResolveShare(sh.Token); err != nil {
t.Fatal(err)
}
s3, _ := New(s.root)
s3.SetShareSecret("une-autre-clé-assez-longue")
if _, err := s3.ResolveShare(sh.Token); !errors.Is(err, ErrNotFound) {
t.Fatalf("autre clé acceptée : %v", err)
}
}
+6
View File
@@ -64,6 +64,10 @@ type Store struct {
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.
@@ -345,6 +349,7 @@ func (s *Store) Move(from, to string) (Entry, error) {
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
@@ -450,6 +455,7 @@ func (s *Store) Delete(rel string, recursive bool) error {
s.removeThumb(c, fi.IsDir())
s.trashHistory(c, trash)
s.removeMeta(c)
s.removeShares(c)
return nil
}