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.
-
+> [!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.
-[](https://github.com/ZeroHost-Code/legal/blob/main/zhsl.md)
+
---
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 = `| Error: ${err.message} |
`;
+ if (tbody) tbody.innerHTML = `| Error: ${escapeHtml(err.message)} |
`;
}
initIcons();
}
@@ -1151,7 +1172,7 @@ async function renderAdminDashboard() {
});
}
} catch (err) {
- el.innerHTML = ``;
+ 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 = `| Error: ${err.message} |
`;
+ if (tbody) tbody.innerHTML = `| Error: ${escapeHtml(err.message)} |
`;
}
initIcons();
}
@@ -1639,7 +1660,7 @@ async function renderAdminEggsSettings() {
});
} catch (err) {
const tbody = $a('#admin-nests-tbody');
- if (tbody) tbody.innerHTML = `| Error: ${err.message} |
`;
+ if (tbody) tbody.innerHTML = `| Error: ${escapeHtml(err.message)} |
`;
}
}
@@ -1737,7 +1758,7 @@ async function renderAdminNestEggs(nestId) {
});
} catch (err) {
const tbody = $a('#admin-eggs-tbody');
- if (tbody) tbody.innerHTML = `| Error: ${err.message} |
`;
+ if (tbody) tbody.innerHTML = `| Error: ${escapeHtml(err.message)} |
`;
}
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 = `| Error: ${err.message} |
`;
+ if (tbody) tbody.innerHTML = `| Error: ${escapeHtml(err.message)} |
`;
}
}
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' });
}