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:
1 parent
c1ea1e84a7
commit
50d40cd929
18 files changed
+1076
-12
No files matched your search
@@ -7,4 +7,5 @@ UPDATE_CHECK_INTERVAL=12h
|
||||
HISTORY_KEEP=50
|
||||
HISTORY_INTERVAL=10m
|
||||
HISTORY_MAX_AGE=0
|
||||
SHARE_SECRET=
|
||||
EXCALIDRAW_VERSION=
|
||||
@@ -1,6 +1,6 @@
|
||||
# excalidraw-go
|
||||
|
||||
Excalidraw auto-hébergé avec un **gestionnaire de fichiers intégré** : arborescence de dossiers, **vignettes des schémas**, création, renommage, **sélection et déplacement multiples** (glisser-déposer ou dialogue « Déplacer vers… »), duplication, suppression (corbeille), **recherche plein texte** (noms et contenu des schémas), **tags et favoris**, import/export de fichiers `.excalidraw`, sauvegarde automatique côté serveur, **historique des versions** avec comparaison visuelle et restauration. Interface web unique, responsive (l'explorateur devient un tiroir sur mobile).
|
||||
Excalidraw auto-hébergé avec un **gestionnaire de fichiers intégré** : arborescence de dossiers, **vignettes des schémas**, création, renommage, **sélection et déplacement multiples** (glisser-déposer ou dialogue « Déplacer vers… »), duplication, suppression (corbeille), **recherche plein texte** (noms et contenu des schémas), **tags et favoris**, **partage en lecture seule par lien signé**, import/export de fichiers `.excalidraw`, sauvegarde automatique côté serveur, **historique des versions** avec comparaison visuelle et restauration. Interface web unique, responsive (l'explorateur devient un tiroir sur mobile).
|
||||
|
||||
- **Backend** : Go 1.24, bibliothèque standard uniquement (aucune dépendance externe), API REST/JSON, stockage en fichiers `.excalidraw` sur un volume.
|
||||
- **Frontend** : React 19 + composant officiel [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/excalidraw) 0.18.1 (licence MIT), build Vite **embarqué dans le binaire Go** (`embed`).
|
||||
@@ -12,6 +12,7 @@ Excalidraw auto-hébergé avec un **gestionnaire de fichiers intégré** : arbor
|
||||
Navigateur ──HTTP──▶ conteneur excalidraw-go (:8080, uid 65532, rootfs read-only)
|
||||
├─ / SPA React + Excalidraw (embarquée dans le binaire)
|
||||
├─ /api/... API fichiers (Go net/http)
|
||||
├─ /s/<jeton> schéma partagé en lecture seule (hors authentification)
|
||||
└─ /healthz sonde
|
||||
│
|
||||
▼
|
||||
@@ -21,6 +22,7 @@ Navigateur ──HTTP──▶ conteneur excalidraw-go (:8080, uid 65532, rootfs
|
||||
├─ .settings.json, .logo.png ← titre et logo de la barre
|
||||
├─ .thumbs/projets/réseau/sdn.excalidraw.png ← miniatures (régénérables)
|
||||
├─ .meta.json ← tags et favoris
|
||||
├─ .shares.json, .share-secret ← liens de partage et clé de signature
|
||||
├─ .history/projets/réseau/sdn.excalidraw/<date>.excalidraw ← versions
|
||||
└─ .trash/<horodatage>/... ← éléments supprimés
|
||||
```
|
||||
@@ -47,6 +49,7 @@ docker compose up -d --build
|
||||
| `HISTORY_KEEP` | `50` | Versions conservées par schéma (`0` : historique désactivé) |
|
||||
| `HISTORY_INTERVAL` | `10m` | Écart minimal entre deux versions automatiques (`0` : une version à chaque enregistrement) |
|
||||
| `HISTORY_MAX_AGE` | `0` | Âge au-delà duquel une version est purgée (ex. `2160h` = 90 jours ; `0` : jamais) |
|
||||
| `SHARE_SECRET` | vide | Clé de signature des liens de partage (16 caractères min.) ; vide : clé aléatoire générée dans `DATA_DIR/.share-secret` |
|
||||
| `EXCALIDRAW_VERSION` (build) | vide | Version d'Excalidraw imposée à la construction de l'image ; vide = celle de `package-lock.json` |
|
||||
|
||||
Bind mount au lieu du volume nommé : `- ./data:/data` puis `chown -R 65532:65532 ./data`.
|
||||
@@ -106,6 +109,15 @@ Menu « … » d'un élément (ou barre de sélection pour plusieurs) : **Favori
|
||||
|
||||
Stockage : `DATA_DIR/.meta.json` (`{"items":{"chemin":{"tags":[…],"favorite":true}}}`), mis à jour aux renommages, déplacements, copies (tags seuls) et suppressions.
|
||||
|
||||
### Partage en lecture seule
|
||||
|
||||
Icône de partage de la barre du haut (schéma ouvert) ou menu « … » → **Partager…** : crée un lien `https://<hôte>/s/<jeton>` valable 1, 7 ou 30 jours, ou sans expiration, copié dans le presse-papiers. Le destinataire voit le schéma dans Excalidraw en mode lecture (zoom, déplacement, export PNG/SVG, téléchargement du `.excalidraw`), **sans authentification**, dans sa dernière version enregistrée ; il n'a accès à rien d'autre (ni explorateur, ni API).
|
||||
|
||||
- Jeton = `<id>.<signature>` : id aléatoire de 128 bits et HMAC-SHA256 tronqué (128 bits) par la clé du serveur. Un jeton n'est accepté que si la signature est valide, si le lien existe encore dans `DATA_DIR/.shares.json` et s'il n'a pas expiré.
|
||||
- **Révocation** lien par lien (dialogue *Partager* ou *Paramètres → Liens de partage*), ou globale avec **Tout révoquer**, qui renouvelle aussi la clé (sauf si elle est imposée par `SHARE_SECRET`).
|
||||
- Le lien suit le schéma s'il est renommé ou déplacé, et disparaît s'il est supprimé.
|
||||
- Avec `AUTH_USER`/`AUTH_PASSWORD`, seuls `/s/…`, `/api/public/…`, `/healthz` et les fichiers statiques du build (code de l'application, polices) échappent à l'authentification. Derrière un reverse proxy avec SSO, exclure de la même façon `/s/` et `/api/public/` de l'authentification du proxy. Réponses en `X-Robots-Tag: noindex`.
|
||||
|
||||
### Historique des versions
|
||||
|
||||
Avant qu'un schéma soit écrasé, son contenu précédent est copié dans `DATA_DIR/.history/<chemin du schéma>/<date>.excalidraw` (fichier Excalidraw complet, ouvrable tel quel). L'enregistrement automatique écrit toutes les 1,5 s pendant l'édition : une version n'est donc conservée que si la précédente date d'au moins `HISTORY_INTERVAL` (10 min par défaut), ce qui donne une version par séance de travail ou par tranche de 10 minutes. Les `HISTORY_KEEP` plus récentes sont gardées, et `HISTORY_MAX_AGE` purge les plus anciennes.
|
||||
@@ -148,6 +160,11 @@ Tous les chemins sont relatifs à `DATA_DIR`, en notation `/`. Les composants co
|
||||
| GET | `/api/history/version?path=x.excalidraw&id=<id>` | | JSON de la version |
|
||||
| POST | `/api/history/snapshot` | `{"path":"x.excalidraw"}` | `{"id","time","size"}` (version du contenu courant) |
|
||||
| POST | `/api/history/restore` | `{"path":"x.excalidraw","id":"<id>"}` | `Entry` + `ETag` |
|
||||
| GET | `/api/shares[?path=x.excalidraw]` | | `[{"id","token","path","created","expires"}]` (d'un schéma ou tous) |
|
||||
| POST | `/api/shares` | `{"path":"x.excalidraw","days":7}` (`0` : sans expiration) | lien créé |
|
||||
| DELETE | `/api/shares?id=<id>` | | `204` |
|
||||
| POST | `/api/shares/revoke-all` | | `204` (tous les liens, nouvelle clé) |
|
||||
| GET | `/api/public/<jeton>` | | `{"name","title","logo","scene"}`, **hors authentification** ; `404` si invalide, expiré ou révoqué |
|
||||
| GET/PUT | `/api/library` | bibliothèque `excalidrawlib` | |
|
||||
| GET | `/api/settings` | | `{"title":"...","logo":"<version>","channel":"latest"}` (`logo` vide sans logo) |
|
||||
| PUT | `/api/settings` | `{"title":"..."}` (80 caractères max, vide = titre par défaut) et/ou `{"channel":"latest"\|"next"}` | réglages |
|
||||
@@ -156,6 +173,7 @@ Tous les chemins sont relatifs à `DATA_DIR`, en notation `/`. Les composants co
|
||||
| DELETE | `/api/logo` | | réglages |
|
||||
| GET | `/api/update` | | `{"enabled","tags":{"latest","next"},"latest","checkedAt"}` |
|
||||
| GET | `/healthz` | | `ok` (hors authentification) |
|
||||
| GET | `/s/<jeton>` | | vue publique en lecture seule (hors authentification) |
|
||||
|
||||
`Entry` = `{"name","path","isDir","size","modTime","tags","favorite"}` (les deux derniers omis s'ils sont vides).
|
||||
|
||||
@@ -193,6 +211,8 @@ frontend/src/Thumb.tsx vignette chargée à l'affichage, générée si abse
|
||||
frontend/src/thumbs.ts rendu PNG des miniatures et file de génération
|
||||
frontend/src/HistoryDialog.tsx historique : aperçu, différences, restauration
|
||||
frontend/src/TagsDialog.tsx tags d'un ou plusieurs éléments
|
||||
frontend/src/ShareDialog.tsx liens de partage d'un schéma
|
||||
frontend/src/ShareView.tsx vue publique /s/<jeton> (lecture seule)
|
||||
```
|
||||
|
||||
## Sécurité
|
||||
@@ -200,6 +220,7 @@ frontend/src/TagsDialog.tsx tags d'un ou plusieurs éléments
|
||||
- Conteneur non-root, rootfs en lecture seule, `cap_drop: ALL`, `no-new-privileges`.
|
||||
- L'authentification Basic intégrée suffit pour un usage local ; en exposition réseau, placer le service derrière un reverse proxy TLS (Traefik, Caddy, nginx) avec SSO (Authelia, oauth2-proxy, Authentik) et laisser `AUTH_*` vides.
|
||||
- Écritures atomiques (fichier temporaire + `fsync` + `rename`) : pas de schéma tronqué en cas d'arrêt brutal.
|
||||
- Liens de partage : seule surface accessible sans authentification ; jetons non énumérables (id 128 bits signé HMAC), révocables, à durée limitée au choix. Un lien donne la dernière version enregistrée du schéma, images embarquées comprises.
|
||||
- Aucune suppression physique : la purge de `.trash/` est à la charge de l'exploitant (ex. `find /data/.trash -mindepth 1 -maxdepth 1 -mtime +30 -exec rm -rf {} +`).
|
||||
|
||||
## Limites et évolutions possibles
|
||||
|
||||
+32
-4
@@ -13,6 +13,7 @@ import (
|
||||
"os/signal"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -57,6 +58,15 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Clé de signature des liens de partage : SHARE_SECRET, sinon DATA_DIR/.share-secret.
|
||||
if secret := os.Getenv("SHARE_SECRET"); secret != "" {
|
||||
if len(secret) < 16 {
|
||||
slog.Error("SHARE_SECRET trop court (16 caractères minimum)")
|
||||
os.Exit(1)
|
||||
}
|
||||
store.SetShareSecret(secret)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
@@ -85,7 +95,7 @@ func main() {
|
||||
|
||||
var h http.Handler = mux
|
||||
if user, pass := os.Getenv("AUTH_USER"), os.Getenv("AUTH_PASSWORD"); user != "" && pass != "" {
|
||||
h = basicAuth(h, user, pass)
|
||||
h = basicAuth(h, user, pass, public)
|
||||
slog.Info("authentification Basic activée", "user", user)
|
||||
}
|
||||
h = logRequests(securityHeaders(h))
|
||||
@@ -155,7 +165,7 @@ func spa() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := path.Clean(r.URL.Path)
|
||||
if p != "/" {
|
||||
if _, err := fs.Stat(dist, p[1:]); err == nil {
|
||||
if fi, err := fs.Stat(dist, p[1:]); err == nil && !fi.IsDir() {
|
||||
// Les assets Vite sont nommés par empreinte : cache long.
|
||||
if path.Dir(p) == "/assets" {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
@@ -175,9 +185,24 @@ func spa() http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func basicAuth(next http.Handler, user, pass string) http.Handler {
|
||||
// public indique les chemins servis sans authentification : sonde, schémas
|
||||
// partagés (/s/<jeton> et son API) et fichiers statiques du build (code de
|
||||
// l'application et polices, sans aucune donnée).
|
||||
func public(p string) bool {
|
||||
switch {
|
||||
case p == "/healthz", strings.HasPrefix(p, "/s/"), strings.HasPrefix(p, "/api/public/"):
|
||||
return true
|
||||
case strings.HasPrefix(p, "/api/"), p == "/", p == "/index.html":
|
||||
return false
|
||||
}
|
||||
dist, _ := fs.Sub(web.Dist, "dist")
|
||||
fi, err := fs.Stat(dist, strings.TrimPrefix(path.Clean(p), "/"))
|
||||
return err == nil && !fi.IsDir()
|
||||
}
|
||||
|
||||
func basicAuth(next http.Handler, user, pass string, public func(string) bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/healthz" {
|
||||
if public(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -198,6 +223,9 @@ func securityHeaders(next http.Handler) http.Handler {
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
h.Set("X-Frame-Options", "SAMEORIGIN")
|
||||
if strings.HasPrefix(r.URL.Path, "/s/") {
|
||||
h.Set("X-Robots-Tag", "noindex, nofollow") // liens de partage
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBasicAuthPublicPaths(t *testing.T) {
|
||||
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})
|
||||
h := basicAuth(ok, "u", "p", public)
|
||||
cases := map[string]int{
|
||||
"/healthz": 200,
|
||||
"/s/abc.def": 200,
|
||||
"/api/public/abc.def": 200,
|
||||
"/": 401,
|
||||
"/index.html": 401,
|
||||
"/api/list": 401,
|
||||
"/api/file?path=x": 401,
|
||||
"/api/publicx": 401,
|
||||
"/assets/": 401, // dossier : pas un fichier statique
|
||||
"/inconnu": 401,
|
||||
}
|
||||
for p, want := range cases {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
|
||||
if rec.Code != want {
|
||||
t.Errorf("%s : %d, attendu %d", p, rec.Code, want)
|
||||
}
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api/list", nil)
|
||||
req.SetBasicAuth("u", "p")
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Errorf("authentifié : %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ services:
|
||||
HISTORY_KEEP: ${HISTORY_KEEP:-50}
|
||||
HISTORY_INTERVAL: ${HISTORY_INTERVAL:-10m}
|
||||
HISTORY_MAX_AGE: ${HISTORY_MAX_AGE:-0}
|
||||
# Clé de signature des liens de partage /s/<jeton> (16 caractères min.).
|
||||
# Vide : clé aléatoire générée dans DATA_DIR/.share-secret.
|
||||
SHARE_SECRET: ${SHARE_SECRET:-}
|
||||
volumes:
|
||||
# Volume nommé : les droits (uid 65532) sont hérités de l'image.
|
||||
# Pour un bind mount (ex. ./data:/data), faire `chown -R 65532:65532 ./data`.
|
||||
|
||||
+14
-1
@@ -16,7 +16,8 @@ import type {
|
||||
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
||||
import { api, ApiError, basename, stripExt, type Settings } from "./api";
|
||||
import { Explorer } from "./Explorer";
|
||||
import { CloseIcon, HistoryIcon, MenuIcon, MoonIcon, SaveIcon, SettingsIcon, SunIcon, UpdateIcon } from "./icons";
|
||||
import { CloseIcon, HistoryIcon, MenuIcon, MoonIcon, SaveIcon, SettingsIcon, ShareIcon, SunIcon, UpdateIcon } from "./icons";
|
||||
import { ShareDialog } from "./ShareDialog";
|
||||
import { HistoryDialog } from "./HistoryDialog";
|
||||
import { Splitter } from "./Splitter";
|
||||
import { SettingsDialog, DEFAULT_TITLE } from "./SettingsDialog";
|
||||
@@ -93,6 +94,7 @@ export default function App() {
|
||||
const pending = pendingVersion(updateStatus, settings.channel);
|
||||
const [customizing, setCustomizing] = useState<false | "all" | "excalidraw">(false);
|
||||
const [historyFor, setHistoryFor] = useState<string | null>(null);
|
||||
const [shareFor, setShareFor] = useState<string | null>(null);
|
||||
|
||||
const excalidraw = useRef<ExcalidrawImperativeAPI | null>(null);
|
||||
const pathRef = useRef<string | null>(null); // schéma ouvert (survit aux renommages)
|
||||
@@ -410,6 +412,14 @@ export default function App() {
|
||||
>
|
||||
<HistoryIcon />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label="Partager en lecture seule"
|
||||
title="Partager en lecture seule"
|
||||
onClick={() => save().then(() => setShareFor(scene.path))}
|
||||
>
|
||||
<ShareIcon />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label="Fermer le schéma"
|
||||
@@ -458,6 +468,7 @@ export default function App() {
|
||||
onMoved={onMoved}
|
||||
onDeleted={onDeleted}
|
||||
onHistory={showHistory}
|
||||
onShare={setShareFor}
|
||||
/>
|
||||
</aside>
|
||||
<Splitter
|
||||
@@ -507,6 +518,8 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{shareFor && <ShareDialog path={shareFor} onClose={() => setShareFor(null)} />}
|
||||
|
||||
{customizing && (
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
|
||||
@@ -60,6 +60,8 @@ interface Props {
|
||||
onDeleted: (path: string) => void;
|
||||
/** Affiche l'historique des versions d'un schéma. */
|
||||
onHistory: (path: string) => void;
|
||||
/** Affiche les liens de partage d'un schéma. */
|
||||
onShare: (path: string) => void;
|
||||
}
|
||||
|
||||
type View = "list" | "grid";
|
||||
@@ -86,7 +88,7 @@ const initialView = (): View => {
|
||||
const canMoveInto = (path: string, targetDir: string) =>
|
||||
dirname(path) !== targetDir && targetDir !== path && !targetDir.startsWith(path + "/");
|
||||
|
||||
export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHistory }: Props) {
|
||||
export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHistory, onShare }: Props) {
|
||||
const [dir, setDir] = useState(() => (current ? dirname(current) : ""));
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -456,6 +458,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
||||
<button onClick={() => setMoving([e.path])}>Déplacer…</button>
|
||||
{!e.isDir && <button onClick={() => duplicate(e)}>Dupliquer</button>}
|
||||
{!e.isDir && <button onClick={() => onHistory(e.path)}>Historique…</button>}
|
||||
{!e.isDir && <button onClick={() => onShare(e.path)}>Partager…</button>}
|
||||
<button onClick={() => toggleFavorite([e])}>
|
||||
{e.favorite ? <StarFilledIcon /> : <StarIcon />}
|
||||
{e.favorite ? "Retirer des favoris" : "Favori"}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, type Channel, type Settings, type UpdateStatus } from "./api";
|
||||
import { CopyIcon } from "./icons";
|
||||
import { api, type Channel, type Settings, type Share, type UpdateStatus } from "./api";
|
||||
import { CopyIcon, TrashIcon } from "./icons";
|
||||
import { fmtExpiry } from "./ShareDialog";
|
||||
import { INSTALLED, pendingVersion, rebuildCommand } from "./update";
|
||||
|
||||
export const DEFAULT_TITLE = "Excalidraw";
|
||||
@@ -30,6 +31,7 @@ export function SettingsDialog({ settings, update, focusExcalidraw, onChange, on
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [shares, setShares] = useState<Share[] | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const exSection = useRef<HTMLElement>(null);
|
||||
|
||||
@@ -37,6 +39,27 @@ export function SettingsDialog({ settings, update, focusExcalidraw, onChange, on
|
||||
if (focusExcalidraw) exSection.current?.scrollIntoView({ block: "start" });
|
||||
}, [focusExcalidraw]);
|
||||
|
||||
const loadShares = () =>
|
||||
api
|
||||
.shares()
|
||||
.then(setShares)
|
||||
.catch((e) => setError(e.message));
|
||||
|
||||
useEffect(() => {
|
||||
loadShares();
|
||||
}, []);
|
||||
|
||||
const revoke = async (op: () => Promise<void>, question: string) => {
|
||||
if (!confirm(question)) return;
|
||||
setError(null);
|
||||
try {
|
||||
await op();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
loadShares();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
@@ -190,6 +213,53 @@ export function SettingsDialog({ settings, update, focusExcalidraw, onChange, on
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="field">
|
||||
<h4>Liens de partage</h4>
|
||||
{shares?.length ? (
|
||||
<ul className="share-list all">
|
||||
{shares.map((s) => (
|
||||
<li key={s.id}>
|
||||
<div className="cmd">
|
||||
<code title={api.shareUrl(s.token)}>{s.path}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn danger"
|
||||
onClick={() => revoke(() => api.revokeShare(s.id), `Révoquer le lien vers « ${s.path} » ?`)}
|
||||
title="Révoquer"
|
||||
aria-label={`Révoquer le lien vers ${s.path}`}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
<small className="muted">
|
||||
Créé le {new Date(s.created).toLocaleDateString(undefined, { dateStyle: "short" })} · {fmtExpiry(s)}
|
||||
</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<small className="muted">
|
||||
Aucun lien actif. Pour partager un schéma en lecture seule : icône de partage de la barre du haut, ou menu
|
||||
« … » de l'explorateur.
|
||||
</small>
|
||||
)}
|
||||
{!!shares?.length && (
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
onClick={() =>
|
||||
revoke(
|
||||
api.revokeAllShares,
|
||||
"Révoquer tous les liens de partage ? La clé de signature est renouvelée : aucun lien existant ne fonctionnera plus.",
|
||||
)
|
||||
}
|
||||
>
|
||||
Tout révoquer
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<div className="modal-actions">
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, basename, stripExt, type Share } from "./api";
|
||||
import { CopyIcon, ShareIcon, TrashIcon } from "./icons";
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EXPIRY: { days: number; label: string }[] = [
|
||||
{ days: 1, label: "1 jour" },
|
||||
{ days: 7, label: "7 jours" },
|
||||
{ days: 30, label: "30 jours" },
|
||||
{ days: 0, label: "Sans expiration" },
|
||||
];
|
||||
|
||||
export const fmtExpiry = (s: Share) =>
|
||||
s.expires
|
||||
? `expire le ${new Date(s.expires).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}`
|
||||
: "sans expiration";
|
||||
|
||||
/** Liens de partage en lecture seule d'un schéma. */
|
||||
export function ShareDialog({ path, onClose }: Props) {
|
||||
const [links, setLinks] = useState<Share[] | null>(null);
|
||||
const [days, setDays] = useState(7);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const name = stripExt(basename(path));
|
||||
|
||||
const run = async (op: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await op();
|
||||
setLinks(await api.shares(path));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
run(async () => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [path]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const copy = (s: Share) =>
|
||||
navigator.clipboard?.writeText(api.shareUrl(s.token)).then(() => {
|
||||
setCopied(s.id);
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
});
|
||||
|
||||
const create = () =>
|
||||
run(async () => {
|
||||
const s = await api.createShare(path, days);
|
||||
await copy(s)?.catch(() => {});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal share-dialog" role="dialog" aria-modal="true" aria-label={`Partager ${name}`} onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Partager · {name}</h3>
|
||||
<small className="muted">
|
||||
Toute personne disposant du lien voit le schéma en lecture seule, sans compte, dans sa version enregistrée
|
||||
la plus récente. Le reste des schémas reste inaccessible.
|
||||
</small>
|
||||
|
||||
<div className="share-create">
|
||||
<div className="radio-group" role="radiogroup" aria-label="Durée de validité">
|
||||
{EXPIRY.map((e) => (
|
||||
<button
|
||||
key={e.days}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={days === e.days}
|
||||
className={days === e.days ? "on" : ""}
|
||||
onClick={() => setDays(e.days)}
|
||||
>
|
||||
{e.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="primary" disabled={busy} onClick={create}>
|
||||
<ShareIcon />
|
||||
<span>Créer un lien</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="share-list">
|
||||
{links?.map((s) => (
|
||||
<li key={s.id}>
|
||||
<div className="cmd">
|
||||
<code>{api.shareUrl(s.token)}</code>
|
||||
<button className="icon-btn" onClick={() => copy(s)} title="Copier le lien" aria-label="Copier le lien">
|
||||
<CopyIcon />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn danger"
|
||||
disabled={busy}
|
||||
onClick={() => confirm("Révoquer ce lien ? Il cessera de fonctionner immédiatement.") && run(() => api.revokeShare(s.id))}
|
||||
title="Révoquer"
|
||||
aria-label="Révoquer le lien"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
<small className="muted">
|
||||
{copied === s.id ? "Lien copié." : `Créé le ${new Date(s.created).toLocaleDateString(undefined, { dateStyle: "short" })} · ${fmtExpiry(s)}`}
|
||||
</small>
|
||||
</li>
|
||||
))}
|
||||
{links?.length === 0 && <li className="muted empty">Aucun lien actif pour ce schéma.</li>}
|
||||
</ul>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<div className="modal-actions">
|
||||
<span className="spacer" />
|
||||
<button onClick={onClose}>Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import type { ExcalidrawInitialDataState } from "@excalidraw/excalidraw/types";
|
||||
import { api, type PublicScene } from "./api";
|
||||
import { DEFAULT_TITLE } from "./SettingsDialog";
|
||||
import { DownloadIcon, EyeIcon, MoonIcon, SunIcon } from "./icons";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
const initialTheme = (): Theme => {
|
||||
try {
|
||||
const t = localStorage.getItem("theme");
|
||||
if (t === "light" || t === "dark") return t;
|
||||
} catch {
|
||||
/* stockage indisponible */
|
||||
}
|
||||
return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
};
|
||||
|
||||
/** Vue publique d'un schéma partagé (/s/<jeton>) : lecture seule, sans explorateur. */
|
||||
export function ShareView({ token }: { token: string }) {
|
||||
const [data, setData] = useState<PublicScene | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [theme, setTheme] = useState<Theme>(initialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.publicScene(token)
|
||||
.then(setData)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)));
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}, [theme]);
|
||||
|
||||
const title = data?.title || DEFAULT_TITLE;
|
||||
useEffect(() => {
|
||||
document.title = data ? `${data.name} – ${title}` : title;
|
||||
}, [data, title]);
|
||||
|
||||
const download = () => {
|
||||
if (!data) return;
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(data.scene)], { type: "application/json" }));
|
||||
const a = Object.assign(document.createElement("a"), { href: url, download: `${data.name}.excalidraw` });
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
|
||||
const initialData: ExcalidrawInitialDataState | null = data && {
|
||||
elements: (data.scene.elements ?? []) as ExcalidrawInitialDataState["elements"],
|
||||
appState: { ...(data.scene.appState ?? {}), theme } as ExcalidrawInitialDataState["appState"],
|
||||
files: (data.scene.files ?? {}) as ExcalidrawInitialDataState["files"],
|
||||
scrollToContent: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="share-view">
|
||||
<header className="topbar">
|
||||
<span className="brand">
|
||||
{data?.logo && <img className="logo" src={data.logo} alt="" />}
|
||||
<span className="brand-title">{title}</span>
|
||||
</span>
|
||||
{data ? (
|
||||
<span className="title" title={data.name}>
|
||||
<span className="sep muted" aria-hidden>
|
||||
/
|
||||
</span>
|
||||
{data.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="spacer" />
|
||||
)}
|
||||
<span className="status readonly" title="Ce schéma est partagé en lecture seule">
|
||||
<EyeIcon size={14} />
|
||||
<span className="save-label">Lecture seule</span>
|
||||
</span>
|
||||
<div className="topbar-tools">
|
||||
{data && (
|
||||
<button className="icon-btn" onClick={download} title="Télécharger le fichier .excalidraw" aria-label="Télécharger">
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label={theme === "dark" ? "Passer en mode clair" : "Passer en mode sombre"}
|
||||
title={theme === "dark" ? "Mode clair" : "Mode sombre"}
|
||||
onClick={() => setTheme((t) => (t === "dark" ? "light" : "dark"))}
|
||||
>
|
||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="canvas">
|
||||
{initialData ? (
|
||||
<Excalidraw
|
||||
initialData={initialData}
|
||||
viewModeEnabled
|
||||
theme={theme}
|
||||
langCode="fr-FR"
|
||||
name={data!.name}
|
||||
UIOptions={{
|
||||
canvasActions: {
|
||||
loadScene: false,
|
||||
saveToActiveFile: false,
|
||||
clearCanvas: false,
|
||||
changeViewBackgroundColor: false,
|
||||
toggleTheme: false,
|
||||
export: false,
|
||||
saveAsImage: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="placeholder muted">
|
||||
<p>{error ?? "Chargement…"}</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,23 @@ export interface Version {
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** Lien de partage en lecture seule (token : à placer dans /s/<token>). */
|
||||
export interface Share {
|
||||
id: string;
|
||||
token: string;
|
||||
path: string;
|
||||
created: string;
|
||||
expires?: string;
|
||||
}
|
||||
|
||||
/** Schéma partagé tel que servi sans authentification. */
|
||||
export interface PublicScene {
|
||||
name: string;
|
||||
title: string;
|
||||
logo?: string;
|
||||
scene: { elements?: unknown[]; appState?: Record<string, unknown>; files?: Record<string, unknown> };
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
@@ -151,6 +168,27 @@ export const api = {
|
||||
|
||||
versionUrl: (path: string, id: string) => `/api/history/version?path=${q(path)}&id=${q(id)}`,
|
||||
|
||||
/** Liens d'un schéma, ou tous les liens si path est omis. */
|
||||
shares: async (path?: string): Promise<Share[]> =>
|
||||
(await request(`/api/shares${path ? `?path=${q(path)}` : ""}`)).json(),
|
||||
|
||||
/** days = 0 : lien sans expiration. */
|
||||
createShare: async (path: string, days: number): Promise<Share> =>
|
||||
(await request("/api/shares", json({ path, days }))).json(),
|
||||
|
||||
revokeShare: async (id: string): Promise<void> => {
|
||||
await request(`/api/shares?id=${q(id)}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
revokeAllShares: async (): Promise<void> => {
|
||||
await request("/api/shares/revoke-all", { method: "POST" });
|
||||
},
|
||||
|
||||
publicScene: async (token: string): Promise<PublicScene> =>
|
||||
(await request(`/api/public/${q(token)}`)).json(),
|
||||
|
||||
shareUrl: (token: string) => `${location.origin}/s/${token}`,
|
||||
|
||||
readLibrary: async (): Promise<unknown> => (await request("/api/library")).json(),
|
||||
|
||||
writeLibrary: async (body: string): Promise<void> => {
|
||||
|
||||
@@ -183,3 +183,19 @@ export const TagIcon = icon(
|
||||
<path d="M4 7v3.86a2 2 0 0 0 .59 1.41l7.71 7.71a2.4 2.4 0 0 0 3.4 0l4.27-4.27a2.4 2.4 0 0 0 0-3.4L12.26 4.59A2 2 0 0 0 10.85 4H7a3 3 0 0 0-3 3z" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const ShareIcon = icon(
|
||||
<>
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="18" cy="18" r="3" />
|
||||
<path d="M8.7 10.7l6.6-3.4M8.7 13.3l6.6 3.4" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const EyeIcon = icon(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<path d="M22 12c-2.67 4.67-6 7-10 7s-7.33-2.33-10-7c2.67-4.67 6-7 10-7s7.33 2.33 10 7" />
|
||||
</>,
|
||||
);
|
||||
@@ -3,9 +3,11 @@ import { createRoot } from "react-dom/client";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
import "./styles.css";
|
||||
import App from "./App";
|
||||
import { ShareView } from "./ShareView";
|
||||
|
||||
// /s/<jeton> : schéma partagé en lecture seule (sans authentification).
|
||||
const share = location.pathname.match(/^\/s\/([^/]+)$/);
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
<StrictMode>{share ? <ShareView token={decodeURIComponent(share[1])} /> : <App />}</StrictMode>,
|
||||
);
|
||||
@@ -1227,3 +1227,66 @@ button.chip.on,
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* --- Partage en lecture seule ---------------------------------------------- */
|
||||
|
||||
.share-view {
|
||||
display: grid;
|
||||
grid-template-rows: var(--topbar-h) 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
height: 100dvh;
|
||||
}
|
||||
.share-view .canvas {
|
||||
grid-area: 2 / 1;
|
||||
}
|
||||
.share-view .brand {
|
||||
cursor: default;
|
||||
}
|
||||
.status.readonly {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--ex-primary);
|
||||
}
|
||||
.share-dialog {
|
||||
width: min(520px, 100%);
|
||||
}
|
||||
.share-create {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.share-create .radio-group button {
|
||||
min-width: 0;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.share-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.share-list li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.share-list .empty {
|
||||
font-size: 13px;
|
||||
}
|
||||
.share-list .cmd code {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.share-view .status.readonly {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,11 @@
|
||||
// GET /api/history/version?path=x&id=v contenu d'une version
|
||||
// POST /api/history/snapshot {"path"} version du contenu courant
|
||||
// POST /api/history/restore {"path","id"} restauration (ETag en en-tête)
|
||||
// GET /api/shares[?path=x] liens de partage (d'un schéma ou tous)
|
||||
// POST /api/shares {"path","days"} nouveau lien (days 0 : sans expiration)
|
||||
// DELETE /api/shares?id=x révocation d'un lien
|
||||
// POST /api/shares/revoke-all révocation de tous les liens
|
||||
// GET /api/public/{jeton} schéma partagé (sans authentification)
|
||||
// GET /api/library / PUT /api/library bibliothèque Excalidraw
|
||||
// GET /api/settings / PUT {"title","channel"} paramètres (titre, logo, canal)
|
||||
// GET /api/logo / PUT (image/png) / DELETE logo 64x64
|
||||
@@ -24,11 +29,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/vblogio/excalidraw-go/internal/storage"
|
||||
"github.com/vblogio/excalidraw-go/internal/update"
|
||||
@@ -61,6 +70,11 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/history/version", h.readVersion)
|
||||
mux.HandleFunc("POST /api/history/snapshot", h.snapshot)
|
||||
mux.HandleFunc("POST /api/history/restore", h.restore)
|
||||
mux.HandleFunc("GET /api/shares", h.shares)
|
||||
mux.HandleFunc("POST /api/shares", h.createShare)
|
||||
mux.HandleFunc("DELETE /api/shares", h.revokeShare)
|
||||
mux.HandleFunc("POST /api/shares/revoke-all", h.revokeAllShares)
|
||||
mux.HandleFunc("GET /api/public/{token}", h.public)
|
||||
mux.HandleFunc("GET /api/library", h.readLibrary)
|
||||
mux.HandleFunc("PUT /api/library", h.writeLibrary)
|
||||
mux.HandleFunc("GET /api/settings", h.readSettings)
|
||||
@@ -391,6 +405,83 @@ func (h *Handler) restore(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, entry)
|
||||
}
|
||||
|
||||
func (h *Handler) shares(w http.ResponseWriter, r *http.Request) {
|
||||
l, err := h.Store.Shares(r.URL.Query().Get("path"))
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, l)
|
||||
}
|
||||
|
||||
type shareReq struct {
|
||||
Path string `json:"path"`
|
||||
Days int `json:"days"`
|
||||
}
|
||||
|
||||
func (h *Handler) createShare(w http.ResponseWriter, r *http.Request) {
|
||||
var req shareReq
|
||||
if !h.decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Days < 0 || req.Days > 3650 {
|
||||
writeErr(w, http.StatusBadRequest, "durée de 0 (sans expiration) à 3650 jours")
|
||||
return
|
||||
}
|
||||
sh, err := h.Store.CreateShare(req.Path, time.Duration(req.Days)*24*time.Hour)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, sh)
|
||||
}
|
||||
|
||||
func (h *Handler) revokeShare(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.Store.RevokeShare(r.URL.Query().Get("id")); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) revokeAllShares(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.Store.RevokeAllShares(); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// public sert un schéma partagé, hors authentification (voir cmd/server).
|
||||
// Seuls le nom du schéma, son contenu et l'apparence (titre, logo) sont exposés.
|
||||
func (h *Handler) public(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
||||
sh, data, err := h.Store.ReadShared(r.PathValue("token"))
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
writeErr(w, http.StatusNotFound, "lien invalide, expiré ou révoqué")
|
||||
} else {
|
||||
fail(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
out := struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
Scene json.RawMessage `json:"scene"`
|
||||
}{Name: strings.TrimSuffix(path.Base(sh.Path), storage.Ext), Scene: data}
|
||||
if st, err := h.Store.ReadSettings(); err == nil {
|
||||
out.Title = st.Title
|
||||
}
|
||||
if logo, err := h.Store.ReadLogo(); err == nil {
|
||||
out.Logo = "data:image/png;base64," + base64.StdEncoding.EncodeToString(logo)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *Handler) readLibrary(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.Store.ReadLibrary()
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user