diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml index fa7402b..8938c6e 100644 --- a/.github/workflows/deploy-beta.yml +++ b/.github/workflows/deploy-beta.yml @@ -4,6 +4,9 @@ on: push: branches: [beta] +permissions: + contents: read + jobs: deploy: runs-on: ubuntu-latest diff --git a/.github/workflows/deploy-main.yml b/.github/workflows/deploy-main.yml index 4b4461d..8aa3d58 100644 --- a/.github/workflows/deploy-main.yml +++ b/.github/workflows/deploy-main.yml @@ -4,6 +4,9 @@ on: push: branches: [main] +permissions: + contents: read + jobs: deploy: runs-on: ubuntu-latest diff --git a/README.md b/README.md index aff50c7..c03d2b8 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ Game server management dashboard for the ZeroHost platform. Provides user-facing server lifecycle management (creation, renewal, suspension, deletion) backed by a panel API. -image +> [!NOTE] +> The dashboard is now in "cruise mode", instead of dozens of commits a day, expect around 2-3 commits per day going forward, as I'm juggling other projects on the side. That said, there will still be days with bigger pushes when needed. -[![ZHSL](https://img.zero-host.org/assets/github.png)](https://github.com/ZeroHost-Code/legal/blob/main/zhsl.md) +image --- diff --git a/package.json b/package.json index 2e7abd3..81b96bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zerohost-dashboard", - "version": "1.0.8", + "version": "1.0.9", "private": true, "type": "module", "scripts": { diff --git a/public/js/admin.js b/public/js/admin.js index 36c4908..c298f61 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -501,17 +501,23 @@ function updateAdminNav() { } let adminServersPage = 1; +let adminServersSearch = ''; async function renderAdminServers() { document.querySelectorAll('.admin-page').forEach(p => p.classList.remove('active')); const el = $a('#admin-page-servers'); if (!el) return; el.classList.add('active'); + adminServersSearch = ''; el.innerHTML = ahtml` +
+ + +
@@ -531,6 +537,19 @@ async function renderAdminServers() { `; + const searchInput = $a('#admin-servers-search'); + if (searchInput) { + let searchTimer; + searchInput.addEventListener('input', () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + adminServersSearch = searchInput.value.trim(); + adminServersPage = 1; + fetchAdminServers(1); + }, 400); + }); + } + await fetchAdminServers(adminServersPage); } @@ -539,9 +558,11 @@ async function fetchAdminServers(pageNum) { const limit = 10; const offset = (pageNum - 1) * limit; const paginationEl = $a('#admin-servers-pagination'); + const params = new URLSearchParams({ limit, offset }); + if (adminServersSearch) params.set('search', adminServersSearch); try { - const data = await adminApi(`/servers?limit=${limit}&offset=${offset}`); + const data = await adminApi(`/servers?${params.toString()}`); const tbody = $a('#admin-servers-tbody'); if (!tbody) return; @@ -589,7 +610,7 @@ async function fetchAdminServers(pageNum) { } } catch (err) { const tbody = $a('#admin-servers-tbody'); - if (tbody) tbody.innerHTML = ``; + if (tbody) tbody.innerHTML = ``; } initIcons(); } @@ -1151,7 +1172,7 @@ async function renderAdminDashboard() { }); } } catch (err) { - el.innerHTML = `
Error
${err.message}
`; + el.innerHTML = `
Error
${escapeHtml(err.message)}
`; } } @@ -1212,7 +1233,7 @@ async function renderAdminUsers() { `).join(''); } catch (err) { const tbody = $a('#admin-users-tbody'); - if (tbody) tbody.innerHTML = ``; + if (tbody) tbody.innerHTML = ``; } initIcons(); } @@ -1639,7 +1660,7 @@ async function renderAdminEggsSettings() { }); } catch (err) { const tbody = $a('#admin-nests-tbody'); - if (tbody) tbody.innerHTML = ``; + if (tbody) tbody.innerHTML = ``; } } @@ -1737,7 +1758,7 @@ async function renderAdminNestEggs(nestId) { }); } catch (err) { const tbody = $a('#admin-eggs-tbody'); - if (tbody) tbody.innerHTML = ``; + if (tbody) tbody.innerHTML = ``; } initIcons(); } @@ -1870,7 +1891,9 @@ async function renderAdminEggSettings(nestId, eggId) { const val = $a('#egg-logo').value; if (val) { if (preview) preview.style.display = 'block'; - if (img) img.src = val; + if (img) { + try { const u = new URL(val); if (u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'data:' || u.protocol === 'blob:') img.src = u.href; else img.src = ''; } catch { img.src = ''; } + } } else { if (preview) preview.style.display = 'none'; } @@ -2228,7 +2251,9 @@ function showRenameNestModal(nestId, currentName, currentLogo, currentDescriptio const img = preview?.querySelector('img'); if (logoInput.value) { if (preview) preview.style.display = 'block'; - if (img) img.src = logoInput.value; + if (img) { + try { const u = new URL(logoInput.value); if (u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'data:' || u.protocol === 'blob:') img.src = u.href; else img.src = ''; } catch { img.src = ''; } + } } else { if (preview) preview.style.display = 'none'; } @@ -2381,7 +2406,7 @@ async function renderAdminNodes() { }).join(''); } catch (err) { const tbody = $a('#admin-nodes-tbody'); - if (tbody) tbody.innerHTML = ``; + if (tbody) tbody.innerHTML = ``; } } diff --git a/public/js/app.js b/public/js/app.js index 90fc2e9..1e87ffb 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1496,7 +1496,7 @@ async function renderDashboard() {
-
v1.0.8
+
v1.0.9
@@ -3296,7 +3296,7 @@ async function loadPasskeys() { initIcons(); } catch (err) { const list = $('#passkey-list'); - if (list) list.innerHTML = `

Failed to load passkeys: ${err.message}

`; + if (list) list.innerHTML = `

Failed to load passkeys: ${escapeHtml(err.message)}

`; } } diff --git a/routes/admin.js b/routes/admin.js index c417ed3..3fbe430 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -138,11 +138,16 @@ router.get('/servers', authenticateToken, requireAdmin, async (req, res) => { try { const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50); const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0); + const search = (req.query.search || '').trim().toLowerCase(); - const result = await getAllServers(limit, offset); - const { servers: paginatedServers, total } = result; - const page = Math.floor(offset / limit) + 1; - const totalPages = Math.ceil(total / limit) || 1; + let allServers; + if (search) { + const result = await getAllServers(); + allServers = Array.isArray(result) ? result : result.servers || []; + } else { + const result = await getAllServers(limit, offset); + allServers = result.servers || []; + } const users = await query('SELECT id, email, username, ptero_user_id FROM users'); const userMap = {}; @@ -150,7 +155,7 @@ router.get('/servers', authenticateToken, requireAdmin, async (req, res) => { userMap[u.ptero_user_id] = { id: u.id, email: u.email, username: u.username }; } - for (const s of paginatedServers) { + for (const s of allServers) { s.owner = userMap[s.user] || { id: null, email: 'Unknown', username: 'Unknown' }; try { const meta = await query('SELECT * FROM server_meta WHERE ptero_server_id = ?', [s.id]); @@ -160,7 +165,22 @@ router.get('/servers', authenticateToken, requireAdmin, async (req, res) => { } } - res.json({ servers: paginatedServers, total, page, totalPages, limit }); + let filtered = allServers; + if (search) { + filtered = allServers.filter(s => { + const name = (s.name || '').toLowerCase(); + const ownerName = (s.owner?.username || '').toLowerCase(); + const ownerEmail = (s.owner?.email || '').toLowerCase(); + return name.includes(search) || ownerName.includes(search) || ownerEmail.includes(search); + }); + } + + const filteredTotal = filtered.length; + const page = Math.floor(offset / limit) + 1; + const totalPages = Math.ceil(filteredTotal / limit) || 1; + const sliced = filtered.slice(offset, offset + limit); + + res.json({ servers: sliced, total: filteredTotal, page, totalPages, limit }); } catch (err) { console.error('Admin servers list error:', err.message); res.status(500).json({ error: 'Failed to fetch servers' }); diff --git a/server.js b/server.js index 8ea5e2e..6f83b48 100644 --- a/server.js +++ b/server.js @@ -162,6 +162,30 @@ app.use((err, req, res, next) => { }); app.use(cookieParser(process.env.COOKIE_SECRET)); +const csrfExemptPaths = ['/api/auth/login', '/api/auth/register', '/api/auth/passkey/options', '/api/auth/passkey/verify']; +app.use((req, res, next) => { + if (!req.path.startsWith('/api/')) return next(); + if (csrfExemptPaths.includes(req.path)) return next(); + if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) { + const token = crypto.randomBytes(32).toString('hex'); + if (!req.cookies['XSRF-TOKEN']) { + res.cookie('XSRF-TOKEN', token, { + sameSite: 'strict', + secure: process.env.NODE_ENV === 'production', + httpOnly: false, + maxAge: 24 * 60 * 60 * 1000, + }); + } + return next(); + } + const headerToken = req.headers['x-csrf-token']; + const cookieToken = req.cookies['XSRF-TOKEN']; + if (!headerToken || !cookieToken || headerToken !== cookieToken) { + return res.status(403).json({ error: 'Invalid CSRF token', requestId: req.requestId }); + } + next(); +}); + app.use((req, res, next) => { res.setHeader('X-Frame-Options', 'SAMEORIGIN'); res.setHeader('X-Robots-Tag', 'noindex, nofollow'); @@ -197,6 +221,15 @@ const activityLimiter = rateLimit({ trustProxy: trustProxy ? 1 : 0, }); +const staticLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 60, + message: { error: 'Too many requests' }, + standardHeaders: true, + legacyHeaders: false, + trustProxy: trustProxy ? 1 : 0, +}); + app.use('/api/auth/login', authLimiter); app.use('/api/auth/register', authLimiter); app.use('/api/activity', activityLimiter); @@ -287,17 +320,17 @@ app.get('/api/health', async (req, res) => { app.use(express.static(path.join(__dirname, 'public'))); -app.get('/admin/*', (req, res) => { +app.get('/admin/*', staticLimiter, (req, res) => { res.set('X-Robots-Tag', 'noindex, nofollow'); res.sendFile(path.join(__dirname, 'public', 'index.html')); }); -app.get('/admin', (req, res) => { +app.get('/admin', staticLimiter, (req, res) => { res.set('X-Robots-Tag', 'noindex, nofollow'); res.sendFile(path.join(__dirname, 'public', 'index.html')); }); -app.get('*', (req, res) => { +app.get('*', staticLimiter, (req, res) => { if (req.path.startsWith('/api/')) { return res.status(404).json({ error: 'Not found' }); }
Error: ${err.message}
Error: ${escapeHtml(err.message)}
Error: ${err.message}
Error: ${escapeHtml(err.message)}
Error: ${err.message}
Error: ${escapeHtml(err.message)}
Error: ${err.message}
Error: ${escapeHtml(err.message)}
Error: ${err.message}
Error: ${escapeHtml(err.message)}