const express = require('express'); const fs = require('fs'); const path = require('path'); const config = require('../utils/config.js'); const { requireAuth } = require('../utils/middleware.js'); const PATHS = { FRONTEND_DATA: path.join(__dirname, '../../frontend/data'), BASE_DIR: path.join(__dirname, '../../frontend/data') }; const themeRoutes = require('./theme.js'); const themesRoutes = require('./themes.js'); const featuresRoutes = require('./features.js'); const authRoutes = require('./auth-routes.js'); const taskRoutes = require('./task-routes.js'); const serviceRoutes = require('./service-routes.js'); const setupRoutes = require('./setup-routes.js'); const systemRoutes = require('./system-routes.js'); const dockerInfoRoutes = require('./docker-info-routes.js'); const { testConnection } = require('../utils/mail.js'); module.exports = { setup: (app) => { // Auth routes — public (no requireAuth) app.use('/api/auth', authRoutes); // Theme discovery is public so the login overlay can pick the right // palette before the user logs in. app.use('/api/themes', themesRoutes); // Feature/page discovery is public for the same reason — the navigation // kernel needs the page manifest before login to route the first paint. app.use('/api/features', featuresRoutes); // Protected API routes app.use('/api/theme', requireAuth, themeRoutes); app.use('/api/tasks', taskRoutes); // requireAuth applied per-route inside app.use('/api/apps', serviceRoutes); // requireAuth applied per-route inside app.use('/api/setup', setupRoutes); // requireAuth applied per-route inside app.use('/api/system', requireAuth, systemRoutes); // live host metrics (/proc) app.use('/api/system', requireAuth, dockerInfoRoutes); // /containers/*, /storage app.post('/api/test-mail-connection', requireAuth, testConnection); app.post('/api/gluetun/mullvad-wireguard', requireAuth, async (req, res) => { try { const account = String(req.body?.accountNumber || '').replace(/\s+/g, ''); if (!/^\d{16}$/.test(account)) { return res.status(400).json({ success: false, error: 'Account number must be 16 digits.' }); } const cryptoMod = require('crypto'); const kp = cryptoMod.generateKeyPairSync('x25519'); const pkcs8 = kp.privateKey.export({ format: 'der', type: 'pkcs8' }); const spki = kp.publicKey.export({ format: 'der', type: 'spki' }); const privateKey = pkcs8.subarray(pkcs8.length - 32).toString('base64'); const publicKey = spki.subarray(spki.length - 32).toString('base64'); const body = new URLSearchParams({ account, pubkey: publicKey }).toString(); const upstream = await fetch('https://api.mullvad.net/wg/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body }); const text = (await upstream.text()).trim(); if (!upstream.ok || !text) { return res.status(502).json({ success: false, error: text || `Mullvad API returned ${upstream.status}.` }); } if (/account/i.test(text) && /(invalid|not.*found|expired)/i.test(text)) { return res.status(400).json({ success: false, error: text }); } const ipv4Only = text .split(',') .map((s) => s.trim()) .filter((s) => /^\d+\.\d+\.\d+\.\d+\/\d+$/.test(s)) .join(','); res.json({ success: true, privateKey, publicKey, addresses: ipv4Only || text }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.post('/write-file', requireAuth, async (req, res) => { try { const { path: filePath, content } = req.body; const fsPromises = require('fs').promises; const pathModule = require('path'); const fullPath = pathModule.join(PATHS.FRONTEND_DATA, filePath); if (!fullPath.startsWith(PATHS.BASE_DIR)) { return res.status(403).json({ success: false, error: 'Access denied' }); } const dir = pathModule.dirname(fullPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } await fsPromises.writeFile(fullPath, content, 'utf8'); res.json({ success: true }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.get('/read-file', requireAuth, (req, res) => { try { const { path: filePath, position } = req.query; const pathModule = require('path'); const fullPath = pathModule.join(PATHS.FRONTEND_DATA, filePath); if (!fullPath.startsWith(PATHS.BASE_DIR)) { return res.status(403).json({ success: false, error: 'Access denied' }); } const fsPromises = require('fs').promises; const handleReadError = (error) => { if (error.code === 'ENOENT') { return res.status(404).json({ success: false, error: 'File not found' }); } res.status(500).json({ success: false, error: error.message }); }; if (position !== undefined) { const pos = parseInt(position) || 0; fsPromises.readFile(fullPath, 'utf8') .then(data => res.send(data.substring(pos))) .catch(handleReadError); } else { fsPromises.readFile(fullPath, 'utf8') .then(data => res.send(data)) .catch(handleReadError); } } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // Batch task loading app.post('/read-tasks-batch', requireAuth, async (req, res) => { try { const { taskIds } = req.body; const fsPromises = require('fs').promises; if (!Array.isArray(taskIds)) { return res.status(400).json({ success: false, error: 'taskIds must be an array' }); } const loadPromises = taskIds.map(async (taskId) => { try { const taskFilePath = path.join(PATHS.FRONTEND_DATA, 'tasks', `${taskId}.json`); const taskData = await fsPromises.readFile(taskFilePath, 'utf8'); return JSON.parse(taskData); } catch { return null; } }); const results = await Promise.all(loadPromises); res.json(results.filter(Boolean)); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // Directory listing app.get('/read-directory', requireAuth, (req, res) => { try { const { path: dirPath } = req.query; const fullPath = path.join(PATHS.FRONTEND_DATA, dirPath); if (!fullPath.startsWith(PATHS.BASE_DIR)) { return res.status(403).json({ success: false, error: 'Access denied' }); } try { const stats = fs.statSync(fullPath); if (!stats.isDirectory()) { return res.status(400).json({ success: false, error: 'Not a directory' }); } } catch { return res.status(404).json({ success: false, error: 'Directory not found' }); } fs.readdir(fullPath, (err, files) => { if (err) return res.status(500).json({ success: false, error: err.message }); res.json(files || []); }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // File delete (task files only) app.post('/delete-file', requireAuth, async (req, res) => { try { const { path: filePath } = req.body; const pathModule = require('path'); const fsPromises = require('fs').promises; const fullPath = pathModule.join(PATHS.FRONTEND_DATA, filePath); if (!fullPath.startsWith(PATHS.BASE_DIR)) { return res.status(403).json({ success: false, error: 'Access denied' }); } if (!filePath.startsWith('task_') || !filePath.endsWith('.json')) { return res.status(403).json({ success: false, error: 'Only task files can be deleted' }); } await fsPromises.unlink(fullPath); res.json({ success: true }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // SPA fallback — must be last app.get('*', (req, res) => { res.sendFile(path.join(config.FRONTEND_PATH, 'index.html')); }); } };