Partage en lecture seule par lien signé

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H6J7vKwXx7uopxKhWfFJpm
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-07 18:10:09 +02:00
1 parent c1ea1e84a7
commit 50d40cd929
18 files changed
+1076 -12

No files matched your search

+14 -1
View File
@@ -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}
+4 -1
View File
@@ -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"}
+72 -2
View File
@@ -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">
+132
View File
@@ -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>
);
}
+122
View File
@@ -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>
);
}
+38
View File
@@ -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> => {
+16
View File
@@ -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" />
</>,
);
+5 -3
View File
@@ -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>,
);
+63
View File
@@ -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;
}
}