feat: enhance backup functionality and logging
- Updated `createBackup` method to accept a `force` parameter, allowing backups to be created even if no changes are detected. - Added user alerts in `AdvancedSettingsPageClient` and `BackupSettingsPageClient` for backup status feedback. - Implemented `getBackupLogs` method in `BackupService` to retrieve backup logs, with a new API route for accessing logs. - Enhanced UI in `BackupSettingsPageClient` to display backup logs and provide a refresh option. - Updated `BackupManagerCLI` to support forced backups via command line.
This commit is contained in:
2
TODO.md
2
TODO.md
@@ -308,7 +308,7 @@ Endpoints complexes → API Routes conservées
|
||||
## Autre Todos #2
|
||||
- [ ] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
|
||||
- [ ] refacto des allpreferences : ca devrait eter un contexte dans le layout qui balance serverside dans le hook
|
||||
- [ ] backups : ne backuper que si il y a eu un changement entre le dernier backup et la base actuelle
|
||||
- [x] backups : ne backuper que si il y a eu un changement entre le dernier backup et la base actuelle
|
||||
- [ ] refacto des dates avec le utils qui pour l'instant n'est pas utilisé
|
||||
- [ ] split de certains gros composants.
|
||||
|
||||
|
||||
@@ -28,11 +28,17 @@ export class BackupClient {
|
||||
/**
|
||||
* Crée une nouvelle sauvegarde manuelle
|
||||
*/
|
||||
async createBackup(): Promise<BackupInfo> {
|
||||
const response = await httpClient.post<{ data: BackupInfo }>(this.baseUrl, {
|
||||
action: 'create'
|
||||
async createBackup(force: boolean = false): Promise<BackupInfo | null> {
|
||||
const response = await httpClient.post<{ data?: BackupInfo; skipped?: boolean; message?: string }>(this.baseUrl, {
|
||||
action: 'create',
|
||||
force
|
||||
});
|
||||
return response.data;
|
||||
|
||||
if (response.skipped) {
|
||||
return null; // Backup was skipped
|
||||
}
|
||||
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,6 +101,14 @@ export class BackupClient {
|
||||
action: 'restore'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les logs de backup
|
||||
*/
|
||||
async getBackupLogs(maxLines: number = 100): Promise<string[]> {
|
||||
const response = await httpClient.get<{ data: { logs: string[] } }>(`${this.baseUrl}?action=logs&maxLines=${maxLines}`);
|
||||
return response.data.logs;
|
||||
}
|
||||
}
|
||||
|
||||
export const backupClient = new BackupClient();
|
||||
|
||||
@@ -43,10 +43,16 @@ export function AdvancedSettingsPageClient({
|
||||
const handleCreateBackup = async () => {
|
||||
setIsCreatingBackup(true);
|
||||
try {
|
||||
await backupClient.createBackup();
|
||||
const result = await backupClient.createBackup();
|
||||
if (result === null) {
|
||||
alert('⏭️ Sauvegarde sautée : aucun changement détecté');
|
||||
} else {
|
||||
alert('✅ Sauvegarde créée avec succès');
|
||||
}
|
||||
await reloadBackupData();
|
||||
} catch (error) {
|
||||
console.error('Failed to create backup:', error);
|
||||
alert('❌ Erreur lors de la création de la sauvegarde');
|
||||
} finally {
|
||||
setIsCreatingBackup(false);
|
||||
}
|
||||
|
||||
@@ -23,11 +23,15 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
||||
const [config, setConfig] = useState<BackupConfig | null>(initialData?.config || null);
|
||||
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [messages, setMessages] = useState<{[key: string]: {type: 'success' | 'error', text: string} | null}>({
|
||||
verify: null,
|
||||
config: null,
|
||||
restore: null,
|
||||
delete: null,
|
||||
backup: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,13 +70,30 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
const handleCreateBackup = async (force: boolean = false) => {
|
||||
setIsCreatingBackup(true);
|
||||
try {
|
||||
await backupClient.createBackup();
|
||||
const result = await backupClient.createBackup(force);
|
||||
|
||||
if (result === null) {
|
||||
setMessage('backup', {
|
||||
type: 'success',
|
||||
text: 'Sauvegarde sautée : aucun changement détecté. Utilisez "Forcer" pour créer malgré tout.'
|
||||
});
|
||||
} else {
|
||||
setMessage('backup', {
|
||||
type: 'success',
|
||||
text: `Sauvegarde créée : ${result.filename}`
|
||||
});
|
||||
}
|
||||
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to create backup:', error);
|
||||
setMessage('backup', {
|
||||
type: 'error',
|
||||
text: 'Erreur lors de la création de la sauvegarde'
|
||||
});
|
||||
} finally {
|
||||
setIsCreatingBackup(false);
|
||||
}
|
||||
@@ -144,6 +165,19 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
|
||||
}
|
||||
};
|
||||
|
||||
const loadLogs = async () => {
|
||||
setIsLoadingLogs(true);
|
||||
try {
|
||||
const backupLogs = await backupClient.getBackupLogs(50);
|
||||
setLogs(backupLogs);
|
||||
} catch (error) {
|
||||
console.error('Failed to load backup logs:', error);
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setIsLoadingLogs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
@@ -360,15 +394,30 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={isCreatingBackup}
|
||||
className="bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)]"
|
||||
>
|
||||
{isCreatingBackup ? 'Création...' : 'Créer une sauvegarde'}
|
||||
</Button>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleCreateBackup(false)}
|
||||
disabled={isCreatingBackup}
|
||||
className="bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)]"
|
||||
>
|
||||
{isCreatingBackup ? 'Création...' : 'Créer sauvegarde'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleCreateBackup(true)}
|
||||
disabled={isCreatingBackup}
|
||||
className="bg-orange-600 hover:bg-orange-700 text-white"
|
||||
>
|
||||
{isCreatingBackup ? 'Création...' : 'Forcer'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
<strong>Créer :</strong> Vérifie les changements • <strong>Forcer :</strong> Crée toujours
|
||||
</div>
|
||||
<InlineMessage messageKey="backup" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleVerifyDatabase}
|
||||
disabled={isVerifying}
|
||||
@@ -377,17 +426,17 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
|
||||
{isVerifying ? 'Vérification...' : 'Vérifier l\'intégrité'}
|
||||
</Button>
|
||||
<InlineMessage messageKey="verify" />
|
||||
|
||||
<Button
|
||||
onClick={handleToggleScheduler}
|
||||
className={data.scheduler.isRunning
|
||||
? 'bg-[var(--destructive)] hover:bg-[var(--destructive)]/90 text-[var(--destructive-foreground)]'
|
||||
: 'bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)]'
|
||||
}
|
||||
>
|
||||
{data.scheduler.isRunning ? 'Arrêter le planificateur' : 'Démarrer le planificateur'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleToggleScheduler}
|
||||
className={data.scheduler.isRunning
|
||||
? 'bg-[var(--destructive)] hover:bg-[var(--destructive)]/90 text-[var(--destructive-foreground)]'
|
||||
: 'bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)]'
|
||||
}
|
||||
>
|
||||
{data.scheduler.isRunning ? 'Arrêter le planificateur' : 'Démarrer le planificateur'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -514,6 +563,71 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section des logs */}
|
||||
<div className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<span className="text-blue-600">📋</span>
|
||||
Logs des sauvegardes
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Historique des opérations de sauvegarde
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!showLogs) {
|
||||
loadLogs();
|
||||
}
|
||||
setShowLogs(!showLogs);
|
||||
}}
|
||||
className="bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] hover:bg-[var(--muted)]"
|
||||
>
|
||||
{showLogs ? 'Masquer' : 'Afficher'} les logs
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{showLogs && (
|
||||
<CardContent>
|
||||
{isLoadingLogs ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Chargement des logs...</p>
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Aucun log disponible</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{logs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-xs font-mono p-2 bg-[var(--muted)] rounded border"
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showLogs && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--border)]">
|
||||
<Button
|
||||
onClick={loadLogs}
|
||||
disabled={isLoadingLogs}
|
||||
className="text-xs bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] hover:bg-[var(--muted)]"
|
||||
>
|
||||
{isLoadingLogs ? 'Actualisation...' : 'Actualiser'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -50,10 +50,17 @@ export function SettingsIndexPageClient({ initialPreferences, initialSystemInfo
|
||||
setIsBackupLoading(true);
|
||||
try {
|
||||
const backup = await backupClient.createBackup();
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
backup: { type: 'success', text: `Sauvegarde créée: ${backup.filename}` }
|
||||
}));
|
||||
if (backup) {
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
backup: { type: 'success', text: `Sauvegarde créée: ${backup.filename}` }
|
||||
}));
|
||||
} else {
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
backup: { type: 'success', text: 'Sauvegarde sautée: aucun changement détecté' }
|
||||
}));
|
||||
}
|
||||
|
||||
// Recharger les infos système pour mettre à jour le nombre de sauvegardes
|
||||
loadSystemInfo();
|
||||
|
||||
211
lib/backup-utils.ts
Normal file
211
lib/backup-utils.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Utilitaires pour les opérations de backup
|
||||
*/
|
||||
export class BackupUtils {
|
||||
/**
|
||||
* Calcule un hash SHA-256 d'un fichier
|
||||
*/
|
||||
static async calculateFileHash(filePath: string): Promise<string> {
|
||||
try {
|
||||
const fileBuffer = await fs.readFile(filePath);
|
||||
return createHash('sha256').update(fileBuffer).digest('hex');
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to calculate hash for ${filePath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout le chemin de la base de données selon la configuration
|
||||
*/
|
||||
static resolveDatabasePath(): string {
|
||||
if (process.env.BACKUP_DATABASE_PATH) {
|
||||
return path.resolve(process.cwd(), process.env.BACKUP_DATABASE_PATH);
|
||||
} else if (process.env.DATABASE_URL) {
|
||||
return path.resolve(process.env.DATABASE_URL.replace('file:', ''));
|
||||
} else {
|
||||
return path.resolve(process.cwd(), 'prisma', 'dev.db');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout le chemin de stockage des backups
|
||||
*/
|
||||
static resolveBackupStoragePath(): string {
|
||||
if (process.env.BACKUP_STORAGE_PATH) {
|
||||
return path.resolve(process.cwd(), process.env.BACKUP_STORAGE_PATH);
|
||||
}
|
||||
|
||||
return process.env.NODE_ENV === 'production'
|
||||
? path.join(process.cwd(), 'data', 'backups')
|
||||
: path.join(process.cwd(), 'backups');
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une sauvegarde SQLite en utilisant la commande .backup
|
||||
*/
|
||||
static async createSQLiteBackup(sourcePath: string, backupPath: string): Promise<void> {
|
||||
// Vérifier que le fichier source existe
|
||||
try {
|
||||
await fs.stat(sourcePath);
|
||||
} catch {
|
||||
throw new Error(`Source database not found: ${sourcePath}`);
|
||||
}
|
||||
|
||||
// Méthode 1: Utiliser sqlite3 CLI (plus fiable)
|
||||
try {
|
||||
const command = `sqlite3 "${sourcePath}" ".backup '${backupPath}'"`;
|
||||
await execAsync(command);
|
||||
console.log(`✅ SQLite backup created using CLI: ${backupPath}`);
|
||||
return;
|
||||
} catch (cliError) {
|
||||
console.warn(`⚠️ SQLite CLI backup failed, trying copy method:`, cliError);
|
||||
}
|
||||
|
||||
// Méthode 2: Copie simple du fichier (fallback)
|
||||
try {
|
||||
await fs.copyFile(sourcePath, backupPath);
|
||||
console.log(`✅ SQLite backup created using file copy: ${backupPath}`);
|
||||
} catch (copyError) {
|
||||
throw new Error(`Failed to create SQLite backup: ${copyError}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresse un fichier avec gzip
|
||||
*/
|
||||
static async compressFile(filePath: string): Promise<string> {
|
||||
const compressedPath = `${filePath}.gz`;
|
||||
|
||||
try {
|
||||
const command = `gzip -c "${filePath}" > "${compressedPath}"`;
|
||||
await execAsync(command);
|
||||
console.log(`✅ File compressed: ${compressedPath}`);
|
||||
return compressedPath;
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Compression failed, keeping uncompressed file:`, error);
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Décompresse un fichier gzip temporairement
|
||||
*/
|
||||
static async decompressFileTemp(compressedPath: string, tempPath: string): Promise<void> {
|
||||
try {
|
||||
await execAsync(`gunzip -c "${compressedPath}" > "${tempPath}"`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to decompress ${compressedPath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate la taille de fichier en unités lisibles
|
||||
*/
|
||||
static formatFileSize(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* S'assure qu'un dossier existe
|
||||
*/
|
||||
static async ensureDirectory(dirPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.access(dirPath);
|
||||
} catch {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
console.log(`📁 Created directory: ${dirPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse le nom de fichier de backup pour extraire les métadonnées
|
||||
*/
|
||||
static parseBackupFilename(filename: string): { type: 'manual' | 'automatic'; date: Date | null } {
|
||||
// Nouveau format: towercontrol_manual_2025-09-18T14-12-05-737Z.db
|
||||
// Ancien format: towercontrol_2025-09-18T14-12-05-737Z.db (considéré comme automatic)
|
||||
let type: 'manual' | 'automatic' = 'automatic';
|
||||
let dateMatch = filename.match(/towercontrol_(manual|automatic)_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/);
|
||||
|
||||
if (!dateMatch) {
|
||||
// Format ancien sans type - considérer comme automatic
|
||||
dateMatch = filename.match(/towercontrol_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/);
|
||||
if (dateMatch) {
|
||||
dateMatch = [dateMatch[0], 'automatic', dateMatch[1]]; // Restructurer pour compatibilité
|
||||
}
|
||||
} else {
|
||||
type = dateMatch[1] as 'manual' | 'automatic';
|
||||
}
|
||||
|
||||
let date: Date | null = null;
|
||||
if (dateMatch && dateMatch[2]) {
|
||||
// Convertir le format de fichier vers ISO string valide
|
||||
// Format: 2025-09-18T14-12-05-737Z -> 2025-09-18T14:12:05.737Z
|
||||
const isoString = dateMatch[2]
|
||||
.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, 'T$1:$2:$3.$4Z');
|
||||
date = new Date(isoString);
|
||||
}
|
||||
|
||||
return { type, date };
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un nom de fichier de backup
|
||||
*/
|
||||
static generateBackupFilename(type: 'manual' | 'automatic'): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
return `towercontrol_${type}_${timestamp}.db`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Écrit une entrée dans le fichier de log
|
||||
*/
|
||||
static async writeLogEntry(
|
||||
logPath: string,
|
||||
type: 'manual' | 'automatic',
|
||||
action: 'created' | 'skipped' | 'failed',
|
||||
details: string,
|
||||
extra?: { hash?: string; size?: number; previousHash?: string }
|
||||
): Promise<void> {
|
||||
try {
|
||||
const date = new Date().toLocaleString('fr-FR');
|
||||
|
||||
let logEntry = `[${date}] ${type.toUpperCase()} BACKUP ${action.toUpperCase()}: ${details}`;
|
||||
|
||||
if (extra) {
|
||||
if (extra.hash) {
|
||||
logEntry += ` | Hash: ${extra.hash.substring(0, 12)}...`;
|
||||
}
|
||||
if (extra.size) {
|
||||
logEntry += ` | Size: ${BackupUtils.formatFileSize(extra.size)}`;
|
||||
}
|
||||
if (extra.previousHash) {
|
||||
logEntry += ` | Previous: ${extra.previousHash.substring(0, 12)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
logEntry += '\n';
|
||||
|
||||
await fs.appendFile(logPath, logEntry);
|
||||
} catch (error) {
|
||||
console.error('Error writing to backup log:', error);
|
||||
// Ne pas faire échouer l'opération si on ne peut pas logger
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class BackupManagerCLI {
|
||||
🔧 TowerControl Backup Manager
|
||||
|
||||
COMMANDES:
|
||||
create Créer une nouvelle sauvegarde
|
||||
create [--force] Créer une nouvelle sauvegarde (--force pour ignorer la détection de changements)
|
||||
list Lister toutes les sauvegardes
|
||||
delete <filename> Supprimer une sauvegarde
|
||||
restore <filename> Restaurer une sauvegarde
|
||||
@@ -35,6 +35,7 @@ COMMANDES:
|
||||
|
||||
EXEMPLES:
|
||||
tsx backup-manager.ts create
|
||||
tsx backup-manager.ts create --force
|
||||
tsx backup-manager.ts list
|
||||
tsx backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db
|
||||
tsx backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||
@@ -105,7 +106,7 @@ OPTIONS:
|
||||
try {
|
||||
switch (options.command) {
|
||||
case 'create':
|
||||
await this.createBackup();
|
||||
await this.createBackup(options.force || false);
|
||||
break;
|
||||
|
||||
case 'list':
|
||||
@@ -167,13 +168,22 @@ OPTIONS:
|
||||
}
|
||||
}
|
||||
|
||||
private async createBackup(): Promise<void> {
|
||||
private async createBackup(force: boolean = false): Promise<void> {
|
||||
console.log('🔄 Création d\'une sauvegarde...');
|
||||
const result = await backupService.createBackup('manual');
|
||||
const result = await backupService.createBackup('manual', force);
|
||||
|
||||
if (result === null) {
|
||||
console.log('⏭️ Sauvegarde sautée: Aucun changement détecté depuis la dernière sauvegarde');
|
||||
console.log(' 💡 Utilisez --force pour créer une sauvegarde malgré tout');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'success') {
|
||||
console.log(`✅ Sauvegarde créée: ${result.filename}`);
|
||||
console.log(` Taille: ${this.formatFileSize(result.size)}`);
|
||||
if (result.databaseHash) {
|
||||
console.log(` Hash: ${result.databaseHash.substring(0, 12)}...`);
|
||||
}
|
||||
} else {
|
||||
console.error(`❌ Échec de la sauvegarde: ${result.error}`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -70,7 +70,9 @@ export class BackupScheduler {
|
||||
console.log('🔄 Starting scheduled backup...');
|
||||
const result = await backupService.createBackup('automatic');
|
||||
|
||||
if (result.status === 'success') {
|
||||
if (result === null) {
|
||||
console.log('⏭️ Scheduled backup skipped: no changes detected');
|
||||
} else if (result.status === 'success') {
|
||||
console.log(`✅ Scheduled backup completed: ${result.filename}`);
|
||||
} else {
|
||||
console.error(`❌ Scheduled backup failed: ${result.error}`);
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { prisma } from './database';
|
||||
import { userPreferencesService } from './user-preferences';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import { BackupUtils } from '../lib/backup-utils';
|
||||
|
||||
export interface BackupConfig {
|
||||
enabled: boolean;
|
||||
@@ -24,6 +21,7 @@ export interface BackupInfo {
|
||||
type: 'manual' | 'automatic';
|
||||
status: 'success' | 'failed' | 'in_progress';
|
||||
error?: string;
|
||||
databaseHash?: string;
|
||||
}
|
||||
|
||||
export class BackupService {
|
||||
@@ -39,15 +37,7 @@ export class BackupService {
|
||||
}
|
||||
|
||||
private getDefaultBackupPath(): string {
|
||||
// 1. Variable d'environnement explicite
|
||||
if (process.env.BACKUP_STORAGE_PATH) {
|
||||
return path.resolve(process.cwd(), process.env.BACKUP_STORAGE_PATH);
|
||||
}
|
||||
|
||||
// 2. Chemin par défaut selon l'environnement
|
||||
return process.env.NODE_ENV === 'production'
|
||||
? path.join(process.cwd(), 'data', 'backups') // Docker: /app/data/backups
|
||||
: path.join(process.cwd(), 'backups'); // Local: ./backups
|
||||
return BackupUtils.resolveBackupStoragePath();
|
||||
}
|
||||
|
||||
private config: BackupConfig;
|
||||
@@ -106,27 +96,180 @@ export class BackupService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une sauvegarde complète de la base de données
|
||||
* Calcule un hash de la base de données pour détecter les changements
|
||||
*/
|
||||
async createBackup(type: 'manual' | 'automatic' = 'manual'): Promise<BackupInfo> {
|
||||
private async calculateDatabaseHash(): Promise<string> {
|
||||
try {
|
||||
const dbPath = BackupUtils.resolveDatabasePath();
|
||||
return await BackupUtils.calculateFileHash(dbPath);
|
||||
} catch (error) {
|
||||
console.error('Error calculating database hash:', error);
|
||||
throw new Error(`Failed to calculate database hash: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si la base de données a changé depuis le dernier backup
|
||||
*/
|
||||
async hasChangedSinceLastBackup(): Promise<boolean> {
|
||||
try {
|
||||
const currentHash = await this.calculateDatabaseHash();
|
||||
const backups = await this.listBackups();
|
||||
|
||||
if (backups.length === 0) {
|
||||
// Pas de backup précédent, donc il y a forcément des changements
|
||||
return true;
|
||||
}
|
||||
|
||||
// Récupérer le hash du dernier backup
|
||||
const lastBackup = backups[0]; // Les backups sont triés par date décroissante
|
||||
const lastBackupHash = await this.getBackupHash(lastBackup.filename);
|
||||
|
||||
if (!lastBackupHash) {
|
||||
// Pas de hash disponible pour le dernier backup, considérer qu'il y a des changements
|
||||
console.log('No hash available for last backup, assuming changes');
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasChanged = currentHash !== lastBackupHash;
|
||||
console.log(`Database hash comparison: current=${currentHash.substring(0, 8)}..., last=${lastBackupHash.substring(0, 8)}..., changed=${hasChanged}`);
|
||||
|
||||
return hasChanged;
|
||||
} catch (error) {
|
||||
console.error('Error checking database changes:', error);
|
||||
// En cas d'erreur, on assume qu'il y a des changements pour être sûr
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le hash d'un backup depuis ses métadonnées
|
||||
*/
|
||||
private async getBackupHash(filename: string): Promise<string | null> {
|
||||
try {
|
||||
const metadataPath = path.join(this.getCurrentBackupPath(), `${filename}.meta.json`);
|
||||
|
||||
try {
|
||||
const metadataContent = await fs.readFile(metadataPath, 'utf-8');
|
||||
const metadata = JSON.parse(metadataContent);
|
||||
return metadata.databaseHash || null;
|
||||
} catch {
|
||||
// Fichier de métadonnées n'existe pas, essayer de calculer le hash du backup
|
||||
return await this.calculateBackupFileHash(filename);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error getting backup hash for ${filename}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le hash d'un fichier de backup existant
|
||||
*/
|
||||
private async calculateBackupFileHash(filename: string): Promise<string | null> {
|
||||
try {
|
||||
const backupPath = path.join(this.getCurrentBackupPath(), filename);
|
||||
|
||||
// Si le fichier est compressé, il faut le décompresser temporairement
|
||||
if (filename.endsWith('.gz')) {
|
||||
const tempFile = path.join(this.getCurrentBackupPath(), `temp_${Date.now()}.db`);
|
||||
|
||||
try {
|
||||
await BackupUtils.decompressFileTemp(backupPath, tempFile);
|
||||
const hash = await BackupUtils.calculateFileHash(tempFile);
|
||||
|
||||
// Nettoyer le fichier temporaire
|
||||
await fs.unlink(tempFile);
|
||||
|
||||
return hash;
|
||||
} catch (error) {
|
||||
// Nettoyer le fichier temporaire en cas d'erreur
|
||||
try {
|
||||
await fs.unlink(tempFile);
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// Fichier non compressé
|
||||
return await BackupUtils.calculateFileHash(backupPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error calculating hash for backup file ${filename}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde les métadonnées d'un backup
|
||||
*/
|
||||
private async saveBackupMetadata(filename: string, metadata: { databaseHash: string; createdAt: Date; type: string }): Promise<void> {
|
||||
try {
|
||||
const metadataPath = path.join(this.getCurrentBackupPath(), `${filename}.meta.json`);
|
||||
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
} catch (error) {
|
||||
console.error(`Error saving backup metadata for ${filename}:`, error);
|
||||
// Ne pas faire échouer le backup si on ne peut pas sauvegarder les métadonnées
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Écrit une entrée dans le fichier de log des backups
|
||||
*/
|
||||
private async logBackupAction(type: 'manual' | 'automatic', action: 'created' | 'skipped' | 'failed', details: string, extra?: { hash?: string; size?: number; previousHash?: string }): Promise<void> {
|
||||
const logPath = path.join(this.getCurrentBackupPath(), 'backup.log');
|
||||
await BackupUtils.writeLogEntry(logPath, type, action, details, extra);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une sauvegarde complète de la base de données
|
||||
* Vérifie d'abord s'il y a eu des changements (sauf si forcé)
|
||||
*/
|
||||
async createBackup(type: 'manual' | 'automatic' = 'manual', forceCreate: boolean = false): Promise<BackupInfo | null> {
|
||||
const backupId = `backup_${Date.now()}`;
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `towercontrol_${type}_${timestamp}.db`;
|
||||
const filename = BackupUtils.generateBackupFilename(type);
|
||||
const backupPath = path.join(this.getCurrentBackupPath(), filename);
|
||||
|
||||
console.log(`🔄 Starting ${type} backup: ${filename}`);
|
||||
|
||||
try {
|
||||
// Créer le dossier de backup si nécessaire
|
||||
await this.ensureBackupDirectory();
|
||||
// Vérifier les changements (sauf si forcé)
|
||||
if (!forceCreate) {
|
||||
const hasChanged = await this.hasChangedSinceLastBackup();
|
||||
|
||||
if (!hasChanged) {
|
||||
const currentHash = await this.calculateDatabaseHash();
|
||||
const backups = await this.listBackups();
|
||||
const lastBackupHash = backups.length > 0 ? await this.getBackupHash(backups[0].filename) : null;
|
||||
|
||||
const message = `No changes detected since last backup`;
|
||||
console.log(`⏭️ Skipping ${type} backup: ${message}`);
|
||||
await this.logBackupAction(type, 'skipped', message, {
|
||||
hash: currentHash,
|
||||
previousHash: lastBackupHash || undefined
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`📝 Changes detected, proceeding with ${type} backup`);
|
||||
} else {
|
||||
console.log(`🔧 Forced ${type} backup, skipping change detection`);
|
||||
}
|
||||
|
||||
// Créer la sauvegarde SQLite (sans vérification de santé pour éviter les conflits)
|
||||
await this.createSQLiteBackup(backupPath);
|
||||
// Calculer le hash de la base de données avant le backup
|
||||
const databaseHash = await this.calculateDatabaseHash();
|
||||
|
||||
// Créer le dossier de backup si nécessaire
|
||||
await BackupUtils.ensureDirectory(this.getCurrentBackupPath());
|
||||
|
||||
// Créer la sauvegarde SQLite
|
||||
const dbPath = BackupUtils.resolveDatabasePath();
|
||||
await BackupUtils.createSQLiteBackup(dbPath, backupPath);
|
||||
|
||||
// Compresser si activé
|
||||
let finalPath = backupPath;
|
||||
if (this.config.compression) {
|
||||
finalPath = await this.compressBackup(backupPath);
|
||||
finalPath = await BackupUtils.compressFile(backupPath);
|
||||
await fs.unlink(backupPath); // Supprimer le fichier non compressé
|
||||
}
|
||||
|
||||
@@ -140,16 +283,31 @@ export class BackupService {
|
||||
createdAt: new Date(),
|
||||
type,
|
||||
status: 'success',
|
||||
databaseHash,
|
||||
};
|
||||
|
||||
// Sauvegarder les métadonnées du backup
|
||||
await this.saveBackupMetadata(path.basename(finalPath), {
|
||||
databaseHash,
|
||||
createdAt: new Date(),
|
||||
type,
|
||||
});
|
||||
|
||||
// Nettoyer les anciennes sauvegardes
|
||||
await this.cleanOldBackups();
|
||||
|
||||
console.log(`✅ Backup completed: ${backupInfo.filename} (${this.formatFileSize(backupInfo.size)})`);
|
||||
const successMessage = `${backupInfo.filename} created successfully`;
|
||||
console.log(`✅ Backup completed: ${successMessage}`);
|
||||
await this.logBackupAction(type, 'created', successMessage, {
|
||||
hash: databaseHash,
|
||||
size: backupInfo.size
|
||||
});
|
||||
|
||||
return backupInfo;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error(`❌ Backup failed:`, error);
|
||||
await this.logBackupAction(type, 'failed', `${filename} failed: ${errorMessage}`);
|
||||
|
||||
return {
|
||||
id: backupId,
|
||||
@@ -158,71 +316,11 @@ export class BackupService {
|
||||
createdAt: new Date(),
|
||||
type,
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une sauvegarde SQLite en utilisant la commande .backup
|
||||
*/
|
||||
private async createSQLiteBackup(backupPath: string): Promise<void> {
|
||||
// Résoudre le chemin de la base de données
|
||||
let dbPath: string;
|
||||
if (process.env.BACKUP_DATABASE_PATH) {
|
||||
// Utiliser la variable spécifique aux backups
|
||||
dbPath = path.resolve(process.cwd(), process.env.BACKUP_DATABASE_PATH);
|
||||
} else if (process.env.DATABASE_URL) {
|
||||
// Fallback sur DATABASE_URL si BACKUP_DATABASE_PATH n'est pas défini
|
||||
dbPath = path.resolve(process.env.DATABASE_URL.replace('file:', ''));
|
||||
} else {
|
||||
// Chemin par défaut vers prisma/dev.db
|
||||
dbPath = path.resolve(process.cwd(), 'prisma', 'dev.db');
|
||||
}
|
||||
|
||||
// Vérifier que le fichier source existe
|
||||
try {
|
||||
await fs.stat(dbPath);
|
||||
} catch (error) {
|
||||
console.error(`❌ Source database not found: ${dbPath}`, error);
|
||||
throw new Error(`Source database not found: ${dbPath}`);
|
||||
}
|
||||
|
||||
// Méthode 1: Utiliser sqlite3 CLI (plus fiable)
|
||||
try {
|
||||
const command = `sqlite3 "${dbPath}" ".backup '${backupPath}'"`;
|
||||
await execAsync(command);
|
||||
console.log(`✅ SQLite backup created using CLI: ${backupPath}`);
|
||||
return;
|
||||
} catch (cliError) {
|
||||
console.warn(`⚠️ SQLite CLI backup failed, trying copy method:`, cliError);
|
||||
}
|
||||
|
||||
// Méthode 2: Copie simple du fichier (fallback)
|
||||
try {
|
||||
await fs.copyFile(dbPath, backupPath);
|
||||
console.log(`✅ SQLite backup created using file copy: ${backupPath}`);
|
||||
} catch (copyError) {
|
||||
throw new Error(`Failed to create SQLite backup: ${copyError}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresse une sauvegarde
|
||||
*/
|
||||
private async compressBackup(filePath: string): Promise<string> {
|
||||
const compressedPath = `${filePath}.gz`;
|
||||
|
||||
try {
|
||||
const command = `gzip -c "${filePath}" > "${compressedPath}"`;
|
||||
await execAsync(command);
|
||||
console.log(`✅ Backup compressed: ${compressedPath}`);
|
||||
return compressedPath;
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Compression failed, keeping uncompressed backup:`, error);
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaure une sauvegarde
|
||||
@@ -258,7 +356,7 @@ export class BackupService {
|
||||
console.log(`🔄 Decompressing ${backupPath} to ${tempFile}`);
|
||||
|
||||
try {
|
||||
await execAsync(`gunzip -c "${backupPath}" > "${tempFile}"`);
|
||||
await BackupUtils.decompressFileTemp(backupPath, tempFile);
|
||||
console.log(`✅ Decompression successful`);
|
||||
|
||||
// Vérifier que le fichier décompressé existe
|
||||
@@ -273,8 +371,10 @@ export class BackupService {
|
||||
}
|
||||
|
||||
// Créer une sauvegarde de la base actuelle avant restauration
|
||||
const currentBackup = await this.createBackup('manual');
|
||||
console.log(`✅ Current database backed up as: ${currentBackup.filename}`);
|
||||
const currentBackup = await this.createBackup('manual', true); // Forcer la création
|
||||
if (currentBackup) {
|
||||
console.log(`✅ Current database backed up as: ${currentBackup.filename}`);
|
||||
}
|
||||
|
||||
// Fermer toutes les connexions
|
||||
await prisma.$disconnect();
|
||||
@@ -322,41 +422,19 @@ export class BackupService {
|
||||
async listBackups(): Promise<BackupInfo[]> {
|
||||
try {
|
||||
const currentBackupPath = this.getCurrentBackupPath();
|
||||
await this.ensureBackupDirectory();
|
||||
await BackupUtils.ensureDirectory(currentBackupPath);
|
||||
const files = await fs.readdir(currentBackupPath);
|
||||
|
||||
const backups: BackupInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
for (const file of files) {
|
||||
if (file.startsWith('towercontrol_') && (file.endsWith('.db') || file.endsWith('.db.gz'))) {
|
||||
const filePath = path.join(currentBackupPath, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
// Extraire le type et la date du nom de fichier
|
||||
// Nouveau format: towercontrol_manual_2025-09-18T14-12-05-737Z.db
|
||||
// Ancien format: towercontrol_2025-09-18T14-12-05-737Z.db (considéré comme automatic)
|
||||
let type: 'manual' | 'automatic' = 'automatic';
|
||||
let dateMatch = file.match(/towercontrol_(manual|automatic)_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/);
|
||||
|
||||
if (!dateMatch) {
|
||||
// Format ancien sans type - considérer comme automatic
|
||||
dateMatch = file.match(/towercontrol_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/);
|
||||
if (dateMatch) {
|
||||
dateMatch = [dateMatch[0], 'automatic', dateMatch[1]]; // Restructurer pour compatibilité
|
||||
}
|
||||
} else {
|
||||
type = dateMatch[1] as 'manual' | 'automatic';
|
||||
}
|
||||
|
||||
let createdAt = stats.birthtime;
|
||||
|
||||
if (dateMatch && dateMatch[2]) {
|
||||
// Convertir le format de fichier vers ISO string valide
|
||||
// Format: 2025-09-18T14-12-05-737Z -> 2025-09-18T14:12:05.737Z
|
||||
const isoString = dateMatch[2]
|
||||
.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, 'T$1:$2:$3.$4Z');
|
||||
createdAt = new Date(isoString);
|
||||
}
|
||||
// Utiliser l'utilitaire pour parser le nom de fichier
|
||||
const { type, date } = BackupUtils.parseBackupFilename(file);
|
||||
const createdAt = date || stats.birthtime;
|
||||
|
||||
backups.push({
|
||||
id: file,
|
||||
@@ -381,9 +459,19 @@ export class BackupService {
|
||||
*/
|
||||
async deleteBackup(filename: string): Promise<void> {
|
||||
const backupPath = path.join(this.getCurrentBackupPath(), filename);
|
||||
const metadataPath = path.join(this.getCurrentBackupPath(), `${filename}.meta.json`);
|
||||
|
||||
try {
|
||||
// Supprimer le fichier de backup
|
||||
await fs.unlink(backupPath);
|
||||
|
||||
// Supprimer le fichier de métadonnées s'il existe
|
||||
try {
|
||||
await fs.unlink(metadataPath);
|
||||
} catch {
|
||||
// Ignorer si le fichier de métadonnées n'existe pas
|
||||
}
|
||||
|
||||
console.log(`✅ Backup deleted: ${filename}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to delete backup ${filename}:`, error);
|
||||
@@ -434,34 +522,6 @@ export class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* S'assure que le dossier de backup existe
|
||||
*/
|
||||
private async ensureBackupDirectory(): Promise<void> {
|
||||
const currentBackupPath = this.getCurrentBackupPath();
|
||||
try {
|
||||
await fs.access(currentBackupPath);
|
||||
} catch {
|
||||
await fs.mkdir(currentBackupPath, { recursive: true });
|
||||
console.log(`📁 Created backup directory: ${currentBackupPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate la taille de fichier
|
||||
*/
|
||||
private formatFileSize(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la configuration
|
||||
@@ -481,6 +541,29 @@ export class BackupService {
|
||||
backupPath: this.getCurrentBackupPath()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le fichier de log des backups
|
||||
*/
|
||||
async getBackupLogs(maxLines: number = 100): Promise<string[]> {
|
||||
try {
|
||||
const logPath = path.join(this.getCurrentBackupPath(), 'backup.log');
|
||||
|
||||
try {
|
||||
const logContent = await fs.readFile(logPath, 'utf-8');
|
||||
const lines = logContent.trim().split('\n').filter(line => line.length > 0);
|
||||
|
||||
// Retourner les dernières lignes (les plus récentes)
|
||||
return lines.slice(-maxLines).reverse();
|
||||
} catch {
|
||||
// Fichier de log n'existe pas encore
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading backup logs:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton
|
||||
|
||||
@@ -2,8 +2,21 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { backupService } from '@/services/backup';
|
||||
import { backupScheduler } from '@/services/backup-scheduler';
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get('action');
|
||||
|
||||
if (action === 'logs') {
|
||||
const maxLines = parseInt(searchParams.get('maxLines') || '100');
|
||||
const logs = await backupService.getBackupLogs(maxLines);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { logs }
|
||||
});
|
||||
}
|
||||
|
||||
console.log('🔄 API GET /api/backups called');
|
||||
|
||||
// Test de la configuration d'abord
|
||||
@@ -51,7 +64,17 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
switch (action) {
|
||||
case 'create':
|
||||
const backup = await backupService.createBackup('manual');
|
||||
const forceCreate = params.force === true;
|
||||
const backup = await backupService.createBackup('manual', forceCreate);
|
||||
|
||||
if (backup === null) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
message: 'No changes detected since last backup. Use force=true to create anyway.'
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: backup });
|
||||
|
||||
case 'verify':
|
||||
|
||||
Reference in New Issue
Block a user