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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user