Phase 2.1 : preparation au deploiement public
Rend une instance exposee sur Internet exploitable sans la transformer en infrastructure : le produit reste local-first, sans compte et sans telemetrie. Protection du service : limitation de debit en memoire par client et par famille d'endpoint (health et ready jamais limites), interrupteur SHARING_ENABLED, capacite maximale de partages, taille propre aux partages. X-Forwarded-For n'est cru que si TRUST_PROXY_HEADERS l'autorise, et on lit alors la derniere valeur, celle ajoutee par le proxy. Expiration facultative des partages (24 h a 90 jours, ou jamais) : un lien expire repond 410 et la page publique le dit, sans autre detail. Le proprietaire peut prolonger ; aucune purge automatique. Protection des liens non repertories : robots.txt, X-Robots-Tag et meta robots sur /s/ et /api/, Referrer-Policy no-referrer sur tout le site, redaction des jetons dans les journaux d'acces, share_token porte a 128 bits (les anciens restent valides). Schema gere par Alembic, applique au demarrage. Une base creee avant Alembic est estampillee puis migree : verifie sur la base reelle de developpement, 4 partages inchanges et expires_at NULL. Administration par CLI locale au conteneur (stats, liste, suppression, purge, sauvegarde a chaud via l'API SQLite, restauration prudente) : aucune interface /admin exposee. Le jeton de gestion peut enfin etre exporte dans un fichier .openmind-share puis reimporte depuis un autre navigateur, apres verification aupres du serveur. Il n'est toujours jamais ajoute a un export .mmap. Ajoute une page /privacy dont le texte est centralise, et corrige un bug trouve en observant les logs : Alembic, lance dans le processus applicatif, reappliquait la configuration de logs d'alembic.ini et desactivait les loggers existants, ce qui faisait disparaitre les journaux d'acces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
7
frontend/public/robots.txt
Normal file
7
frontend/public/robots.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
# Les liens de partage sont non repertories : ils ne doivent pas etre indexes.
|
||||
# Ce fichier reste une convention respectee par les robots corrects ; ce qui
|
||||
# autorise reellement la lecture d'une carte, c'est la connaissance du jeton.
|
||||
User-agent: *
|
||||
Disallow: /s/
|
||||
Disallow: /api/
|
||||
Allow: /
|
||||
@@ -4,6 +4,7 @@ import { Toasts } from '@/components/Toasts';
|
||||
import { DashboardPage } from '@/features/maps/DashboardPage';
|
||||
import { EditorPage } from '@/features/editor/EditorPage';
|
||||
import { SharedMapPage } from '@/features/sharing/SharedMapPage';
|
||||
import { PrivacyPage } from '@/features/privacy/PrivacyPage';
|
||||
import { useAppConfigStore } from '@/stores/appConfigStore';
|
||||
import { navigate, useRoute } from '@/hooks/useRoute';
|
||||
|
||||
@@ -36,6 +37,7 @@ export function App() {
|
||||
{route.name === 'dashboard' ? <DashboardPage /> : null}
|
||||
{route.name === 'editor' ? <EditorPage mapId={route.mapId} /> : null}
|
||||
{route.name === 'shared' ? <SharedMapPage token={route.token} /> : null}
|
||||
{route.name === 'privacy' ? <PrivacyPage /> : null}
|
||||
{route.name === 'notFound' ? <NotFoundPage /> : null}
|
||||
</main>
|
||||
<Toasts />
|
||||
|
||||
94
frontend/src/config/privacy.ts
Normal file
94
frontend/src/config/privacy.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Texte de la page Confidentialite.
|
||||
*
|
||||
* Centralise ici pour qu'un exploitant puisse l'adapter a son instance sans
|
||||
* fouiller dans les composants. Volontairement factuel : ce sont des faits
|
||||
* verifiables sur le fonctionnement de l'application, pas un texte juridique
|
||||
* invente.
|
||||
*
|
||||
* `{app}` est remplace par le nom de l'instance.
|
||||
*/
|
||||
|
||||
export interface PrivacySection {
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
bullets?: string[];
|
||||
}
|
||||
|
||||
export const PRIVACY_INTRO =
|
||||
"{app} fonctionne d'abord dans votre navigateur. Cette page decrit ce qui reste sur " +
|
||||
'votre appareil, ce qui part vers le serveur, et a quel moment.';
|
||||
|
||||
export const PRIVACY_SECTIONS: PrivacySection[] = [
|
||||
{
|
||||
title: 'Vos cartes restent sur cet appareil',
|
||||
paragraphs: [
|
||||
"Les cartes que vous creez sont enregistrees dans l'IndexedDB de ce navigateur. " +
|
||||
"Elles ne sont pas envoyees au serveur, qui n'en a donc aucune connaissance.",
|
||||
'Consequence directe : vider les donnees du site, changer de navigateur ou de ' +
|
||||
'machine vous fait perdre ces cartes. Exportez-les en .mmap pour en garder une ' +
|
||||
'copie portable.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Le partage est une action explicite',
|
||||
paragraphs: [
|
||||
"Quand vous creez un lien de partage, et seulement a ce moment, une copie de la " +
|
||||
'carte est envoyee au serveur et enregistree dans sa base de donnees.',
|
||||
],
|
||||
bullets: [
|
||||
'Le lien obtenu est non repertorie : il ne figure dans aucune liste publique.',
|
||||
"Il n'est pas protege par un mot de passe : toute personne qui connait le lien " +
|
||||
'peut consulter la carte.',
|
||||
'La copie serveur reste figee tant que vous ne demandez pas sa mise a jour.',
|
||||
"Elle est conservee jusqu'a son expiration, ou jusqu'a ce que vous la supprimiez.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'La cle de gestion',
|
||||
paragraphs: [
|
||||
'Mettre a jour ou supprimer un partage demande une cle de gestion, generee au ' +
|
||||
'moment du partage. Le serveur ne stocke que son empreinte : il ne peut donc ' +
|
||||
'pas la retrouver, et personne ne peut vous la redonner.',
|
||||
"Elle reste dans ce navigateur. Vous pouvez l'exporter dans un fichier pour la " +
|
||||
'conserver ailleurs, puis la reimporter depuis un autre navigateur.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Aucun compte, aucune publicite, aucune telemetrie',
|
||||
paragraphs: [
|
||||
"{app} ne demande aucune inscription et ne cree aucun profil utilisateur. " +
|
||||
"L'application n'affiche aucune publicite et n'envoie aucune statistique " +
|
||||
"d'usage : il n'existe pas de code de mesure d'audience dans le projet.",
|
||||
"Les seules requetes reseau sont celles vers cette instance : la configuration " +
|
||||
"publique, et les partages que vous creez ou consultez. Aucune ressource n'est " +
|
||||
'chargee depuis un service tiers.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Journaux du serveur',
|
||||
paragraphs: [
|
||||
"Le serveur journalise les requetes qu'il recoit pour permettre son exploitation. " +
|
||||
'Les jetons de partage y sont remplaces par [REDACTED], et la cle de gestion ' +
|
||||
"n'y apparait jamais.",
|
||||
"Un reverse proxy place devant cette instance possede ses propres journaux, qui " +
|
||||
"ne dependent pas d'{app} : leur contenu releve de son exploitant.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Reprendre la main',
|
||||
paragraphs: [
|
||||
'Vous pouvez a tout moment supprimer un partage depuis la carte correspondante : ' +
|
||||
'le lien cesse alors de fonctionner et la copie serveur est effacee. Une carte ' +
|
||||
'locale se supprime depuis le tableau de bord.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Phrase affichee discretement sur le tableau de bord. */
|
||||
export const LOCAL_FIRST_NOTE =
|
||||
'Vos cartes restent sur cet appareil tant que vous ne les partagez pas.';
|
||||
|
||||
export function applyAppName(text: string, appName: string): string {
|
||||
return text.split('{app}').join(appName);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
resolveSpacing,
|
||||
} from '@/features/editor/layout';
|
||||
import { THEMES } from '@/features/editor/themes';
|
||||
import { useAppConfigStore } from '@/stores/appConfigStore';
|
||||
import { selectCanRedo, selectCanUndo, useEditorStore } from '@/stores/editorStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { navigate } from '@/hooks/useRoute';
|
||||
@@ -73,6 +74,9 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
|
||||
const collapseToLevel = useEditorStore((state) => state.collapseToLevel);
|
||||
const balanceSides = useEditorStore((state) => state.balanceSides);
|
||||
|
||||
const sharingEnabled = useAppConfigStore((state) => state.sharingEnabled);
|
||||
const serverReachable = useAppConfigStore((state) => state.serverReachable);
|
||||
|
||||
const colorMode = useUiStore((state) => state.colorMode);
|
||||
const toggleColorMode = useUiStore((state) => state.toggleColorMode);
|
||||
const pushToast = useUiStore((state) => state.pushToast);
|
||||
@@ -395,10 +399,24 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
|
||||
|
||||
<div className="mx-1 h-6 w-px bg-zinc-200 dark:bg-zinc-700" />
|
||||
|
||||
<Button size="sm" onClick={onOpenShare}>
|
||||
<ShareIcon className="h-4 w-4" />
|
||||
<span className="hidden md:inline">Partager</span>
|
||||
</Button>
|
||||
{/*
|
||||
Instance privee (le serveur repond que le partage est coupe) : le
|
||||
bouton disparait. Serveur injoignable : il reste visible mais inactif,
|
||||
car l'etat du partage est simplement inconnu.
|
||||
*/}
|
||||
{sharingEnabled || !serverReachable ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpenShare}
|
||||
disabled={!serverReachable}
|
||||
title={
|
||||
serverReachable ? undefined : 'Serveur de partage injoignable : travail local en cours'
|
||||
}
|
||||
>
|
||||
<ShareIcon className="h-4 w-4" />
|
||||
<span className="hidden md:inline">Partager</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<ExportMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
import { MapFormatError, readMapFile, serializeMap } from '@/services/mapSerialization';
|
||||
import { useAppConfigStore } from '@/stores/appConfigStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { buildMapPath, navigate } from '@/hooks/useRoute';
|
||||
import { LOCAL_FIRST_NOTE } from '@/config/privacy';
|
||||
import { buildMapPath, navigate, PRIVACY_PATH } from '@/hooks/useRoute';
|
||||
import { downloadBlob, toSafeFilename } from '@/utils/download';
|
||||
|
||||
export function DashboardPage() {
|
||||
@@ -157,6 +158,7 @@ export function DashboardPage() {
|
||||
<p className="mt-1 max-w-xl text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{APP_CONFIG.tagline}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-zinc-400">{LOCAL_FIRST_NOTE}</p>
|
||||
</div>
|
||||
<IconButton
|
||||
label={colorMode === 'dark' ? 'Passer en mode clair' : 'Passer en mode sombre'}
|
||||
@@ -283,7 +285,14 @@ export function DashboardPage() {
|
||||
<footer className="mt-10 border-t border-zinc-200 pt-4 text-xs leading-relaxed text-zinc-500 dark:border-zinc-800 dark:text-zinc-400">
|
||||
Vos cartes sont enregistrees <strong>dans ce navigateur</strong> (IndexedDB). Elles ne sont
|
||||
envoyees au serveur que si vous creez explicitement un lien de partage. Exportez-les en{' '}
|
||||
{APP_CONFIG.fileExtension} pour en conserver une sauvegarde portable.
|
||||
{APP_CONFIG.fileExtension} pour en conserver une sauvegarde portable.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="underline underline-offset-2 hover:text-zinc-700 dark:hover:text-zinc-200"
|
||||
onClick={() => navigate(PRIVACY_PATH)}
|
||||
>
|
||||
En savoir plus sur la confidentialite
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<TemplateGallery
|
||||
|
||||
58
frontend/src/features/privacy/PrivacyPage.tsx
Normal file
58
frontend/src/features/privacy/PrivacyPage.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Button } from '@/components/Button';
|
||||
import { ArrowLeftIcon } from '@/components/Icons';
|
||||
import { PRIVACY_INTRO, PRIVACY_SECTIONS, applyAppName } from '@/config/privacy';
|
||||
import { useAppConfigStore } from '@/stores/appConfigStore';
|
||||
import { navigate } from '@/hooks/useRoute';
|
||||
|
||||
/**
|
||||
* Page /privacy.
|
||||
*
|
||||
* Le texte vit dans `config/privacy.ts` : cette page ne fait que le mettre en
|
||||
* forme, pour qu'un exploitant puisse l'adapter a son instance en un seul
|
||||
* endroit.
|
||||
*/
|
||||
export function PrivacyPage() {
|
||||
const appName = useAppConfigStore((state) => state.name);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-3xl flex-col overflow-y-auto px-5 py-8 sm:px-8">
|
||||
<header className="mb-6">
|
||||
<Button onClick={() => navigate('/')}>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Retour
|
||||
</Button>
|
||||
<h1 className="mt-5 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
Confidentialite
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
|
||||
{applyAppName(PRIVACY_INTRO, appName)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-7 pb-10">
|
||||
{PRIVACY_SECTIONS.map((section) => (
|
||||
<section key={section.title}>
|
||||
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
{section.title}
|
||||
</h2>
|
||||
{section.paragraphs.map((paragraph) => (
|
||||
<p
|
||||
key={paragraph.slice(0, 40)}
|
||||
className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300"
|
||||
>
|
||||
{applyAppName(paragraph, appName)}
|
||||
</p>
|
||||
))}
|
||||
{section.bullets ? (
|
||||
<ul className="mt-2 list-inside list-disc space-y-1 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
|
||||
{section.bullets.map((bullet) => (
|
||||
<li key={bullet.slice(0, 40)}>{applyAppName(bullet, appName)}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/Button';
|
||||
import { Modal } from '@/components/Modal';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { CheckIcon, CopyIcon, SpinnerIcon } from '@/components/Icons';
|
||||
import { CheckIcon, CopyIcon, DownloadIcon, ImportIcon, SpinnerIcon } from '@/components/Icons';
|
||||
import { type ShareRecord } from '@/types/map';
|
||||
import { ShareApiError, createShare, deleteShare, updateShare } from '@/services/shareApi';
|
||||
import {
|
||||
deleteShareRecord,
|
||||
getShareRecord,
|
||||
saveShareRecord,
|
||||
} from '@/services/shareRepository';
|
||||
ShareApiError,
|
||||
createShare,
|
||||
deleteShare,
|
||||
setShareExpiry,
|
||||
updateShare,
|
||||
verifyManagementToken,
|
||||
} from '@/services/shareApi';
|
||||
import { ShareKeyError, SHARE_KEY_EXTENSION, readShareKeyFile, serializeShareKey } from '@/services/shareKey';
|
||||
import { deleteShareRecord, getShareRecord, saveShareRecord } from '@/services/shareRepository';
|
||||
import { useAppConfigStore } from '@/stores/appConfigStore';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { buildShareUrl } from '@/hooks/useRoute';
|
||||
import { buildShareUrl, navigate, PRIVACY_PATH } from '@/hooks/useRoute';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { downloadBlob, toSafeFilename } from '@/utils/download';
|
||||
|
||||
export interface ShareDialogProps {
|
||||
open: boolean;
|
||||
@@ -21,15 +27,63 @@ export interface ShareDialogProps {
|
||||
onShareStateChange?: (shared: boolean) => void;
|
||||
}
|
||||
|
||||
/** `null` represente l'option "Jamais". */
|
||||
type ExpiryChoice = number | null;
|
||||
|
||||
function expiryLabel(days: ExpiryChoice): string {
|
||||
if (days === null) return 'Jamais';
|
||||
if (days === 1) return '24 heures';
|
||||
if (days === 7) return '7 jours';
|
||||
if (days === 30) return '30 jours';
|
||||
if (days === 90) return '90 jours';
|
||||
return `${days} jours`;
|
||||
}
|
||||
|
||||
function serializeChoice(days: ExpiryChoice): string {
|
||||
return days === null ? 'never' : String(days);
|
||||
}
|
||||
|
||||
function parseChoice(value: string): ExpiryChoice {
|
||||
return value === 'never' ? null : Number(value);
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Duree a afficher pour un partage existant.
|
||||
*
|
||||
* Le serveur ne stocke qu'une date absolue : la duree choisie a l'origine est
|
||||
* perdue. On rapproche donc le temps restant de l'option la plus proche, la
|
||||
* date exacte etant de toute facon rappelee juste en dessous du menu.
|
||||
*/
|
||||
function choiceForExpiry(expiresAt: string | null, options: ExpiryChoice[]): ExpiryChoice {
|
||||
if (!expiresAt) return null;
|
||||
const remaining = (new Date(expiresAt).getTime() - Date.now()) / DAY_MS;
|
||||
const durations = options.filter((value): value is number => value !== null);
|
||||
if (durations.length === 0) return null;
|
||||
return durations.find((value) => value >= remaining) ?? (durations.at(-1) as number);
|
||||
}
|
||||
|
||||
export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogProps) {
|
||||
const map = useEditorStore((state) => state.map);
|
||||
const pushToast = useUiStore((state) => state.pushToast);
|
||||
|
||||
const appName = useAppConfigStore((state) => state.name);
|
||||
const sharingEnabled = useAppConfigStore((state) => state.sharingEnabled);
|
||||
const allowPermanent = useAppConfigStore((state) => state.allowPermanentShares);
|
||||
const defaultExpiryDays = useAppConfigStore((state) => state.defaultShareExpiryDays);
|
||||
const expiryOptions = useAppConfigStore((state) => state.shareExpiryOptions);
|
||||
|
||||
const [record, setRecord] = useState<ShareRecord | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [choice, setChoice] = useState<ExpiryChoice>(null);
|
||||
const keyInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Les options refusees par l'instance ne sont pas proposees.
|
||||
const options = expiryOptions.filter((value) => value !== null || allowPermanent);
|
||||
|
||||
const refresh = useCallback(async (mapId: string) => {
|
||||
setLoading(true);
|
||||
@@ -43,16 +97,32 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
void refresh(map.id);
|
||||
}, [open, map, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (record) {
|
||||
setChoice(choiceForExpiry(record.expiresAt ?? null, options));
|
||||
return;
|
||||
}
|
||||
setChoice(defaultExpiryDays > 0 ? defaultExpiryDays : allowPermanent ? null : 30);
|
||||
// `options` est recalcule a chaque rendu : le comparer relancerait l'effet en boucle.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, record, defaultExpiryDays, allowPermanent]);
|
||||
|
||||
const handleError = (error: unknown, fallback: string) => {
|
||||
pushToast(error instanceof ShareApiError ? error.message : fallback, 'error');
|
||||
};
|
||||
|
||||
const persist = async (next: ShareRecord) => {
|
||||
await saveShareRecord(next);
|
||||
setRecord(next);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!map || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await createShare(map, map.name);
|
||||
const created: ShareRecord = {
|
||||
const response = await createShare(map, map.name, choice);
|
||||
await persist({
|
||||
mapId: map.id,
|
||||
shareToken: response.shareToken,
|
||||
managementToken: response.managementToken,
|
||||
@@ -60,9 +130,8 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
title: response.title,
|
||||
createdAt: response.createdAt,
|
||||
updatedAt: response.updatedAt,
|
||||
};
|
||||
await saveShareRecord(created);
|
||||
setRecord(created);
|
||||
expiresAt: response.expiresAt,
|
||||
});
|
||||
onShareStateChange?.(true);
|
||||
pushToast('Lien de partage cree.', 'success');
|
||||
} catch (error) {
|
||||
@@ -76,15 +145,13 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
if (!map || !record || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await updateShare(
|
||||
record.shareToken,
|
||||
record.managementToken,
|
||||
map,
|
||||
map.name,
|
||||
);
|
||||
const updated = { ...record, title: map.name, updatedAt: response.updatedAt };
|
||||
await saveShareRecord(updated);
|
||||
setRecord(updated);
|
||||
const response = await updateShare(record.shareToken, record.managementToken, map, map.name);
|
||||
await persist({
|
||||
...record,
|
||||
title: map.name,
|
||||
updatedAt: response.updatedAt,
|
||||
expiresAt: response.expiresAt,
|
||||
});
|
||||
pushToast('Le lien public affiche maintenant la version actuelle.', 'success');
|
||||
} catch (error) {
|
||||
handleError(error, "La mise a jour du partage n'a pas abouti.");
|
||||
@@ -93,6 +160,24 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpiryChange = async (next: ExpiryChoice) => {
|
||||
setChoice(next);
|
||||
if (!record || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await setShareExpiry(record.shareToken, record.managementToken, next);
|
||||
await persist({ ...record, expiresAt: response.expiresAt, updatedAt: response.updatedAt });
|
||||
pushToast(
|
||||
next === null ? "Ce partage n'expire plus." : `Expiration fixee a ${expiryLabel(next)}.`,
|
||||
'success',
|
||||
);
|
||||
} catch (error) {
|
||||
handleError(error, "L'expiration n'a pas pu etre modifiee.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!map || !record || busy) return;
|
||||
setConfirmDelete(false);
|
||||
@@ -116,9 +201,8 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!record) return;
|
||||
const url = buildShareUrl(record.shareToken);
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
await navigator.clipboard.writeText(buildShareUrl(record.shareToken));
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
@@ -126,8 +210,55 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
}
|
||||
};
|
||||
|
||||
/** Sauvegarde volontaire du secret de gestion, hors du fichier .mmap. */
|
||||
const handleExportKey = () => {
|
||||
if (!record) return;
|
||||
const blob = new Blob([serializeShareKey(record, window.location.origin)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
});
|
||||
downloadBlob(blob, toSafeFilename(record.title || 'partage', SHARE_KEY_EXTENSION));
|
||||
pushToast('Cle de gestion exportee. Conservez ce fichier en lieu sur.', 'success');
|
||||
};
|
||||
|
||||
const handleImportKey = async (file: File) => {
|
||||
if (!map || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const key = await readShareKeyFile(file);
|
||||
// La cle n'est acceptee qu'apres verification par le serveur concerne.
|
||||
const owner = await verifyManagementToken(key.shareToken, key.managementToken);
|
||||
await persist({
|
||||
mapId: map.id,
|
||||
shareToken: owner.shareToken,
|
||||
managementToken: key.managementToken,
|
||||
sharePath: `/s/${owner.shareToken}`,
|
||||
title: owner.title,
|
||||
createdAt: owner.createdAt,
|
||||
updatedAt: owner.updatedAt,
|
||||
expiresAt: owner.expiresAt,
|
||||
});
|
||||
onShareStateChange?.(true);
|
||||
pushToast('Cle de gestion importee : vous pouvez a nouveau gerer ce partage.', 'success');
|
||||
} catch (error) {
|
||||
if (error instanceof ShareKeyError) pushToast(error.message, 'error');
|
||||
else handleError(error, "Cette cle de gestion n'a pas pu etre verifiee.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const shareUrl = record ? buildShareUrl(record.shareToken) : '';
|
||||
|
||||
const disabledBody = (
|
||||
<div className="space-y-3 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
|
||||
<p>Le partage est desactive sur cette instance.</p>
|
||||
<p className="text-zinc-500 dark:text-zinc-400">
|
||||
Vos cartes continuent de fonctionner normalement : elles restent enregistrees dans ce
|
||||
navigateur, et vous pouvez toujours les exporter en fichier.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
@@ -135,13 +266,11 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
title={record ? 'Carte partagee' : 'Partager cette carte'}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
record ? (
|
||||
!sharingEnabled ? (
|
||||
<Button onClick={onClose}>Fermer</Button>
|
||||
) : record ? (
|
||||
<>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={busy}
|
||||
>
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)} disabled={busy}>
|
||||
Supprimer le partage
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => void handleUpdate()} disabled={busy}>
|
||||
@@ -162,12 +291,23 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
)
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
{!sharingEnabled ? (
|
||||
disabledBody
|
||||
) : loading ? (
|
||||
<p className="text-sm text-zinc-500">Chargement...</p>
|
||||
) : record ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
Partage non repertorie
|
||||
</span>
|
||||
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
Lecture seule
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="field-label">Lien public (lecture seule)</span>
|
||||
<span className="field-label">Lien public</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
readOnly
|
||||
@@ -183,6 +323,30 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="share-expiry">
|
||||
Expiration
|
||||
</label>
|
||||
<select
|
||||
id="share-expiry"
|
||||
className="field-input"
|
||||
disabled={busy}
|
||||
value={serializeChoice(choice)}
|
||||
onChange={(event) => void handleExpiryChange(parseChoice(event.target.value))}
|
||||
>
|
||||
{options.map((value) => (
|
||||
<option key={serializeChoice(value)} value={serializeChoice(value)}>
|
||||
{expiryLabel(value)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-[11px] text-zinc-400">
|
||||
{record.expiresAt
|
||||
? `Le lien cessera de fonctionner le ${formatDateTime(record.expiresAt)}.`
|
||||
: "Ce partage n'expire pas automatiquement."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="rounded-lg border border-zinc-100 bg-zinc-50 p-3 text-xs dark:border-zinc-800 dark:bg-zinc-900/60">
|
||||
<div className="flex justify-between gap-4 py-0.5">
|
||||
<dt className="text-zinc-500">Publie le</dt>
|
||||
@@ -198,29 +362,93 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div>
|
||||
<Button size="sm" onClick={handleExportKey} disabled={busy}>
|
||||
<DownloadIcon className="h-3.5 w-3.5" />
|
||||
Exporter la cle de gestion
|
||||
</Button>
|
||||
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
|
||||
Ce fichier contient le secret qui permet de mettre a jour ou de supprimer ce
|
||||
partage. Il n'est jamais inclus dans un export {'.mmap'}. Conservez-le si vous
|
||||
comptez gerer ce partage depuis un autre navigateur.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
|
||||
Le lien est <strong>non repertorie</strong> : il n'apparait dans aucune liste
|
||||
publique, mais toute personne qui le connait peut consulter la carte. Les
|
||||
<strong>Toute personne disposant de ce lien peut consulter cette carte.</strong> Les
|
||||
modifications faites ici ne sont visibles publiquement qu'apres un
|
||||
<em> Mettre a jour le partage</em>.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
|
||||
<div className="space-y-4 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
|
||||
<p>
|
||||
Cette carte est actuellement <strong>uniquement dans votre navigateur</strong>. Pour
|
||||
generer un lien, une copie de la carte sera envoyee au serveur et stockee dans sa base
|
||||
de donnees.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="share-expiry-new">
|
||||
Expiration
|
||||
</label>
|
||||
<select
|
||||
id="share-expiry-new"
|
||||
className="field-input"
|
||||
disabled={busy}
|
||||
value={serializeChoice(choice)}
|
||||
onChange={(event) => setChoice(parseChoice(event.target.value))}
|
||||
>
|
||||
{options.map((value) => (
|
||||
<option key={serializeChoice(value)} value={serializeChoice(value)}>
|
||||
{expiryLabel(value)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ul className="list-inside list-disc space-y-1 text-zinc-500 dark:text-zinc-400">
|
||||
<li>Le lien obtenu est non repertorie et en lecture seule.</li>
|
||||
<li>Aucun compte n'est necessaire pour le consulter.</li>
|
||||
<li>Toute personne disposant de ce lien peut consulter cette carte.</li>
|
||||
<li>Les cartes non partagees restent uniquement dans votre navigateur.</li>
|
||||
<li>Vous pourrez mettre a jour ou supprimer ce partage a tout moment.</li>
|
||||
<li>
|
||||
Le secret permettant de le modifier reste dans ce navigateur : depuis un autre
|
||||
appareil, vous ne pourrez pas gerer ce partage.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="border-t border-zinc-100 pt-3 dark:border-zinc-800">
|
||||
<Button size="sm" onClick={() => keyInputRef.current?.click()} disabled={busy}>
|
||||
<ImportIcon className="h-3.5 w-3.5" />
|
||||
Importer une cle de gestion
|
||||
</Button>
|
||||
<input
|
||||
ref={keyInputRef}
|
||||
type="file"
|
||||
accept={`${SHARE_KEY_EXTENSION},application/json`}
|
||||
className="sr-only"
|
||||
aria-label="Importer une cle de gestion"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (file) void handleImportKey(file);
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
|
||||
Cette carte a deja ete partagee depuis un autre navigateur ? Reimportez le fichier{' '}
|
||||
{SHARE_KEY_EXTENSION} exporte a l'epoque pour reprendre la main dessus.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-zinc-400">
|
||||
<button
|
||||
type="button"
|
||||
className="underline underline-offset-2 hover:text-zinc-600 dark:hover:text-zinc-300"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate(PRIVACY_PATH);
|
||||
}}
|
||||
>
|
||||
Comment {appName} traite vos donnees
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -18,8 +18,8 @@ import { MapFormatError } from '@/services/mapSerialization';
|
||||
import { useAppConfigStore } from '@/stores/appConfigStore';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { navigate } from '@/hooks/useRoute';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { navigate, PRIVACY_PATH } from '@/hooks/useRoute';
|
||||
import { formatDate, formatDateTime } from '@/utils/date';
|
||||
|
||||
export interface SharedMapPageProps {
|
||||
token: string;
|
||||
@@ -27,8 +27,25 @@ export interface SharedMapPageProps {
|
||||
|
||||
type Status =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'ready'; title: string; updatedAt: string }
|
||||
| { kind: 'error'; message: string };
|
||||
| { kind: 'ready'; title: string; updatedAt: string; expiresAt: string | null }
|
||||
| { kind: 'error'; message: string; expired?: boolean };
|
||||
|
||||
/**
|
||||
* Un lien de partage est une capacite de lecture : il ne doit pas se retrouver
|
||||
* dans un index public. L'en-tete X-Robots-Tag est pose par le serveur ; cette
|
||||
* balise couvre le cas d'un robot qui ne lirait que le document.
|
||||
*/
|
||||
function useNoIndex(): void {
|
||||
useEffect(() => {
|
||||
const meta = document.createElement('meta');
|
||||
meta.name = 'robots';
|
||||
meta.content = 'noindex, nofollow, noarchive';
|
||||
document.head.appendChild(meta);
|
||||
return () => {
|
||||
meta.remove();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
function SharedMapContent({ token }: SharedMapPageProps) {
|
||||
const appName = useAppConfigStore((state) => state.name);
|
||||
@@ -40,6 +57,7 @@ function SharedMapContent({ token }: SharedMapPageProps) {
|
||||
|
||||
const { zoomIn, zoomOut } = useReactFlow();
|
||||
const [status, setStatus] = useState<Status>({ kind: 'loading' });
|
||||
useNoIndex();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -49,7 +67,12 @@ function SharedMapContent({ token }: SharedMapPageProps) {
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
loadMap(response.map, { readOnly: true });
|
||||
setStatus({ kind: 'ready', title: response.title, updatedAt: response.updatedAt });
|
||||
setStatus({
|
||||
kind: 'ready',
|
||||
title: response.title,
|
||||
updatedAt: response.updatedAt,
|
||||
expiresAt: response.expiresAt,
|
||||
});
|
||||
document.title = `${response.title} - ${appName}`;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -58,7 +81,8 @@ function SharedMapContent({ token }: SharedMapPageProps) {
|
||||
error instanceof ShareApiError || error instanceof MapFormatError
|
||||
? error.message
|
||||
: 'Cette carte partagee est introuvable.';
|
||||
setStatus({ kind: 'error', message });
|
||||
const expired = error instanceof ShareApiError && error.status === 410;
|
||||
setStatus({ kind: 'error', message, expired });
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -82,7 +106,9 @@ function SharedMapContent({ token }: SharedMapPageProps) {
|
||||
<MapIcon className="h-10 w-10 text-zinc-300 dark:text-zinc-600" />
|
||||
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">{status.message}</h1>
|
||||
<p className="max-w-md text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Le lien a peut-etre ete supprime par son auteur, ou l'adresse est incorrecte.
|
||||
{status.expired
|
||||
? "Son auteur avait fixe une date de fin. Lui seul peut le prolonger ou le republier."
|
||||
: "Le lien a peut-etre ete supprime par son auteur, ou l'adresse est incorrecte."}
|
||||
</p>
|
||||
<Button variant="primary" onClick={() => navigate('/')}>
|
||||
Ouvrir {appName}
|
||||
@@ -100,7 +126,8 @@ function SharedMapContent({ token }: SharedMapPageProps) {
|
||||
{status.title}
|
||||
</h1>
|
||||
<p className="truncate text-[11px] text-zinc-400">
|
||||
Carte partagee - lecture seule - mise a jour le {formatDateTime(status.updatedAt)}
|
||||
Carte partagee en lecture seule - mise a jour le {formatDateTime(status.updatedAt)}
|
||||
{status.expiresAt ? ` - expire le ${formatDate(status.expiresAt)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -122,8 +149,11 @@ function SharedMapContent({ token }: SharedMapPageProps) {
|
||||
<MindMapCanvas readOnly showMiniMap={false} />
|
||||
</div>
|
||||
|
||||
<footer className="no-print flex h-8 shrink-0 items-center justify-center border-t border-zinc-200 bg-white text-[11px] text-zinc-400 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
Carte partagee via {appName} - aucune inscription necessaire
|
||||
<footer className="no-print flex h-8 shrink-0 items-center justify-center gap-2 border-t border-zinc-200 bg-white text-[11px] text-zinc-400 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<span>Carte partagee via {appName} - aucune inscription necessaire</span>
|
||||
<a href={PRIVACY_PATH} className="underline underline-offset-2 hover:text-zinc-600">
|
||||
Confidentialite
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
30
frontend/src/hooks/useRoute.test.ts
Normal file
30
frontend/src/hooks/useRoute.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PRIVACY_PATH, parseRoute } from '@/hooks/useRoute';
|
||||
|
||||
describe('routes', () => {
|
||||
it('reconnait le tableau de bord', () => {
|
||||
expect(parseRoute('/')).toEqual({ name: 'dashboard' });
|
||||
expect(parseRoute('')).toEqual({ name: 'dashboard' });
|
||||
});
|
||||
|
||||
it('reconnait la page de confidentialite', () => {
|
||||
expect(parseRoute(PRIVACY_PATH)).toEqual({ name: 'privacy' });
|
||||
expect(parseRoute('/privacy/')).toEqual({ name: 'privacy' });
|
||||
});
|
||||
|
||||
it('reconnait une carte locale', () => {
|
||||
expect(parseRoute('/map/abc-123')).toEqual({ name: 'editor', mapId: 'abc-123' });
|
||||
});
|
||||
|
||||
it('reconnait un partage', () => {
|
||||
expect(parseRoute('/s/OZGrnetO4RPbqPtS')).toEqual({
|
||||
name: 'shared',
|
||||
token: 'OZGrnetO4RPbqPtS',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejette le reste', () => {
|
||||
expect(parseRoute('/inconnu')).toEqual({ name: 'notFound' });
|
||||
expect(parseRoute('/s/')).toEqual({ name: 'notFound' });
|
||||
});
|
||||
});
|
||||
@@ -5,14 +5,18 @@ export type Route =
|
||||
| { name: 'dashboard' }
|
||||
| { name: 'editor'; mapId: string }
|
||||
| { name: 'shared'; token: string }
|
||||
| { name: 'privacy' }
|
||||
| { name: 'notFound' };
|
||||
|
||||
const NAVIGATION_EVENT = 'openmind:navigate';
|
||||
|
||||
export const PRIVACY_PATH = '/privacy';
|
||||
|
||||
export function parseRoute(pathname: string): Route {
|
||||
const clean = pathname.replace(/\/+$/, '') || '/';
|
||||
|
||||
if (clean === '/' || clean === '') return { name: 'dashboard' };
|
||||
if (clean === PRIVACY_PATH) return { name: 'privacy' };
|
||||
|
||||
const editorMatch = clean.match(/^\/map\/([^/]+)$/);
|
||||
if (editorMatch?.[1]) return { name: 'editor', mapId: decodeURIComponent(editorMatch[1]) };
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface ShareCreatedResponse {
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface SharedMapResponse {
|
||||
@@ -27,6 +28,29 @@ export interface SharedMapResponse {
|
||||
map: MindMap;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
/** Fiche d'un partage sans aucune donnee de carte, reservee au proprietaire. */
|
||||
export interface ShareOwnerResponse {
|
||||
shareToken: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duree de vie demandee pour un partage.
|
||||
* `null` = sans expiration ; `undefined` = laisser l'instance decider.
|
||||
*/
|
||||
export type ShareExpiry = number | null | undefined;
|
||||
|
||||
function expiryBody(expiry: ShareExpiry): Record<string, unknown> {
|
||||
if (expiry === undefined) return {};
|
||||
// Le serveur distingue "aucune preference" de "explicitement permanent" :
|
||||
// un `null` JSON seul ne permettrait pas de faire la difference.
|
||||
return expiry === null ? { permanent: true } : { expires_in_days: expiry };
|
||||
}
|
||||
|
||||
const MANAGEMENT_TOKEN_HEADER = 'X-Management-Token';
|
||||
@@ -34,8 +58,15 @@ const MANAGEMENT_TOKEN_HEADER = 'X-Management-Token';
|
||||
function messageForStatus(status: number, serverDetail?: string): string {
|
||||
if (status === 404) return "Ce partage n'existe plus.";
|
||||
if (status === 403) return "Vous n'avez pas le droit de modifier ce partage depuis ce navigateur.";
|
||||
if (status === 410) return serverDetail || 'Ce partage a expire.';
|
||||
if (status === 413) return serverDetail || 'Cette carte est trop volumineuse pour etre partagee.';
|
||||
if (status === 422) return serverDetail || "Cette carte n'a pas pu etre validee par le serveur.";
|
||||
if (status === 429) {
|
||||
return serverDetail || 'Trop de requetes envoyees recemment. Reessayez dans un moment.';
|
||||
}
|
||||
// 503 porte un message explicite (partage desactive, instance pleine) :
|
||||
// il est plus utile a l'utilisateur que le message generique des 5xx.
|
||||
if (status === 503) return serverDetail || 'Le service de partage est indisponible.';
|
||||
if (status >= 500) return 'Le serveur de partage est momentanement indisponible.';
|
||||
return serverDetail || 'Le partage a echoue. Reessayez dans un instant.';
|
||||
}
|
||||
@@ -88,6 +119,7 @@ interface RawShareCreated {
|
||||
title: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
interface RawSharePublic {
|
||||
@@ -97,13 +129,36 @@ interface RawSharePublic {
|
||||
map: unknown;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
interface RawShareOwner {
|
||||
share_token: string;
|
||||
title: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
function toOwner(raw: RawShareOwner): ShareOwnerResponse {
|
||||
return {
|
||||
shareToken: raw.share_token,
|
||||
title: raw.title,
|
||||
createdAt: raw.created_at,
|
||||
updatedAt: raw.updated_at,
|
||||
expiresAt: raw.expires_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Publie un snapshot de la carte. Appele uniquement sur action explicite. */
|
||||
export async function createShare(map: MindMap, title: string): Promise<ShareCreatedResponse> {
|
||||
export async function createShare(
|
||||
map: MindMap,
|
||||
title: string,
|
||||
expiry: ShareExpiry = undefined,
|
||||
): Promise<ShareCreatedResponse> {
|
||||
const raw = await request<RawShareCreated>('/api/shares', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, map }),
|
||||
body: JSON.stringify({ title, map, ...expiryBody(expiry) }),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -113,6 +168,7 @@ export async function createShare(map: MindMap, title: string): Promise<ShareCre
|
||||
title: raw.title,
|
||||
createdAt: raw.created_at,
|
||||
updatedAt: raw.updated_at,
|
||||
expiresAt: raw.expires_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,8 +177,8 @@ export async function updateShare(
|
||||
managementToken: string,
|
||||
map: MindMap,
|
||||
title: string,
|
||||
): Promise<{ updatedAt: string }> {
|
||||
const raw = await request<{ updated_at: string }>(
|
||||
): Promise<{ updatedAt: string; expiresAt: string | null }> {
|
||||
const raw = await request<{ updated_at: string; expires_at: string | null }>(
|
||||
`/api/shares/${encodeURIComponent(shareToken)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
@@ -130,7 +186,41 @@ export async function updateShare(
|
||||
body: JSON.stringify({ title, map }),
|
||||
},
|
||||
);
|
||||
return { updatedAt: raw.updated_at };
|
||||
return { updatedAt: raw.updated_at, expiresAt: raw.expires_at ?? null };
|
||||
}
|
||||
|
||||
/** Change la date d'expiration d'un partage. Necessite la cle de gestion. */
|
||||
export async function setShareExpiry(
|
||||
shareToken: string,
|
||||
managementToken: string,
|
||||
expiry: number | null,
|
||||
): Promise<ShareOwnerResponse> {
|
||||
const raw = await request<RawShareOwner>(`/api/shares/${encodeURIComponent(shareToken)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { [MANAGEMENT_TOKEN_HEADER]: managementToken },
|
||||
body: JSON.stringify(expiryBody(expiry)),
|
||||
});
|
||||
return toOwner(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie qu'une cle de gestion correspond bien a ce partage.
|
||||
*
|
||||
* La cle n'est envoyee qu'a cette API, dans l'en-tete prevu pour elle, et
|
||||
* jamais dans une URL. La reponse ne contient aucune donnee de carte.
|
||||
*/
|
||||
export async function verifyManagementToken(
|
||||
shareToken: string,
|
||||
managementToken: string,
|
||||
): Promise<ShareOwnerResponse> {
|
||||
const raw = await request<RawShareOwner>(
|
||||
`/api/shares/${encodeURIComponent(shareToken)}/verify`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { [MANAGEMENT_TOKEN_HEADER]: managementToken },
|
||||
},
|
||||
);
|
||||
return toOwner(raw);
|
||||
}
|
||||
|
||||
export async function deleteShare(shareToken: string, managementToken: string): Promise<void> {
|
||||
@@ -149,6 +239,7 @@ export async function fetchSharedMap(shareToken: string): Promise<SharedMapRespo
|
||||
map: normalizeMapDocument(raw.map, { source: 'share' }),
|
||||
createdAt: raw.created_at,
|
||||
updatedAt: raw.updated_at,
|
||||
expiresAt: raw.expires_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,7 +247,12 @@ export interface PublicAppConfig {
|
||||
appName: string;
|
||||
maxNodes: number;
|
||||
maxMapSizeMb: number;
|
||||
maxShareSizeMb: number;
|
||||
sharingEnabled: boolean;
|
||||
allowPermanentShares: boolean;
|
||||
defaultShareExpiryDays: number;
|
||||
/** Durees proposees dans l'interface ; `null` signifie "jamais". */
|
||||
shareExpiryOptions: Array<number | null>;
|
||||
}
|
||||
|
||||
export async function fetchAppConfig(): Promise<PublicAppConfig> {
|
||||
|
||||
101
frontend/src/services/shareKey.test.ts
Normal file
101
frontend/src/services/shareKey.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { type ShareRecord } from '@/types/map';
|
||||
import {
|
||||
SHARE_KEY_VERSION,
|
||||
ShareKeyError,
|
||||
buildShareKeyFile,
|
||||
parseShareKey,
|
||||
serializeShareKey,
|
||||
} from '@/services/shareKey';
|
||||
|
||||
function record(): ShareRecord {
|
||||
return {
|
||||
mapId: 'map-1',
|
||||
shareToken: 'OZGrnetO4RPbqPtS',
|
||||
managementToken: 'wYq3-Zx8_secret-de-gestion-tres-long-1234',
|
||||
sharePath: '/s/OZGrnetO4RPbqPtS',
|
||||
title: 'Infrastructure',
|
||||
createdAt: '2026-09-01T10:00:00.000Z',
|
||||
updatedAt: '2026-09-02T10:00:00.000Z',
|
||||
expiresAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('export d une cle de gestion', () => {
|
||||
it('contient les deux jetons et la version', () => {
|
||||
const key = buildShareKeyFile(record(), 'https://mind.example.com');
|
||||
expect(key.version).toBe(SHARE_KEY_VERSION);
|
||||
expect(key.shareToken).toBe('OZGrnetO4RPbqPtS');
|
||||
expect(key.managementToken).toBe('wYq3-Zx8_secret-de-gestion-tres-long-1234');
|
||||
expect(key.title).toBe('Infrastructure');
|
||||
expect(key.origin).toBe('https://mind.example.com');
|
||||
});
|
||||
|
||||
it('ne contient aucune donnee de carte', () => {
|
||||
const serialized = serializeShareKey(record());
|
||||
const parsed = JSON.parse(serialized) as Record<string, unknown>;
|
||||
expect(Object.keys(parsed).sort()).toEqual([
|
||||
'exportedAt',
|
||||
'managementToken',
|
||||
'shareToken',
|
||||
'title',
|
||||
'version',
|
||||
]);
|
||||
expect(serialized).not.toContain('nodes');
|
||||
expect(serialized).not.toContain('mapId');
|
||||
});
|
||||
|
||||
it('produit un fichier relisable', () => {
|
||||
const parsed = parseShareKey(JSON.parse(serializeShareKey(record())));
|
||||
expect(parsed.shareToken).toBe(record().shareToken);
|
||||
expect(parsed.managementToken).toBe(record().managementToken);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation stricte a l import', () => {
|
||||
const valid = {
|
||||
version: 1,
|
||||
shareToken: 'OZGrnetO4RPbqPtS',
|
||||
managementToken: 'wYq3-Zx8_secret-de-gestion-tres-long-1234',
|
||||
};
|
||||
|
||||
it('accepte un fichier minimal', () => {
|
||||
expect(parseShareKey(valid).shareToken).toBe('OZGrnetO4RPbqPtS');
|
||||
});
|
||||
|
||||
it('refuse ce qui n est pas un objet', () => {
|
||||
for (const value of [null, 42, 'texte', [], undefined]) {
|
||||
expect(() => parseShareKey(value)).toThrow(ShareKeyError);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuse un fichier sans version', () => {
|
||||
expect(() => parseShareKey({ ...valid, version: undefined })).toThrow(ShareKeyError);
|
||||
expect(() => parseShareKey({ ...valid, version: 'un' })).toThrow(ShareKeyError);
|
||||
});
|
||||
|
||||
it('refuse une version future', () => {
|
||||
expect(() => parseShareKey({ ...valid, version: 99 })).toThrow(
|
||||
/version plus recente/,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuse un jeton manquant ou mal forme', () => {
|
||||
expect(() => parseShareKey({ ...valid, shareToken: '' })).toThrow(ShareKeyError);
|
||||
expect(() => parseShareKey({ ...valid, managementToken: 'court' })).toThrow(ShareKeyError);
|
||||
expect(() => parseShareKey({ ...valid, shareToken: 'avec espace et /' })).toThrow(
|
||||
ShareKeyError,
|
||||
);
|
||||
expect(() => parseShareKey({ ...valid, managementToken: 42 })).toThrow(ShareKeyError);
|
||||
});
|
||||
|
||||
it('ignore les champs superflus plutot que de les recopier', () => {
|
||||
const parsed = parseShareKey({ ...valid, mechant: 'valeur', origin: 'https://ailleurs' });
|
||||
expect(Object.keys(parsed).sort()).toEqual(['managementToken', 'shareToken', 'version']);
|
||||
});
|
||||
|
||||
it('borne le titre importe', () => {
|
||||
const parsed = parseShareKey({ ...valid, title: 'x'.repeat(500) });
|
||||
expect(parsed.title).toHaveLength(200);
|
||||
});
|
||||
});
|
||||
108
frontend/src/services/shareKey.ts
Normal file
108
frontend/src/services/shareKey.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { type ShareRecord } from '@/types/map';
|
||||
|
||||
/**
|
||||
* Cle de gestion d'un partage, exportable et reimportable.
|
||||
*
|
||||
* Le probleme resolu : le secret qui permet de mettre a jour ou de supprimer un
|
||||
* partage vit uniquement dans l'IndexedDB du navigateur d'origine. Changer de
|
||||
* machine, ou perdre son profil, revenait a perdre la main sur son propre lien.
|
||||
*
|
||||
* La reponse reste sans compte : un petit fichier que le proprietaire range ou
|
||||
* il veut. L'export est une action explicite, et ce secret n'est jamais ajoute
|
||||
* automatiquement a un fichier `.mmap`.
|
||||
*/
|
||||
|
||||
export const SHARE_KEY_VERSION = 1;
|
||||
export const SHARE_KEY_EXTENSION = '.openmind-share';
|
||||
|
||||
export interface ShareKeyFile {
|
||||
version: number;
|
||||
shareToken: string;
|
||||
managementToken: string;
|
||||
title?: string;
|
||||
/** Informatif : d'ou vient ce fichier. Jamais utilise pour appeler quoi que ce soit. */
|
||||
origin?: string;
|
||||
exportedAt?: string;
|
||||
}
|
||||
|
||||
/** Erreur de lecture d'un fichier de cle, deja formulee pour l'utilisateur. */
|
||||
export class ShareKeyError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ShareKeyError';
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 64 * 1024;
|
||||
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{8,256}$/;
|
||||
|
||||
export function buildShareKeyFile(record: ShareRecord, origin?: string): ShareKeyFile {
|
||||
return {
|
||||
version: SHARE_KEY_VERSION,
|
||||
shareToken: record.shareToken,
|
||||
managementToken: record.managementToken,
|
||||
...(record.title ? { title: record.title } : {}),
|
||||
...(origin ? { origin } : {}),
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeShareKey(record: ShareRecord, origin?: string): string {
|
||||
return JSON.stringify(buildShareKeyFile(record, origin), null, 2);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide strictement un fichier de cle.
|
||||
*
|
||||
* Aucune tolerance : ce fichier porte un secret, et une structure approximative
|
||||
* signalerait plutot une erreur de manipulation qu'un fichier a rattraper.
|
||||
*/
|
||||
export function parseShareKey(raw: unknown): ShareKeyFile {
|
||||
if (!isRecord(raw)) {
|
||||
throw new ShareKeyError("Ce fichier n'est pas une cle de gestion valide.");
|
||||
}
|
||||
|
||||
if (typeof raw.version !== 'number' || !Number.isFinite(raw.version) || raw.version < 1) {
|
||||
throw new ShareKeyError("Ce fichier ne precise aucune version de cle de gestion.");
|
||||
}
|
||||
if (raw.version > SHARE_KEY_VERSION) {
|
||||
throw new ShareKeyError(
|
||||
"Cette cle a ete produite par une version plus recente d'OpenMind.",
|
||||
);
|
||||
}
|
||||
|
||||
const shareToken = typeof raw.shareToken === 'string' ? raw.shareToken.trim() : '';
|
||||
const managementToken =
|
||||
typeof raw.managementToken === 'string' ? raw.managementToken.trim() : '';
|
||||
|
||||
if (!TOKEN_PATTERN.test(shareToken) || !TOKEN_PATTERN.test(managementToken)) {
|
||||
throw new ShareKeyError('Cette cle de gestion est incomplete ou mal formee.');
|
||||
}
|
||||
|
||||
const title = typeof raw.title === 'string' ? raw.title.slice(0, 200) : undefined;
|
||||
return {
|
||||
version: SHARE_KEY_VERSION,
|
||||
shareToken,
|
||||
managementToken,
|
||||
...(title ? { title } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Lit un fichier `.openmind-share` choisi par l'utilisateur. */
|
||||
export async function readShareKeyFile(file: File): Promise<ShareKeyFile> {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new ShareKeyError("Ce fichier est trop volumineux pour etre une cle de gestion.");
|
||||
}
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(await file.text());
|
||||
} catch {
|
||||
throw new ShareKeyError("Ce fichier n'est pas une cle de gestion valide (JSON illisible).");
|
||||
}
|
||||
return parseShareKey(raw);
|
||||
}
|
||||
@@ -6,11 +6,23 @@ interface AppConfigState {
|
||||
name: string;
|
||||
maxNodes: number;
|
||||
maxMapSizeMb: number;
|
||||
maxShareSizeMb: number;
|
||||
/**
|
||||
* Le partage serveur est-il ouvert sur cette instance ?
|
||||
* Faux tant que le backend n'a pas repondu : mieux vaut ne pas proposer une
|
||||
* action qui echouerait que l'inverse.
|
||||
*/
|
||||
sharingEnabled: boolean;
|
||||
allowPermanentShares: boolean;
|
||||
defaultShareExpiryDays: number;
|
||||
shareExpiryOptions: Array<number | null>;
|
||||
/** Faux tant que le backend n'a pas repondu : l'app reste utilisable hors ligne. */
|
||||
serverReachable: boolean;
|
||||
load: () => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_EXPIRY_OPTIONS: Array<number | null> = [1, 7, 30, 90, null];
|
||||
|
||||
/**
|
||||
* Le nom du produit vient de la configuration serveur (APP_NAME), avec repli
|
||||
* sur `APP_CONFIG.name`. Aucun composant ne doit ecrire le nom en dur.
|
||||
@@ -19,6 +31,11 @@ export const useAppConfigStore = create<AppConfigState>((set) => ({
|
||||
name: APP_CONFIG.name,
|
||||
maxNodes: APP_CONFIG.maxNodes,
|
||||
maxMapSizeMb: APP_CONFIG.maxMapSizeMb,
|
||||
maxShareSizeMb: APP_CONFIG.maxMapSizeMb,
|
||||
sharingEnabled: false,
|
||||
allowPermanentShares: true,
|
||||
defaultShareExpiryDays: 30,
|
||||
shareExpiryOptions: DEFAULT_EXPIRY_OPTIONS,
|
||||
serverReachable: false,
|
||||
|
||||
load: async () => {
|
||||
@@ -28,6 +45,14 @@ export const useAppConfigStore = create<AppConfigState>((set) => ({
|
||||
name: config.appName || APP_CONFIG.name,
|
||||
maxNodes: config.maxNodes || APP_CONFIG.maxNodes,
|
||||
maxMapSizeMb: config.maxMapSizeMb || APP_CONFIG.maxMapSizeMb,
|
||||
maxShareSizeMb: config.maxShareSizeMb || config.maxMapSizeMb || APP_CONFIG.maxMapSizeMb,
|
||||
sharingEnabled: config.sharingEnabled !== false,
|
||||
allowPermanentShares: config.allowPermanentShares !== false,
|
||||
defaultShareExpiryDays: config.defaultShareExpiryDays ?? 30,
|
||||
shareExpiryOptions:
|
||||
Array.isArray(config.shareExpiryOptions) && config.shareExpiryOptions.length > 0
|
||||
? config.shareExpiryOptions
|
||||
: DEFAULT_EXPIRY_OPTIONS,
|
||||
serverReachable: true,
|
||||
});
|
||||
if (typeof document !== 'undefined') {
|
||||
@@ -35,7 +60,8 @@ export const useAppConfigStore = create<AppConfigState>((set) => ({
|
||||
}
|
||||
} catch {
|
||||
// Hors ligne : on garde les valeurs par defaut, sans bloquer l'interface.
|
||||
set({ serverReachable: false });
|
||||
// Le partage reste presente comme indisponible plutot que d'echouer au clic.
|
||||
set({ serverReachable: false, sharingEnabled: false });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -288,4 +288,6 @@ export interface ShareRecord {
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Date d'expiration ISO, ou `null` pour un partage sans expiration. */
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user