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": { diff --git a/public/css/style.css b/public/css/style.css index cac12c4..1b77bf0 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1069,9 +1069,6 @@ cap-widget { margin-bottom: 1px; position: relative; z-index: 2; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } .nav-sub-item:hover { @@ -1083,11 +1080,46 @@ 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; + display: flex; + align-items: center; + gap: 6px; +} + +.nav-sub-egg-name { + font-size: 0.7rem; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nav-sub-nest-icon { + 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 { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; + display: inline-block; } .nav-sub-dot.dot-active { @@ -4607,3 +4639,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..201e86e 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1511,7 +1511,7 @@ async function renderDashboard() {
Performing security checks...
+"${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` + +${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 +3062,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..f1df97f 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, @@ -81,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; @@ -228,6 +250,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]);