From 50d40cd9290ddd7510f7f05708146c68d7bc6877 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 7 Oct 2026 18:10:09 +0200 Subject: [PATCH] =?UTF-8?q?Partage=20en=20lecture=20seule=20par=20lien=20s?= =?UTF-8?q?ign=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lien /s/ : 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) Claude-Session: https://claude.ai/code/session_01H6J7vKwXx7uopxKhWfFJpm --- .env.example | 1 + README.md | 23 ++- cmd/server/main.go | 36 +++- cmd/server/main_test.go | 38 ++++ docker-compose.yml | 3 + frontend/src/App.tsx | 15 +- frontend/src/Explorer.tsx | 5 +- frontend/src/SettingsDialog.tsx | 74 +++++++- frontend/src/ShareDialog.tsx | 132 ++++++++++++++ frontend/src/ShareView.tsx | 122 +++++++++++++ frontend/src/api.ts | 38 ++++ frontend/src/icons.tsx | 16 ++ frontend/src/main.tsx | 8 +- frontend/src/styles.css | 63 +++++++ internal/api/api.go | 91 ++++++++++ internal/storage/share.go | 310 ++++++++++++++++++++++++++++++++ internal/storage/share_test.go | 107 +++++++++++ internal/storage/storage.go | 6 + 18 files changed, 1076 insertions(+), 12 deletions(-) create mode 100644 cmd/server/main_test.go create mode 100644 frontend/src/ShareDialog.tsx create mode 100644 frontend/src/ShareView.tsx create mode 100644 internal/storage/share.go create mode 100644 internal/storage/share_test.go diff --git a/.env.example b/.env.example index abcc81c..8bc2e16 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,5 @@ UPDATE_CHECK_INTERVAL=12h HISTORY_KEEP=50 HISTORY_INTERVAL=10m HISTORY_MAX_AGE=0 +SHARE_SECRET= EXCALIDRAW_VERSION= diff --git a/README.md b/README.md index 30aab87..1b7af84 100644 --- a/README.md +++ b/README.md @@ -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/ 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/.excalidraw ← versions └─ .trash//... ← é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:///s/` 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 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//.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=` | | 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":""}` | `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=` | | `204` | +| POST | `/api/shares/revoke-all` | | `204` (tous les liens, nouvelle clé) | +| GET | `/api/public/` | | `{"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":"","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/` | | 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/ (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 diff --git a/cmd/server/main.go b/cmd/server/main.go index 4bdb994..95eda2b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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/ 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) }) } diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..1036fef --- /dev/null +++ b/cmd/server/main_test.go @@ -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) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 23ac194..ee00280 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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/ (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`. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0c0f9e5..c7239a4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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); const [historyFor, setHistoryFor] = useState(null); + const [shareFor, setShareFor] = useState(null); const excalidraw = useRef(null); const pathRef = useRef(null); // schéma ouvert (survit aux renommages) @@ -410,6 +412,14 @@ export default function App() { > + {!e.isDir && } {!e.isDir && } + {!e.isDir && } + + + Créé le {new Date(s.created).toLocaleDateString(undefined, { dateStyle: "short" })} · {fmtExpiry(s)} + + + ))} + + ) : ( + + 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. + + )} + {!!shares?.length && ( + + )} + + {error &&
{error}
}
diff --git a/frontend/src/ShareDialog.tsx b/frontend/src/ShareDialog.tsx new file mode 100644 index 0000000..5f0e6fb --- /dev/null +++ b/frontend/src/ShareDialog.tsx @@ -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(null); + const [days, setDays] = useState(7); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(null); + const name = stripExt(basename(path)); + + const run = async (op: () => Promise) => { + 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 ( +
+
e.stopPropagation()}> +

Partager · {name}

+ + 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. + + +
+
+ {EXPIRY.map((e) => ( + + ))} +
+ +
+ +
    + {links?.map((s) => ( +
  • +
    + {api.shareUrl(s.token)} + + +
    + + {copied === s.id ? "Lien copié." : `Créé le ${new Date(s.created).toLocaleDateString(undefined, { dateStyle: "short" })} · ${fmtExpiry(s)}`} + +
  • + ))} + {links?.length === 0 &&
  • Aucun lien actif pour ce schéma.
  • } +
+ + {error &&
{error}
} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/ShareView.tsx b/frontend/src/ShareView.tsx new file mode 100644 index 0000000..36b1a2e --- /dev/null +++ b/frontend/src/ShareView.tsx @@ -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/) : lecture seule, sans explorateur. */ +export function ShareView({ token }: { token: string }) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [theme, setTheme] = useState(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 ( +
+
+ + {data?.logo && } + {title} + + {data ? ( + + + / + + {data.name} + + ) : ( + + )} + + + Lecture seule + +
+ {data && ( + + )} + +
+
+
+ {initialData ? ( + + ) : ( +
+

{error ?? "Chargement…"}

+
+ )} +
+
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 9e2e45b..dd06115 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -57,6 +57,23 @@ export interface Version { size: number; } +/** Lien de partage en lecture seule (token : à placer dans /s/). */ +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; files?: Record }; +} + 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 => + (await request(`/api/shares${path ? `?path=${q(path)}` : ""}`)).json(), + + /** days = 0 : lien sans expiration. */ + createShare: async (path: string, days: number): Promise => + (await request("/api/shares", json({ path, days }))).json(), + + revokeShare: async (id: string): Promise => { + await request(`/api/shares?id=${q(id)}`, { method: "DELETE" }); + }, + + revokeAllShares: async (): Promise => { + await request("/api/shares/revoke-all", { method: "POST" }); + }, + + publicScene: async (token: string): Promise => + (await request(`/api/public/${q(token)}`)).json(), + + shareUrl: (token: string) => `${location.origin}/s/${token}`, + readLibrary: async (): Promise => (await request("/api/library")).json(), writeLibrary: async (body: string): Promise => { diff --git a/frontend/src/icons.tsx b/frontend/src/icons.tsx index 07267e5..1f25425 100644 --- a/frontend/src/icons.tsx +++ b/frontend/src/icons.tsx @@ -183,3 +183,19 @@ export const TagIcon = icon( , ); + +export const ShareIcon = icon( + <> + + + + + , +); + +export const EyeIcon = icon( + <> + + + , +); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8f24a9b..69c9c75 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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/ : schéma partagé en lecture seule (sans authentification). +const share = location.pathname.match(/^\/s\/([^/]+)$/); createRoot(document.getElementById("root")!).render( - - - , + {share ? : }, ); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 4a00a98..928bc88 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; + } +} diff --git a/internal/api/api.go b/internal/api/api.go index d7ae176..c3193e0 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 { diff --git a/internal/storage/share.go b/internal/storage/share.go new file mode 100644 index 0000000..c5a284d --- /dev/null +++ b/internal/storage/share.go @@ -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/ donne accès à un schéma sans +// authentification. Le jeton est . : 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 + }) +} diff --git a/internal/storage/share_test.go b/internal/storage/share_test.go new file mode 100644 index 0000000..8326266 --- /dev/null +++ b/internal/storage/share_test.go @@ -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) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 4f2a878..de8df34 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -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 }