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:
2026-09-10 09:22:17 +02:00
parent e3c530348e
commit 49870dcd56
51 changed files with 3341 additions and 110 deletions

View File

@@ -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> {

View 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);
});
});

View 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);
}