diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml index 9048598..fa7402b 100644 --- a/.github/workflows/deploy-beta.yml +++ b/.github/workflows/deploy-beta.yml @@ -13,8 +13,9 @@ jobs: with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} - password: ${{ secrets.SSH_PASSWORD }} + key: ${{ secrets.SSH_PRIVATE_KEY }} port: ${{ secrets.SSH_PORT || 22 }} + host_key_checking: false script: | cd ${{ secrets.BETA_PATH }} cp .env /tmp/beta.env 2>/dev/null; cp port.txt /tmp/beta.port.txt 2>/dev/null diff --git a/.github/workflows/deploy-main.yml b/.github/workflows/deploy-main.yml index 6eeb022..4b4461d 100644 --- a/.github/workflows/deploy-main.yml +++ b/.github/workflows/deploy-main.yml @@ -13,8 +13,9 @@ jobs: with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} - password: ${{ secrets.SSH_PASSWORD }} + key: ${{ secrets.SSH_PRIVATE_KEY }} port: ${{ secrets.SSH_PORT || 22 }} + host_key_checking: false script: | cd ${{ secrets.MAIN_PATH }} cp .env /tmp/main.env 2>/dev/null; cp port.txt /tmp/main.port.txt 2>/dev/null diff --git a/config/pyrodactyl.js b/config/pyrodactyl.js index cbe1cba..f03a6ed 100644 --- a/config/pyrodactyl.js +++ b/config/pyrodactyl.js @@ -1,6 +1,7 @@ export const PTERO_URL = process.env.PTERO_URL || 'https://panel.zero-host.org'; export const PTERO_API_KEY = process.env.PTERO_API_KEY || ''; -export const PANEL_DB_NAME = (process.env.PANEL_DB_NAME || 'panel').replace(/[^a-zA-Z0-9_]/g, ''); +const _rawPanelDb = (process.env.PANEL_DB_NAME || 'panel').replace(/[^a-zA-Z0-9_]/g, ''); +export const PANEL_DB_NAME = '`' + _rawPanelDb + '`'; export const SERVER_LIMITS = { memory: 512, diff --git a/middleware/auth.js b/middleware/auth.js index 3f7faa9..309c511 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -75,9 +75,17 @@ export function requireNotRestricted(req, res, next) { next(); } +const ALLOWED_TABLES = new Set(['server_meta']); +const ALLOWED_COLUMNS = new Set(['ptero_server_id', 'user_id', 'id']); + export function requireOwnership(table, column, paramName, idSource = 'params') { return async (req, res, next) => { try { + if (!ALLOWED_TABLES.has(table) || !ALLOWED_COLUMNS.has(column)) { + console.error(`Blocked ownership check on unauthorized table/column: ${table}.${column}`); + return res.status(500).json({ error: 'Ownership verification failed' }); + } + const id = parseInt(idSource === 'params' ? req.params[paramName] : req.body[paramName], 10); if (isNaN(id)) { return res.status(400).json({ error: 'Invalid ID' }); diff --git a/public/css/style.css b/public/css/style.css index 6532807..483ada3 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -424,6 +424,34 @@ cap-widget { display: block; } +.auth-error.show:has(.auth-resend-btn) { + display: flex; + flex-direction: column; + gap: 10px; +} + +.auth-resend-btn { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.4); + color: var(--accent-red); + padding: 6px 14px; + border-radius: var(--radius-sm); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background var(--transition), opacity var(--transition); + align-self: flex-start; +} + +.auth-resend-btn:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.25); +} + +.auth-resend-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* ===== DASHBOARD LAYOUT ===== */ .dashboard-layout { display: flex; @@ -528,6 +556,24 @@ cap-widget { gap: 0; } +.sidebar.collapsed .nav-parent { + font-size: 0; + justify-content: center; + padding: 10px 0; + gap: 0; +} + +.sidebar.collapsed .nav-parent-label, +.sidebar.collapsed .nav-parent-chevron { + display: none; +} + +.sidebar.collapsed .nav-parent svg { + font-size: 1rem; + width: 20px; + height: 20px; +} + .sidebar.collapsed .nav-indicator { left: 8px; width: calc(100% - 16px); @@ -635,6 +681,9 @@ cap-widget { position: relative; } +.nav-items { +} + .nav-indicator { position: absolute; left: 12px; @@ -678,7 +727,8 @@ cap-widget { background: rgba(238, 129, 50, 0.08); } -.nav-item.active { +.nav-item.active, +.nav-parent.active { color: var(--accent-1); } @@ -688,6 +738,120 @@ cap-widget { flex-shrink: 0; } +.nav-parent { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: color var(--transition); + text-decoration: none; + margin-bottom: 2px; + position: relative; + z-index: 2; + user-select: none; +} + +.nav-parent:hover { + color: var(--text-primary); + background: rgba(238, 129, 50, 0.08); +} + +.nav-parent svg { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.nav-parent-label { + flex: 1; + min-width: 0; +} + +.nav-parent-chevron { + width: 16px; + height: 16px; + flex-shrink: 0; + transition: transform 0.2s ease; +} + +.nav-parent.open .nav-parent-chevron { + transform: rotate(90deg); +} + +.nav-sub-list { + max-height: 0; + overflow: hidden; + transition: max-height 0.25s ease; + margin-bottom: 2px; +} + +.nav-sub-list.open { + max-height: 400px; +} + +.sidebar.collapsed .nav-sub-list { + display: none; +} + +.nav-sub-item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 12px 7px 42px; + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 0.82rem; + font-weight: 500; + cursor: pointer; + transition: color var(--transition); + text-decoration: none; + margin-bottom: 1px; + position: relative; + z-index: 2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nav-sub-item:hover { + color: var(--text-primary); + background: rgba(238, 129, 50, 0.08); +} + +.nav-sub-item.active { + color: var(--accent-1); +} + +.nav-sub-item .nav-sub-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.nav-sub-dot.dot-active { + background: #22c55e; +} + +.nav-sub-dot.dot-installing { + background: #f59e0b; +} + +.nav-sub-dot.dot-suspended { + background: var(--accent-red); +} + +.nav-sub-empty { + padding: 8px 12px 8px 42px; + font-size: 0.8rem; + color: var(--text-muted); +} + .sidebar-footer { padding: 12px 12px 4px; border-top: 1px solid var(--border); @@ -1343,12 +1507,22 @@ tbody tr:hover { flex-shrink: 0; } +.notif-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + .notif-panel-header h3 { font-size: 1.1rem; font-weight: 700; color: var(--text-primary); } +.notif-close-mobile { + display: none; +} + .notif-mark-all { background: none; border: none; @@ -1581,9 +1755,49 @@ tbody tr:hover { padding: 8px; } +/* ─── Tablet & below (≤ 1024px) ─── */ +@media (max-width: 1024px) { + .main-content { + padding: 24px; + } + + .admin-content { + padding: 24px; + } + + .page-title { + font-size: 1.5rem; + } + + .stat-value { + font-size: 1.7rem; + } + + .modal { + max-width: 480px; + } + + .wizard-progress { + max-width: 100%; + } + + .wizard-step-label { + font-size: 0.75rem; + } + + .wizard-step-indicator { + padding: 6px 8px; + gap: 6px; + } +} + +/* ─── Mobile (≤ 768px) ─── */ @media (max-width: 768px) { + /* Sidebar: off-canvas */ .sidebar { transform: translateX(-100%); + width: 260px; + z-index: 200; } .sidebar.open { @@ -1594,106 +1808,1247 @@ tbody tr:hover { display: none; } + .sidebar.collapsed { + width: 260px; + } + + .sidebar.collapsed .sidebar-resizer { + display: none; + } + + .sidebar.collapsed ~ .main-content { + margin-left: 0; + } + + /* Sidebar backdrop for mobile */ + .sidebar-backdrop { + display: none; + } + + .sidebar.open ~ .sidebar-backdrop { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 99; + -webkit-backdrop-filter: blur(2px); + backdrop-filter: blur(2px); + } + + /* Main content: full width */ .main-content { margin-left: 0; - padding: 20px; + padding: 16px; + padding-top: 60px; } + /* Hamburger button */ .hamburger-toggle { display: flex; align-items: center; justify-content: center; position: fixed; - top: 16px; - left: 16px; + top: 12px; + left: 12px; z-index: 101; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + } + + /* Page header */ + .page-header { + margin-bottom: 24px; + } + + .page-title { + font-size: 1.35rem; + word-break: break-word; + } + + .page-subtitle { + font-size: 0.85rem; } + /* Stat grid: 2 columns on tablet, 1 on mobile */ .stat-grid { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + } + + .stat-card { + padding: 16px; + } + + .stat-value { + font-size: 1.5rem; + } + + .stat-label { + font-size: 0.75rem; } + /* Server grid: single column */ .server-grid { grid-template-columns: 1fr; + gap: 12px; } - .form-row { - flex-direction: column; - gap: 0; + .server-card { + padding: 16px; } - .auth-card { - padding: 32px 24px; + .server-card-name { + font-size: 0.92rem; } - .table-container { - overflow-x: auto; + /* Cards */ + .card { + padding: 16px; + border-radius: var(--radius-md); } -} -/* ===== PYRODACTYL PAGE ===== */ -.ptero-grid { - max-width: 640px; -} + .card-header { + flex-wrap: wrap; + gap: 8px; + } -.ptero-card-icon { - width: 56px; - height: 56px; - border-radius: var(--radius-md); - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 20px; - color: var(--accent-cyan); - background: rgba(34, 211, 238, 0.1); -} + /* Tables: card layout on mobile */ + .table-container { + background: transparent; + border: none; + border-radius: 0; + overflow: visible; + } -.ptero-card-title { - font-size: 1.4rem; - font-weight: 700; - margin-bottom: 12px; -} + .table-container table { + width: 100%; + } -.ptero-card-desc { - color: var(--text-secondary); - line-height: 1.7; - margin-bottom: 24px; -} + .table-container thead { + display: none; + } -.ptero-info { - display: flex; - flex-direction: column; - gap: 14px; -} + .table-container tbody { + display: flex; + flex-direction: column; + gap: 10px; + } -.ptero-info-item { - display: flex; - align-items: flex-start; - gap: 12px; - font-size: 0.9rem; - color: var(--text-secondary); - line-height: 1.5; -} + .table-container tbody tr { + display: flex; + flex-direction: column; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 14px 16px; + gap: 6px; + } -.ptero-info-item svg { - flex-shrink: 0; - margin-top: 2px; - color: var(--accent-cyan); -} + .table-container tbody tr:hover { + background: var(--bg-card); + } -.ptero-info-item span { - flex: 1; -} + .table-container tbody td { + padding: 0; + border-bottom: none; + font-size: 0.85rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } -.ptero-info-item strong { - color: var(--text-primary); -} + .table-container tbody td::before { + content: attr(data-label); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; + } -/* ===== ACCOUNT PAGE ===== */ + .table-container tbody td:last-child { + border-top: 1px solid rgba(255, 255, 255, 0.04); + padding-top: 8px; + margin-top: 2px; + justify-content: flex-end; + } + + .table-container tbody td:first-child::before { + display: none; + } + + .table-container tbody td:first-child { + font-weight: 700; + font-size: 0.92rem; + } + + /* Form rows */ + .form-row { + flex-direction: column; + gap: 0; + } + + /* Auth card */ + .auth-card { + padding: 28px 20px; + border-radius: var(--radius-lg); + } + + .auth-title { + font-size: 1.35rem; + } + + .auth-logo-text { + font-size: 1.2rem; + } + + /* Tabs: scrollable */ + .tabs { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + gap: 0; + padding-bottom: 0; + } + + .tabs::-webkit-scrollbar { + display: none; + } + + .tab { + padding: 10px 14px; + font-size: 0.82rem; + white-space: nowrap; + flex-shrink: 0; + } + + /* Server detail */ + .server-detail-grid { + grid-template-columns: 1fr; + gap: 16px; + } + + .detail-item { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + + .detail-label { + font-size: 0.72rem; + } + + .detail-value { + font-size: 0.85rem; + word-break: break-all; + } + + /* Resource gauges */ + .resource-gauges { + grid-template-columns: 1fr; + gap: 12px; + } + + .resource-gauge { + padding: 16px 12px; + } + + .resource-gauge-value { + font-size: 1.4rem; + } + + /* Servers toolbar */ + .servers-toolbar { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .search-wrapper { + min-width: 0; + } + + .filter-group { + justify-content: center; + flex-wrap: wrap; + } + + .filter-btn { + padding: 6px 12px; + font-size: 0.75rem; + } + + /* Modal */ + .modal-overlay { + padding: 16px; + align-items: flex-end; + } + + .modal { + max-width: 100%; + border-radius: var(--radius-xl) var(--radius-xl) 0 0; + padding: 24px 20px; + max-height: 85vh; + } + + .modal-title { + font-size: 1.15rem; + margin-bottom: 20px; + } + + .modal-actions { + flex-direction: column; + gap: 8px; + margin-top: 20px; + } + + .modal-actions .btn { + width: 100%; + } + + /* Toast */ + .toast-container { + bottom: 16px; + right: 16px; + left: 16px; + z-index: 500; + } + + .toast { + padding: 12px 16px; + font-size: 0.82rem; + } + + /* Notification panel: adapts on tablet */ + .notif-panel { + left: 0; + width: 100%; + top: 0; + bottom: 0; + height: 100%; + border-right: none; + border-radius: 0; + z-index: 150; + } + + .sidebar.collapsed ~ .notif-panel { + left: 0; + } + + .notif-panel-header { + padding: 16px 16px 14px; + border-bottom: 1px solid var(--border); + } + + .notif-panel-header h3 { + font-size: 1rem; + } + + .notif-mark-all { + display: none; + } + + .notif-close-mobile { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + border-radius: var(--radius-sm); + transition: all var(--transition); + } + + .notif-close-mobile:hover { + color: var(--text-primary); + background: var(--bg-3); + } + + .notif-item { + padding: 12px 16px; + gap: 10px; + } + + .notif-item-icon { + width: 32px; + height: 32px; + } + + .notif-icon { + width: 16px; + height: 16px; + } + + .notif-item-title { + font-size: 0.85rem; + } + + .notif-item-msg { + font-size: 0.78rem; + line-height: 1.4; + } + + .notif-item-time { + font-size: 0.7rem; + } + + .notif-empty { + padding: 40px 16px; + font-size: 0.85rem; + } + + .notif-backdrop { + z-index: 149; + } + + /* Cookie banner */ + .cookie-banner { + flex-direction: column; + padding: 16px; + gap: 12px; + align-items: stretch; + } + + .cookie-banner-text { + min-width: 0; + } + + .cookie-banner-text p { + font-size: 0.82rem; + } + + .cookie-banner-actions { + justify-content: stretch; + flex-wrap: nowrap; + } + + .cookie-banner-actions .btn { + flex: 1; + } + + /* Empty state */ + .empty-state { + padding: 40px 16px; + } + + .empty-state-icon { + width: 48px; + height: 48px; + } + + .empty-state-title { + font-size: 1rem; + } + + .empty-state-desc { + font-size: 0.82rem; + } + + /* Wizard */ + .wizard-progress { + display: none; + } + + .wizard-progress::-webkit-scrollbar { + display: none; + } + + .wizard-step-indicator { + padding: 6px 8px; + gap: 5px; + } + + .wizard-step-circle { + width: 26px; + height: 26px; + } + + .wizard-step-circle svg { + width: 13px !important; + height: 13px !important; + } + + .wizard-step-label { + font-size: 0.7rem; + } + + .wizard-step-title { + font-size: 1.1rem; + } + + .wizard-step-desc { + font-size: 0.82rem; + margin-bottom: 16px; + } + + .wizard-actions { + flex-direction: column-reverse; + gap: 8px; + } + + .wizard-actions .btn { + width: 100%; + } + + #wizard-next-btn { + margin-left: 0; + } + + /* Create page header fix */ + #page-create .page-header { + display: none !important; + } + + /* Nest grid */ + .nest-grid { + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } + + .nest-card { + padding: 16px 12px; + border-radius: var(--radius-md); + } + + .nest-card-logo { + width: 48px; + height: 48px; + } + + .nest-card-logo img, + .nest-card-logo i { + width: 32px !important; + height: 32px !important; + } + + .nest-card-name { + font-size: 0.85rem; + line-height: 1.2; + } + + .nest-card-desc { + font-size: 0.72rem; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + /* Egg grid */ + .egg-grid { + grid-template-columns: 1fr; + gap: 8px; + } + + .egg-card { + padding: 12px; + gap: 10px; + border-radius: var(--radius-md); + } + + .egg-card-logo { + width: 36px; + height: 36px; + } + + .egg-card-logo img, + .egg-card-logo i { + width: 28px !important; + height: 28px !important; + } + + .egg-card-logo { + width: 32px; + height: 32px; + } + + .egg-card-name { + font-size: 0.85rem; + } + + .egg-card-desc { + font-size: 0.75rem; + } + + /* Summary card */ + .summary-card { + padding: 16px; + max-width: 100%; + } + + .summary-row { + flex-direction: column; + align-items: flex-start; + gap: 2px; + } + + .summary-value { + text-align: left; + max-width: 100%; + } + + /* Account page */ + .account-grid { + max-width: 100%; + } + + .account-menu-icon { + width: 36px; + height: 36px; + } + + /* Pyrodactyl page */ + .ptero-grid { + max-width: 100%; + } + + .ptero-card-icon { + width: 44px; + height: 44px; + margin-bottom: 14px; + } + + .ptero-card-title { + font-size: 1.15rem; + } + + /* Activity timeline */ + .activity-item { + gap: 10px; + padding: 10px 0; + } + + .activity-icon { + width: 28px; + height: 28px; + font-size: 0.7rem; + } + + .activity-action { + font-size: 0.82rem; + } + + .activity-details { + font-size: 0.72rem; + } + + .activity-time { + font-size: 0.68rem; + } + + /* Log pagination */ + .log-pagination { + flex-wrap: wrap; + gap: 8px; + } + + .log-pagination-info { + font-size: 0.75rem; + width: 100%; + text-align: center; + } + + /* API key input group */ + .api-key-input-group { + flex-direction: column; + gap: 8px; + } + + /* Buttons: ensure touch targets */ + .btn { + min-height: 40px; + } + + .btn-sm { + min-height: 34px; + padding: 6px 12px; + font-size: 0.78rem; + } + + /* SVG picker */ + .svg-picker-grid { + grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); + } + + /* Admin navbar */ + .admin-navbar { + padding: 0 12px; + gap: 8px; + height: 52px; + } + + .admin-navbar-brand { + font-size: 0.82rem; + } + + .admin-navbar-logo { + width: 24px; + height: 24px; + } + + .admin-badge { + font-size: 0.55rem; + padding: 1px 5px; + } + + .admin-navbar-center { + gap: 2px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + padding: 0 4px; + } + + .admin-navbar-center::-webkit-scrollbar { + display: none; + } + + .admin-nav-link { + padding: 6px 10px; + font-size: 0.78rem; + white-space: nowrap; + flex-shrink: 0; + } + + .admin-nav-link svg { + width: 14px; + height: 14px; + } + + .admin-navbar-user { + display: none; + } + + .admin-content { + padding: 16px; + } + + .admin-notice { + font-size: 0.78rem; + padding: 10px 12px; + } + + /* Toggle switch */ + .toggle-switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + } + + /* Cap modal */ + .cap-modal { + padding: 16px; + margin: 16px; + } + + .cap-modal cap-widget { + width: 100% !important; + } + + /* Notif view modal */ + .notif-view-modal-content { + width: 95%; + max-height: 75vh; + } + + .notif-view-modal-header { + padding: 16px 16px 0; + } + + .notif-view-modal-body { + padding: 12px 16px 16px; + font-size: 0.85rem; + } + + /* Server detail page header */ + .server-detail-grid .card-title { + font-size: 0.92rem; + } + + /* Action cards */ + .action-card { + padding: 16px; + border-radius: var(--radius-md); + margin-bottom: 12px; + } + + .action-card-header { + gap: 10px; + margin-bottom: 12px; + } + + .action-card-header svg { + width: 20px; + height: 20px; + } + + .action-card-title { + font-size: 0.92rem; + } + + .action-card-desc { + font-size: 0.8rem; + } + + /* Server card actions */ + .server-card-actions { + flex-wrap: wrap; + } + + .server-card-actions .btn { + flex: 1; + min-width: 0; + } + + /* Power controls grid */ + [style*="display:flex"][style*="gap:8px"][style*="flex-wrap:wrap"] { + gap: 6px !important; + } +} + +/* ─── Small mobile (≤ 480px) ─── */ +@media (max-width: 480px) { + .main-content { + padding: 12px; + padding-top: 56px; + } + + .hamburger-toggle { + top: 10px; + left: 10px; + padding: 8px; + } + + .stat-grid { + grid-template-columns: 1fr; + gap: 10px; + } + + .stat-card { + padding: 14px; + display: flex; + align-items: center; + gap: 14px; + } + + .stat-icon { + margin-bottom: 0; + } + + .stat-card::before { + height: 100%; + } + + .page-title { + font-size: 1.2rem; + } + + .auth-card { + padding: 24px 16px; + } + + .auth-title { + font-size: 1.2rem; + } + + .auth-subtitle { + font-size: 0.82rem; + margin-bottom: 24px; + } + + .form-group input, + .form-group select, + .form-group textarea { + padding: 10px 12px; + font-size: 0.88rem; + } + + .btn { + min-height: 44px; + font-size: 0.85rem; + } + + .btn-sm { + min-height: 36px; + font-size: 0.75rem; + } + + .server-card-name { + font-size: 0.88rem; + } + + .server-card-status { + font-size: 0.65rem; + padding: 2px 8px; + } + + .server-detail-tag { + font-size: 0.68rem; + padding: 3px 7px; + } + + .nest-grid { + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + .nest-card { + padding: 12px 8px; + } + + .nest-card-logo { + width: 40px; + height: 40px; + } + + .nest-card-name { + font-size: 0.78rem; + } + + .wizard-step-indicator { + padding: 4px 6px; + gap: 2px; + } + + .wizard-step-circle { + width: 22px; + height: 22px; + } + + .wizard-step-circle svg { + width: 11px !important; + height: 11px !important; + } + + .wizard-step-label { + font-size: 0.62rem; + } + + .wizard-step-title { + font-size: 1rem; + } + + .wizard-step-desc { + font-size: 0.78rem; + } + + .nest-grid { + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + .nest-card { + padding: 12px 8px; + } + + .nest-card-logo { + width: 40px; + height: 40px; + } + + .nest-card-logo img, + .nest-card-logo i { + width: 28px !important; + height: 28px !important; + } + + .nest-card-name { + font-size: 0.78rem; + } + + .egg-card { + padding: 10px; + gap: 8px; + } + + .egg-card-logo { + width: 30px; + height: 30px; + } + + .egg-card-logo img, + .egg-card-logo i { + width: 24px !important; + height: 24px !important; + } + + .egg-card-name { + font-size: 0.82rem; + } + + .egg-card-desc { + font-size: 0.72rem; + } + + .detail-item { + padding-bottom: 6px; + } + + .resource-gauge-value { + font-size: 1.2rem; + } + + .resource-gauge-label { + font-size: 0.68rem; + } + + .resource-gauge-sub { + font-size: 0.6rem; + } + + .modal { + padding: 20px 16px; + } + + .modal-title { + font-size: 1.05rem; + } + + .admin-navbar { + height: 48px; + padding: 0 8px; + gap: 6px; + } + + .admin-navbar-brand { + font-size: 0.75rem; + gap: 4px; + } + + .admin-nav-link { + padding: 5px 8px; + font-size: 0.72rem; + } + + .admin-content { + padding: 12px; + } + + .page-header .btn-ghost[style*="margin-bottom:16px"] { + margin-bottom: 12px !important; + } + + /* Summary card on very small screens */ + .summary-row { + gap: 4px; + padding: 8px 0; + } + + .summary-label { + font-size: 0.78rem; + } + + .summary-value { + font-size: 0.85rem; + } + + .toast-container { + bottom: 12px; + right: 12px; + left: 12px; + } + + .toast { + padding: 10px 14px; + font-size: 0.78rem; + } + + .cookie-banner { + padding: 12px; + } + + .cookie-banner-actions .btn { + padding: 8px 12px; + font-size: 0.78rem; + } + + /* Notif panel */ + .notif-panel-header { + padding: 12px 12px 10px; + } + + .notif-panel-header h3 { + font-size: 0.92rem; + } + + .notif-mark-all { + font-size: 0.72rem; + padding: 4px 8px; + } + + .notif-item { + padding: 10px 12px; + gap: 8px; + } + + .notif-item-icon { + width: 28px; + height: 28px; + } + + .notif-icon { + width: 14px; + height: 14px; + } + + .notif-item-title { + font-size: 0.8rem; + } + + .notif-item-msg { + font-size: 0.72rem; + } + + .notif-item-time { + font-size: 0.65rem; + } + + .notif-dot { + width: 6px; + height: 6px; + margin-top: 4px; + } + + /* Create page 480px */ + .wizard-step-indicator { + padding: 3px 5px; + gap: 2px; + } + + .wizard-step-circle { + width: 20px; + height: 20px; + } + + .wizard-step-circle svg { + width: 10px !important; + height: 10px !important; + } + + .wizard-step-label { + font-size: 0.58rem; + } + + /* Notif panel */ + .notif-panel-header { + padding: 14px 14px 12px; + } + + .notif-panel-header h3 { + font-size: 0.95rem; + } + + .notif-item { + padding: 10px 14px; + } + + .notif-item-title { + font-size: 0.82rem; + } + + .notif-item-msg { + font-size: 0.75rem; + } + + /* Activity */ + .activity-item { + gap: 8px; + padding: 8px 0; + } + + .activity-icon { + width: 24px; + height: 24px; + font-size: 0.6rem; + } + + .activity-action { + font-size: 0.78rem; + } + + .activity-time { + font-size: 0.65rem; + } + + /* Admin table card layout adjustments */ + .admin-content .table-container tbody td:first-child { + font-size: 0.88rem; + } + + .admin-content .table-container tbody td { + font-size: 0.8rem; + } +} + +/* ===== PYRODACTYL PAGE ===== */ +.ptero-grid { + max-width: 640px; +} + +.ptero-card-icon { + width: 56px; + height: 56px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 20px; + color: var(--accent-cyan); + background: rgba(34, 211, 238, 0.1); +} + +.ptero-card-title { + font-size: 1.4rem; + font-weight: 700; + margin-bottom: 12px; +} + +.ptero-card-desc { + color: var(--text-secondary); + line-height: 1.7; + margin-bottom: 24px; +} + +.ptero-info { + display: flex; + flex-direction: column; + gap: 14px; +} + +.ptero-info-item { + display: flex; + align-items: flex-start; + gap: 12px; + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.5; +} + +.ptero-info-item svg { + flex-shrink: 0; + margin-top: 2px; + color: var(--accent-cyan); +} + +.ptero-info-item span { + flex: 1; +} + +.ptero-info-item strong { + color: var(--text-primary); +} + +/* ===== ACCOUNT PAGE ===== */ .account-grid { display: flex; flex-direction: column; @@ -1811,11 +3166,7 @@ tbody tr:hover { font-weight: 500; } -@media (max-width: 768px) { - .server-detail-grid { - grid-template-columns: 1fr; - } -} +/* server-detail-grid mobile: handled in RESPONSIVE section */ /* ===== RGPD / COOKIE CONSENT ===== */ .cookie-banner { @@ -2237,18 +3588,7 @@ tbody tr:hover { flex: 1; } -@media (max-width: 768px) { - .servers-toolbar { - flex-direction: column; - align-items: stretch; - } - .filter-group { - justify-content: center; - } - .resource-gauges { - grid-template-columns: 1fr; - } -} +/* servers-toolbar + resource-gauges mobile: handled in RESPONSIVE section */ /* ===== ADMIN PANEL ===== */ .admin-layout { @@ -2402,21 +3742,7 @@ tbody tr:hover { z-index: 1; } -@media (max-width: 768px) { - .admin-navbar { - padding: 0 12px; - gap: 8px; - } - .admin-navbar-brand { - font-size: 0.85rem; - } - .admin-navbar-user { - display: none; - } - .admin-content { - padding: 20px; - } -} +/* admin mobile: handled in RESPONSIVE section */ /* ─── Create Wizard ─── */ .wizard-progress { @@ -2538,6 +3864,16 @@ tbody tr:hover { padding-top: 20px; } +.wizard-progress::-webkit-scrollbar { + display: none; +} + +@media (max-width: 768px) { + .wizard-progress { + display: none; + } +} + /* Nest Cards Grid */ .nest-grid { display: grid; diff --git a/public/index.html b/public/index.html index f208569..f02948a 100644 --- a/public/index.html +++ b/public/index.html @@ -17,7 +17,7 @@
- - + + diff --git a/public/js/admin.js b/public/js/admin.js index e7c6dca..1086c56 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -563,12 +563,12 @@ async function renderAdminServers() { return ahtml` - ${escapeHtml(s.name)} - ${escapeHtml(ownerName)} - ${escapeHtml(eggName)} - ${escapeHtml(allocStr)} - ${escapeHtml(statusLabel)} - + ${escapeHtml(s.name)} + ${escapeHtml(ownerName)} + ${escapeHtml(eggName)} + ${escapeHtml(allocStr)} + ${escapeHtml(statusLabel)} + Details @@ -1181,13 +1181,13 @@ async function renderAdminUsers() { tbody.innerHTML = data.users.map(u => ahtml` - ${u.id} - ${escapeHtml(u.username)} - ${escapeHtml(u.email)} - ${u.is_admin ? 'Admin' : 'User'} - ${u.server_count} - ${formatDateWithTooltip(u.created_at)} - + ${u.id} + ${escapeHtml(u.username)} + ${escapeHtml(u.email)} + ${u.is_admin ? 'Admin' : 'User'} + ${u.server_count} + ${formatDateWithTooltip(u.created_at)} + Details @@ -1278,11 +1278,11 @@ async function renderAdminUserDetail(userId) { ${data.servers.map(s => ahtml` - ${s.server_name || 'Unknown'} - ${s.status} - ${formatDateWithTooltip(s.created_at)} - ${formatDateWithTooltip(s.expires_at)} - + ${s.server_name || 'Unknown'} + ${s.status} + ${formatDateWithTooltip(s.created_at)} + ${formatDateWithTooltip(s.expires_at)} + Manage @@ -1516,10 +1516,10 @@ async function renderAdminActivity() { tbody.innerHTML = data.activities.map(a => ahtml` - ${formatDateWithTooltip(a.created_at)} - ${a.username || 'Unknown'} - ${a.action} - ${a.details || ''} + ${formatDateWithTooltip(a.created_at)} + ${a.username || 'Unknown'} + ${a.action} + ${a.details || ''} `).join(''); } catch (err) { @@ -1613,19 +1613,19 @@ async function renderAdminEggsSettings() { tbody.innerHTML = data.nests.map(n => ahtml` - ${n.id} - ${n.logo ? renderLogoDisplay(n.logo, 32) : ''} - ${n.name} - ${n.description || '—'} - ${n.ptero_nest_id} - + ${n.id} + ${n.logo ? renderLogoDisplay(n.logo, 32) : ''} + ${n.name} + ${n.description || '—'} + ${n.ptero_nest_id} + ${n.unavailable ? 'Unavailable' : 'Available'} - + @@ -1723,19 +1723,19 @@ async function renderAdminNestEggs(nestId) { const isUnavailable = res?.unavailable; return ahtml` - ${e.id} - ${res?.logo ? renderLogoDisplay(res.logo, 28) : ''} - ${e.name} - ${e.description || '—'} - ${resStr} - + ${e.id} + ${res?.logo ? renderLogoDisplay(res.logo, 28) : ''} + ${e.name} + ${e.description || '—'} + ${resStr} + ${isUnavailable ? 'Unavailable' : 'Available'} - + Configure @@ -2395,14 +2395,14 @@ async function renderAdminNodes() { const isOnline = n.is_online; return ahtml` - ${n.id} - ${escapeHtml(n.name)} - ${escapeHtml(n.fqdn)} - ${ramMB !== '—' ? ramMB + ' MB' : '—'} - ${diskGB !== '—' ? diskGB + ' GB' : '—'} - ${cpuPct} - ${allocCount} - ${isOnline !== undefined ? (isOnline ? 'Online' : 'Offline') : '—'} + ${n.id} + ${escapeHtml(n.name)} + ${escapeHtml(n.fqdn)} + ${ramMB !== '—' ? ramMB + ' MB' : '—'} + ${diskGB !== '—' ? diskGB + ' GB' : '—'} + ${cpuPct} + ${allocCount} + ${isOnline !== undefined ? (isOnline ? 'Online' : 'Offline') : '—'} `; }).join(''); @@ -2572,11 +2572,11 @@ async function renderAdminNodeDetail(nodeId) { } else { allocTbody.innerHTML = allocs.map(a => ahtml` - ${a.id} - ${escapeHtml(a.ip)} - ${a.port} - ${a.alias ? escapeHtml(a.alias) : '—'} - ${a.server ? `${a.server}` : 'Free'} + ${a.id} + ${escapeHtml(a.ip)} + ${a.port} + ${a.alias ? escapeHtml(a.alias) : '—'} + ${a.server ? `${a.server}` : 'Free'} `).join(''); } @@ -2590,10 +2590,10 @@ async function renderAdminNodeDetail(nodeId) { } else { serversTbody.innerHTML = servers.map(s => ahtml` - ${s.id} - ${escapeHtml(s.name)} - ${escapeHtml(s.identifier)} - ${s.status || '—'} + ${s.id} + ${escapeHtml(s.name)} + ${escapeHtml(s.identifier)} + ${s.status || '—'} `).join(''); } diff --git a/public/js/app.js b/public/js/app.js index 6ae3401..b104b2c 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -18,6 +18,8 @@ const state = { notifPanelOpen: false, sidebarMode: 'main', accountTab: 'info', + sidebarServersOpen: false, + sidebarServersLoading: false, }; function setRgpdConsent(preferences) { @@ -276,7 +278,33 @@ function showToast(message, type = 'success') { function showError(form, message) { const errorEl = form.querySelector('.auth-error'); if (errorEl) { - errorEl.textContent = message; + if (message && message.includes('verify your email')) { + const email = (form.querySelector('#login-email')?.value || '').trim(); + errorEl.innerHTML = html` + ${escapeHtml(message)} + + `; + const resendBtn = errorEl.querySelector('#auth-resend-btn'); + if (resendBtn) { + resendBtn.addEventListener('click', async () => { + if (!email) return; + resendBtn.disabled = true; + resendBtn.textContent = 'Sending...'; + try { + await api('/auth/resend-verification', { + method: 'POST', + body: JSON.stringify({ email }), + }); + resendBtn.textContent = 'Email sent!'; + } catch (err) { + resendBtn.textContent = 'Failed to send'; + resendBtn.disabled = false; + } + }); + } + } else { + errorEl.textContent = message; + } errorEl.classList.add('show'); } } @@ -441,6 +469,10 @@ function openNotifPanel() { $('#notif-backdrop').classList.add('open'); document.body.style.overflow = 'hidden'; + if (window.innerWidth <= 768) { + $('#sidebar').classList.remove('open'); + } + document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); $('#nav-notifications').classList.add('active'); updateNavIndicator(); @@ -551,6 +583,8 @@ function renderLoginPage() { }); setupPasskeyAutofill(); + initIcons(); + setTimeout(initIcons, 100); } function renderRegisterPage() { @@ -1004,11 +1038,17 @@ async function renderDashboard() { +

Notifications

- +
+ + +
No notifications yet
@@ -1067,11 +1107,13 @@ async function renderDashboard() { }); $('#notif-backdrop').addEventListener('click', closeNotifPanel); + $('#notif-close-mobile').addEventListener('click', closeNotifPanel); $('#notif-mark-all').addEventListener('click', markAllAsRead); $('#sidebar-logo-link').addEventListener('click', (e) => { e.preventDefault(); + if (window.innerWidth <= 768) return; toggleSidebarCollapse(); }); @@ -1079,6 +1121,10 @@ async function renderDashboard() { $('#sidebar').classList.toggle('open'); }); + $('#sidebar-backdrop').addEventListener('click', () => { + $('#sidebar').classList.remove('open'); + }); + initSidebarResize(); initSidebarTooltip(); @@ -1117,7 +1163,7 @@ function initSidebarTooltip() { } sidebarNav.addEventListener('mouseover', (e) => { - const item = e.target.closest('.nav-item'); + const item = e.target.closest('.nav-item, .nav-parent'); if (!item) return; if (!sidebar.classList.contains('collapsed')) return; @@ -1143,13 +1189,44 @@ function initSidebarTooltip() { }); } +function buildServerSubList() { + if (state.sidebarServersLoading) return html``; + if (state.servers.length === 0) return html``; + return state.servers.map(s => { + const isInstalling = s.status === 'installing' || s.installed === 0 || s.installed === '0' || s.installed === false; + const isSuspended = s.status === 'suspended'; + const dotClass = isSuspended ? 'dot-suspended' : (isInstalling ? 'dot-installing' : 'dot-active'); + const isActive = state.currentPage === 'server' && state.serverId === s.id; + return html` + + + ${escapeHtml(s.name)} + + `; + }).join(''); +} + function renderSidebarNav() { const nav = $('#sidebar-nav'); if (!nav) return; + let indicator = document.getElementById('nav-indicator'); + if (!indicator) { + indicator = document.createElement('div'); + indicator.className = 'nav-indicator'; + indicator.id = 'nav-indicator'; + nav.appendChild(indicator); + } + + let itemsContainer = nav.querySelector('.nav-items'); + if (!itemsContainer) { + itemsContainer = document.createElement('div'); + itemsContainer.className = 'nav-items'; + nav.appendChild(itemsContainer); + } + if (state.sidebarMode === 'account') { - nav.innerHTML = html` - + itemsContainer.innerHTML = html` @@ -1174,17 +1251,20 @@ function renderSidebarNav() { `; } else { - nav.innerHTML = html` - + itemsContainer.innerHTML = html` Overview - + + @@ -1215,28 +1295,93 @@ function renderSidebarNav() { `; } - document.querySelectorAll('.nav-item[data-page]').forEach(item => { + itemsContainer.querySelectorAll('.nav-item[data-page]').forEach(item => { item.addEventListener('click', (e) => { e.preventDefault(); navigateTo(item.dataset.page); }); }); - document.querySelectorAll('.nav-item[data-account-page]').forEach(item => { + itemsContainer.querySelectorAll('.nav-item[data-account-page]').forEach(item => { item.addEventListener('click', (e) => { e.preventDefault(); navigateTo('account/' + item.dataset.accountPage); }); }); - const notifItem = document.querySelector('#nav-notifications'); + const notifItem = itemsContainer.querySelector('#nav-notifications'); if (notifItem) { notifItem.addEventListener('click', (e) => { e.preventDefault(); + itemsContainer.querySelectorAll('.nav-item, .nav-parent').forEach(n => n.classList.remove('active')); + notifItem.classList.add('active'); + updateNavIndicator(); toggleNotifPanel(); }); } + const serversToggle = itemsContainer.querySelector('#nav-servers-toggle'); + if (serversToggle) { + serversToggle.addEventListener('click', async (e) => { + e.preventDefault(); + state.sidebarServersOpen = !state.sidebarServersOpen; + serversToggle.classList.toggle('open', state.sidebarServersOpen); + const subList = document.querySelector('#nav-servers-list'); + if (subList) subList.classList.toggle('open', state.sidebarServersOpen); + if (state.sidebarServersOpen && state.servers.length === 0 && !state.sidebarServersLoading) { + state.sidebarServersLoading = true; + const subListEl = document.querySelector('#nav-servers-list'); + if (subListEl) subListEl.innerHTML = html``; + try { + const data = await api('/servers/list'); + state.servers = data.servers || []; + } catch (err) { + state.servers = []; + } + state.sidebarServersLoading = false; + const subListRefresh = document.querySelector('#nav-servers-list'); + if (subListRefresh) { + subListRefresh.innerHTML = buildServerSubList(); + subListRefresh.querySelectorAll('.nav-sub-item[data-server-nav]').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + navigateTo('server/' + item.dataset.serverNav); + }); + }); + initIcons(); + } + } + navigateTo('servers'); + }); + } + + itemsContainer.querySelectorAll('.nav-sub-item[data-server-nav]').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + navigateTo('server/' + item.dataset.serverNav); + }); + }); + + if (state.sidebarMode !== 'account' && state.servers.length === 0 && !state.sidebarServersLoading) { + state.sidebarServersLoading = true; + api('/servers/list').then(data => { + state.servers = data.servers || []; + state.sidebarServersLoading = false; + const subList = document.querySelector('#nav-servers-list'); + if (subList) { + subList.innerHTML = buildServerSubList(); + subList.querySelectorAll('.nav-sub-item[data-server-nav]').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + navigateTo('server/' + item.dataset.serverNav); + }); + }); + } + }).catch(() => { + state.sidebarServersLoading = false; + }); + } + updateNavIndicator(); initIcons(); } @@ -1265,6 +1410,8 @@ document.addEventListener('click', (e) => { function navigateTo(page) { if (state.notifPanelOpen) closeNotifPanel(); + const sidebar = $('#sidebar'); + if (sidebar) sidebar.classList.remove('open'); if (passkeyAbortController) { passkeyAbortController.abort(); passkeyAbortController = null; @@ -1312,7 +1459,7 @@ function navigateTo(page) { } document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); - document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); + document.querySelectorAll('.nav-item, .nav-parent').forEach(n => n.classList.remove('active')); state.currentPage = basePage; state.serverId = param ? parseInt(param) : null; @@ -1325,6 +1472,25 @@ function navigateTo(page) { renderSidebarNav(); } + if (basePage === 'server' || basePage === 'servers') { + const parent = document.querySelector('#nav-servers-toggle'); + if (parent) parent.classList.add('active'); + } else if (state.sidebarServersOpen) { + state.sidebarServersOpen = false; + const parent = document.querySelector('#nav-servers-toggle'); + const subList = document.querySelector('#nav-servers-list'); + if (parent) parent.classList.remove('open'); + if (subList) { + subList.style.transition = 'none'; + subList.classList.remove('open'); + void subList.offsetHeight; + subList.style.transition = ''; + } + } + const targetNav = document.querySelector(`.nav-item[data-page="${basePage}"]`); + if (targetNav) targetNav.classList.add('active'); + updateNavIndicator(); + const url = basePage === 'overview' && !param ? '/' : `/${page}`; history.pushState({ page: basePage, serverId: state.serverId, sidebarMode: state.sidebarMode }, '', url); @@ -1334,9 +1500,7 @@ function navigateTo(page) { renderServerDetail(state.serverId); } else { const targetPage = $(`#page-${basePage}`); - const targetNav = document.querySelector(`.nav-item[data-page="${basePage}"]`); if (targetPage) targetPage.classList.add('active'); - if (targetNav) targetNav.classList.add('active'); if (basePage === 'overview') renderOverview(); else if (basePage === 'servers') renderServers(); @@ -1391,7 +1555,7 @@ function navigateTo(page) { } function updateNavIndicator() { - const activeNav = document.querySelector('.nav-item.active'); + const activeNav = document.querySelector('.nav-item.active, .nav-parent.active'); const indicator = document.getElementById('nav-indicator'); if (activeNav && indicator) { indicator.style.top = activeNav.offsetTop + 'px'; @@ -1426,7 +1590,7 @@ window.addEventListener('popstate', () => { if (!state.token) { renderLoginPage(); return; } document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); - document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); + document.querySelectorAll('.nav-item, .nav-parent').forEach(n => n.classList.remove('active')); state.currentPage = basePage; state.serverId = param ? parseInt(param) : null; @@ -1439,15 +1603,32 @@ window.addEventListener('popstate', () => { renderSidebarNav(); } + if (basePage === 'server' || basePage === 'servers') { + const parent = document.querySelector('#nav-servers-toggle'); + if (parent) parent.classList.add('active'); + } else if (state.sidebarServersOpen) { + state.sidebarServersOpen = false; + const parent = document.querySelector('#nav-servers-toggle'); + const subList = document.querySelector('#nav-servers-list'); + if (parent) parent.classList.remove('open'); + if (subList) { + subList.style.transition = 'none'; + subList.classList.remove('open'); + void subList.offsetHeight; + subList.style.transition = ''; + } + } + const targetNav = document.querySelector(`.nav-item[data-page="${basePage}"]`); + if (targetNav) targetNav.classList.add('active'); + updateNavIndicator(); + if (basePage === 'server' && state.serverId) { const targetPage = $('#page-server-detail'); if (targetPage) targetPage.classList.add('active'); renderServerDetail(state.serverId); } else { const targetPage = $(`#page-${basePage}`); - const targetNav = document.querySelector(`.nav-item[data-page="${basePage}"]`); if (targetPage) targetPage.classList.add('active'); - if (targetNav) targetNav.classList.add('active'); if (basePage === 'overview') renderOverview(); else if (basePage === 'servers') renderServers(); @@ -1753,14 +1934,14 @@ function renderServerRow(s) { const allocStr = alloc ? `${alloc.alias || alloc.nodeFqdn || alloc.ip}:${alloc.port}` : (s.nodeFqdn || `Node #${s.node}`); return html` - ${escapeHtml(s.name)} - ${eggName} - ${allocStr} - + ${escapeHtml(s.name)} + ${eggName} + ${allocStr} + ${statusLabel} - -
+ +
Settings ${canRenew && !isAdminSuspended ? html` diff --git a/routes/admin.js b/routes/admin.js index 801e1f5..409b7a5 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -4,7 +4,7 @@ import argon2 from 'argon2'; import jwt from 'jsonwebtoken'; import { authenticateToken, requireAdmin } from '../middleware/auth.js'; import { query } from '../config/db.js'; -import { getAllServers, getServerById, getEgg, getPteroNests, getPteroNestEggs, suspendPteroServer, unsuspendPteroServer, deletePteroServer, deletePteroUser, updatePteroServerBuild, getPergoServerIdsByEgg, getAllNodes, getNodeDetail, getNodeAllocations, getNodeServers } from '../services/pyrodactyl.js'; +import { getAllServers, getServerById, getEgg, getPteroNests, getPteroNestEggs, suspendPteroServer, unsuspendPteroServer, deletePteroServer, deletePteroUser, updatePteroServerBuild, getPergoServerIdsByEgg, getAllNodes, getNodeDetail, getNodeAllocations, getNodeServers, PANEL_DB_NAME } from '../services/pyrodactyl.js'; import { verifyCap } from '../config/cap.js'; import { logActivity } from '../services/activity.js'; import { createNotification } from '../services/notification.js'; @@ -12,7 +12,6 @@ import { createNotification } from '../services/notification.js'; const router = Router(); const JWT_SECRET = process.env.JWT_SECRET; const PTERO_URL = process.env.PTERO_URL; -const PANEL_DB_NAME = (process.env.PANEL_DB_NAME || 'panel').replace(/[^a-zA-Z0-9_]/g, ''); const adminLoginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, @@ -22,6 +21,51 @@ const adminLoginLimiter = rateLimit({ legacyHeaders: false, }); +const adminLoginAttempts = new Map(); + +function getAdminLoginDelay(ip) { + const now = Date.now(); + const entry = adminLoginAttempts.get(ip); + if (!entry) return 0; + const sinceFirst = now - entry.firstAttempt; + if (sinceFirst > 15 * 60 * 1000) { + adminLoginAttempts.delete(ip); + return 0; + } + if (entry.count <= 3) return 0; + if (entry.count <= 5) return 1000; + if (entry.count <= 8) return 3000; + if (entry.count <= 12) return 5000; + return 10000; +} + +function recordAdminLoginAttempt(ip, success) { + if (success) { + adminLoginAttempts.delete(ip); + return; + } + const now = Date.now(); + const entry = adminLoginAttempts.get(ip); + if (entry) { + entry.count++; + } else { + adminLoginAttempts.set(ip, { count: 1, firstAttempt: now }); + } +} + +setInterval(() => { + const cutoff = Date.now() - 15 * 60 * 1000; + for (const [ip, entry] of adminLoginAttempts) { + if (entry.firstAttempt < cutoff) adminLoginAttempts.delete(ip); + } +}, 60 * 1000); + +function getClientIp(req) { + const forwarded = req.headers['x-forwarded-for']; + if (forwarded) return forwarded.split(',')[0].trim(); + return req.ip || req.socket.remoteAddress || '0.0.0.0'; +} + router.post('/login', adminLoginLimiter, async (req, res) => { try { const { email, password, capToken } = req.body; @@ -33,28 +77,41 @@ router.post('/login', adminLoginLimiter, async (req, res) => { return res.status(400).json({ error: 'Invalid input types' }); } + const ip = getClientIp(req); + const delay = getAdminLoginDelay(ip); + if (delay > 0) { + await new Promise(r => setTimeout(r, delay)); + } + if (!await verifyCap(capToken)) { + recordAdminLoginAttempt(ip, false); return res.status(400).json({ error: 'Please complete the security check' }); } const users = await query('SELECT * FROM users WHERE email = ?', [email]); if (users.length === 0) { + recordAdminLoginAttempt(ip, false); return res.status(401).json({ error: 'Invalid credentials' }); } const user = users[0]; if (user.auth_restricted) { + recordAdminLoginAttempt(ip, false); return res.status(403).json({ error: 'Your account has been restricted. Contact support for assistance.' }); } if (!user.is_admin) { + recordAdminLoginAttempt(ip, false); return res.status(403).json({ error: 'Access denied. Admin privileges required.' }); } const valid = await argon2.verify(user.password_hash, password, { type: argon2.argon2id }); if (!valid) { + recordAdminLoginAttempt(ip, false); return res.status(401).json({ error: 'Invalid credentials' }); } + recordAdminLoginAttempt(ip, true); + const token = jwt.sign( { userId: user.id, email: user.email, username: user.username, pteroId: user.ptero_user_id, isAdmin: true, restricted: false, tokenVersion: user.token_version }, JWT_SECRET, @@ -132,7 +189,7 @@ router.post('/servers/:id/suspend', authenticateToken, requireAdmin, async (req, } const reason = (req.body.reason && typeof req.body.reason === 'string') - ? req.body.reason.slice(0, 500) : 'Suspended by an Administrator. Please contact support.'; + ? req.body.reason.replace(/[<>"']/g, '').slice(0, 500) : 'Suspended by an Administrator. Please contact support.'; await suspendPteroServer(serverId); await query('UPDATE server_meta SET status = ?, suspend_reason = ?, suspended_by = ? WHERE ptero_server_id = ?', ['suspended', reason, 'admin', serverId]); @@ -144,7 +201,7 @@ router.post('/servers/:id/suspend', authenticateToken, requireAdmin, async (req, res.json({ success: true }); } catch (err) { console.error('Admin suspend error:', err.message); - res.status(500).json({ error: 'Failed to suspend server: ' + err.message }); + res.status(500).json({ error: 'Failed to suspend server' }); } }); @@ -165,7 +222,7 @@ router.post('/servers/:id/unsuspend', authenticateToken, requireAdmin, async (re res.json({ success: true }); } catch (err) { console.error('Admin unsuspend error:', err.message); - res.status(500).json({ error: 'Failed to unsuspend server: ' + err.message }); + res.status(500).json({ error: 'Failed to unsuspend server' }); } }); @@ -206,7 +263,7 @@ router.post('/servers/:id/stop', authenticateToken, requireAdmin, async (req, re res.json({ success: true }); } catch (err) { console.error('Admin stop error:', err.message); - res.status(500).json({ error: 'Failed to stop server: ' + err.message }); + res.status(500).json({ error: 'Failed to stop server' }); } }); @@ -227,7 +284,7 @@ router.post('/servers/:id/renew-now', authenticateToken, requireAdmin, async (re res.json({ success: true }); } catch (err) { console.error('Admin renew-now error:', err.message); - res.status(500).json({ error: 'Failed to expire server: ' + err.message }); + res.status(500).json({ error: 'Failed to expire server' }); } }); @@ -252,7 +309,7 @@ router.delete('/servers/:id', authenticateToken, requireAdmin, async (req, res) res.json({ success: true }); } catch (err) { console.error('Admin delete error:', err.message); - res.status(500).json({ error: 'Failed to delete server: ' + err.message }); + res.status(500).json({ error: 'Failed to delete server' }); } }); @@ -554,7 +611,7 @@ router.post('/users/:id/notify', authenticateToken, requireAdmin, async (req, re res.json({ success: true }); } catch (err) { console.error('Admin notify error:', err.message); - res.status(500).json({ error: 'Failed to send notification: ' + err.message }); + res.status(500).json({ error: 'Failed to send notification' }); } }); @@ -594,7 +651,7 @@ router.post('/notify-all', authenticateToken, requireAdmin, async (req, res) => res.json({ success: true, count: users.length }); } catch (err) { console.error('Admin notify-all error:', err.message); - res.status(500).json({ error: 'Failed to send notifications: ' + err.message }); + res.status(500).json({ error: 'Failed to send notifications' }); } }); @@ -638,7 +695,7 @@ router.delete('/users/:id', authenticateToken, requireAdmin, async (req, res) => res.json({ success: true }); } catch (err) { console.error('Admin delete user error:', err.message); - res.status(500).json({ error: 'Failed to delete user: ' + err.message }); + res.status(500).json({ error: 'Failed to delete user' }); } }); @@ -697,7 +754,7 @@ router.get('/settings/nests', authenticateToken, requireAdmin, async (req, res) const nests = await query('SELECT * FROM nests ORDER BY name ASC'); res.json({ nests }); } catch (err) { - res.status(500).json({ error: 'Failed to fetch nests: ' + err.message }); + res.status(500).json({ error: 'Failed to fetch nests' }); } }); @@ -709,7 +766,7 @@ router.get('/settings/nests/available', authenticateToken, requireAdmin, async ( const available = pteroNests.filter(n => !localIds.has(n.id)); res.json({ nests: available }); } catch (err) { - res.status(500).json({ error: 'Failed to fetch available nests: ' + err.message }); + res.status(500).json({ error: 'Failed to fetch available nests' }); } }); @@ -739,7 +796,7 @@ router.post('/settings/nests', authenticateToken, requireAdmin, async (req, res) if (err.code === 'ER_DUP_ENTRY') { return res.status(409).json({ error: 'Nest already added' }); } - res.status(500).json({ error: 'Failed to add nest: ' + err.message }); + res.status(500).json({ error: 'Failed to add nest' }); } }); @@ -760,7 +817,7 @@ router.put('/settings/nests/:id', authenticateToken, requireAdmin, async (req, r await query(`UPDATE nests SET ${updates.join(', ')} WHERE id = ?`, params); res.json({ success: true }); } catch (err) { - res.status(500).json({ error: 'Failed to update nest: ' + err.message }); + res.status(500).json({ error: 'Failed to update nest' }); } }); @@ -793,7 +850,7 @@ router.get('/settings/nests/:nestId/eggs', authenticateToken, requireAdmin, asyn })); res.json({ eggs }); } catch (err) { - res.status(500).json({ error: 'Failed to fetch eggs: ' + err.message }); + res.status(500).json({ error: 'Failed to fetch eggs' }); } }); @@ -847,7 +904,7 @@ router.put('/settings/eggs/:nestId/:eggId', authenticateToken, requireAdmin, asy } res.json({ success: true }); } catch (err) { - res.status(500).json({ error: 'Failed to save egg settings: ' + err.message }); + res.status(500).json({ error: 'Failed to save egg settings' }); } }); @@ -900,7 +957,7 @@ router.post('/settings/eggs/:nestId/:eggId/apply-all', authenticateToken, requir res.json({ success: true, updated, total: pteroIds.length }); } catch (err) { - res.status(500).json({ error: 'Failed to apply resources to all servers: ' + err.message }); + res.status(500).json({ error: 'Failed to apply resources to all servers' }); } }); diff --git a/routes/auth.js b/routes/auth.js index 48800c0..48b01e0 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -114,10 +114,6 @@ router.post('/register', async (req, res) => { const { email, username, password, capToken, rgpdConsent } = req.body; const ip = getClientIp(req); - const delay = getLoginDelay(ip); - if (delay > 0) { - await new Promise(r => setTimeout(r, delay)); - } if (!email || !username || !password) { return res.status(400).json({ error: 'Email, username and password are required' }); @@ -143,6 +139,11 @@ router.post('/register', async (req, res) => { return res.status(400).json({ error: 'Password must be between 8 and 128 characters' }); } + const delay = getLoginDelay(ip); + if (delay > 0) { + await new Promise(r => setTimeout(r, delay)); + } + if (!await verifyCap(capToken)) { recordLoginAttempt(ip, false); return res.status(400).json({ error: 'Please complete the security check' }); @@ -299,6 +300,47 @@ router.get('/verify-email', async (req, res) => { } }); +router.post('/resend-verification', async (req, res) => { + try { + const { email } = req.body; + if (!email || typeof email !== 'string') { + return res.status(400).json({ error: 'Email is required' }); + } + + const users = await query('SELECT id, email, username, email_verified FROM users WHERE email = ?', [email]); + if (users.length === 0) { + return res.json({ message: 'If an account with that email exists, a verification link has been sent.' }); + } + + const user = users[0]; + if (user.email_verified) { + return res.json({ message: 'Email already verified. You can now sign in.' }); + } + + const verificationToken = randomBytes(32).toString('hex'); + const tokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); + + await query( + 'UPDATE users SET verification_token = ?, verification_token_expires = ? WHERE id = ?', + [verificationToken, tokenExpires, user.id] + ); + + try { + await sendVerificationEmail(user.email, user.username, verificationToken); + } catch (err) { + console.error('Failed to send verification email:', err.message); + return res.status(500).json({ error: 'Failed to send verification email. Please try again later.' }); + } + + await logActivity(user.id, 'verification_resent', 'Resent verification email'); + + res.json({ message: 'Verification email sent. Check your inbox.' }); + } catch (err) { + console.error('Resend verification error:', err.message); + res.status(500).json({ error: 'Failed to resend verification email' }); + } +}); + router.post('/login', async (req, res) => { try { const { email, password, capToken } = req.body; @@ -308,10 +350,6 @@ router.post('/login', async (req, res) => { } const ip = getClientIp(req); - const delay = getLoginDelay(ip); - if (delay > 0) { - await new Promise(r => setTimeout(r, delay)); - } // Cap verification if (!await verifyCap(capToken)) { @@ -325,6 +363,12 @@ router.post('/login', async (req, res) => { return res.status(403).json({ error: 'VPNs and proxies are not allowed. Please disable them to sign in.' }); } + // Progressive delay applied only after validation to prevent resource exhaustion + const delay = getLoginDelay(ip); + if (delay > 0) { + await new Promise(r => setTimeout(r, delay)); + } + const users = await query('SELECT * FROM users WHERE email = ?', [email]); if (users.length === 0) { recordLoginAttempt(ip, false); @@ -488,11 +532,14 @@ router.post('/change-email', authenticateToken, sensitiveLimiter, async (req, re return res.status(500).json({ error: 'Failed to update email on panel' }); } - // Update local DB - await query('UPDATE users SET email = ? WHERE id = ?', [newEmail, userId]); + // Update local DB and invalidate existing sessions + await query('UPDATE users SET email = ?, token_version = token_version + 1 WHERE id = ?', [newEmail, userId]); await logActivity(userId, 'email_changed', `Changed email to ${newEmail}`); + // Fetch updated token_version + const [updatedUser] = await query('SELECT token_version FROM users WHERE id = ?', [userId]); + // Generate new token with updated email const token = generateToken({ userId: user.id, @@ -501,7 +548,7 @@ router.post('/change-email', authenticateToken, sensitiveLimiter, async (req, re pteroId: user.ptero_user_id, isAdmin: !!user.is_admin, restricted: !!user.restricted, - tokenVersion: user.token_version, + tokenVersion: updatedUser.token_version, }); res.json({ @@ -580,7 +627,13 @@ router.post('/delete-account', authenticateToken, sensitiveLimiter, async (req, } }); -router.post('/logout', (req, res) => { +router.post('/logout', authenticateToken, async (req, res) => { + try { + // Bump token_version to invalidate all existing sessions + await query('UPDATE users SET token_version = token_version + 1 WHERE id = ?', [req.user.userId]); + } catch (err) { + console.error('Logout token_version bump failed:', err.message); + } res.clearCookie('token', { httpOnly: true, secure: true, sameSite: 'strict' }); res.json({ message: 'Logged out' }); }); diff --git a/routes/passkeys.js b/routes/passkeys.js index ac8412b..2a9077a 100644 --- a/routes/passkeys.js +++ b/routes/passkeys.js @@ -1,4 +1,5 @@ import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; import { generateRegistrationOptions, verifyRegistrationResponse, @@ -7,13 +8,29 @@ import { } from '@simplewebauthn/server'; import { isoBase64URL } from '@simplewebauthn/server/helpers'; import { authenticateToken } from '../middleware/auth.js'; -import { createHash } from 'crypto'; +import { createHash, randomBytes } from 'crypto'; import { query } from '../config/db.js'; import { generateToken } from '../middleware/auth.js'; import { logActivity } from '../services/activity.js'; const router = Router(); +const passkeyRegisterLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + message: { error: 'Too many passkey registration attempts, try again later' }, + standardHeaders: true, + legacyHeaders: false, +}); + +const passkeyLoginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + message: { error: 'Too many passkey login attempts, try again later' }, + standardHeaders: true, + legacyHeaders: false, +}); + const RP_NAME = 'ZeroHost'; function gravatarHash(email) { @@ -32,15 +49,21 @@ function getWebAuthnConfig(req) { }; } +// Store challenges keyed by userId (register) or sessionToken (login) +// to prevent attacker-controlled lookup via clientDataJSON.challenge const challengeMap = new Map(); +function generateSessionToken() { + return randomBytes(32).toString('hex'); +} + function getClientIp(req) { const forwarded = req.headers['x-forwarded-for']; if (forwarded) return forwarded.split(',')[0].trim(); return req.ip || req.socket.remoteAddress || '0.0.0.0'; } -router.post('/passkeys/register/begin', authenticateToken, async (req, res) => { +router.post('/passkeys/register/begin', authenticateToken, passkeyRegisterLimiter, async (req, res) => { try { const userId = req.user.userId; const { rpID, origin } = getWebAuthnConfig(req); @@ -77,7 +100,7 @@ router.post('/passkeys/register/begin', authenticateToken, async (req, res) => { }, }); - challengeMap.set(options.challenge, { + challengeMap.set(`register:${userId}`, { challenge: options.challenge, timestamp: Date.now(), userId, @@ -90,7 +113,7 @@ router.post('/passkeys/register/begin', authenticateToken, async (req, res) => { } }); -router.post('/passkeys/register/complete', authenticateToken, async (req, res) => { +router.post('/passkeys/register/complete', authenticateToken, passkeyRegisterLimiter, async (req, res) => { try { const userId = req.user.userId; const { response } = req.body; @@ -99,18 +122,17 @@ router.post('/passkeys/register/complete', authenticateToken, async (req, res) = return res.status(400).json({ error: 'Registration response is required' }); } - const challengeFromResponse = JSON.parse(isoBase64URL.toUTF8String(response.response.clientDataJSON)).challenge; - const expectedChallenge = challengeMap.get(challengeFromResponse); - if (!expectedChallenge) { + const stored = challengeMap.get(`register:${userId}`); + if (!stored) { return res.status(400).json({ error: 'No registration in progress. Please try again.' }); } - challengeMap.delete(challengeFromResponse); + challengeMap.delete(`register:${userId}`); const { rpID, origin } = getWebAuthnConfig(req); const verification = await verifyRegistrationResponse({ response, - expectedChallenge: expectedChallenge.challenge, + expectedChallenge: stored.challenge, expectedOrigin: origin, expectedRPID: rpID, }); @@ -138,11 +160,11 @@ router.post('/passkeys/register/complete', authenticateToken, async (req, res) = res.json({ verified: true }); } catch (err) { console.error('Passkey register complete error:', err.message); - res.status(400).json({ error: 'Failed to complete passkey registration: ' + err.message }); + res.status(400).json({ error: 'Failed to complete passkey registration' }); } }); -router.post('/passkeys/login/begin', async (req, res) => { +router.post('/passkeys/login/begin', passkeyLoginLimiter, async (req, res) => { try { const { email } = req.body; let userId = null; @@ -183,34 +205,53 @@ router.post('/passkeys/login/begin', async (req, res) => { userVerification: 'preferred', }); - challengeMap.set(options.challenge, { + const sessionToken = generateSessionToken(); + challengeMap.set(`login:${sessionToken}`, { challenge: options.challenge, timestamp: Date.now(), userId, }); - res.json({ options, userId }); + res.json({ options, userId, sessionToken }); } catch (err) { console.error('Passkey login begin error:', err.message); res.status(500).json({ error: 'Failed to initiate passkey login' }); } }); -router.post('/passkeys/login/complete', async (req, res) => { +router.post('/passkeys/login/complete', passkeyLoginLimiter, async (req, res) => { try { - const { response } = req.body; + const { response, sessionToken } = req.body; if (!response) { return res.status(400).json({ error: 'Response is required' }); } - const challengeFromResponse = JSON.parse(isoBase64URL.toUTF8String(response.response.clientDataJSON)).challenge; - const expectedChallenge = challengeMap.get(challengeFromResponse); - if (!expectedChallenge) { - return res.status(400).json({ error: 'No login in progress. Please try again.' }); + let stored; + if (sessionToken) { + stored = challengeMap.get(`login:${sessionToken}`); + if (!stored) { + return res.status(400).json({ error: 'No login in progress. Please try again.' }); + } + challengeMap.delete(`login:${sessionToken}`); + } else { + // Autofill / conditional mediation flow: look up challenge from clientDataJSON + try { + const challengeFromResponse = JSON.parse(isoBase64URL.toUTF8String(response.response.clientDataJSON)).challenge; + for (const [key, entry] of challengeMap) { + if (key.startsWith('login:') && entry.challenge === challengeFromResponse) { + stored = entry; + challengeMap.delete(key); + break; + } + } + } catch { + return res.status(400).json({ error: 'No login in progress. Please try again.' }); + } + if (!stored) { + return res.status(400).json({ error: 'No login in progress. Please try again.' }); + } } - challengeMap.delete(challengeFromResponse); - const passkeys = await query( 'SELECT id, credential_id, public_key, counter, transports, user_id FROM passkeys WHERE credential_id = ?', [response.id] @@ -225,7 +266,7 @@ router.post('/passkeys/login/complete', async (req, res) => { const { rpID, origin } = getWebAuthnConfig(req); const verification = await verifyAuthenticationResponse({ response, - expectedChallenge: expectedChallenge.challenge, + expectedChallenge: stored.challenge, expectedOrigin: origin, expectedRPID: rpID, credential: { @@ -278,11 +319,11 @@ router.post('/passkeys/login/complete', async (req, res) => { }); } catch (err) { console.error('Passkey login complete error:', err.message); - res.status(400).json({ error: 'Failed to complete passkey login: ' + err.message }); + res.status(400).json({ error: 'Failed to complete passkey login' }); } }); -router.get('/passkeys', authenticateToken, async (req, res) => { +router.get('/passkeys', authenticateToken, passkeyRegisterLimiter, async (req, res) => { try { const passkeys = await query( 'SELECT id, name, transports, created_at FROM passkeys WHERE user_id = ? ORDER BY created_at DESC', @@ -296,7 +337,7 @@ router.get('/passkeys', authenticateToken, async (req, res) => { } }); -router.delete('/passkeys/:id', authenticateToken, async (req, res) => { +router.delete('/passkeys/:id', authenticateToken, passkeyRegisterLimiter, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (isNaN(id)) { diff --git a/routes/servers.js b/routes/servers.js index de95589..d8a965b 100644 --- a/routes/servers.js +++ b/routes/servers.js @@ -166,7 +166,7 @@ router.get('/nests', authenticateToken, async (req, res) => { res.json({ nests: result }); } catch (err) { console.error('Get nests error:', err.message); - res.status(500).json({ error: 'Failed to fetch nests: ' + err.message }); + res.status(500).json({ error: 'Failed to fetch nests' }); } }); @@ -322,7 +322,7 @@ router.post('/create', authenticateToken, requireNotRestricted, createServerLimi if (err.message.includes('NoViableAllocationException')) { return res.status(400).json({ error: 'No available allocations found' }); } - res.status(500).json({ error: 'Failed to create server: ' + err.message }); + res.status(500).json({ error: 'Failed to create server' }); } }); @@ -416,7 +416,7 @@ router.post('/renew/:id', authenticateToken, requireNotRestricted, requireOwners res.json({ serverMeta: updated[0] }); } catch (err) { console.error('Renew server error:', err.message); - res.status(500).json({ error: 'Failed to renew server: ' + err.message }); + res.status(500).json({ error: 'Failed to renew server' }); } }); @@ -444,7 +444,7 @@ router.patch('/:id', authenticateToken, requireNotRestricted, requireOwnership(' res.json({ success: true }); } catch (err) { console.error('Rename server error:', err.message); - res.status(500).json({ error: 'Failed to rename server: ' + err.message }); + res.status(500).json({ error: 'Failed to rename server' }); } }); @@ -461,7 +461,7 @@ router.post('/:id/reinstall', authenticateToken, requireNotRestricted, requireOw res.json({ success: true }); } catch (err) { console.error('Reinstall server error:', err.message); - res.status(500).json({ error: 'Failed to reinstall server: ' + err.message }); + res.status(500).json({ error: 'Failed to reinstall server' }); } }); @@ -486,7 +486,7 @@ router.delete('/:id', authenticateToken, requireNotRestricted, requireOwnership( res.json({ success: true }); } catch (err) { console.error('Delete server error:', err.message); - res.status(500).json({ error: 'Failed to delete server: ' + err.message }); + res.status(500).json({ error: 'Failed to delete server' }); } }); @@ -531,11 +531,24 @@ router.get('/overview', authenticateToken, async (req, res) => { } }); +async function verifyServerOwnership(userId, identifier) { + try { + const servers = await getServersByUser(userId); + return servers.some(s => s.identifier === identifier); + } catch { + return false; + } +} + router.get('/resources/:identifier', authenticateToken, async (req, res) => { try { const { identifier } = req.params; const userId = req.user.userId; + if (!await verifyServerOwnership(userId, identifier)) { + return res.status(403).json({ error: 'You do not own this server' }); + } + const users = await query('SELECT ptero_client_api_key FROM users WHERE id = ?', [userId]); if (users.length === 0 || !users[0].ptero_client_api_key) { return res.json({ resources: null, error: 'No Pyrodactyl API key configured. Set one in Account settings.' }); @@ -568,6 +581,10 @@ router.post('/power/:identifier', authenticateToken, powerLimiter, async (req, r const { signal } = req.body; const userId = req.user.userId; + if (!await verifyServerOwnership(userId, identifier)) { + return res.status(403).json({ error: 'You do not own this server' }); + } + const VALID_SIGNALS = ['start', 'stop', 'restart', 'kill']; if (!signal || typeof signal !== 'string' || !VALID_SIGNALS.includes(signal)) { return res.status(400).json({ error: 'Invalid power signal. Valid signals: start, stop, restart, kill' }); diff --git a/scripts/admin-cli.js b/scripts/admin-cli.js index 6ee4b0f..0e76e94 100644 --- a/scripts/admin-cli.js +++ b/scripts/admin-cli.js @@ -39,6 +39,17 @@ try { process.exit(1); } +async function auditLog(action, details) { + try { + await conn.query( + 'INSERT INTO activity_log (user_id, action, details) VALUES (?, ?, ?)', + [0, action, '[CLI] ' + details] + ); + } catch (err) { + console.error('Failed to audit log:', err.message); + } +} + async function usage() { console.log(` Usage: @@ -89,6 +100,7 @@ async function createAdmin(email, username, password) { [email, username, hash, username, 'Admin'] ); + await auditLog('admin_cli_create', `Created admin account: ${username} (${email}) - ID: ${result.insertId}`); console.log(`Admin account created: ${username} (${email}) - ID: ${result.insertId}`); } @@ -105,6 +117,7 @@ async function setAdmin(email) { process.exit(1); } + await auditLog('admin_cli_set_admin', `Granted admin privileges to ${email}`); console.log(`Admin privileges granted to ${email}`); } @@ -121,6 +134,7 @@ async function setAdminByUsername(username) { process.exit(1); } + await auditLog('admin_cli_set_admin', `Granted admin privileges to ${username} (by username)`); console.log(`Admin privileges granted to ${username}`); } @@ -150,6 +164,7 @@ async function removeAdmin(email) { process.exit(1); } + await auditLog('admin_cli_remove_admin', `Removed admin privileges from ${email}`); console.log(`Admin privileges removed from ${email}`); } diff --git a/server.js b/server.js index 13bd90f..15d867b 100644 --- a/server.js +++ b/server.js @@ -90,7 +90,7 @@ app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.jsdelivr.net", "https://unpkg.com", "https://static.cloudflareinsights.com"], + scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "'wasm-unsafe-eval'", "https://cdn.jsdelivr.net", "https://unpkg.com", "https://static.cloudflareinsights.com"], scriptSrcAttr: ["'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdn.jsdelivr.net"], fontSrc: ["'self'", "https://fonts.gstatic.com", "https://cdn.jsdelivr.net"], @@ -116,7 +116,7 @@ app.use(helmet({ app.use(cors({ origin: process.env.NODE_ENV === 'production' ? ['https://dashboard.zero-host.org', 'https://zero-host.org'] - : '*', + : ['http://localhost:3000', 'http://127.0.0.1:3000'], credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], allowedHeaders: ['Content-Type', 'Authorization'], diff --git a/services/activity.js b/services/activity.js index 6a53695..6f5e076 100644 --- a/services/activity.js +++ b/services/activity.js @@ -2,9 +2,10 @@ import { query } from '../config/db.js'; export async function logActivity(userId, action, details = '', serverId = null) { try { + const safeDetails = String(details).replace(/[<>"']/g, '').slice(0, 255); await query( 'INSERT INTO activity_log (user_id, action, details, server_id) VALUES (?, ?, ?, ?)', - [userId, action, details, serverId] + [userId, action, safeDetails, serverId] ); } catch (err) { console.error('Failed to log activity:', err.message); diff --git a/services/pyrodactyl.js b/services/pyrodactyl.js index c25b9a5..3f10ff5 100644 --- a/services/pyrodactyl.js +++ b/services/pyrodactyl.js @@ -1,4 +1,6 @@ -import { PTERO_URL, PTERO_API_KEY, SERVER_LIMITS, FEATURE_LIMITS, DEPLOY_LOCATIONS } from '../config/pyrodactyl.js'; +import { PTERO_URL, PTERO_API_KEY, SERVER_LIMITS, FEATURE_LIMITS, DEPLOY_LOCATIONS, PANEL_DB_NAME } from '../config/pyrodactyl.js'; + +export { PANEL_DB_NAME }; const FETCH_TIMEOUT = 15000; const CACHE_TTL = 5 * 60 * 1000; @@ -74,7 +76,9 @@ async function pteroFetch(path, options = {}) { continue; } if (!res.ok) { - recordPteroError(`${res.status} ${res.statusText}`); + if (res.status >= 500) { + recordPteroError(`${res.status} ${res.statusText}`); + } const text = await res.text(); throw new Error(`Pterodactyl API error ${res.status}: ${text.slice(0, 200)}`); } @@ -333,7 +337,7 @@ export async function getPergoServerIdsByEgg(nestId, eggId) { export async function renamePteroServer(serverId, name) { const server = await getServerById(serverId); - await pteroFetch(`/servers/${serverId}/details`, { + await pteroFetch(`/servers/${serverId}`, { method: 'PATCH', body: JSON.stringify({ name,