Skip to content
Merged

sync #30

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/deploy-beta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ on:
push:
branches: [beta]

permissions:
contents: read

jobs:
deploy:
runs-on: ubuntu-latest
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/deploy-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ on:
push:
branches: [main]

permissions:
contents: read

jobs:
deploy:
runs-on: ubuntu-latest
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<img width="1920" height="1080" alt="image" src="https://github.com/user-attachments/assets/403b7c93-1121-4f4c-b407-ed1235b73218" />
> [!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)
<img width="1920" height="1080" alt="image" src="https://github.com/user-attachments/assets/403b7c93-1121-4f4c-b407-ed1235b73218" />

---

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zerohost-dashboard",
"version": "1.0.8",
"version": "1.0.9",
"private": true,
"type": "module",
"scripts": {
Expand Down
43 changes: 34 additions & 9 deletions public/js/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<div class="page-header">
<h1 class="page-title">All Servers</h1>
<p class="page-subtitle">All servers across all users</p>
</div>
<div class="search-wrapper" style="margin-bottom:16px;max-width:400px" id="admin-servers-search-wrapper">
<span data-lucide="search"></span>
<input type="text" id="admin-servers-search" placeholder="Search by server name or owner...">
</div>
<div class="table-container">
<table>
<thead>
Expand All @@ -531,6 +537,19 @@ async function renderAdminServers() {
</div>
`;

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);
}

Expand All @@ -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;

Expand Down Expand Up @@ -589,7 +610,7 @@ async function fetchAdminServers(pageNum) {
}
} catch (err) {
const tbody = $a('#admin-servers-tbody');
if (tbody) tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;padding:32px;color:var(--accent-red)">Error: ${err.message}</td></tr>`;
if (tbody) tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;padding:32px;color:var(--accent-red)">Error: ${escapeHtml(err.message)}</td></tr>`;
}
initIcons();
}
Expand Down Expand Up @@ -1151,7 +1172,7 @@ async function renderAdminDashboard() {
});
}
} catch (err) {
el.innerHTML = `<div class="empty-state"><div class="empty-state-title">Error</div><div class="empty-state-desc">${err.message}</div></div>`;
el.innerHTML = `<div class="empty-state"><div class="empty-state-title">Error</div><div class="empty-state-desc">${escapeHtml(err.message)}</div></div>`;
}
}

Expand Down Expand Up @@ -1212,7 +1233,7 @@ async function renderAdminUsers() {
`).join('');
} catch (err) {
const tbody = $a('#admin-users-tbody');
if (tbody) tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;padding:32px;color:var(--accent-red)">Error: ${err.message}</td></tr>`;
if (tbody) tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;padding:32px;color:var(--accent-red)">Error: ${escapeHtml(err.message)}</td></tr>`;
}
initIcons();
}
Expand Down Expand Up @@ -1639,7 +1660,7 @@ async function renderAdminEggsSettings() {
});
} catch (err) {
const tbody = $a('#admin-nests-tbody');
if (tbody) tbody.innerHTML = `<tr><td colspan="4" style="text-align:center;padding:32px;color:var(--accent-red)">Error: ${err.message}</td></tr>`;
if (tbody) tbody.innerHTML = `<tr><td colspan="4" style="text-align:center;padding:32px;color:var(--accent-red)">Error: ${escapeHtml(err.message)}</td></tr>`;
}
}

Expand Down Expand Up @@ -1737,7 +1758,7 @@ async function renderAdminNestEggs(nestId) {
});
} catch (err) {
const tbody = $a('#admin-eggs-tbody');
if (tbody) tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;padding:32px;color:var(--accent-red)">Error: ${err.message}</td></tr>`;
if (tbody) tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;padding:32px;color:var(--accent-red)">Error: ${escapeHtml(err.message)}</td></tr>`;
}
initIcons();
}
Expand Down Expand Up @@ -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';
}
Expand Down Expand Up @@ -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';
}
Expand Down Expand Up @@ -2381,7 +2406,7 @@ async function renderAdminNodes() {
}).join('');
} catch (err) {
const tbody = $a('#admin-nodes-tbody');
if (tbody) tbody.innerHTML = `<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--accent-red)">Error: ${err.message}</td></tr>`;
if (tbody) tbody.innerHTML = `<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--accent-red)">Error: ${escapeHtml(err.message)}</td></tr>`;
}
}

Expand Down
4 changes: 2 additions & 2 deletions public/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1496,7 +1496,7 @@ async function renderDashboard() {
<div style="padding:8px 12px 0;display:flex;gap:16px;justify-content:center;flex-wrap:wrap">

</div>
<div style="padding:4px 0 8px;text-align:center;font-size:0.7rem;color:var(--text-muted);letter-spacing:0.05em">v1.0.8</div>
<div style="padding:4px 0 8px;text-align:center;font-size:0.7rem;color:var(--text-muted);letter-spacing:0.05em">v1.0.9</div>
</div>
<div class="sidebar-resizer" id="sidebar-resizer"></div>
</aside>
Expand Down Expand Up @@ -3296,7 +3296,7 @@ async function loadPasskeys() {
initIcons();
} catch (err) {
const list = $('#passkey-list');
if (list) list.innerHTML = `<p style="color:var(--accent-red);font-size:0.85rem">Failed to load passkeys: ${err.message}</p>`;
if (list) list.innerHTML = `<p style="color:var(--accent-red);font-size:0.85rem">Failed to load passkeys: ${escapeHtml(err.message)}</p>`;
}
}

Expand Down
32 changes: 26 additions & 6 deletions routes/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,19 +138,24 @@ 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 = {};
for (const u of users) {
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]);
Expand All @@ -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' });
Expand Down
39 changes: 36 additions & 3 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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' });
}
Expand Down
Loading