From 40ab8acd85a2824c5277a8d3f4501cc5629ab5b0 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 17 Jul 2026 10:10:14 +0200 Subject: [PATCH 01/12] Add server creation security checks (VPN, UA, email, country) with verification overlay --- public/css/style.css | 123 +++++++++++++++++++++++++++++++++++++++++ public/js/app.js | 127 ++++++++++++++++++++++++++++++++++++++++++- routes/auth.js | 2 +- routes/servers.js | 35 ++++++++++++ 4 files changed, 283 insertions(+), 4 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index cac12c4..7d4c5fb 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -4607,3 +4607,126 @@ tbody tr:hover { border-radius: 4px; } +/* ===== VERIFICATION OVERLAY ===== */ +.verification-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(8px); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} + +.verification-overlay.open { + opacity: 1; + pointer-events: all; +} + +.verification-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 48px 40px; + max-width: 420px; + width: 90%; + text-align: center; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5); +} + +.verification-spinner { + width: 48px; + height: 48px; + border: 3px solid rgba(255, 255, 255, 0.1); + border-top-color: var(--accent-1); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 0 auto 24px; +} + +.verification-title { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 6px; + color: var(--text-primary); +} + +.verification-subtitle { + font-size: 0.875rem; + color: var(--text-secondary); + margin-bottom: 28px; +} + +.verification-steps { + display: flex; + flex-direction: column; + gap: 14px; + text-align: left; +} + +.verification-step { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-radius: var(--radius-sm); + background: var(--bg-tertiary); + font-size: 0.85rem; + color: var(--text-muted); + transition: all var(--transition); +} + +.verification-step.active { + color: var(--text-primary); + background: rgba(238, 129, 50, 0.08); + border: 1px solid rgba(238, 129, 50, 0.2); +} + +.verification-step.done { + color: var(--accent-green); +} + +.verification-step.failed { + color: var(--accent-red); +} + +.verification-step-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.vstep-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(238, 129, 50, 0.25); + border-top-color: var(--accent-1); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +.vstep-pending { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--text-muted); + border-radius: 50%; + opacity: 0.4; +} + +.vstep-check, .vstep-cross { + flex-shrink: 0; +} + +.verification-success-icon, .verification-error-icon { + margin-bottom: 16px; +} + diff --git a/public/js/app.js b/public/js/app.js index 50897ef..6fe706b 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -2917,6 +2917,122 @@ function isValidServerName(name) { return name && name.length >= 1 && name.length <= 255 && /^[a-zA-Z0-9 _.-]+$/.test(name); } +function showVerificationPage(name) { + const overlay = document.createElement('div'); + overlay.id = 'verification-overlay'; + overlay.className = 'verification-overlay'; + overlay.innerHTML = html` +
+
+

Creating "${escapeHtml(name)}"

+

Performing security checks...

+
+
+
+ Checking connection security +
+
+
+ Verifying browser +
+
+
+ Checking email verification +
+
+
+ Verifying region +
+
+
+ Creating server +
+
+
+ `; + document.body.appendChild(overlay); + requestAnimationFrame(() => overlay.classList.add('open')); + + let stepIndex = 0; + const steps = ['vstep-vpn', 'vstep-ua', 'vstep-email', 'vstep-country', 'vstep-create']; + const interval = setInterval(() => { + if (stepIndex < steps.length) { + const el = document.getElementById(steps[stepIndex]); + if (el) { + el.classList.remove('active'); + el.classList.add('done'); + el.querySelector('.vstep-spinner, .vstep-pending').outerHTML = ''; + } + stepIndex++; + if (stepIndex < steps.length) { + const next = document.getElementById(steps[stepIndex]); + if (next) { + next.classList.add('active'); + next.querySelector('.vstep-pending').outerHTML = ''; + } + } + } else { + clearInterval(interval); + } + }, 600); + + overlay._interval = interval; +} + +function showVerificationSuccess(name) { + const overlay = document.getElementById('verification-overlay'); + if (!overlay) return; + clearInterval(overlay._interval); + + const steps = overlay.querySelectorAll('.verification-step'); + steps.forEach(s => { + s.classList.remove('active'); + s.classList.add('done'); + const icon = s.querySelector('.vstep-spinner, .vstep-pending'); + if (icon) icon.outerHTML = ''; + }); + + overlay.querySelector('.verification-card').innerHTML = html` + +

Server Created!

+

"${escapeHtml(name)}" is now being set up.

+ `; +} + +function showVerificationError(message) { + const overlay = document.getElementById('verification-overlay'); + if (!overlay) return; + clearInterval(overlay._interval); + + const steps = overlay.querySelectorAll('.verification-step.active'); + steps.forEach(s => { + s.classList.remove('active'); + s.classList.add('failed'); + const icon = s.querySelector('.vstep-spinner'); + if (icon) icon.outerHTML = ''; + }); + + const card = overlay.querySelector('.verification-card'); + card.innerHTML = html` + +

Verification Failed

+

${escapeHtml(message)}

+ + `; + + document.getElementById('verification-retry-btn').addEventListener('click', () => { + overlay.remove(); + }); +} + +function removeVerificationOverlay() { + const overlay = document.getElementById('verification-overlay'); + if (overlay) { + clearInterval(overlay._interval); + overlay.remove(); + } +} + async function handleWizardCreate() { const btn = $('#wizard-create-btn'); btn.disabled = true; @@ -2935,16 +3051,21 @@ async function handleWizardCreate() { const environment = {}; const dockerImage = createState.selectedDockerImage || ''; + showVerificationPage(name); + try { const capToken = document.querySelector('[name="cap-token"]')?.value || ''; await api('/servers/create', { method: 'POST', body: JSON.stringify({ name, nestId: nest.pteroNestId, eggId: egg.eggId, environment, capToken, dockerImage }), }); - showToast(`Server "${name}" created successfully!`, 'success'); - navigateTo('servers'); + showVerificationSuccess(name); + setTimeout(() => { + removeVerificationOverlay(); + navigateTo('servers'); + }, 2000); } catch (err) { - showToast(err.message, 'error'); + showVerificationError(err.message); btn.disabled = false; btn.innerHTML = ' Create Server'; initIcons(); diff --git a/routes/auth.js b/routes/auth.js index 4573b26..dc01d71 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -911,6 +911,6 @@ router.get('/export-data', authenticateToken, sensitiveLimiter, async (req, res) } }); -export { getClientIp }; +export { getClientIp, fetchWithTimeout, normalizeClientIp, isPrivateIp }; export default router; diff --git a/routes/servers.js b/routes/servers.js index 85dabb4..491d6e4 100644 --- a/routes/servers.js +++ b/routes/servers.js @@ -1,6 +1,7 @@ import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import { authenticateToken, requireNotRestricted, requireOwnership } from '../middleware/auth.js'; +import { getClientIp, isVpnOrProxy, fetchWithTimeout, normalizeClientIp, isPrivateIp } from './auth.js'; import { getServersByUser, getServerById, @@ -21,6 +22,20 @@ import { createNotification } from '../services/notification.js'; const router = Router(); +const BLOCKED_COUNTRY_CODES = ['CN', 'RU']; + +async function checkBlockedCountry(ip) { + const cleanIp = normalizeClientIp(ip); + if (!cleanIp || isPrivateIp(cleanIp)) return false; + try { + const res = await fetchWithTimeout(`http://ip-api.com/json/${encodeURIComponent(cleanIp)}?fields=countryCode`, {}, 5000); + const data = await res.json(); + return BLOCKED_COUNTRY_CODES.includes(data.countryCode); + } catch { + return false; + } +} + const createServerLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 3, @@ -228,6 +243,26 @@ router.post('/create', authenticateToken, requireNotRestricted, createServerLimi return res.status(400).json({ error: 'Please complete the security check' }); } + const ip = getClientIp(req); + const userAgent = (req.headers['user-agent'] || '').toString().slice(0, 512); + + if (await isVpnOrProxy(ip)) { + return res.status(403).json({ error: 'VPN or proxy detected. Please disable your VPN.', check: 'vpn' }); + } + + if (!userAgent || /curl|wget|node-fetch|python-requests|python-httpx|urllib|aiohttp|go-http-client|java\/|libcurl|okhttp|httpie|postmanruntime|insomnia|axios|fetch\//i.test(userAgent)) { + return res.status(403).json({ error: 'Automated requests are not allowed. Please use a real browser.', check: 'useragent' }); + } + + const userRows = await query('SELECT email_verified FROM users WHERE id = ?', [req.user.userId]); + if (!userRows[0]?.email_verified) { + return res.status(403).json({ error: 'Please verify your email before creating a server.', check: 'email' }); + } + + if (await checkBlockedCountry(ip)) { + return res.status(403).json({ error: 'Service not available in your region.', check: 'country' }); + } + // Check if nest or egg is unavailable try { const [nestRow] = await query('SELECT unavailable FROM nests WHERE ptero_nest_id = ?', [nestId]); From 98e29adfbbef266fecd7ff425c835d11d6d59d6f Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 17 Jul 2026 10:13:09 +0200 Subject: [PATCH 02/12] Bump version to 1.1.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 219bed2..8409027 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zerohost-dashboard", - "version": "1.1.0", + "version": "1.1.1", "private": true, "type": "module", "scripts": { From 96fec9e77c857a159a89e28f0182daa4dbc5b18e Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 17 Jul 2026 10:13:40 +0200 Subject: [PATCH 03/12] Update sidebar version to 1.1.1 --- public/js/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/js/app.js b/public/js/app.js index 6fe706b..ace5eaa 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1511,7 +1511,7 @@ async function renderDashboard() {
-
v1.1.0
+
v1.1.1
From 98875b28cdb074aaa58c1ffa95759e8e9b683c01 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 17 Jul 2026 10:21:45 +0200 Subject: [PATCH 04/12] Add nest icon to sidebar server cards and move status dot layout --- public/css/style.css | 15 +++++++++++++-- public/js/app.js | 10 +++++++++- routes/servers.js | 7 +++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 7d4c5fb..3c608bf 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1070,8 +1070,6 @@ cap-widget { position: relative; z-index: 2; white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } .nav-sub-item:hover { @@ -1083,6 +1081,19 @@ cap-widget { color: var(--accent-1); } +.nav-sub-server-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.nav-sub-nest-icon { + flex-shrink: 0; + display: flex; + align-items: center; + margin-left: auto; +} + .nav-sub-item .nav-sub-dot { width: 6px; height: 6px; diff --git a/public/js/app.js b/public/js/app.js index ace5eaa..5a45fa6 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1672,6 +1672,13 @@ function initSidebarTooltip() { }); } +function renderNestLogoSmall(logo) { + if (!logo) return html``; + if (logo.startsWith('si:')) return html``; + if (logo.startsWith('lucide:')) return html``; + return html``; +} + function buildServerSubList() { if (state.sidebarServersLoading) return html``; if (state.servers.length === 0) return html``; @@ -1683,7 +1690,8 @@ function buildServerSubList() { return html` - ${escapeHtml(s.name)} + ${escapeHtml(s.name)} + ${renderNestLogoSmall(s.nestLogo)} `; }).join(''); diff --git a/routes/servers.js b/routes/servers.js index 491d6e4..f1df97f 100644 --- a/routes/servers.js +++ b/routes/servers.js @@ -96,6 +96,13 @@ router.get('/list', authenticateToken, async (req, res) => { } } + const nestRows = await query('SELECT ptero_nest_id, logo FROM nests').catch(() => []); + const nestLogoMap = {}; + for (const nr of nestRows) nestLogoMap[nr.ptero_nest_id] = nr.logo || null; + for (const s of servers) { + s.nestLogo = nestLogoMap[s.nest] || null; + } + // Fetch live power state from Pyrodactyl Client API const userRows = await query('SELECT ptero_client_api_key FROM users WHERE id = ?', [req.user.userId]); const clientApiKey = userRows[0]?.ptero_client_api_key; From 3b5990304d6366ce9291b4cf04449c527f327460 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 17 Jul 2026 10:44:56 +0200 Subject: [PATCH 05/12] Fix sidebar server card: nest icon left, status dot right --- public/css/style.css | 1 - public/js/app.js | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 3c608bf..bee3cac 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1091,7 +1091,6 @@ cap-widget { flex-shrink: 0; display: flex; align-items: center; - margin-left: auto; } .nav-sub-item .nav-sub-dot { diff --git a/public/js/app.js b/public/js/app.js index 5a45fa6..b7c29a0 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1689,9 +1689,9 @@ function buildServerSubList() { const isActive = state.currentPage === 'server' && state.serverId === s.id; return html` - - ${escapeHtml(s.name)} ${renderNestLogoSmall(s.nestLogo)} + ${escapeHtml(s.name)} + `; }).join(''); From ff6873d11545240629ef2aa18b802c020cc866d1 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 17 Jul 2026 10:45:51 +0200 Subject: [PATCH 06/12] Keep status dot next to server name instead of far right --- public/css/style.css | 1 - 1 file changed, 1 deletion(-) diff --git a/public/css/style.css b/public/css/style.css index bee3cac..64117fa 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1082,7 +1082,6 @@ cap-widget { } .nav-sub-server-name { - flex: 1; overflow: hidden; text-overflow: ellipsis; } From e20fa013097c1334819a879e2236d778f36d6d83 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 17 Jul 2026 10:46:34 +0200 Subject: [PATCH 07/12] Add rounded square background behind nest icon in sidebar --- public/css/style.css | 5 +++++ public/js/app.js | 8 ++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 64117fa..46476c0 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1090,6 +1090,11 @@ cap-widget { flex-shrink: 0; display: flex; align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 6px; + background: var(--bg-tertiary); } .nav-sub-item .nav-sub-dot { diff --git a/public/js/app.js b/public/js/app.js index b7c29a0..a0f1587 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1673,10 +1673,10 @@ function initSidebarTooltip() { } function renderNestLogoSmall(logo) { - if (!logo) return html``; - if (logo.startsWith('si:')) return html``; - if (logo.startsWith('lucide:')) return html``; - return html``; + if (!logo) return html``; + if (logo.startsWith('si:')) return html``; + if (logo.startsWith('lucide:')) return html``; + return html``; } function buildServerSubList() { From b44ef2ac70404402f0a0bfa19e3ce847b528271d Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 17 Jul 2026 10:48:25 +0200 Subject: [PATCH 08/12] Show egg name below server name in sidebar dropdown --- public/css/style.css | 16 +++++++++++++++- public/js/app.js | 6 +++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 46476c0..a1f8bcd 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1069,7 +1069,6 @@ cap-widget { margin-bottom: 1px; position: relative; z-index: 2; - white-space: nowrap; } .nav-sub-item:hover { @@ -1081,9 +1080,24 @@ cap-widget { color: var(--accent-1); } +.nav-sub-server-info { + display: flex; + flex-direction: column; + min-width: 0; +} + .nav-sub-server-name { overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; +} + +.nav-sub-egg-name { + font-size: 0.7rem; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .nav-sub-nest-icon { diff --git a/public/js/app.js b/public/js/app.js index a0f1587..f8dc8d7 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1687,10 +1687,14 @@ function buildServerSubList() { const isSuspended = s.status === 'suspended'; const dotClass = isSuspended ? 'dot-suspended' : (isInstalling ? 'dot-installing' : 'dot-active'); const isActive = state.currentPage === 'server' && state.serverId === s.id; + const eggName = s.eggDetails?.name || ''; return html` ${renderNestLogoSmall(s.nestLogo)} - ${escapeHtml(s.name)} + + ${escapeHtml(s.name)} + ${eggName ? html`${escapeHtml(eggName)}` : ''} + `; From 874234dc608c314799a2491299e05a6eb56d16b1 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 17 Jul 2026 10:49:49 +0200 Subject: [PATCH 09/12] Keep status dot inline next to server name --- public/css/style.css | 4 ++++ public/js/app.js | 3 +-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index a1f8bcd..1b77bf0 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1090,6 +1090,9 @@ cap-widget { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + display: flex; + align-items: center; + gap: 6px; } .nav-sub-egg-name { @@ -1116,6 +1119,7 @@ cap-widget { height: 6px; border-radius: 50%; flex-shrink: 0; + display: inline-block; } .nav-sub-dot.dot-active { diff --git a/public/js/app.js b/public/js/app.js index f8dc8d7..72a21e2 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1692,10 +1692,9 @@ function buildServerSubList() { ${renderNestLogoSmall(s.nestLogo)} - ${escapeHtml(s.name)} + ${escapeHtml(s.name)} ${eggName ? html`${escapeHtml(eggName)}` : ''} - `; }).join(''); From 475f86abc976e1f057526bb88140cb0d98d0a7ef Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 17 Jul 2026 10:51:44 +0200 Subject: [PATCH 10/12] Move status dot to right of server name --- public/js/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/js/app.js b/public/js/app.js index 72a21e2..201e86e 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1692,7 +1692,7 @@ function buildServerSubList() { ${renderNestLogoSmall(s.nestLogo)} - ${escapeHtml(s.name)} + ${escapeHtml(s.name)} ${eggName ? html`${escapeHtml(eggName)}` : ''} From 9347575480592b8bf0a5b75008d522984755ad89 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 17 Jul 2026 15:41:45 +0200 Subject: [PATCH 11/12] Add auto-merge workflow for ddrayko PRs with versioned titles --- .github/workflows/auto-merge-pr.yml | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/auto-merge-pr.yml diff --git a/.github/workflows/auto-merge-pr.yml b/.github/workflows/auto-merge-pr.yml new file mode 100644 index 0000000..bc56887 --- /dev/null +++ b/.github/workflows/auto-merge-pr.yml @@ -0,0 +1,32 @@ +name: Auto-merge PR from ddrayko + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + if: github.event.pull_request.user.login == 'ddrayko' + runs-on: ubuntu-latest + steps: + - name: Check PR title format + id: check_title + env: + TITLE: ${{ github.event.pull_request.title }} + run: | + if echo "$TITLE" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "match=true" >> "$GITHUB_OUTPUT" + else + echo "match=false" >> "$GITHUB_OUTPUT" + fi + + - name: Auto-merge + if: steps.check_title.outputs.match == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr merge "${{ github.event.pull_request.html_url }}" --merge --auto From 24ae4c7f9609b94ea7561c63c602a29e1cade21a Mon Sep 17 00:00:00 2001 From: ddrayko Date: Fri, 17 Jul 2026 15:44:22 +0200 Subject: [PATCH 12/12] Use BOT_TOKEN instead of GITHUB_TOKEN for auto-merge --- .github/workflows/auto-merge-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-merge-pr.yml b/.github/workflows/auto-merge-pr.yml index bc56887..5ad0ab1 100644 --- a/.github/workflows/auto-merge-pr.yml +++ b/.github/workflows/auto-merge-pr.yml @@ -27,6 +27,6 @@ jobs: - name: Auto-merge if: steps.check_title.outputs.match == 'true' env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.BOT_TOKEN }} run: | gh pr merge "${{ github.event.pull_request.html_url }}" --merge --auto