Skip to content

Commit 60c3912

Browse files
committed
Initial commit
0 parents  commit 60c3912

19 files changed

Lines changed: 6062 additions & 0 deletions

File tree

.env.example

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Server
2+
PORT=3000
3+
NODE_ENV=development
4+
5+
# JWT — Generate with: openssl rand -hex 64
6+
JWT_SECRET=change_me_to_a_random_hex_string
7+
JWT_EXPIRES_IN=2h
8+
9+
# MariaDB
10+
DB_HOST=127.0.0.1
11+
DB_PORT=3306
12+
DB_USER=zerohost
13+
DB_PASSWORD=change_me
14+
DB_NAME=zerohost_dashboard
15+
16+
# Pterodactyl Panel (Application API)
17+
PTERO_URL=https://panel.your-domain.com
18+
PTERO_API_KEY=change_me
19+
20+
# Encryption key for sensitive data — Generate with: openssl rand -hex 32
21+
ENCRYPTION_KEY=change_me_to_a_random_hex_string
22+
23+
# Cloudflare Turnstile
24+
TURNSTILE_SECRET=change_me
25+
26+
# Session & Cookie
27+
COOKIE_SECRET=change_me_to_a_random_string
28+
29+
# Logging
30+
LOG_LEVEL=info

.github/workflows/deploy-beta.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: Deploy Beta
2+
3+
on:
4+
push:
5+
branches: [beta]
6+
7+
jobs:
8+
deploy:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- name: SSH and deploy
12+
uses: appleboy/ssh-action@v1.2.0
13+
with:
14+
host: ${{ secrets.SSH_HOST }}
15+
username: ${{ secrets.SSH_USER }}
16+
password: ${{ secrets.SSH_PASSWORD }}
17+
port: ${{ secrets.SSH_PORT || 22 }}
18+
script: |
19+
cd ${{ secrets.BETA_PATH }}
20+
git pull

.github/workflows/deploy-main.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: Deploy Main
2+
3+
on:
4+
push:
5+
branches: [main]
6+
7+
jobs:
8+
deploy:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- name: SSH and deploy
12+
uses: appleboy/ssh-action@v1.2.0
13+
with:
14+
host: ${{ secrets.SSH_HOST }}
15+
username: ${{ secrets.SSH_USER }}
16+
password: ${{ secrets.SSH_PASSWORD }}
17+
port: ${{ secrets.SSH_PORT || 22 }}
18+
script: |
19+
cd ${{ secrets.MAIN_PATH }}
20+
git pull

.gitignore

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Environnement
2+
.env
3+
4+
# Node
5+
node_modules/
6+
npm-debug.log*
7+
yarn-debug.log*
8+
yarn-error.log*
9+
10+
# OS
11+
.DS_Store
12+
Thumbs.db
13+
*.swp
14+
*.swo
15+
16+
# IDE
17+
.idea/
18+
.vscode/
19+
*.sublime-project
20+
*.sublime-workspace
21+
22+
# Logs
23+
logs/
24+
*.log
25+
26+
# Runtime
27+
port.txt
28+
reboot.sh
29+
30+
# Secrets (if any local dump)
31+
*.pem
32+
*.key

