const express = require('express'); const fs = require('fs'); const path = require('path'); const multer = require('multer'); const app = express(); const CONFIG_PATH = process.env.CONFIG_PATH || '/data/config.json'; const PORT = process.env.PORT || 3000; const UPTIME_KUMA_URL = process.env.UPTIME_KUMA_URL || 'http://100.114.205.53:3001'; const UPLOAD_DIR = '/data/icons'; const storage = multer.diskStorage({ destination: (req, file, cb) => { fs.mkdirSync(UPLOAD_DIR, { recursive: true }); cb(null, UPLOAD_DIR); }, filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); const base = path.basename(file.originalname, ext).replace(/[^a-z0-9_-]/gi, '_').toLowerCase(); cb(null, base + ext); } }); const ALLOWED = new Set(['.jpg','.jpeg','.png','.gif','.webp','.svg','.ico']); const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); ALLOWED.has(ext) ? cb(null, true) : cb(new Error('Invalid file type: ' + ext)); } }); app.use(express.json({ limit: '2mb' })); app.use(express.static(path.join(__dirname, '../frontend'))); app.use('/uploads', express.static('/data', { index: false })); app.post('/api/upload', upload.single('file'), (req, res) => { if (!req.file) return res.status(400).json({ error: 'No file received' }); const url = '/uploads/icons/' + req.file.filename; const slot = req.query.slot; if (slot && ['banner','logo','favicon'].includes(slot)) { try { const cfg = readConfig(); cfg.site[slot] = url; writeConfig(cfg); } catch (e) {} } res.json({ ok: true, url, filename: req.file.filename, slot: slot || null }); }); app.get('/api/uploads', (req, res) => { try { fs.mkdirSync(UPLOAD_DIR, { recursive: true }); const files = fs.readdirSync(UPLOAD_DIR) .filter(f => ALLOWED.has(path.extname(f).toLowerCase())) .map(f => ({ filename: f, url: '/uploads/icons/' + f })); res.json(files); } catch (e) { res.json([]); } }); function readConfig() { try { if (!fs.existsSync(CONFIG_PATH)) { const def = defaultConfig(); fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true }); fs.writeFileSync(CONFIG_PATH, JSON.stringify(def, null, 2)); return def; } return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch (e) { return defaultConfig(); } } function writeConfig(cfg) { fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true }); fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2)); } function defaultConfig() { return { site: { title: 'Dashgaard', slogans: ['all systems nominal.', 'boogaard is watching.'], banner: '', logo: '', favicon: '' }, uptime: { url: UPTIME_KUMA_URL, slug: 'homelab', enabled: true }, theme: { fontBody: 'Rajdhani', fontMono: 'Share Tech Mono', fontDisplay: 'JetBrains Mono', cardSize: 'medium', accentColor: '#f5a623', bgColor: '#0a0600', cardBg: '#120c04', textColor: 'rgba(255,220,170,0.92)', pageMaxWidth: '960', baseFontSize: '16', cardGap: '10', cardRadius: '10', heartbeatHeight: '16' }, groups: [ { id: 'g1', name: 'Core', order: 0, services: [ { id: 's1', name: 'BourBites', url: 'https://bourbites.boogaardmusic.com', icon: '🍖', description: 'Academic knowledge OS', color: '#f5a623', order: 0 }, { id: 's2', name: 'booops', url: 'https://booops.boogaardmusic.com', icon: '🤖', description: 'Open WebUI — LLM chat', color: '#ff2d6b', order: 1 }, { id: 's3', name: 'b2b', url: 'https://b2b.boogaardmusic.com', icon: '⚡', description: 'n8n automation', color: '#c8e06b', order: 2 }, { id: 's4', name: 'Kimai', url: 'https://time.boogaardmusic.com', icon: '⏱', description: 'Time tracking', color: '#f5a623', order: 3 } ]}, { id: 'g2', name: 'Infrastructure', order: 1, services: [ { id: 's5', name: 'Komodo', url: 'http://100.114.205.53:9120', icon: '🦎', description: 'Container management', color: '#c8e06b', order: 0 }, { id: 's6', name: 'File Browser', url: 'https://files.boogaardmusic.com', icon: '📁', description: 'File management', color: '#f5a623', order: 1 }, { id: 's7', name: 'AdGuard', url: 'https://adguard.boogaardmusic.com', icon: '🛡', description: 'DNS + ad blocking', color: '#00e87a', order: 2 }, { id: 's8', name: 'Uptime Kuma', url: 'https://uptime.boogaardmusic.com', icon: '📡', description: 'Uptime monitoring', color: '#ff2d6b', order: 3 } ]}, { id: 'g3', name: 'Tools', order: 2, services: [ { id: 's9', name: 'CalDAV', url: 'https://cal.boogaardmusic.com', icon: '📅', description: 'Baikal calendar', color: '#c8e06b', order: 0 }, { id: 's10', name: '808notes', url: 'https://808notes.boogaardmusic.com', icon: '🎵', description: 'Music notes', color: '#ff2d6b', order: 1 }, { id: 's11', name: 'Wonkcrop', url: 'https://wonkcrop.boogaardmusic.com', icon: '✂️', description: 'Image cropper', color: '#f5a623', order: 2 }, { id: 's12', name: 'DubDrive', url: 'http://100.114.205.53:9200', icon: '☁️', description: 'Cloud storage', color: '#c084fc', order: 3 }, { id: 's13', name: 'Grafana', url: 'https://monitoring.boogaardmusic.com', icon: '📊', description: 'Dashboards', color: '#ff6b35', order: 4 }, { id: 's14', name: 'Dashy', url: 'http://100.114.205.53:4000', icon: '🗂', description: 'Old dashboard', color: '#f5a623', order: 5 } ]} ] }; } app.get('/api/config', (req, res) => res.json(readConfig())); app.put('/api/config', (req, res) => { try { writeConfig(req.body); res.json({ ok: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.get('/api/uptime/:slug', async (req, res) => { const cfg = readConfig(); const base = cfg.uptime?.url || UPTIME_KUMA_URL; try { const [pr, hr] = await Promise.all([ fetch(`${base}/api/status-page/${req.params.slug}`), fetch(`${base}/api/status-page/heartbeat/${req.params.slug}`) ]); if (!pr.ok || !hr.ok) return res.status(502).json({ error: `${pr.status}/${hr.status}` }); res.json({ page: await pr.json(), heartbeat: await hr.json() }); } catch (e) { res.status(502).json({ error: e.message }); } }); app.use((err, req, res, next) => { if (err.code === 'LIMIT_FILE_SIZE') return res.status(413).json({ error: 'File too large (max 10MB)' }); res.status(400).json({ error: err.message }); }); app.listen(PORT, () => console.log(`Dashgaard :${PORT}`));