472 lines
16 KiB
JavaScript
472 lines
16 KiB
JavaScript
|
|
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;
|