config/db.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import mariadb from 'mariadb';
2+
import dotenv from 'dotenv';
3+
import { fileURLToPath } from 'url';
4+
import { dirname, resolve } from 'path';
5+
6+
const __dirname = dirname(fileURLToPath(import.meta.url));
7+
dotenv.config({ path: resolve(__dirname, '..', '.env') });
8+
9+
const pool = mariadb.createPool({
10+
host: process.env.DB_HOST,
11+
port: parseInt(process.env.DB_PORT || '3306'),
12+
user: process.env.DB_USER,
13+
password: process.env.DB_PASSWORD,
14+
database: process.env.DB_NAME,
15+
connectionLimit: 10,
16+
acquireTimeout: 10000,
17+
insertIdAsNumber: true,
18+
});
19+
20+
export async function query(sql, params = []) {
21+
let lastErr;
22+
for (let attempt = 1; attempt <= 3; attempt++) {
23+
let conn;
24+
try {
25+
conn = await pool.getConnection();
26+
const rows = await conn.query(sql, params);
27+
return rows;
28+
} catch (err) {
29+
lastErr = err;
30+
if (err.code === 'ECONNRESET' || err.code === 'PROTOCOL_CONNECTION_LOST' || err.message?.includes('timeout')) {
31+
console.error(`DB query attempt ${attempt}/3 failed:`, err.message);
32+
if (attempt < 3) await new Promise(r => setTimeout(r, 100 * attempt));
33+
continue;
34+
}
35+
throw err;
36+
} finally {
37+
if (conn) conn.release();
38+
}
39+
}
40+
throw lastErr;
41+
}
42+
43+
export async function getConnection() {
44+
return await pool.getConnection();
45+
}
46+
47+
export default pool;

