Themes are already modular via folder discovery (GET /api/themes/list scans themes/<name>/). This brings the SAME model to pages: - backend/routes/features.js: public GET /api/features/list scans frontend/features/<id>/feature.json and returns the page manifest. The Node process reads its own bind-mounted /app/frontend — no runFileOp / regen / source-array plumbing needed (sidesteps the shell-generator gotchas). - features/<id>/feature.json: each page now self-describes (id, routes, module, handler, navId, nav, order). 6 real features + 3 redirect-only (config/peers/ssh) so behaviour is preserved exactly. - kernel loadManifest() prefers /api/features/list, falls back to the static features/manifest.dev.json when the endpoint isn't up yet. Result: dropping a features/<id>/ folder registers a page; deleting it removes it — zero central edits, exactly like dropping a theme folder. (Backend route needs a Node restart to activate; the static-manifest fallback keeps everything working until then.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
233 lines
8.4 KiB
JavaScript
Executable File
233 lines
8.4 KiB
JavaScript
Executable File
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'));
|
|
});
|
|
}
|
|
};
|