const express = require('express'); const router = express.Router(); const { db } = require('../database/db'); const { authenticate } = require('../middleware/auth'); const { backupLimiter, heavyLimiter, readLimiter } = require('../middleware/rateLimiter'); const fs = require('fs').promises; const path = require('path'); const archiver = require('archiver'); const unzipper = require('unzipper'); const { promisify } = require('util'); const dbAll = promisify(db.all.bind(db)); const dbRun = promisify(db.run.bind(db)); const BACKUP_DIR = path.join(__dirname, '../../data/backups'); // Ensure backup directory exists const ensureBackupDir = async () => { try { await fs.mkdir(BACKUP_DIR, { recursive: true }); } catch (error) { console.error('Failed to create backup directory:', error); } }; /** * GET /api/backup/list * List all backups for the current user */ router.get('/list', authenticate, readLimiter, async (req, res) => { try { await ensureBackupDir(); const userId = req.user.id; const username = req.user.username; const files = await fs.readdir(BACKUP_DIR); const userBackups = files.filter(f => (f.startsWith(`StreamFlow_Backup_${username}_`) || f.startsWith(`backup_user${userId}_`)) && f.endsWith('.zip') ); const backupList = await Promise.all( userBackups.map(async (filename) => { const filePath = path.join(BACKUP_DIR, filename); const stats = await fs.stat(filePath); // Parse filename: StreamFlow_Backup_{username}_{timestamp}.zip or old format backup_user{id}_{timestamp}.zip let match = filename.match(/StreamFlow_Backup_[^_]+_(\d+)\.zip/); if (!match) { match = filename.match(/backup_user\d+_(\d+)\.zip/); } const filenameTimestamp = match ? parseInt(match[1]) : 0; // Use file's actual mtime (modification time) which is more reliable const actualTimestamp = stats.mtimeMs; return { filename, size: stats.size, created: new Date(actualTimestamp).toISOString(), timestamp: actualTimestamp }; }) ); // Sort by timestamp descending (newest first) backupList.sort((a, b) => b.timestamp - a.timestamp); res.json(backupList); } catch (error) { console.error('Failed to list backups:', error); res.status(500).json({ error: 'Failed to list backups' }); } }); /** * POST /api/backup/create * Create a new backup of user data */ router.post('/create', authenticate, backupLimiter, async (req, res) => { try { await ensureBackupDir(); const userId = req.user.id; const username = req.user.username; const timestamp = Date.now(); const filename = `StreamFlow_Backup_${username}_${timestamp}.zip`; const backupPath = path.join(BACKUP_DIR, filename); // Create write stream for zip const output = require('fs').createWriteStream(backupPath); const archive = archiver('zip', { zlib: { level: 9 } }); output.on('close', () => { res.json({ success: true, filename, size: archive.pointer(), created: new Date(timestamp).toISOString() }); }); archive.on('error', (err) => { throw err; }); archive.pipe(output); // Export user data - CWE-532: Exclude password and sensitive fields const userData = await dbAll( `SELECT id, username, email, role, two_factor_enabled, is_active, created_at, updated_at, last_login_at, last_login_ip, password_changed_at, password_expires_at FROM users WHERE id = ?`, [userId] ); archive.append(JSON.stringify(userData, null, 2), { name: 'user.json' }); // Export playlists const playlists = await dbAll('SELECT * FROM playlists WHERE user_id = ?', [userId]); archive.append(JSON.stringify(playlists, null, 2), { name: 'playlists.json' }); // Export channels const channels = await dbAll( `SELECT DISTINCT c.* FROM channels c JOIN playlists p ON c.playlist_id = p.id WHERE p.user_id = ?`, [userId] ); archive.append(JSON.stringify(channels, null, 2), { name: 'channels.json' }); // Export favorites const favorites = await dbAll('SELECT * FROM favorites WHERE user_id = ?', [userId]); archive.append(JSON.stringify(favorites, null, 2), { name: 'favorites.json' }); // Export custom channel logos const customLogos = await dbAll( `SELECT c.id, c.name, c.custom_logo FROM channels c JOIN playlists p ON c.playlist_id = p.id WHERE p.user_id = ? AND c.custom_logo IS NOT NULL`, [userId] ); // Add custom logo files for (const channel of customLogos) { if (channel.custom_logo) { const logoPath = path.join(__dirname, '../../data/logos', channel.custom_logo); try { const logoExists = await fs.access(logoPath).then(() => true).catch(() => false); if (logoExists) { archive.file(logoPath, { name: `logos/${channel.custom_logo}` }); } } catch (err) { console.error(`Failed to add logo ${channel.custom_logo}:`, err); } } } // Export m3u files const m3uFiles = await dbAll('SELECT * FROM m3u_files WHERE user_id = ?', [userId]); archive.append(JSON.stringify(m3uFiles, null, 2), { name: 'm3u_files.json' }); // Add actual m3u files for (const m3uFile of m3uFiles) { if (m3uFile.file_path) { const m3uPath = path.join(__dirname, '../../data/playlists', m3uFile.file_path); try { const m3uExists = await fs.access(m3uPath).then(() => true).catch(() => false); if (m3uExists) { archive.file(m3uPath, { name: `m3u_files/${m3uFile.file_path}` }); } } catch (err) { console.error(`Failed to add m3u file ${m3uFile.file_path}:`, err); } } } // Export user settings (stream settings, preferences) const settings = { created: new Date(timestamp).toISOString(), version: '1.0', userId: userId, username: req.user.username }; archive.append(JSON.stringify(settings, null, 2), { name: 'backup_info.json' }); // Finalize the archive await archive.finalize(); } catch (error) { console.error('Failed to create backup:', error); res.status(500).json({ error: 'Failed to create backup' }); } }); /** * GET /api/backup/download/:filename * Download a backup file */ router.get('/download/:filename', authenticate, heavyLimiter, async (req, res) => { try { const userId = req.user.id; const username = req.user.username; const { filename } = req.params; // Validate filename belongs to user (support both old and new format) const isOldFormat = filename.startsWith(`backup_user${userId}_`); const isNewFormat = filename.startsWith(`StreamFlow_Backup_${username}_`); if ((!isOldFormat && !isNewFormat) || !filename.endsWith('.zip')) { return res.status(403).json({ error: 'Unauthorized' }); } const filePath = path.join(BACKUP_DIR, filename); // Check if file exists try { await fs.access(filePath); } catch (err) { return res.status(404).json({ error: 'Backup not found' }); } res.download(filePath, filename); } catch (error) { console.error('Failed to download backup:', error); res.status(500).json({ error: 'Failed to download backup' }); } }); /** * DELETE /api/backup/:filename * Delete a backup file */ router.delete('/:filename', authenticate, readLimiter, async (req, res) => { try { const userId = req.user.id; const username = req.user.username; const { filename } = req.params; // Validate filename belongs to user (support both old and new format) const isOldFormat = filename.startsWith(`backup_user${userId}_`); const isNewFormat = filename.startsWith(`StreamFlow_Backup_${username}_`); if ((!isOldFormat && !isNewFormat) || !filename.endsWith('.zip')) { return res.status(403).json({ error: 'Unauthorized' }); } const filePath = path.join(BACKUP_DIR, filename); await fs.unlink(filePath); res.json({ success: true, message: 'Backup deleted' }); } catch (error) { console.error('Failed to delete backup:', error); res.status(500).json({ error: 'Failed to delete backup' }); } }); /** * POST /api/backup/upload * Upload a backup file for restoration */ router.post('/upload', authenticate, heavyLimiter, async (req, res) => { try { await ensureBackupDir(); if (!req.files || !req.files.backup) { return res.status(400).json({ error: 'No backup file provided' }); } const userId = req.user.id; const username = req.user.username; const backupFile = req.files.backup; const timestamp = Date.now(); const filename = `StreamFlow_Backup_${username}_${timestamp}.zip`; const uploadPath = path.join(BACKUP_DIR, filename); await backupFile.mv(uploadPath); const stats = await fs.stat(uploadPath); res.json({ success: true, filename, size: stats.size, created: new Date(timestamp).toISOString() }); } catch (error) { console.error('Failed to upload backup:', error); res.status(500).json({ error: 'Failed to upload backup' }); } }); /** * POST /api/backup/restore/:filename * Restore data from a backup file */ router.post('/restore/:filename', authenticate, backupLimiter, async (req, res) => { try { const userId = req.user.id; const username = req.user.username; const { filename } = req.params; // Validate filename belongs to user (support both old and new format) const isOldFormat = filename.startsWith(`backup_user${userId}_`); const isNewFormat = filename.startsWith(`StreamFlow_Backup_${username}_`); if ((!isOldFormat && !isNewFormat) || !filename.endsWith('.zip')) { return res.status(403).json({ error: 'Unauthorized' }); } const backupPath = path.join(BACKUP_DIR, filename); const extractPath = path.join(BACKUP_DIR, `extract_${userId}_${Date.now()}`); // Check if backup exists try { await fs.access(backupPath); } catch (err) { return res.status(404).json({ error: 'Backup not found' }); } // Extract backup await fs.mkdir(extractPath, { recursive: true }); await require('fs') .createReadStream(backupPath) .pipe(unzipper.Extract({ path: extractPath })) .promise(); // Read backup data const playlistsData = JSON.parse(await fs.readFile(path.join(extractPath, 'playlists.json'), 'utf8')); const channelsData = JSON.parse(await fs.readFile(path.join(extractPath, 'channels.json'), 'utf8')); const favoritesData = JSON.parse(await fs.readFile(path.join(extractPath, 'favorites.json'), 'utf8')); // Start transaction-like operations let restoredPlaylists = 0; let restoredChannels = 0; let restoredFavorites = 0; // Restore playlists for (const playlist of playlistsData) { try { await dbRun( `INSERT OR IGNORE INTO playlists (name, url, username, password, user_id, created_at) VALUES (?, ?, ?, ?, ?, ?)`, [playlist.name, playlist.url, playlist.username, playlist.password, userId, playlist.created_at] ); restoredPlaylists++; } catch (err) { console.error('Failed to restore playlist:', err); } } // Get new playlist IDs mapping const newPlaylists = await dbAll('SELECT id, name FROM playlists WHERE user_id = ?', [userId]); const playlistMap = {}; playlistsData.forEach((oldP, idx) => { const newP = newPlaylists.find(p => p.name === oldP.name); if (newP) playlistMap[oldP.id] = newP.id; }); // Restore channels for (const channel of channelsData) { const newPlaylistId = playlistMap[channel.playlist_id]; if (!newPlaylistId) continue; try { await dbRun( `INSERT OR IGNORE INTO channels (name, url, logo, group_name, playlist_id, custom_logo, is_radio, tvg_id, tvg_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ channel.name, channel.url, channel.logo, channel.group_name, newPlaylistId, channel.custom_logo, channel.is_radio, channel.tvg_id, channel.tvg_name ] ); restoredChannels++; } catch (err) { console.error('Failed to restore channel:', err); } } // Restore custom logos const logosDir = path.join(extractPath, 'logos'); try { const logoFiles = await fs.readdir(logosDir); const targetLogosDir = path.join(__dirname, '../../data/logos'); await fs.mkdir(targetLogosDir, { recursive: true }); for (const logoFile of logoFiles) { const src = path.join(logosDir, logoFile); const dest = path.join(targetLogosDir, logoFile); await fs.copyFile(src, dest); } } catch (err) { // Logos directory might not exist in older backups console.log('No custom logos to restore'); } // Restore favorites (map to new channel IDs) const newChannels = await dbAll( `SELECT c.id, c.name, c.url FROM channels c JOIN playlists p ON c.playlist_id = p.id WHERE p.user_id = ?`, [userId] ); for (const fav of favoritesData) { const oldChannel = channelsData.find(c => c.id === fav.channel_id); if (!oldChannel) continue; const newChannel = newChannels.find(c => c.name === oldChannel.name && c.url === oldChannel.url); if (!newChannel) continue; try { await dbRun( `INSERT OR IGNORE INTO favorites (user_id, channel_id, custom_logo, is_radio) VALUES (?, ?, ?, ?)`, [userId, newChannel.id, fav.custom_logo, fav.is_radio] ); restoredFavorites++; } catch (err) { console.error('Failed to restore favorite:', err); } } // Restore M3U files try { const m3uFilesData = JSON.parse(await fs.readFile(path.join(extractPath, 'm3u_files.json'), 'utf8')); const m3uFilesDir = path.join(extractPath, 'm3u_files'); const targetM3uDir = path.join(__dirname, '../../data/playlists'); await fs.mkdir(targetM3uDir, { recursive: true }); for (const m3uFile of m3uFilesData) { try { // Copy physical file if (m3uFile.file_path) { const src = path.join(m3uFilesDir, m3uFile.file_path); const dest = path.join(targetM3uDir, m3uFile.file_path); await fs.copyFile(src, dest); } // Insert database record await dbRun( `INSERT OR IGNORE INTO m3u_files (user_id, name, file_path, uploaded_at) VALUES (?, ?, ?, ?)`, [userId, m3uFile.name, m3uFile.file_path, m3uFile.uploaded_at] ); } catch (err) { console.error('Failed to restore m3u file:', err); } } } catch (err) { console.log('No M3U files to restore'); } // Cleanup extraction directory await fs.rm(extractPath, { recursive: true, force: true }); res.json({ success: true, message: 'Backup restored successfully', stats: { playlists: restoredPlaylists, channels: restoredChannels, favorites: restoredFavorites } }); } catch (error) { console.error('Failed to restore backup:', error); res.status(500).json({ error: 'Failed to restore backup' }); } }); module.exports = router;