config/migrate.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { query } from './db.js';
2+
3+
const tables = {
4+
users: {
5+
columns: [
6+
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
7+
{ name: 'email', def: 'VARCHAR(255) NOT NULL' },
8+
{ name: 'username', def: 'VARCHAR(255) NOT NULL' },
9+
{ name: 'password_hash', def: 'VARCHAR(255) NOT NULL' },
10+
{ name: 'ptero_user_id', def: 'INT' },
11+
{ name: 'ptero_uuid', def: 'VARCHAR(255)' },
12+
{ name: 'first_name', def: 'VARCHAR(255)' },
13+
{ name: 'last_name', def: 'VARCHAR(255)' },
14+
{ name: 'password_set', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
15+
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
16+
],
17+
},
18+
server_meta: {
19+
columns: [
20+
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
21+
{ name: 'ptero_server_id', def: 'INT NOT NULL' },
22+
{ name: 'user_id', def: 'INT NOT NULL' },
23+
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
24+
{ name: 'expires_at', def: 'TIMESTAMP NOT NULL' },
25+
{ name: 'status', def: "ENUM('active', 'suspended', 'expired') DEFAULT 'active'" },
26+
],
27+
},
28+
user_ips: {
29+
columns: [
30+
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
31+
{ name: 'user_id', def: 'INT NOT NULL' },
32+
{ name: 'ip_address', def: 'VARCHAR(45) NOT NULL' },
33+
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
34+
],
35+
},
36+
37+
};
38+
39+
function escapeId(id) {
40+
return '`' + String(id).replace(/`/g, '``') + '`';
41+
}
42+
43+
function escapeLike(id) {
44+
return String(id).replace(/[%_\\]/g, '\\$&');
45+
}
46+
47+
const constraints = [
48+
{ table: 'server_meta', sql: 'ALTER TABLE server_meta ADD INDEX idx_expires (expires_at)', name: 'idx_expires' },
49+
{ table: 'server_meta', sql: 'ALTER TABLE server_meta ADD INDEX idx_user (user_id)', name: 'idx_user' },
50+
{ table: 'server_meta', sql: 'ALTER TABLE server_meta ADD INDEX idx_status (status)', name: 'idx_status' },
51+
{ table: 'user_ips', sql: 'ALTER TABLE user_ips ADD INDEX idx_ip (ip_address)', name: 'idx_ip' },
52+
{ table: 'user_ips', sql: 'ALTER TABLE user_ips ADD INDEX idx_user (user_id)', name: 'idx_user' },
53+
{ table: 'user_ips', sql: 'ALTER TABLE user_ips ADD CONSTRAINT fk_user_ips_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE', name: 'fk_user_ips_user' },
54+
55+
];
56+
57+
export async function migrate() {
58+
for (const [table, schema] of Object.entries(tables)) {
59+
try {
60+
const safeTable = escapeId(table);
61+
const exists = await query(`SHOW TABLES LIKE ?`, [escapeLike(table)]);
62+
if (exists.length === 0) {
63+
const colDefs = schema.columns.map(c => `${escapeId(c.name)} ${c.def}`).join(', ');
64+
await query(`CREATE TABLE ${safeTable} (${colDefs})`);
65+
console.log(`Created table: ${table}`);
66+
continue;
67+
}
68+
69+
for (const col of schema.columns) {
70+
try {
71+
const safeCol = escapeId(col.name);
72+
const cols = await query(`SHOW COLUMNS FROM ${safeTable} LIKE ?`, [escapeLike(col.name)]);
73+
if (cols.length === 0) {
74+
await query(`ALTER TABLE ${safeTable} ADD COLUMN ${safeCol} ${col.def}`);
75+
console.log(`Added column ${table}.${col.name}`);
76+
}
77+
} catch (err) {
78+
console.error(`Migration error ${table}.${col.name}:`, err.message);
79+
}
80+
}
81+
} catch (err) {
82+
console.error(`Migration error for table ${table}:`, err.message);
83+
}
84+
}
85+
86+
for (const c of constraints) {
87+
try {
88+
await query(c.sql);
89+
console.log(`Applied constraint: ${c.name}`);
90+
} catch {
91+
// Constraint already exists or table missing — safe to ignore
92+
}
93+
}
94+
}

config/pterodactyl.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export const PTERO_URL = process.env.PTERO_URL || 'https://panel.zero-host.org';
2+
export const PTERO_API_KEY = process.env.PTERO_API_KEY || '';
3+
4+
export const SERVER_LIMITS = {
5+
memory: 512,
6+
swap: 0,
7+
disk: 3072,
8+
io: 500,
9+
cpu: 50,
10+
};
11+
12+
export const FEATURE_LIMITS = {
13+
databases: 0,
14+
allocations: 1,
15+
backups: 1,
16+
};
17+
18+
export const DEPLOY_LOCATIONS = [1];

config/turnstile.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET;
2+
3+
if (!TURNSTILE_SECRET) {
4+
console.error('Missing TURNSTILE_SECRET environment variable');
5+
}
6+
7+
async function fetchWithTimeout(url, options = {}, timeout = 10000) {
8+
const controller = new AbortController();
9+
const timer = setTimeout(() => controller.abort(), timeout);
10+
try {
11+
return await fetch(url, { ...options, signal: controller.signal });
12+
} finally {
13+
clearTimeout(timer);
14+
}
15+
}
16+
17+
export async function verifyTurnstile(token) {
18+
if (!token) return false;
19+
try {
20+
const res = await fetchWithTimeout('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
21+
method: 'POST',
22+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
23+
body: new URLSearchParams({ secret: TURNSTILE_SECRET, response: token }),
24+
});
25+
const data = await res.json();
26+
return data.success === true;
27+
} catch {
28+
return false;
29+
}
30+
}

middleware/auth.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import jwt from 'jsonwebtoken';
2+
3+
const JWT_SECRET = process.env.JWT_SECRET;
4+
5+
if (!JWT_SECRET) {
6+
console.error('Missing JWT_SECRET environment variable');
7+
}
8+
9+
export function authenticateToken(req, res, next) {
10+
const authHeader = req.headers['authorization'];
11+
const token = authHeader && authHeader.split(' ')[1];
12+
13+
if (!token) {
14+
return res.status(401).json({ error: 'Access token required' });
15+
}
16+
17+
try {
18+
const decoded = jwt.verify(token, JWT_SECRET);
19+
req.user = decoded;
20+
next();
21+
} catch {
22+
return res.status(403).json({ error: 'Invalid or expired token' });
23+
}
24+
}
25+
26+
export function generateToken(payload) {
27+
return jwt.sign(payload, JWT_SECRET, {
28+
expiresIn: process.env.JWT_EXPIRES_IN || '2h',
29+
algorithm: 'HS256',
30+
});
31+
}

0 commit comments

Comments
 (0)