/** * Transmission RSS Manager * Main Server Application */ const express = require('express'); const path = require('path'); const fs = require('fs').promises; const bodyParser = require('body-parser'); const cors = require('cors'); const morgan = require('morgan'); const http = require('http'); const https = require('https'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcrypt'); const { exec } = require('child_process'); const util = require('util'); const execAsync = util.promisify(exec); const semver = require('semver'); // For semantic version comparison // Import custom modules // Try to import with .js extension first, then fallback to no extension for better compatibility let RssFeedManager, TransmissionClient, PostProcessor; // Always use explicit .js extension when importing our own modules try { RssFeedManager = require('./modules/rss-feed-manager.js'); } catch (err) { console.error('Failed to load RssFeedManager module:', err); process.exit(1); } try { TransmissionClient = require('./modules/transmission-client.js'); } catch (err) { console.error('Failed to load TransmissionClient module:', err); process.exit(1); } try { PostProcessor = require('./modules/post-processor.js'); } catch (err) { console.error('Failed to load PostProcessor module:', err); process.exit(1); } // Constants and configuration const DEFAULT_CONFIG_PATH = '/etc/transmission-rss-manager/config.json'; const FALLBACK_CONFIG_PATH = path.join(__dirname, 'config.json'); const DEFAULT_PORT = 3000; const JWT_SECRET = process.env.JWT_SECRET || 'transmission-rss-manager-secret'; const JWT_EXPIRY = '24h'; // Get the version from package.json (single source of truth) const PACKAGE_JSON = require('./package.json'); const APP_VERSION = PACKAGE_JSON.version; // Create Express app const app = express(); // Middleware app.use(cors()); app.use(bodyParser.json()); app.use(morgan('combined')); app.use(express.static(path.join(__dirname, 'public'))); // Application state let config = null; let transmissionClient = null; let rssFeedManager = null; let postProcessor = null; let isInitialized = false; let server = null; /** * Initialize the application with configuration */ async function initializeApp() { try { // Load configuration config = await loadConfig(); console.log('Configuration loaded'); // Initialize transmission client transmissionClient = new TransmissionClient(config); console.log('Transmission client initialized'); // Initialize RSS feed manager // Ensure config has the feeds property (mapped from rssFeeds) const rssFeedManagerConfig = { ...config, feeds: config.rssFeeds || [] }; rssFeedManager = new RssFeedManager(rssFeedManagerConfig); await rssFeedManager.start(); console.log('RSS feed manager started'); // Initialize post processor postProcessor = new PostProcessor(config, transmissionClient); // Start post processor if auto-processing is enabled if (config.autoProcessing) { postProcessor.start(); console.log('Post-processor auto-started'); } else { console.log('Post-processor not auto-starting (autoProcessing disabled)'); } isInitialized = true; console.log('Application initialized successfully'); } catch (error) { console.error('Failed to initialize application:', error); throw error; } } /** * Load configuration from file * @returns {Promise} Configuration object */ async function loadConfig() { try { // Define default configuration const defaultConfig = { version: APP_VERSION, // Use the version from package.json installPath: __dirname, // Store the path to the installation directory transmissionConfig: { host: 'localhost', port: 9091, username: '', password: '', path: '/transmission/rpc' }, remoteConfig: { isRemote: false, directoryMapping: {} }, destinationPaths: { movies: '/mnt/media/movies', tvShows: '/mnt/media/tvshows', music: '/mnt/media/music', books: '/mnt/media/books', magazines: '/mnt/media/magazines', software: '/mnt/media/software' }, seedingRequirements: { minRatio: 1.0, minTimeMinutes: 60, checkIntervalSeconds: 300 }, processingOptions: { enableBookSorting: true, extractArchives: true, deleteArchives: true, createCategoryFolders: true, ignoreSample: true, ignoreExtras: true, renameFiles: true, autoReplaceUpgrades: true, removeDuplicates: true, keepOnlyBestVersion: true }, rssFeeds: [], rssUpdateIntervalMinutes: 60, autoProcessing: false, securitySettings: { authEnabled: false, httpsEnabled: false, sslCertPath: "", sslKeyPath: "", users: [] }, port: DEFAULT_PORT, logLevel: "info" }; // Try primary config location first try { // Try to read existing config from primary location console.log(`Trying to load config from: ${DEFAULT_CONFIG_PATH}`); const configData = await fs.readFile(DEFAULT_CONFIG_PATH, 'utf8'); const loadedConfig = JSON.parse(configData); // Use recursive merge function to merge configs const mergedConfig = mergeConfigs(defaultConfig, loadedConfig); // If version is different, save updated config if (loadedConfig.version !== APP_VERSION) { console.log(`Updating config from version ${loadedConfig.version || 'unknown'} to ${APP_VERSION}`); mergedConfig.version = APP_VERSION; await saveConfig(mergedConfig); } console.log(`Config loaded from ${DEFAULT_CONFIG_PATH}`); return mergedConfig; } catch (primaryError) { // If file not found in primary location, try fallback location if (primaryError.code === 'ENOENT') { console.log(`Config not found at ${DEFAULT_CONFIG_PATH}, trying fallback location...`); try { const fallbackData = await fs.readFile(FALLBACK_CONFIG_PATH, 'utf8'); const fallbackConfig = JSON.parse(fallbackData); // Merge configs const mergedConfig = mergeConfigs(defaultConfig, fallbackConfig); // If version is different, save updated config if (fallbackConfig.version !== APP_VERSION) { console.log(`Updating config from version ${fallbackConfig.version || 'unknown'} to ${APP_VERSION}`); mergedConfig.version = APP_VERSION; } // Try to save to primary location, but don't fail if we can't try { // Create directory if it doesn't exist await fs.mkdir(path.dirname(DEFAULT_CONFIG_PATH), { recursive: true }); await fs.writeFile(DEFAULT_CONFIG_PATH, JSON.stringify(mergedConfig, null, 2), 'utf8'); console.log(`Migrated config from ${FALLBACK_CONFIG_PATH} to ${DEFAULT_CONFIG_PATH}`); } catch (saveError) { console.warn(`Could not save config to ${DEFAULT_CONFIG_PATH}: ${saveError.message}`); console.warn('Continuing with fallback config location'); } console.log(`Config loaded from fallback location: ${FALLBACK_CONFIG_PATH}`); return mergedConfig; } catch (fallbackError) { // If file not found in fallback location, create default config if (fallbackError.code === 'ENOENT') { console.log('Config file not found in either location, creating default configuration'); // Try to save to primary location first try { await fs.mkdir(path.dirname(DEFAULT_CONFIG_PATH), { recursive: true }); await fs.writeFile(DEFAULT_CONFIG_PATH, JSON.stringify(defaultConfig, null, 2), 'utf8'); console.log(`Created default config at ${DEFAULT_CONFIG_PATH}`); } catch (saveError) { console.warn(`Could not save config to ${DEFAULT_CONFIG_PATH}: ${saveError.message}`); console.warn('Saving to fallback location instead'); // Save to fallback location instead await fs.writeFile(FALLBACK_CONFIG_PATH, JSON.stringify(defaultConfig, null, 2), 'utf8'); console.log(`Created default config at ${FALLBACK_CONFIG_PATH}`); } return defaultConfig; } // For other errors, rethrow throw fallbackError; } } // For other errors, rethrow throw primaryError; } } catch (error) { console.error('Error loading configuration:', error); throw error; } } /** * Recursively merge configurations * @param {Object} defaultConfig - Default configuration object * @param {Object} userConfig - User configuration object * @returns {Object} Merged configuration object */ function mergeConfigs(defaultConfig, userConfig) { // Create a new object to avoid modifying the originals const merged = {}; // Add all properties from default config for (const key in defaultConfig) { // If property exists in user config if (key in userConfig) { // If both are objects (not arrays or null), recursively merge if ( typeof defaultConfig[key] === 'object' && defaultConfig[key] !== null && !Array.isArray(defaultConfig[key]) && typeof userConfig[key] === 'object' && userConfig[key] !== null && !Array.isArray(userConfig[key]) ) { merged[key] = mergeConfigs(defaultConfig[key], userConfig[key]); } else { // Use user value merged[key] = userConfig[key]; } } else { // Property doesn't exist in user config, use default merged[key] = defaultConfig[key]; } } // Add any properties from user config that aren't in default for (const key in userConfig) { if (!(key in defaultConfig)) { merged[key] = userConfig[key]; } } return merged; } /** * Save configuration to file * @param {Object} config - Configuration object * @returns {Promise} */ async function saveConfig(config) { try { // Always try to save to the primary config location first try { // Make sure directory exists await fs.mkdir(path.dirname(DEFAULT_CONFIG_PATH), { recursive: true }); await fs.writeFile(DEFAULT_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); console.log(`Configuration saved to ${DEFAULT_CONFIG_PATH}`); return; } catch (primaryError) { console.warn(`Could not save config to ${DEFAULT_CONFIG_PATH}: ${primaryError.message}`); console.warn('Trying fallback location...'); // If we couldn't save to the primary location, try the fallback await fs.writeFile(FALLBACK_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); console.log(`Configuration saved to fallback location: ${FALLBACK_CONFIG_PATH}`); } } catch (error) { console.error('Error saving configuration:', error); throw error; } } /** * Start the server */ async function startServer() { try { // Initialize the application await initializeApp(); // Determine port const port = config.port || process.env.PORT || DEFAULT_PORT; // Create server based on configuration if (config.securitySettings?.httpsEnabled && config.securitySettings?.sslCertPath && config.securitySettings?.sslKeyPath) { try { const sslOptions = { key: await fs.readFile(config.securitySettings.sslKeyPath), cert: await fs.readFile(config.securitySettings.sslCertPath) }; server = https.createServer(sslOptions, app); console.log('HTTPS server created'); } catch (error) { console.error('Error creating HTTPS server, falling back to HTTP:', error); server = http.createServer(app); } } else { server = http.createServer(app); console.log('HTTP server created'); } // Start the server server.listen(port, () => { console.log(`Transmission RSS Manager server running on port ${port}`); }); } catch (error) { console.error('Failed to start server:', error); process.exit(1); } } // Authentication middleware function authenticateJWT(req, res, next) { // Skip authentication if not enabled if (!config.securitySettings?.authEnabled) { return next(); } // Get token from header const authHeader = req.headers.authorization; if (!authHeader) { return res.status(401).json({ success: false, message: 'Authentication required' }); } const token = authHeader.split(' ')[1]; if (!token) { return res.status(401).json({ success: false, message: 'Authentication token required' }); } // Verify token jwt.verify(token, JWT_SECRET, (err, user) => { if (err) { return res.status(403).json({ success: false, message: 'Invalid or expired token' }); } req.user = user; next(); }); } // API Routes // Status endpoint app.get('/api/status', authenticateJWT, async (req, res) => { try { if (!isInitialized) { return res.status(503).json({ success: false, message: 'Application is initializing, please try again' }); } // Get Transmission connection status const transmissionStatus = await transmissionClient.getStatus(); // Return status information res.json({ success: true, status: 'running', version: '2.0.6', transmissionConnected: transmissionStatus.connected, transmissionVersion: transmissionStatus.version, transmissionStats: { downloadSpeed: transmissionStatus.downloadSpeed, uploadSpeed: transmissionStatus.uploadSpeed, activeTorrentCount: transmissionStatus.activeTorrentCount, torrentCount: transmissionStatus.torrentCount }, rssFeedManagerActive: !!rssFeedManager.updateIntervalId, postProcessorActive: !!postProcessor.processIntervalId, config: { autoProcessing: config.autoProcessing, rssEnabled: Array.isArray(config.rssFeeds) && config.rssFeeds.length > 0 } }); } catch (error) { console.error('Error getting status:', error); res.status(500).json({ success: false, message: `Error getting status: ${error.message}` }); } }); // Configuration endpoint app.get('/api/config', authenticateJWT, (req, res) => { // Return configuration (remove sensitive information) const sanitizedConfig = { ...config }; // Remove passwords from response if (sanitizedConfig.transmissionConfig) { delete sanitizedConfig.transmissionConfig.password; } if (sanitizedConfig.securitySettings?.users) { sanitizedConfig.securitySettings.users = sanitizedConfig.securitySettings.users.map(user => ({ ...user, password: undefined })); } res.json(sanitizedConfig); }); app.post('/api/config', authenticateJWT, async (req, res) => { try { // Merge the new config with the existing one const newConfig = { ...config, ...req.body }; // Preserve existing Transmission configuration values when not explicitly provided if (newConfig.transmissionConfig && config.transmissionConfig) { // First create a copy of the existing configuration const preservedTransConfig = { ...config.transmissionConfig }; // Only update values that are explicitly provided and not empty if (!req.body.transmissionConfig?.host) { newConfig.transmissionConfig.host = preservedTransConfig.host; } if (!req.body.transmissionConfig?.port) { newConfig.transmissionConfig.port = preservedTransConfig.port; } if (!req.body.transmissionConfig?.path) { newConfig.transmissionConfig.path = preservedTransConfig.path; } if (!req.body.transmissionConfig?.username) { newConfig.transmissionConfig.username = preservedTransConfig.username; } // Always preserve password if not provided if (!newConfig.transmissionConfig.password) { newConfig.transmissionConfig.password = preservedTransConfig.password; } } // Preserve remote configuration settings if not explicitly provided if (newConfig.remoteConfig && config.remoteConfig) { // Make sure isRemote setting is preserved if not explicitly set if (req.body.remoteConfig?.isRemote === undefined) { newConfig.remoteConfig.isRemote = config.remoteConfig.isRemote; } // Preserve directory mappings if not provided if (!req.body.remoteConfig?.directoryMapping && config.remoteConfig.directoryMapping) { newConfig.remoteConfig.directoryMapping = { ...config.remoteConfig.directoryMapping }; } } // Keep user passwords if (newConfig.securitySettings?.users && config.securitySettings?.users) { newConfig.securitySettings.users = newConfig.securitySettings.users.map(newUser => { const existingUser = config.securitySettings.users.find(u => u.username === newUser.username); if (existingUser && !newUser.password) { return { ...newUser, password: existingUser.password }; } return newUser; }); } // Save the updated config await saveConfig(newConfig); // Update the application state config = newConfig; // Restart components if necessary if (rssFeedManager) { rssFeedManager.stop(); rssFeedManager = new RssFeedManager(config); await rssFeedManager.start(); } if (transmissionClient) { transmissionClient = new TransmissionClient(config); } if (postProcessor) { postProcessor.stop(); postProcessor = new PostProcessor(config, transmissionClient); if (config.autoProcessing) { postProcessor.start(); } } res.json({ success: true, message: 'Configuration updated successfully' }); } catch (error) { console.error('Error updating configuration:', error); res.status(500).json({ success: false, message: `Error updating configuration: ${error.message}` }); } }); // RSS Feed API app.get('/api/rss/feeds', authenticateJWT, (req, res) => { try { const feeds = rssFeedManager.getAllFeeds(); res.json({ success: true, data: feeds }); } catch (error) { res.status(500).json({ success: false, message: `Error getting RSS feeds: ${error.message}` }); } }); app.post('/api/rss/feeds', authenticateJWT, async (req, res) => { try { const feed = req.body; const newFeed = rssFeedManager.addFeed(feed); res.json({ success: true, message: 'RSS feed added successfully', data: newFeed }); } catch (error) { res.status(500).json({ success: false, message: `Error adding RSS feed: ${error.message}` }); } }); app.put('/api/rss/feeds/:id', authenticateJWT, async (req, res) => { try { const feedId = req.params.id; const updates = req.body; const result = rssFeedManager.updateFeedConfig(feedId, updates); if (result) { res.json({ success: true, message: 'RSS feed updated successfully' }); } else { res.status(404).json({ success: false, message: 'RSS feed not found' }); } } catch (error) { res.status(500).json({ success: false, message: `Error updating RSS feed: ${error.message}` }); } }); app.delete('/api/rss/feeds/:id', authenticateJWT, async (req, res) => { try { const feedId = req.params.id; const result = rssFeedManager.removeFeed(feedId); if (result) { res.json({ success: true, message: 'RSS feed deleted successfully' }); } else { res.status(404).json({ success: false, message: 'RSS feed not found' }); } } catch (error) { res.status(500).json({ success: false, message: `Error deleting RSS feed: ${error.message}` }); } }); app.get('/api/rss/items', authenticateJWT, (req, res) => { try { let items; const filter = req.query.filter; if (filter === 'undownloaded') { items = rssFeedManager.getUndownloadedItems(); } else { items = rssFeedManager.getAllItems(); } res.json({ success: true, data: items }); } catch (error) { res.status(500).json({ success: false, message: `Error getting RSS items: ${error.message}` }); } }); app.post('/api/rss/download', authenticateJWT, async (req, res) => { try { const { itemId } = req.body; if (!itemId) { return res.status(400).json({ success: false, message: 'Item ID is required' }); } // Find the item const items = rssFeedManager.getAllItems(); const item = items.find(i => i.id === itemId); if (!item) { return res.status(404).json({ success: false, message: 'Item not found' }); } // Download the item const result = await rssFeedManager.downloadItem(item, transmissionClient.client); if (result.success) { res.json({ success: true, message: 'Item added to Transmission', data: result.result }); } else { res.status(500).json({ success: false, message: result.message }); } } catch (error) { res.status(500).json({ success: false, message: `Error downloading item: ${error.message}` }); } }); app.post('/api/rss/update', authenticateJWT, async (req, res) => { try { const result = await rssFeedManager.updateAllFeeds(); res.json({ success: true, message: 'RSS feeds updated successfully', data: result }); } catch (error) { res.status(500).json({ success: false, message: `Error updating RSS feeds: ${error.message}` }); } }); // Transmission API app.get('/api/transmission/torrents', authenticateJWT, async (req, res) => { try { const result = await transmissionClient.getTorrents(); if (result.success) { res.json({ success: true, data: result.torrents }); } else { res.status(500).json({ success: false, message: result.error }); } } catch (error) { res.status(500).json({ success: false, message: `Error getting torrents: ${error.message}` }); } }); app.post('/api/transmission/add', authenticateJWT, async (req, res) => { try { const { url } = req.body; if (!url) { return res.status(400).json({ success: false, message: 'URL is required' }); } const result = await transmissionClient.addTorrent(url); if (result.success) { res.json({ success: true, message: 'Torrent added successfully', data: result }); } else { res.status(500).json({ success: false, message: result.error }); } } catch (error) { res.status(500).json({ success: false, message: `Error adding torrent: ${error.message}` }); } }); app.post('/api/transmission/start', authenticateJWT, async (req, res) => { try { const { ids } = req.body; if (!ids) { return res.status(400).json({ success: false, message: 'Torrent ID(s) required' }); } const result = await transmissionClient.startTorrents(ids); if (result.success) { res.json({ success: true, message: result.message }); } else { res.status(500).json({ success: false, message: result.error }); } } catch (error) { res.status(500).json({ success: false, message: `Error starting torrent: ${error.message}` }); } }); app.post('/api/transmission/stop', authenticateJWT, async (req, res) => { try { const { ids } = req.body; if (!ids) { return res.status(400).json({ success: false, message: 'Torrent ID(s) required' }); } const result = await transmissionClient.stopTorrents(ids); if (result.success) { res.json({ success: true, message: result.message }); } else { res.status(500).json({ success: false, message: result.error }); } } catch (error) { res.status(500).json({ success: false, message: `Error stopping torrent: ${error.message}` }); } }); app.post('/api/transmission/remove', authenticateJWT, async (req, res) => { try { const { ids, deleteLocalData } = req.body; if (!ids) { return res.status(400).json({ success: false, message: 'Torrent ID(s) required' }); } const result = await transmissionClient.removeTorrents(ids, deleteLocalData); if (result.success) { res.json({ success: true, message: result.message }); } else { res.status(500).json({ success: false, message: result.error }); } } catch (error) { res.status(500).json({ success: false, message: `Error removing torrent: ${error.message}` }); } }); app.post('/api/transmission/test', authenticateJWT, async (req, res) => { try { const { host, port, username, password, path: rpcPath } = req.body; // Debug info console.log('Testing Transmission connection with params:', { host: host || 'from config', port: port || 'from config', username: username ? 'provided' : 'from config', path: rpcPath || 'from config', }); // Create a temporary client for testing const testConfig = { transmissionConfig: { host: host || config.transmissionConfig.host, port: port || config.transmissionConfig.port, username: username || config.transmissionConfig.username, password: password || config.transmissionConfig.password, path: rpcPath || config.transmissionConfig.path }, // Also include remoteConfig to ensure proper remote handling remoteConfig: { // If host is provided and different from localhost, set isRemote to true isRemote: host && host !== 'localhost' ? true : config.remoteConfig?.isRemote || false, directoryMapping: config.remoteConfig?.directoryMapping || {} } }; // Log the actual test config (without password) console.log('Test configuration:', { host: testConfig.transmissionConfig.host, port: testConfig.transmissionConfig.port, path: testConfig.transmissionConfig.path, isRemote: testConfig.remoteConfig.isRemote }); try { const testClient = new TransmissionClient(testConfig); const status = await testClient.getStatus(); if (status.connected) { res.json({ success: true, message: 'Successfully connected to Transmission server', data: { version: status.version, rpcVersion: status.rpcVersion } }); } else { res.status(400).json({ success: false, message: `Failed to connect: ${status.error}` }); } } catch (error) { console.error('Error testing Transmission connection:', error); res.status(500).json({ success: false, message: `Error testing connection: ${error.message}` }); } } catch (error) { res.status(500).json({ success: false, message: `Connection test failed: ${error.message}` }); } }); // Post-Processor API app.post('/api/post-processor/start', authenticateJWT, (req, res) => { try { const result = postProcessor.start(); if (result) { res.json({ success: true, message: 'Post-processor started successfully' }); } else { res.status(400).json({ success: false, message: 'Post-processor is already running' }); } } catch (error) { res.status(500).json({ success: false, message: `Error starting post-processor: ${error.message}` }); } }); app.post('/api/post-processor/stop', authenticateJWT, (req, res) => { try { const result = postProcessor.stop(); if (result) { res.json({ success: true, message: 'Post-processor stopped successfully' }); } else { res.status(400).json({ success: false, message: 'Post-processor is not running' }); } } catch (error) { res.status(500).json({ success: false, message: `Error stopping post-processor: ${error.message}` }); } }); // Media Library API app.get('/api/media/library', authenticateJWT, async (req, res) => { try { const query = req.query.query; const library = await getMediaLibrary(query); res.json({ success: true, data: library }); } catch (error) { res.status(500).json({ success: false, message: `Error getting media library: ${error.message}` }); } }); /** * Get media library content * @param {string} searchQuery - Optional search query * @returns {Promise} Media library content */ async function getMediaLibrary(searchQuery) { const library = { movies: [], tvShows: [], music: [], books: [], magazines: [], software: [] }; // Get destination paths from config const destinations = config.destinationPaths || {}; // Process each category for (const [category, destinationPath] of Object.entries(destinations)) { if (!destinationPath || typeof destinationPath !== 'string') { continue; } try { // Check if directory exists await fs.access(destinationPath); // Get directory listing const files = await fs.readdir(destinationPath, { withFileTypes: true }); // Process each file/directory for (const file of files) { const fullPath = path.join(destinationPath, file.name); try { const stats = await fs.stat(fullPath); // Create an item object const item = { name: file.name, path: fullPath, isDirectory: stats.isDirectory(), size: stats.size, added: stats.ctime.toISOString() }; // Filter by search query if provided if (searchQuery && !file.name.toLowerCase().includes(searchQuery.toLowerCase())) { continue; } // Add to the appropriate category switch (category) { case 'movies': library.movies.push(item); break; case 'tvShows': library.tvShows.push(item); break; case 'music': library.music.push(item); break; case 'books': library.books.push(item); break; case 'magazines': library.magazines.push(item); break; case 'software': library.software.push(item); break; } } catch (itemError) { console.error(`Error processing media library item ${fullPath}:`, itemError); } } // Sort by most recently added const sortKey = category === 'tvShows' ? 'tvShows' : category; if (Array.isArray(library[sortKey])) { library[sortKey].sort((a, b) => new Date(b.added) - new Date(a.added)); } } catch (error) { console.error(`Error accessing media directory ${destinationPath}:`, error); } } return library; } // Authentication API app.post('/api/auth/login', async (req, res) => { try { // Skip authentication if not enabled if (!config.securitySettings?.authEnabled) { return res.json({ success: true, token: jwt.sign({ username: 'admin' }, JWT_SECRET, { expiresIn: JWT_EXPIRY }), user: { username: 'admin', role: 'admin' } }); } const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ success: false, message: 'Username and password are required' }); } // Find user const users = config.securitySettings?.users || []; const user = users.find(u => u.username === username); if (!user) { return res.status(401).json({ success: false, message: 'Invalid credentials' }); } // Check password let passwordMatch = false; if (user.password.startsWith('$2')) { // Bcrypt hashed password passwordMatch = await bcrypt.compare(password, user.password); } else { // Legacy plain text password passwordMatch = password === user.password; // Update to hashed password if (passwordMatch) { user.password = await bcrypt.hash(password, 10); await saveConfig(config); } } if (!passwordMatch) { return res.status(401).json({ success: false, message: 'Invalid credentials' }); } // Generate token const token = jwt.sign( { username: user.username, role: user.role || 'user' }, JWT_SECRET, { expiresIn: JWT_EXPIRY } ); res.json({ success: true, token, user: { username: user.username, role: user.role || 'user' } }); } catch (error) { res.status(500).json({ success: false, message: `Authentication error: ${error.message}` }); } }); app.get('/api/auth/validate', authenticateJWT, (req, res) => { res.json({ success: true, user: req.user }); }); // System status and update endpoints app.get('/api/system/status', authenticateJWT, async (req, res) => { try { // Get system uptime const uptimeSeconds = Math.floor(process.uptime()); const hours = Math.floor(uptimeSeconds / 3600); const minutes = Math.floor((uptimeSeconds % 3600) / 60); const seconds = uptimeSeconds % 60; const uptime = `${hours}h ${minutes}m ${seconds}s`; // Check transmission connection let transmissionStatus = 'Connected'; try { const status = await transmissionClient.getStatus(); if (!status.connected) { transmissionStatus = 'Disconnected'; } } catch (err) { transmissionStatus = 'Disconnected'; } res.json({ status: 'success', data: { version: APP_VERSION, uptime, transmissionStatus } }); } catch (error) { console.error('Error getting system status:', error); res.status(500).json({ status: 'error', message: 'Failed to get system status' }); } }); // Check for updates app.get('/api/system/check-updates', authenticateJWT, async (req, res) => { try { // Check if git is available and if this is a git repository const isGitRepo = fs.existsSync(path.join(__dirname, '.git')); if (!isGitRepo) { return res.json({ status: 'error', message: 'This installation is not set up for updates. Please use the bootstrap installer.' }); } // Get current version const currentVersion = APP_VERSION; // Check for test mode flag which forces update availability for testing const testMode = req.query.test === 'true'; if (testMode) { // In test mode, always return that an update is available return res.json({ status: 'success', data: { updateAvailable: true, currentVersion, remoteVersion: '2.1.0-test', commitsBehind: 1, testMode: true } }); } try { // Normal mode - fetch latest updates without applying them await execAsync('git fetch'); // Check if we're behind the remote repository const { stdout } = await execAsync('git rev-list HEAD..origin/main --count'); const behindCount = parseInt(stdout.trim()); if (behindCount > 0) { // Get the new version from the remote package.json const { stdout: remotePackageJson } = await execAsync('git show origin/main:package.json'); const remotePackage = JSON.parse(remotePackageJson); const remoteVersion = remotePackage.version; // Compare versions semantically - only consider it an update if remote version is higher const isNewerVersion = semver.gt(remoteVersion, currentVersion); return res.json({ status: 'success', data: { updateAvailable: isNewerVersion, currentVersion, remoteVersion, commitsBehind: behindCount, newerVersion: isNewerVersion } }); } else { return res.json({ status: 'success', data: { updateAvailable: false, currentVersion } }); } } catch (gitError) { console.error('Git error checking for updates:', gitError); // Even if git commands fail, return a valid response with error information return res.json({ status: 'error', message: 'Error checking git repository: ' + gitError.message, data: { updateAvailable: false, currentVersion, error: true, errorDetails: gitError.message } }); } } catch (error) { console.error('Error checking for updates:', error); res.status(500).json({ status: 'error', message: 'Failed to check for updates: ' + error.message }); } }); // Apply updates app.post('/api/system/update', authenticateJWT, async (req, res) => { try { // Check if git is available and if this is a git repository const isGitRepo = fs.existsSync(path.join(__dirname, '.git')); if (!isGitRepo) { return res.status(400).json({ status: 'error', message: 'This installation is not set up for updates. Please use the bootstrap installer.' }); } // Run the update script const updateScriptPath = path.join(__dirname, 'scripts', 'update.sh'); // Make sure the update script is executable await execAsync(`chmod +x ${updateScriptPath}`); // Execute the update script const { stdout, stderr } = await execAsync(updateScriptPath); // If we get here, the update was successful // The service will be restarted by the update script res.json({ status: 'success', message: 'Update applied successfully. The service will restart.', data: { output: stdout, errors: stderr } }); } catch (error) { console.error('Error applying update:', error); res.status(500).json({ status: 'error', message: 'Failed to apply update: ' + error.message }); } }); // Catch-all route for SPA app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); // Start the server startServer(); // Handle process termination process.on('SIGTERM', () => { console.log('SIGTERM received, shutting down gracefully'); if (server) { server.close(() => { console.log('Server closed'); process.exit(0); }); } else { process.exit(0); } }); process.on('SIGINT', () => { console.log('SIGINT received, shutting down gracefully'); if (server) { server.close(() => { console.log('Server closed'); process.exit(0); }); } else { process.exit(0); } }); module.exports = app;