Fix: double déclaration const now, scripts .mjs, /api/utxo/count accepte ancrages
**Motivations:** - Corriger erreur syntaxe double déclaration const now dans bitcoin-rpc.js - Scripts batch en .mjs (ES modules) sans dépendance dotenv - /api/utxo/count doit accepter catégorie ancrages (pluriel) du fichier **Root causes:** - const now déclaré deux fois dans même portée (lignes 294 et 299) - Scripts utilisent dotenv non installé globalement - /api/utxo/count cherchait seulement 'anchor' mais fichier utilise 'ancrages' **Correctifs:** - Supprimer deuxième déclaration const now (ligne 299) - Scripts .mjs : parser .env manuellement sans dotenv - /api/utxo/count : accepter 'anchor' OU 'ancrages' **Evolutions:** - Aucune **Pages affectées:** - signet-dashboard/src/bitcoin-rpc.js - signet-dashboard/src/server.js - scripts/complete-utxo-list-blocktime.mjs - scripts/diagnose-bloc-rewards.mjs
This commit is contained in:
parent
cad73cb265
commit
0db7a76044
@ -72,5 +72,4 @@ La déduplication par hash évite de relayer deux fois le même message.
|
|||||||
|
|
||||||
## Stockage
|
## Stockage
|
||||||
|
|
||||||
Par défaut, le stockage est en mémoire avec sauvegarde optionnelle sur disque.
|
Stockage en mémoire avec persistance sur disque (`{STORAGE_PATH}/messages.json`). Sont persistés : messages, seenHashes, signatures, clés. Sauvegarde à l’arrêt (SIGINT/SIGTERM) et périodiquement si `SAVE_INTERVAL_SECONDS` > 0. En production, une base de données (SQLite, PostgreSQL, etc.) est recommandée.
|
||||||
En production, utiliser une base de données (SQLite, PostgreSQL, etc.).
|
|
||||||
|
|||||||
@ -0,0 +1,44 @@
|
|||||||
|
# API Relay – Persistance signatures/clés et sauvegarde périodique
|
||||||
|
|
||||||
|
**Author:** Équipe 4NK
|
||||||
|
**Date:** 2026-01-26
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
- Persister les signatures et les clés de déchiffrement sur disque (au même titre que les messages et seenHashes), afin qu’elles survivent au redémarrage du relais.
|
||||||
|
- Ajouter une sauvegarde périodique configurable pour limiter la perte de données entre deux arrêts propres.
|
||||||
|
|
||||||
|
## Impacts
|
||||||
|
|
||||||
|
- **Fonctionnels** : Les signatures et clés ne sont plus perdues au redémarrage. Réduction du risque de perte en cas de crash entre deux sauvegardes grâce à la sauvegarde périodique.
|
||||||
|
- **Techniques** : Extension du fichier `messages.json` (signatures, keys), nouveau timer de sauvegarde, variable d’environnement `SAVE_INTERVAL_SECONDS`. Gestion d’erreur explicite au chargement (ENOENT vs autres).
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
### api-relay
|
||||||
|
|
||||||
|
- **`src/services/storage.ts`**
|
||||||
|
- `loadFromDisk` : lecture de `messages.json`. Si `signatures` / `keys` présents, chargement dans les Maps. ENOENT = premier run (démarrage à vide) ; autres erreurs loggées et propagées. Parse JSON avec try/catch, log + rethrow.
|
||||||
|
- `saveToDisk` : écriture de `messages`, `seenHashes`, `signatures`, `keys` dans `messages.json`. Suppression du try/catch silencieux ; les erreurs remontent.
|
||||||
|
- `initialize` : plus de catch qui absorbe les erreurs de `loadFromDisk` ; propagation.
|
||||||
|
- **`src/index.ts`**
|
||||||
|
- `SAVE_INTERVAL_SECONDS` : env, défaut 300, 0 = désactivé.
|
||||||
|
- `setInterval` (si > 0) : appel à `storage.saveToDisk()` toutes les N secondes ; `.catch` logue les erreurs.
|
||||||
|
- Shutdown (SIGINT/SIGTERM) : `clearInterval`, puis `saveToDisk`, puis `process.exit(0)` ou `1` en cas d’erreur.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **`userwallet/docs/storage.md`** : Synthèse, structure disque, Persistance, Configuration, Limitations (suppression des anciennes limitations « sigs/clés non persistées », « pas de sauvegarde périodique »).
|
||||||
|
- **`api-relay/README.md`** : Variable `SAVE_INTERVAL_SECONDS`, section Stockage mise à jour.
|
||||||
|
|
||||||
|
## Modalités de déploiement
|
||||||
|
|
||||||
|
- Redémarrer le serveur api-relay après déploiement.
|
||||||
|
- Les relais existants avec un `messages.json` sans `signatures` ni `keys` continuent de fonctionner ; les champs sont optionnels au chargement.
|
||||||
|
- Pour désactiver la sauvegarde périodique : `SAVE_INTERVAL_SECONDS=0`.
|
||||||
|
|
||||||
|
## Modalités d’analyse
|
||||||
|
|
||||||
|
- Vérifier qu’après POST /signatures et POST /keys, un redémarrage du relais conserve les données (GET /signatures/:hash, GET /keys/:hash).
|
||||||
|
- Vérifier qu’en cas d’erreur disque au chargement (fichier corrompu, permissions), le processus logue et exit avec erreur (pas de démarrage à vide silencieux).
|
||||||
|
- Vérifier que la sauvegarde périodique écrit bien `messages.json` (timestamp de modification) et que les logs « Periodic save failed » apparaissent en cas d’échec.
|
||||||
45
features/userwallet-export-import.md
Normal file
45
features/userwallet-export-import.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# UserWallet – Export / Import des données
|
||||||
|
|
||||||
|
**Author:** Équipe 4NK
|
||||||
|
**Date:** 2026-01-26
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Permettre l’export et l’import de toutes les données UserWallet (identité, relais, pairs, cache de hash, keypair et services legacy) pour sauvegarde, migration ou restauration.
|
||||||
|
|
||||||
|
## Impacts
|
||||||
|
|
||||||
|
- **Fonctionnels** : Export en fichier JSON téléchargeable ; import depuis fichier qui remplace les données locales puis recharge la page.
|
||||||
|
- **Techniques** : Nouveau module `utils/exportImport.ts`, écran `DataExportImportScreen`, route `/data`, boutons sur l’accueil.
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
### utils/exportImport.ts
|
||||||
|
|
||||||
|
- `exportUserWalletData(): string` : lit identity, relays, pairs, hash_cache, keypair, services depuis localStorage, produit un JSON versionné (`version`, `exportedAt`).
|
||||||
|
- `importUserWalletData(json: string): void` : parse, valide (version, exportedAt, relays, pairs, hash_cache, identity optionnel), écrit en localStorage. Lance `Error` si format invalide.
|
||||||
|
- Types : `UserWalletExport`, validateurs `isRelayConfig`, `isPairConfig`, `isLocalIdentity`.
|
||||||
|
|
||||||
|
### DataExportImportScreen
|
||||||
|
|
||||||
|
- Section Export : bouton « Exporter les données » → `exportUserWalletData` puis téléchargement `userwallet-export-{timestamp}.json`.
|
||||||
|
- Section Import : input file caché, bouton « Choisir un fichier à importer » → lecture du fichier, `importUserWalletData`, puis `window.location.reload`. Affichage des erreurs d’import.
|
||||||
|
|
||||||
|
### App & HomeScreen
|
||||||
|
|
||||||
|
- Route `/data` → `DataExportImportScreen`.
|
||||||
|
- Bouton « Export / Import données » sur l’accueil (avec ou sans identité).
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `userwallet/docs/storage.md` : suppression de « Pas de backup / pas d’export-import » ; ajout section « Export / Import » et retrait de la recommandation correspondante.
|
||||||
|
|
||||||
|
## Modalités de déploiement
|
||||||
|
|
||||||
|
- Rebuild du frontend UserWallet et déploiement des assets. Aucune migration de données.
|
||||||
|
|
||||||
|
## Modalités d’analyse
|
||||||
|
|
||||||
|
- Exporter les données, vérifier que le JSON contient identity, relays, pairs, hash_cache, etc.
|
||||||
|
- Importer un export valide : vérifier que les données sont bien présentes après rechargement.
|
||||||
|
- Importer un JSON invalide (mauvais format, champs manquants) : vérifier qu’une erreur est affichée et que les données locales ne sont pas modifiées.
|
||||||
34386
hash_list.txt
34386
hash_list.txt
File diff suppressed because it is too large
Load Diff
@ -7,21 +7,32 @@
|
|||||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
import { createRequire } from 'module';
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
const utxoListPath = join(__dirname, '../utxo_list.txt');
|
const utxoListPath = join(__dirname, '../utxo_list.txt');
|
||||||
|
|
||||||
// Charger les variables d'environnement
|
// Charger les variables d'environnement depuis .env si disponible
|
||||||
import { config } from 'dotenv';
|
let envVars = {};
|
||||||
config({ path: join(__dirname, '../signet-dashboard/.env') });
|
try {
|
||||||
|
const envPath = join(__dirname, '../signet-dashboard/.env');
|
||||||
|
if (existsSync(envPath)) {
|
||||||
|
const envContent = readFileSync(envPath, 'utf8');
|
||||||
|
for (const line of envContent.split('\n')) {
|
||||||
|
const match = line.match(/^([^#=]+)=(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
envVars[match[1].trim()] = match[2].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not load .env file, using defaults');
|
||||||
|
}
|
||||||
|
|
||||||
const BITCOIN_RPC_URL = process.env.BITCOIN_RPC_URL || 'http://127.0.0.1:38332';
|
const BITCOIN_RPC_URL = envVars.BITCOIN_RPC_URL || process.env.BITCOIN_RPC_URL || 'http://127.0.0.1:38332';
|
||||||
const BITCOIN_RPC_USER = process.env.BITCOIN_RPC_USER || 'bitcoin';
|
const BITCOIN_RPC_USER = envVars.BITCOIN_RPC_USER || process.env.BITCOIN_RPC_USER || 'bitcoin';
|
||||||
const BITCOIN_RPC_PASSWORD = process.env.BITCOIN_RPC_PASSWORD || 'bitcoin';
|
const BITCOIN_RPC_PASSWORD = envVars.BITCOIN_RPC_PASSWORD || process.env.BITCOIN_RPC_PASSWORD || 'bitcoin';
|
||||||
const BITCOIN_RPC_WALLET = process.env.BITCOIN_RPC_WALLET || 'custom_signet';
|
const BITCOIN_RPC_WALLET = envVars.BITCOIN_RPC_WALLET || process.env.BITCOIN_RPC_WALLET || 'custom_signet';
|
||||||
|
|
||||||
if (!existsSync(utxoListPath)) {
|
if (!existsSync(utxoListPath)) {
|
||||||
console.error('utxo_list.txt not found');
|
console.error('utxo_list.txt not found');
|
||||||
@ -7,18 +7,31 @@
|
|||||||
import { readFileSync, existsSync } from 'fs';
|
import { readFileSync, existsSync } from 'fs';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
import { config } from 'dotenv';
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
config({ path: join(__dirname, '../signet-dashboard/.env') });
|
// Charger les variables d'environnement depuis .env si disponible
|
||||||
|
let envVars = {};
|
||||||
|
try {
|
||||||
|
const envPath = join(__dirname, '../signet-dashboard/.env');
|
||||||
|
if (existsSync(envPath)) {
|
||||||
|
const envContent = readFileSync(envPath, 'utf8');
|
||||||
|
for (const line of envContent.split('\n')) {
|
||||||
|
const match = line.match(/^([^#=]+)=(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
envVars[match[1].trim()] = match[2].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not load .env file, using defaults');
|
||||||
|
}
|
||||||
|
|
||||||
const BITCOIN_RPC_URL = process.env.BITCOIN_RPC_URL || 'http://127.0.0.1:38332';
|
const BITCOIN_RPC_URL = envVars.BITCOIN_RPC_URL || process.env.BITCOIN_RPC_URL || 'http://127.0.0.1:38332';
|
||||||
const BITCOIN_RPC_USER = process.env.BITCOIN_RPC_USER || 'bitcoin';
|
const BITCOIN_RPC_USER = envVars.BITCOIN_RPC_USER || process.env.BITCOIN_RPC_USER || 'bitcoin';
|
||||||
const BITCOIN_RPC_PASSWORD = process.env.BITCOIN_RPC_PASSWORD || 'bitcoin';
|
const BITCOIN_RPC_PASSWORD = envVars.BITCOIN_RPC_PASSWORD || process.env.BITCOIN_RPC_PASSWORD || 'bitcoin';
|
||||||
const BITCOIN_RPC_WALLET = process.env.BITCOIN_RPC_WALLET || 'custom_signet';
|
const BITCOIN_RPC_WALLET = envVars.BITCOIN_RPC_WALLET || process.env.BITCOIN_RPC_WALLET || 'custom_signet';
|
||||||
|
|
||||||
const auth = Buffer.from(`${BITCOIN_RPC_USER}:${BITCOIN_RPC_PASSWORD}`).toString('base64');
|
const auth = Buffer.from(`${BITCOIN_RPC_USER}:${BITCOIN_RPC_PASSWORD}`).toString('base64');
|
||||||
const rpcUrl = `${BITCOIN_RPC_URL}/wallet/${BITCOIN_RPC_WALLET}`;
|
const rpcUrl = `${BITCOIN_RPC_URL}/wallet/${BITCOIN_RPC_WALLET}`;
|
||||||
@ -296,7 +296,6 @@ class BitcoinRPC {
|
|||||||
writeFileSync(cachePath, cacheContent, 'utf8');
|
writeFileSync(cachePath, cacheContent, 'utf8');
|
||||||
|
|
||||||
// Écrire le fichier de sortie avec date
|
// Écrire le fichier de sortie avec date
|
||||||
const now = new Date().toISOString();
|
|
||||||
const outputLines = hashList.map((item) =>
|
const outputLines = hashList.map((item) =>
|
||||||
`${item.hash};${item.txid};${item.blockHeight || ''};${item.confirmations || 0};${item.date || now}`
|
`${item.hash};${item.txid};${item.blockHeight || ''};${item.confirmations || 0};${item.date || now}`
|
||||||
);
|
);
|
||||||
@ -314,7 +313,6 @@ class BitcoinRPC {
|
|||||||
writeFileSync(cachePath, cacheContent, 'utf8');
|
writeFileSync(cachePath, cacheContent, 'utf8');
|
||||||
|
|
||||||
// Écrire le fichier de sortie final avec date
|
// Écrire le fichier de sortie final avec date
|
||||||
const now = new Date().toISOString();
|
|
||||||
const outputLines = hashList.map((item) =>
|
const outputLines = hashList.map((item) =>
|
||||||
`${item.hash};${item.txid};${item.blockHeight || ''};${item.confirmations || 0};${item.date || now}`
|
`${item.hash};${item.txid};${item.blockHeight || ''};${item.confirmations || 0};${item.date || now}`
|
||||||
);
|
);
|
||||||
|
|||||||
@ -306,7 +306,8 @@ app.get('/api/utxo/count', async (req, res) => {
|
|||||||
const confirmations = parseInt(parts[4], 10) || 0;
|
const confirmations = parseInt(parts[4], 10) || 0;
|
||||||
|
|
||||||
// Compter les UTXOs de type anchor avec les critères minimum
|
// Compter les UTXOs de type anchor avec les critères minimum
|
||||||
if (category === 'anchor' && amount >= minAnchorAmount && confirmations > 0) {
|
// Le fichier utilise 'ancrages' (pluriel), pas 'anchor'
|
||||||
|
if ((category === 'anchor' || category === 'ancrages') && amount >= minAnchorAmount && confirmations > 0) {
|
||||||
anchors++;
|
anchors++;
|
||||||
// Note: On ne peut pas savoir depuis le fichier si l'UTXO est dépensé
|
// Note: On ne peut pas savoir depuis le fichier si l'UTXO est dépensé
|
||||||
// On compte tous les UTXOs avec confirmations > 0 comme disponibles
|
// On compte tous les UTXOs avec confirmations > 0 comme disponibles
|
||||||
|
|||||||
@ -161,11 +161,14 @@ Le front utilise **LocalStorage** du navigateur pour toutes les données locales
|
|||||||
- **Taille limitée** : LocalStorage a une limite (~5-10MB selon navigateur)
|
- **Taille limitée** : LocalStorage a une limite (~5-10MB selon navigateur)
|
||||||
- **Pas de synchronisation** : Données locales uniquement
|
- **Pas de synchronisation** : Données locales uniquement
|
||||||
- **Sécurité** : Clés privées stockées en clair (à chiffrer avec mot de passe)
|
- **Sécurité** : Clés privées stockées en clair (à chiffrer avec mot de passe)
|
||||||
- **Pas de backup** : Pas de mécanisme d'export/import automatique
|
|
||||||
|
### Export / Import
|
||||||
|
|
||||||
|
- **Export** : Écran « Export / Import données » (`/data`). Télécharge un JSON (identité, relais, pairs, hash_cache, keypair, services).
|
||||||
|
- **Import** : Même écran, bouton « Choisir un fichier ». Remplace les données locales puis recharge la page.
|
||||||
|
|
||||||
### Recommandations
|
### Recommandations
|
||||||
|
|
||||||
- Chiffrer les clés privées avec un mot de passe utilisateur
|
- Chiffrer les clés privées avec un mot de passe utilisateur
|
||||||
- Implémenter un mécanisme d'export/import
|
|
||||||
- Utiliser IndexedDB pour des données plus volumineuses
|
- Utiliser IndexedDB pour des données plus volumineuses
|
||||||
- Implémenter une synchronisation cloud optionnelle (chiffrée)
|
- Implémenter une synchronisation cloud optionnelle (chiffrée)
|
||||||
|
|||||||
@ -59,7 +59,8 @@ api-relay/
|
|||||||
|
|
||||||
1. **Stockage**
|
1. **Stockage**
|
||||||
- Stockage en mémoire des messages, signatures et clés
|
- Stockage en mémoire des messages, signatures et clés
|
||||||
- Sauvegarde optionnelle sur disque (JSON)
|
- Persistance sur disque (`messages.json`) : messages, seenHashes, signatures, clés
|
||||||
|
- Sauvegarde à l’arrêt et périodique (voir `SAVE_INTERVAL_SECONDS`)
|
||||||
- Déduplication par hash
|
- Déduplication par hash
|
||||||
- Indexation par hash pour accès rapide
|
- Indexation par hash pour accès rapide
|
||||||
|
|
||||||
@ -98,7 +99,7 @@ api-relay/
|
|||||||
- Gestion du stockage en mémoire
|
- Gestion du stockage en mémoire
|
||||||
- Déduplication par hash
|
- Déduplication par hash
|
||||||
- Méthodes pour messages, signatures et clés
|
- Méthodes pour messages, signatures et clés
|
||||||
- Sauvegarde/chargement depuis disque (optionnel)
|
- Sauvegarde/chargement depuis disque (messages, seenHashes, signatures, clés) ; sauvegarde périodique configurable
|
||||||
|
|
||||||
#### RelayService
|
#### RelayService
|
||||||
|
|
||||||
@ -128,6 +129,7 @@ Variables d'environnement :
|
|||||||
- `HOST` : Adresse d'écoute (défaut: 0.0.0.0)
|
- `HOST` : Adresse d'écoute (défaut: 0.0.0.0)
|
||||||
- `STORAGE_PATH` : Chemin de stockage (défaut: ./data)
|
- `STORAGE_PATH` : Chemin de stockage (défaut: ./data)
|
||||||
- `PEER_RELAYS` : Liste de relais pairs séparés par virgule
|
- `PEER_RELAYS` : Liste de relais pairs séparés par virgule
|
||||||
|
- `SAVE_INTERVAL_SECONDS` : Sauvegarde périodique en secondes (défaut: 300, 0 = désactivé)
|
||||||
|
|
||||||
Exemple :
|
Exemple :
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { PairManagementScreen } from './components/PairManagementScreen';
|
|||||||
import { SyncScreen } from './components/SyncScreen';
|
import { SyncScreen } from './components/SyncScreen';
|
||||||
import { LoginScreen } from './components/LoginScreen';
|
import { LoginScreen } from './components/LoginScreen';
|
||||||
import { ServiceListScreen } from './components/ServiceListScreen';
|
import { ServiceListScreen } from './components/ServiceListScreen';
|
||||||
|
import { DataExportImportScreen } from './components/DataExportImportScreen';
|
||||||
import { useChannel } from './hooks/useChannel';
|
import { useChannel } from './hooks/useChannel';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
@ -23,6 +24,7 @@ function AppContent(): JSX.Element {
|
|||||||
<Route path="/relay-settings" element={<RelaySettingsScreen />} />
|
<Route path="/relay-settings" element={<RelaySettingsScreen />} />
|
||||||
<Route path="/sync" element={<SyncScreen />} />
|
<Route path="/sync" element={<SyncScreen />} />
|
||||||
<Route path="/services" element={<ServiceListScreen />} />
|
<Route path="/services" element={<ServiceListScreen />} />
|
||||||
|
<Route path="/data" element={<DataExportImportScreen />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
99
userwallet/src/components/DataExportImportScreen.tsx
Normal file
99
userwallet/src/components/DataExportImportScreen.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
exportUserWalletData,
|
||||||
|
importUserWalletData,
|
||||||
|
} from '../utils/exportImport';
|
||||||
|
|
||||||
|
function downloadExport(json: string): void {
|
||||||
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `userwallet-export-${Date.now()}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataExportImportScreen(): JSX.Element {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleExport = (): void => {
|
||||||
|
const json = exportUserWalletData();
|
||||||
|
downloadExport(json);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportClick = (): void => {
|
||||||
|
setImportError(null);
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = '';
|
||||||
|
if (file === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const text = reader.result;
|
||||||
|
if (typeof text !== 'string') {
|
||||||
|
setImportError('Fichier non lisible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
importUserWalletData(text);
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
setImportError(err instanceof Error ? err.message : 'Erreur import');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Export / Import des données</h1>
|
||||||
|
<section aria-labelledby="export-section">
|
||||||
|
<h2 id="export-section">Export</h2>
|
||||||
|
<p>
|
||||||
|
Télécharge un fichier JSON contenant l'identité, les relais, les
|
||||||
|
pairs et le cache de hash.
|
||||||
|
</p>
|
||||||
|
<button type="button" onClick={handleExport}>
|
||||||
|
Exporter les données
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="import-section">
|
||||||
|
<h2 id="import-section">Import</h2>
|
||||||
|
<p>
|
||||||
|
Remplace toutes les données locales par le contenu du fichier. La
|
||||||
|
page sera rechargée après import.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
onChange={handleImportFile}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={handleImportClick}>
|
||||||
|
Choisir un fichier à importer
|
||||||
|
</button>
|
||||||
|
{importError !== null && (
|
||||||
|
<p role="alert" style={{ color: 'var(--color-error)' }}>
|
||||||
|
{importError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<p>
|
||||||
|
<button type="button" onClick={() => navigate('/')}>
|
||||||
|
Retour à l'accueil
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -30,6 +30,9 @@ export function HomeScreen(): JSX.Element {
|
|||||||
<button onClick={() => navigate('/import-identity')}>
|
<button onClick={() => navigate('/import-identity')}>
|
||||||
Importer une identité
|
Importer une identité
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => navigate('/data')}>
|
||||||
|
Export / Import données
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@ -90,6 +93,9 @@ export function HomeScreen(): JSX.Element {
|
|||||||
</button>
|
</button>
|
||||||
<button onClick={() => navigate('/sync')}>Synchroniser maintenant</button>
|
<button onClick={() => navigate('/sync')}>Synchroniser maintenant</button>
|
||||||
<button onClick={() => navigate('/services')}>Services disponibles</button>
|
<button onClick={() => navigate('/services')}>Services disponibles</button>
|
||||||
|
<button onClick={() => navigate('/data')}>
|
||||||
|
Export / Import données
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
145
userwallet/src/utils/exportImport.ts
Normal file
145
userwallet/src/utils/exportImport.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { getStoredIdentity } from './identity';
|
||||||
|
import { getStoredRelays } from './relay';
|
||||||
|
import { getStoredPairs } from './pairing';
|
||||||
|
import { getStoredKeyPair, getStoredServices } from './storage';
|
||||||
|
import type { LocalIdentity, RelayConfig, PairConfig } from '../types/identity';
|
||||||
|
import type { KeyPair } from './crypto';
|
||||||
|
import type { ServiceConfig } from '../types/auth';
|
||||||
|
|
||||||
|
const STORAGE_KEY_IDENTITY = 'userwallet_identity';
|
||||||
|
const STORAGE_KEY_RELAYS = 'userwallet_relays';
|
||||||
|
const STORAGE_KEY_PAIRS = 'userwallet_pairs';
|
||||||
|
const STORAGE_KEY_HASH_CACHE = 'userwallet_hash_cache';
|
||||||
|
const STORAGE_KEY_KEYPAIR = 'userwallet_keypair';
|
||||||
|
const STORAGE_KEY_SERVICES = 'userwallet_services';
|
||||||
|
|
||||||
|
export const EXPORT_VERSION = '1.0';
|
||||||
|
|
||||||
|
export interface UserWalletExport {
|
||||||
|
version: string;
|
||||||
|
exportedAt: number;
|
||||||
|
identity: LocalIdentity | null;
|
||||||
|
relays: RelayConfig[];
|
||||||
|
pairs: PairConfig[];
|
||||||
|
hash_cache: string[];
|
||||||
|
keypair?: KeyPair | null;
|
||||||
|
services?: ServiceConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export all UserWallet data from localStorage as JSON string.
|
||||||
|
*/
|
||||||
|
export function exportUserWalletData(): string {
|
||||||
|
const identity = getStoredIdentity();
|
||||||
|
const relays = getStoredRelays();
|
||||||
|
const pairs = getStoredPairs();
|
||||||
|
const keypair = getStoredKeyPair();
|
||||||
|
const services = getStoredServices();
|
||||||
|
const hashCacheRaw = localStorage.getItem(STORAGE_KEY_HASH_CACHE);
|
||||||
|
const hash_cache: string[] =
|
||||||
|
hashCacheRaw !== null ? (JSON.parse(hashCacheRaw) as string[]) : [];
|
||||||
|
|
||||||
|
const data: UserWalletExport = {
|
||||||
|
version: EXPORT_VERSION,
|
||||||
|
exportedAt: Date.now(),
|
||||||
|
identity,
|
||||||
|
relays,
|
||||||
|
pairs,
|
||||||
|
hash_cache,
|
||||||
|
keypair: keypair ?? null,
|
||||||
|
services,
|
||||||
|
};
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRelayConfig(x: unknown): x is RelayConfig {
|
||||||
|
if (x === null || typeof x !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const o = x as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof o.endpoint === 'string' &&
|
||||||
|
typeof o.priority === 'number' &&
|
||||||
|
typeof o.enabled === 'boolean'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPairConfig(x: unknown): x is PairConfig {
|
||||||
|
if (x === null || typeof x !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const o = x as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof o.uuid === 'string' &&
|
||||||
|
Array.isArray(o.membres_parents_uuid) &&
|
||||||
|
typeof o.is_local === 'boolean' &&
|
||||||
|
typeof o.can_sign === 'boolean'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalIdentity(x: unknown): x is LocalIdentity {
|
||||||
|
if (x === null || typeof x !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const o = x as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof o.uuid === 'string' &&
|
||||||
|
typeof o.privateKey === 'string' &&
|
||||||
|
typeof o.publicKey === 'string' &&
|
||||||
|
typeof o.t0_anniversaire === 'number' &&
|
||||||
|
typeof o.version === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import UserWallet data from JSON string into localStorage.
|
||||||
|
* Overwrites existing data. Throws on invalid format.
|
||||||
|
*/
|
||||||
|
export function importUserWalletData(json: string): void {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(json) as unknown;
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error('Invalid JSON');
|
||||||
|
}
|
||||||
|
if (parsed === null || typeof parsed !== 'object') {
|
||||||
|
throw new Error('Export must be an object');
|
||||||
|
}
|
||||||
|
const o = parsed as Record<string, unknown>;
|
||||||
|
if (typeof o.version !== 'string' || typeof o.exportedAt !== 'number') {
|
||||||
|
throw new Error('Missing or invalid version/exportedAt');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(o.relays) || !o.relays.every(isRelayConfig)) {
|
||||||
|
throw new Error('Invalid relays');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(o.pairs) || !o.pairs.every(isPairConfig)) {
|
||||||
|
throw new Error('Invalid pairs');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(o.hash_cache) || !o.hash_cache.every((h) => typeof h === 'string')) {
|
||||||
|
throw new Error('Invalid hash_cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (o.identity !== null && !isLocalIdentity(o.identity)) {
|
||||||
|
throw new Error('Invalid identity');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (o.identity === null) {
|
||||||
|
localStorage.removeItem(STORAGE_KEY_IDENTITY);
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(STORAGE_KEY_IDENTITY, JSON.stringify(o.identity));
|
||||||
|
}
|
||||||
|
localStorage.setItem(STORAGE_KEY_RELAYS, JSON.stringify(o.relays));
|
||||||
|
localStorage.setItem(STORAGE_KEY_PAIRS, JSON.stringify(o.pairs));
|
||||||
|
localStorage.setItem(STORAGE_KEY_HASH_CACHE, JSON.stringify(o.hash_cache));
|
||||||
|
|
||||||
|
if ('keypair' in o && o.keypair !== undefined) {
|
||||||
|
if (o.keypair === null) {
|
||||||
|
localStorage.removeItem(STORAGE_KEY_KEYPAIR);
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(STORAGE_KEY_KEYPAIR, JSON.stringify(o.keypair));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ('services' in o && o.services !== undefined && Array.isArray(o.services)) {
|
||||||
|
localStorage.setItem(STORAGE_KEY_SERVICES, JSON.stringify(o.services));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -34840,7 +34840,7 @@ ancrages;1b60296a88665c6775044246fa88c61324e10b9d9428725f8b29966a175d11c7;5;0.00
|
|||||||
ancrages;dc6b85db83f795087dcb16a7749c952048db9a7f0d40f9924f13ce794b7e5c36;7;0.000025;284;false;
|
ancrages;dc6b85db83f795087dcb16a7749c952048db9a7f0d40f9924f13ce794b7e5c36;7;0.000025;284;false;
|
||||||
ancrages;d7fcaca3f983e4dc591d51515aa77ee85460c67991c6274dc00b6aeddd891f85;7;0.000025;530;false;
|
ancrages;d7fcaca3f983e4dc591d51515aa77ee85460c67991c6274dc00b6aeddd891f85;7;0.000025;530;false;
|
||||||
ancrages;e7e6ed5f12757fcc21ebf47e022df260a7046922932bceb5998efd4919701562;0;0.000025;620;false;
|
ancrages;e7e6ed5f12757fcc21ebf47e022df260a7046922932bceb5998efd4919701562;0;0.000025;620;false;
|
||||||
ancrages;1b60296a88665c6775044246fa88c61324e10b9d9428725f8b29966a175d11c7;7;0.000025;669;false;
|
ancrages;1b60296a88665c6775044246fa88c61324e10b9d9428725f8b29966a175d11c7;7;0.000025;669;false;1769350182
|
||||||
ancrages;1b60296a88665c6775044246fa88c61324e10b9d9428725f8b29966a175d11c7;8;0.000025;669;false;
|
ancrages;1b60296a88665c6775044246fa88c61324e10b9d9428725f8b29966a175d11c7;8;0.000025;669;false;
|
||||||
ancrages;cfb64e274caf85ad9ec3ab85cec1833182634cb630af1d4af917a32673a8f49a;2;0.000025;669;false;
|
ancrages;cfb64e274caf85ad9ec3ab85cec1833182634cb630af1d4af917a32673a8f49a;2;0.000025;669;false;
|
||||||
ancrages;b54e4bb450da7820eefb76ef5b968e00979ce2960bee8b5e0191bf29937a4746;2;0.000025;669;false;
|
ancrages;b54e4bb450da7820eefb76ef5b968e00979ce2960bee8b5e0191bf29937a4746;2;0.000025;669;false;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user