Spaces:
Sleeping
Sleeping
techedstudios commited on
Commit ·
4e00128
1
Parent(s): d6ca85a
Add admin login/setup screen + Firestore-based backups (no Storage needed)
Browse files- backend/firebase-config.js +1 -3
- backend/sync-manager.js +120 -117
- server.js +127 -1
- src/App.tsx +245 -31
backend/firebase-config.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
import { initializeApp } from "firebase/app";
|
| 2 |
-
import { getStorage } from "firebase/storage";
|
| 3 |
import { getFirestore } from "firebase/firestore";
|
| 4 |
|
| 5 |
// Default config provided by the user
|
|
@@ -14,7 +13,6 @@ const defaultFirebaseConfig = {
|
|
| 14 |
};
|
| 15 |
|
| 16 |
const app = initializeApp(defaultFirebaseConfig);
|
| 17 |
-
const storage = getStorage(app);
|
| 18 |
const db = getFirestore(app);
|
| 19 |
|
| 20 |
-
export { app,
|
|
|
|
| 1 |
import { initializeApp } from "firebase/app";
|
|
|
|
| 2 |
import { getFirestore } from "firebase/firestore";
|
| 3 |
|
| 4 |
// Default config provided by the user
|
|
|
|
| 13 |
};
|
| 14 |
|
| 15 |
const app = initializeApp(defaultFirebaseConfig);
|
|
|
|
| 16 |
const db = getFirestore(app);
|
| 17 |
|
| 18 |
+
export { app, db, defaultFirebaseConfig as firebaseConfig };
|
backend/sync-manager.js
CHANGED
|
@@ -1,106 +1,116 @@
|
|
| 1 |
-
import {
|
| 2 |
-
import {
|
| 3 |
import fs from "fs";
|
| 4 |
import path from "path";
|
| 5 |
-
import https from "https";
|
| 6 |
import AdmZip from "adm-zip";
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
return new Promise((resolve, reject) => {
|
| 11 |
-
const file = fs.createWriteStream(destPath);
|
| 12 |
-
https.get(url, (response) => {
|
| 13 |
-
if (response.statusCode !== 200) {
|
| 14 |
-
reject(new Error(`Failed to download: ${response.statusCode}`));
|
| 15 |
-
return;
|
| 16 |
-
}
|
| 17 |
-
response.pipe(file);
|
| 18 |
-
file.on("finish", () => {
|
| 19 |
-
file.close();
|
| 20 |
-
resolve();
|
| 21 |
-
});
|
| 22 |
-
}).on("error", (err) => {
|
| 23 |
-
fs.unlink(destPath, () => {});
|
| 24 |
-
reject(err);
|
| 25 |
-
});
|
| 26 |
-
});
|
| 27 |
-
}
|
| 28 |
|
| 29 |
-
// Compress a directory into a
|
| 30 |
-
function
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
}
|
| 40 |
-
}
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
|
| 43 |
-
// Decompress a
|
| 44 |
-
function
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
const zip = new AdmZip(zipPath);
|
| 51 |
-
zip.extractAllTo(targetDir, true);
|
| 52 |
-
resolve();
|
| 53 |
-
} catch (e) {
|
| 54 |
-
reject(e);
|
| 55 |
-
}
|
| 56 |
-
});
|
| 57 |
}
|
| 58 |
|
| 59 |
/**
|
| 60 |
-
* Backup a Minecraft server folder to
|
| 61 |
*/
|
| 62 |
export async function backupServer(serverId, serverPath, onLog) {
|
| 63 |
-
if (!fs.existsSync(serverPath)) {
|
| 64 |
-
onLog(serverId, `[BACKUP] Server path
|
| 65 |
return false;
|
| 66 |
}
|
| 67 |
|
| 68 |
-
const tempZip = path.join("/tmp", `${serverId}-${Date.now()}.zip`);
|
| 69 |
-
onLog(serverId, `[BACKUP] Compressing server files...`);
|
| 70 |
-
|
| 71 |
try {
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
//
|
|
|
|
| 77 |
const timestamp = Date.now();
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
onLog(serverId, `[BACKUP] Upload successful!`);
|
| 86 |
|
| 87 |
-
//
|
| 88 |
try {
|
| 89 |
-
|
| 90 |
-
const
|
| 91 |
-
const
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
await
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
}
|
|
|
|
| 102 |
}
|
| 103 |
-
onLog(serverId, `[BACKUP] Backup cleanup completed.`);
|
| 104 |
} catch (pruneErr) {
|
| 105 |
onLog(serverId, `[BACKUP] Warning: Failed to prune old backups: ${pruneErr.message}`);
|
| 106 |
}
|
|
@@ -110,64 +120,57 @@ export async function backupServer(serverId, serverPath, onLog) {
|
|
| 110 |
onLog(serverId, `[BACKUP] ERROR: Backup failed: ${err.message}`);
|
| 111 |
console.error(err);
|
| 112 |
return false;
|
| 113 |
-
} finally {
|
| 114 |
-
// Clean up temporary local zip file
|
| 115 |
-
if (fs.existsSync(tempZip)) {
|
| 116 |
-
try {
|
| 117 |
-
fs.unlinkSync(tempZip);
|
| 118 |
-
} catch (e) {}
|
| 119 |
-
}
|
| 120 |
}
|
| 121 |
}
|
| 122 |
|
| 123 |
/**
|
| 124 |
-
* Restore a Minecraft server folder from
|
| 125 |
*/
|
| 126 |
export async function restoreServer(serverId, serverPath, onLog) {
|
| 127 |
-
const folderRef = ref(storage, `backups/${serverId}`);
|
| 128 |
-
onLog(serverId, `[RESTORE] Checking for backups in Firebase Storage...`);
|
| 129 |
-
|
| 130 |
try {
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
| 134 |
return false;
|
| 135 |
}
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
const
|
| 140 |
-
|
| 141 |
-
onLog(serverId, `[RESTORE] Found latest backup: ${latestItem.name}. Downloading...`);
|
| 142 |
-
const downloadUrl = await getDownloadURL(latestItem);
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
if (fs.existsSync(serverPath)) {
|
| 150 |
-
// Delete existing files to avoid conflicts, keeping directory itself
|
| 151 |
const files = fs.readdirSync(serverPath);
|
| 152 |
for (const file of files) {
|
| 153 |
const filePath = path.join(serverPath, file);
|
| 154 |
-
|
| 155 |
-
fs.
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
| 159 |
}
|
| 160 |
-
} else {
|
| 161 |
-
fs.mkdirSync(serverPath, { recursive: true });
|
| 162 |
}
|
| 163 |
|
| 164 |
-
|
| 165 |
-
onLog(serverId, `[RESTORE] Server files restored successfully from backup.`);
|
| 166 |
-
|
| 167 |
-
// Clean up local temp zip
|
| 168 |
-
if (fs.existsSync(tempZip)) {
|
| 169 |
-
fs.unlinkSync(tempZip);
|
| 170 |
-
}
|
| 171 |
return true;
|
| 172 |
} catch (err) {
|
| 173 |
onLog(serverId, `[RESTORE] ERROR: Restore failed: ${err.message}`);
|
|
|
|
| 1 |
+
import { doc, getDoc, setDoc, deleteDoc, collection, getDocs } from "firebase/firestore";
|
| 2 |
+
import { db } from "./firebase-config.js";
|
| 3 |
import fs from "fs";
|
| 4 |
import path from "path";
|
|
|
|
| 5 |
import AdmZip from "adm-zip";
|
| 6 |
|
| 7 |
+
const CHUNK_SIZE = 900 * 1024; // 900KB per Firestore document chunk (limit is ~1MB)
|
| 8 |
+
const MAX_BACKUPS = 3;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
+
// Compress a directory into a Buffer
|
| 11 |
+
function compressDirectoryToBuffer(sourceDir) {
|
| 12 |
+
const zip = new AdmZip();
|
| 13 |
+
// Add all files except large binaries (playit, java, downloads)
|
| 14 |
+
const skipNames = ['playit', 'playit.exe', 'downloads', 'tmp'];
|
| 15 |
+
|
| 16 |
+
function addDir(dirPath, zipPath) {
|
| 17 |
+
const entries = fs.readdirSync(dirPath);
|
| 18 |
+
for (const entry of entries) {
|
| 19 |
+
if (skipNames.includes(entry)) continue;
|
| 20 |
+
const fullPath = path.join(dirPath, entry);
|
| 21 |
+
const stat = fs.statSync(fullPath);
|
| 22 |
+
if (stat.isDirectory()) {
|
| 23 |
+
addDir(fullPath, path.join(zipPath, entry));
|
| 24 |
+
} else {
|
| 25 |
+
const content = fs.readFileSync(fullPath);
|
| 26 |
+
zip.addFile(path.join(zipPath, entry).replace(/\\/g, '/'), content);
|
| 27 |
+
}
|
| 28 |
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
addDir(sourceDir, '');
|
| 32 |
+
return zip.toBuffer();
|
| 33 |
}
|
| 34 |
|
| 35 |
+
// Decompress a Buffer into a directory
|
| 36 |
+
function decompressBufferToDir(buffer, targetDir) {
|
| 37 |
+
if (!fs.existsSync(targetDir)) {
|
| 38 |
+
fs.mkdirSync(targetDir, { recursive: true });
|
| 39 |
+
}
|
| 40 |
+
const zip = new AdmZip(buffer);
|
| 41 |
+
zip.extractAllTo(targetDir, true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
/**
|
| 45 |
+
* Backup a Minecraft server folder to Firestore (chunked Base64)
|
| 46 |
*/
|
| 47 |
export async function backupServer(serverId, serverPath, onLog) {
|
| 48 |
+
if (!fs.existsSync(serverPath) || fs.readdirSync(serverPath).length === 0) {
|
| 49 |
+
onLog(serverId, `[BACKUP] Server path empty or missing. Skipping backup.`);
|
| 50 |
return false;
|
| 51 |
}
|
| 52 |
|
|
|
|
|
|
|
|
|
|
| 53 |
try {
|
| 54 |
+
onLog(serverId, `[BACKUP] Compressing server files...`);
|
| 55 |
+
const zipBuffer = compressDirectoryToBuffer(serverPath);
|
| 56 |
+
const sizeMB = (zipBuffer.length / (1024 * 1024)).toFixed(2);
|
| 57 |
+
onLog(serverId, `[BACKUP] Compression complete. Size: ${sizeMB} MB`);
|
| 58 |
+
|
| 59 |
+
if (zipBuffer.length > 50 * 1024 * 1024) {
|
| 60 |
+
onLog(serverId, `[BACKUP] WARNING: Backup is ${sizeMB}MB. Firestore free tier stores up to ~1GB total. Monitor usage.`);
|
| 61 |
+
}
|
| 62 |
|
| 63 |
+
// Convert to Base64 string
|
| 64 |
+
const base64Data = zipBuffer.toString('base64');
|
| 65 |
const timestamp = Date.now();
|
| 66 |
+
|
| 67 |
+
// Split into chunks
|
| 68 |
+
const chunks = [];
|
| 69 |
+
for (let i = 0; i < base64Data.length; i += CHUNK_SIZE) {
|
| 70 |
+
chunks.push(base64Data.slice(i, i + CHUNK_SIZE));
|
| 71 |
+
}
|
| 72 |
+
onLog(serverId, `[BACKUP] Uploading ${chunks.length} chunk(s) to Firestore...`);
|
| 73 |
+
|
| 74 |
+
// Store metadata
|
| 75 |
+
const metaRef = doc(db, `backups/${serverId}/list/${timestamp}`);
|
| 76 |
+
await setDoc(metaRef, {
|
| 77 |
+
timestamp,
|
| 78 |
+
chunkCount: chunks.length,
|
| 79 |
+
size: zipBuffer.length,
|
| 80 |
+
createdAt: new Date().toISOString()
|
| 81 |
});
|
| 82 |
+
|
| 83 |
+
// Store chunks
|
| 84 |
+
for (let i = 0; i < chunks.length; i++) {
|
| 85 |
+
const chunkRef = doc(db, `backups/${serverId}/data/${timestamp}_chunk_${i}`);
|
| 86 |
+
await setDoc(chunkRef, { chunk: chunks[i], index: i, backupId: String(timestamp) });
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
onLog(serverId, `[BACKUP] Upload successful!`);
|
| 90 |
|
| 91 |
+
// Prune old backups (keep latest MAX_BACKUPS)
|
| 92 |
try {
|
| 93 |
+
const listRef = collection(db, `backups/${serverId}/list`);
|
| 94 |
+
const snapshot = await getDocs(listRef);
|
| 95 |
+
const allBackups = snapshot.docs.map(d => ({ id: d.id, ...d.data() }))
|
| 96 |
+
.sort((a, b) => a.timestamp - b.timestamp);
|
| 97 |
+
|
| 98 |
+
if (allBackups.length > MAX_BACKUPS) {
|
| 99 |
+
const toDelete = allBackups.slice(0, allBackups.length - MAX_BACKUPS);
|
| 100 |
+
for (const backup of toDelete) {
|
| 101 |
+
onLog(serverId, `[BACKUP] Pruning old backup: ${backup.id}`);
|
| 102 |
+
// Delete all chunks for this backup
|
| 103 |
+
const dataRef = collection(db, `backups/${serverId}/data`);
|
| 104 |
+
const dataSnap = await getDocs(dataRef);
|
| 105 |
+
for (const chunkDoc of dataSnap.docs) {
|
| 106 |
+
if (chunkDoc.data().backupId === String(backup.timestamp)) {
|
| 107 |
+
await deleteDoc(chunkDoc.ref);
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
await deleteDoc(doc(db, `backups/${serverId}/list/${backup.id}`));
|
| 111 |
}
|
| 112 |
+
onLog(serverId, `[BACKUP] Pruned ${toDelete.length} old backup(s).`);
|
| 113 |
}
|
|
|
|
| 114 |
} catch (pruneErr) {
|
| 115 |
onLog(serverId, `[BACKUP] Warning: Failed to prune old backups: ${pruneErr.message}`);
|
| 116 |
}
|
|
|
|
| 120 |
onLog(serverId, `[BACKUP] ERROR: Backup failed: ${err.message}`);
|
| 121 |
console.error(err);
|
| 122 |
return false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
}
|
| 124 |
}
|
| 125 |
|
| 126 |
/**
|
| 127 |
+
* Restore a Minecraft server folder from Firestore
|
| 128 |
*/
|
| 129 |
export async function restoreServer(serverId, serverPath, onLog) {
|
|
|
|
|
|
|
|
|
|
| 130 |
try {
|
| 131 |
+
onLog(serverId, `[RESTORE] Checking for backups in Firestore...`);
|
| 132 |
+
const listRef = collection(db, `backups/${serverId}/list`);
|
| 133 |
+
const snapshot = await getDocs(listRef);
|
| 134 |
+
|
| 135 |
+
if (snapshot.empty) {
|
| 136 |
+
onLog(serverId, `[RESTORE] No backups found in Firestore. Starting fresh.`);
|
| 137 |
return false;
|
| 138 |
}
|
| 139 |
|
| 140 |
+
const allBackups = snapshot.docs.map(d => ({ id: d.id, ...d.data() }))
|
| 141 |
+
.sort((a, b) => a.timestamp - b.timestamp);
|
| 142 |
+
const latest = allBackups[allBackups.length - 1];
|
| 143 |
+
onLog(serverId, `[RESTORE] Found backup from ${new Date(latest.timestamp).toLocaleString()} (${latest.chunkCount} chunk(s)).`);
|
|
|
|
|
|
|
| 144 |
|
| 145 |
+
// Fetch all chunks
|
| 146 |
+
const dataRef = collection(db, `backups/${serverId}/data`);
|
| 147 |
+
const dataSnap = await getDocs(dataRef);
|
| 148 |
+
const chunks = dataSnap.docs
|
| 149 |
+
.filter(d => d.data().backupId === String(latest.timestamp))
|
| 150 |
+
.sort((a, b) => a.data().index - b.data().index)
|
| 151 |
+
.map(d => d.data().chunk);
|
| 152 |
|
| 153 |
+
onLog(serverId, `[RESTORE] Downloading and assembling ${chunks.length} chunk(s)...`);
|
| 154 |
+
const base64Data = chunks.join('');
|
| 155 |
+
const zipBuffer = Buffer.from(base64Data, 'base64');
|
| 156 |
+
|
| 157 |
+
// Clean existing files
|
| 158 |
if (fs.existsSync(serverPath)) {
|
|
|
|
| 159 |
const files = fs.readdirSync(serverPath);
|
| 160 |
for (const file of files) {
|
| 161 |
const filePath = path.join(serverPath, file);
|
| 162 |
+
try {
|
| 163 |
+
if (fs.statSync(filePath).isDirectory()) {
|
| 164 |
+
fs.rmSync(filePath, { recursive: true, force: true });
|
| 165 |
+
} else {
|
| 166 |
+
fs.unlinkSync(filePath);
|
| 167 |
+
}
|
| 168 |
+
} catch (e) {}
|
| 169 |
}
|
|
|
|
|
|
|
| 170 |
}
|
| 171 |
|
| 172 |
+
decompressBufferToDir(zipBuffer, serverPath);
|
| 173 |
+
onLog(serverId, `[RESTORE] Server files restored successfully from Firestore backup.`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
return true;
|
| 175 |
} catch (err) {
|
| 176 |
onLog(serverId, `[RESTORE] ERROR: Restore failed: ${err.message}`);
|
server.js
CHANGED
|
@@ -10,6 +10,10 @@ import { processManager } from './backend/process-manager.js';
|
|
| 10 |
import { backupServer, restoreServer } from './backend/sync-manager.js';
|
| 11 |
import { doc, getDoc, setDoc } from 'firebase/firestore';
|
| 12 |
import { db } from './backend/firebase-config.js';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
const __filename = fileURLToPath(import.meta.url);
|
| 15 |
const __dirname = path.dirname(__filename);
|
|
@@ -183,6 +187,48 @@ async function syncToFirestore(servers) {
|
|
| 183 |
}
|
| 184 |
}
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
// Helper: load servers
|
| 187 |
function getServers() {
|
| 188 |
try {
|
|
@@ -285,6 +331,74 @@ setInterval(async () => {
|
|
| 285 |
|
| 286 |
// --- REST API Endpoints ---
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
// GET /api/servers
|
| 289 |
app.get('/api/servers', async (req, res) => {
|
| 290 |
const servers = getServers();
|
|
@@ -985,7 +1099,19 @@ app.post('/api/servers/:id/modrinth/uninstall', (req, res) => {
|
|
| 985 |
});
|
| 986 |
|
| 987 |
// --- WebSocket Gateway Connection Handler ---
|
| 988 |
-
wss.on('connection', (ws) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 989 |
// Client messages
|
| 990 |
ws.on('message', (messageText) => {
|
| 991 |
try {
|
|
|
|
| 10 |
import { backupServer, restoreServer } from './backend/sync-manager.js';
|
| 11 |
import { doc, getDoc, setDoc } from 'firebase/firestore';
|
| 12 |
import { db } from './backend/firebase-config.js';
|
| 13 |
+
import crypto from 'crypto';
|
| 14 |
+
|
| 15 |
+
const activeSessions = new Set();
|
| 16 |
+
let cachedAuthConfig = null;
|
| 17 |
|
| 18 |
const __filename = fileURLToPath(import.meta.url);
|
| 19 |
const __dirname = path.dirname(__filename);
|
|
|
|
| 187 |
}
|
| 188 |
}
|
| 189 |
|
| 190 |
+
// Auth Config helpers
|
| 191 |
+
async function getAuthConfig() {
|
| 192 |
+
if (cachedAuthConfig) return cachedAuthConfig;
|
| 193 |
+
try {
|
| 194 |
+
const docRef = doc(db, "config", "auth");
|
| 195 |
+
const docSnap = await getDoc(docRef);
|
| 196 |
+
if (docSnap.exists()) {
|
| 197 |
+
cachedAuthConfig = docSnap.data();
|
| 198 |
+
return cachedAuthConfig;
|
| 199 |
+
}
|
| 200 |
+
} catch (e) {
|
| 201 |
+
console.error('[AUTH] Failed to fetch auth config from Firestore:', e.message);
|
| 202 |
+
}
|
| 203 |
+
return null;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
async function saveAuthConfig(passwordHash, salt) {
|
| 207 |
+
try {
|
| 208 |
+
const docRef = doc(db, "config", "auth");
|
| 209 |
+
const configData = { passwordHash, salt };
|
| 210 |
+
await setDoc(docRef, configData);
|
| 211 |
+
cachedAuthConfig = configData;
|
| 212 |
+
console.log('[AUTH] Successfully updated auth config in Firestore.');
|
| 213 |
+
return true;
|
| 214 |
+
} catch (e) {
|
| 215 |
+
console.error('[AUTH] Failed to save auth config to Firestore:', e.message);
|
| 216 |
+
return false;
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function verifySession(req, res, next) {
|
| 221 |
+
const authHeader = req.headers.authorization;
|
| 222 |
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
| 223 |
+
return res.status(401).json({ error: 'Unauthorized. Missing authentication token.' });
|
| 224 |
+
}
|
| 225 |
+
const token = authHeader.substring(7);
|
| 226 |
+
if (!activeSessions.has(token)) {
|
| 227 |
+
return res.status(401).json({ error: 'Unauthorized. Invalid or expired session.' });
|
| 228 |
+
}
|
| 229 |
+
next();
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
// Helper: load servers
|
| 233 |
function getServers() {
|
| 234 |
try {
|
|
|
|
| 331 |
|
| 332 |
// --- REST API Endpoints ---
|
| 333 |
|
| 334 |
+
// --- Public Authentication Routes ---
|
| 335 |
+
|
| 336 |
+
// GET /api/auth/status
|
| 337 |
+
app.get('/api/auth/status', async (req, res) => {
|
| 338 |
+
const config = await getAuthConfig();
|
| 339 |
+
res.json({ setupRequired: !config || !config.passwordHash });
|
| 340 |
+
});
|
| 341 |
+
|
| 342 |
+
// POST /api/auth/setup
|
| 343 |
+
app.post('/api/auth/setup', async (req, res) => {
|
| 344 |
+
const { password } = req.body;
|
| 345 |
+
if (!password || password.length < 6) {
|
| 346 |
+
return res.status(400).json({ error: 'Password must be at least 6 characters long.' });
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
const config = await getAuthConfig();
|
| 350 |
+
if (config && config.passwordHash) {
|
| 351 |
+
return res.status(400).json({ error: 'Setup has already been completed.' });
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
const salt = crypto.randomBytes(16).toString('hex');
|
| 355 |
+
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
|
| 356 |
+
|
| 357 |
+
const success = await saveAuthConfig(hash, salt);
|
| 358 |
+
if (success) {
|
| 359 |
+
const token = crypto.randomBytes(32).toString('hex');
|
| 360 |
+
activeSessions.add(token);
|
| 361 |
+
res.json({ success: true, token });
|
| 362 |
+
} else {
|
| 363 |
+
res.status(500).json({ error: 'Failed to save setup config to Firestore.' });
|
| 364 |
+
}
|
| 365 |
+
});
|
| 366 |
+
|
| 367 |
+
// POST /api/auth/login
|
| 368 |
+
app.post('/api/auth/login', async (req, res) => {
|
| 369 |
+
const { password } = req.body;
|
| 370 |
+
if (!password) {
|
| 371 |
+
return res.status(400).json({ error: 'Password is required.' });
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
const config = await getAuthConfig();
|
| 375 |
+
if (!config || !config.passwordHash) {
|
| 376 |
+
return res.status(400).json({ error: 'Setup is required first.' });
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
const hash = crypto.scryptSync(password, config.salt, 64).toString('hex');
|
| 380 |
+
if (hash === config.passwordHash) {
|
| 381 |
+
const token = crypto.randomBytes(32).toString('hex');
|
| 382 |
+
activeSessions.add(token);
|
| 383 |
+
res.json({ success: true, token });
|
| 384 |
+
} else {
|
| 385 |
+
res.status(401).json({ error: 'Incorrect password.' });
|
| 386 |
+
}
|
| 387 |
+
});
|
| 388 |
+
|
| 389 |
+
// POST /api/auth/logout
|
| 390 |
+
app.post('/api/auth/logout', (req, res) => {
|
| 391 |
+
const authHeader = req.headers.authorization;
|
| 392 |
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
| 393 |
+
const token = authHeader.substring(7);
|
| 394 |
+
activeSessions.delete(token);
|
| 395 |
+
}
|
| 396 |
+
res.json({ success: true });
|
| 397 |
+
});
|
| 398 |
+
|
| 399 |
+
// Apply session verification middleware for all server management API routes
|
| 400 |
+
app.use('/api/servers', verifySession);
|
| 401 |
+
|
| 402 |
// GET /api/servers
|
| 403 |
app.get('/api/servers', async (req, res) => {
|
| 404 |
const servers = getServers();
|
|
|
|
| 1099 |
});
|
| 1100 |
|
| 1101 |
// --- WebSocket Gateway Connection Handler ---
|
| 1102 |
+
wss.on('connection', (ws, req) => {
|
| 1103 |
+
try {
|
| 1104 |
+
const parsedUrl = new URL(req.url, 'http://localhost');
|
| 1105 |
+
const token = parsedUrl.searchParams.get('token');
|
| 1106 |
+
if (!token || !activeSessions.has(token)) {
|
| 1107 |
+
ws.close(4001, 'Unauthorized');
|
| 1108 |
+
return;
|
| 1109 |
+
}
|
| 1110 |
+
} catch (e) {
|
| 1111 |
+
ws.close(4000, 'Invalid Request');
|
| 1112 |
+
return;
|
| 1113 |
+
}
|
| 1114 |
+
|
| 1115 |
// Client messages
|
| 1116 |
ws.on('message', (messageText) => {
|
| 1117 |
try {
|
src/App.tsx
CHANGED
|
@@ -27,7 +27,13 @@ import {
|
|
| 27 |
Download,
|
| 28 |
Trash2,
|
| 29 |
CheckCircle2,
|
| 30 |
-
HardDrive
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
} from 'lucide-react';
|
| 32 |
import { motion, AnimatePresence } from 'motion/react';
|
| 33 |
|
|
@@ -85,7 +91,177 @@ const WS_BASE = window.location.port === '3000'
|
|
| 85 |
? 'ws://localhost:3001'
|
| 86 |
: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`;
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
export default function App() {
|
|
|
|
| 89 |
const [activeTab, setActiveTab] = useState<Tab>('dashboard');
|
| 90 |
const [servers, setServers] = useState<ServerInstance[]>([]);
|
| 91 |
const [currentServerId, setCurrentServerId] = useState<string | null>(null);
|
|
@@ -104,10 +280,27 @@ export default function App() {
|
|
| 104 |
const consoleEndRef = useRef<HTMLDivElement>(null);
|
| 105 |
const wsRef = useRef<WebSocket | null>(null);
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
// Fetch servers from API
|
| 108 |
const fetchServers = async (selectNewId?: string) => {
|
| 109 |
try {
|
| 110 |
-
const res = await
|
|
|
|
| 111 |
if (res.ok) {
|
| 112 |
const data = await res.json();
|
| 113 |
setServers(data);
|
|
@@ -128,8 +321,8 @@ export default function App() {
|
|
| 128 |
};
|
| 129 |
|
| 130 |
useEffect(() => {
|
| 131 |
-
fetchServers();
|
| 132 |
-
}, []);
|
| 133 |
|
| 134 |
// Sync state if active server changes
|
| 135 |
const activeServer = servers.find((s) => s.id === currentServerId);
|
|
@@ -154,7 +347,8 @@ export default function App() {
|
|
| 154 |
|
| 155 |
setLogs([]);
|
| 156 |
|
| 157 |
-
const
|
|
|
|
| 158 |
wsRef.current = ws;
|
| 159 |
|
| 160 |
ws.onopen = () => {
|
|
@@ -208,7 +402,7 @@ export default function App() {
|
|
| 208 |
if (!currentServerId) return;
|
| 209 |
setStatus('starting');
|
| 210 |
try {
|
| 211 |
-
await
|
| 212 |
fetchServers();
|
| 213 |
} catch (e) {
|
| 214 |
console.error(e);
|
|
@@ -219,7 +413,7 @@ export default function App() {
|
|
| 219 |
if (!currentServerId) return;
|
| 220 |
setStatus('stopping');
|
| 221 |
try {
|
| 222 |
-
await
|
| 223 |
fetchServers();
|
| 224 |
} catch (e) {
|
| 225 |
console.error(e);
|
|
@@ -230,7 +424,7 @@ export default function App() {
|
|
| 230 |
if (!currentServerId) return;
|
| 231 |
setStatus('starting');
|
| 232 |
try {
|
| 233 |
-
await
|
| 234 |
fetchServers();
|
| 235 |
} catch (e) {
|
| 236 |
console.error(e);
|
|
@@ -242,7 +436,7 @@ export default function App() {
|
|
| 242 |
if (!confirm('Are you sure you want to delete this server instance permanently? This will erase all server files!')) return;
|
| 243 |
|
| 244 |
try {
|
| 245 |
-
const res = await
|
| 246 |
if (res.ok) {
|
| 247 |
fetchServers();
|
| 248 |
} else {
|
|
@@ -257,9 +451,8 @@ export default function App() {
|
|
| 257 |
const handleUpdateSettings = async (settings: { customAddress: string }) => {
|
| 258 |
if (!currentServerId) return;
|
| 259 |
try {
|
| 260 |
-
const res = await
|
| 261 |
method: 'POST',
|
| 262 |
-
headers: { 'Content-Type': 'application/json' },
|
| 263 |
body: JSON.stringify(settings)
|
| 264 |
});
|
| 265 |
if (res.ok) {
|
|
@@ -276,9 +469,8 @@ export default function App() {
|
|
| 276 |
const handleSaveHeaderAddr = async () => {
|
| 277 |
if (!currentServerId) return;
|
| 278 |
try {
|
| 279 |
-
const res = await
|
| 280 |
method: 'POST',
|
| 281 |
-
headers: { 'Content-Type': 'application/json' },
|
| 282 |
body: JSON.stringify({ customAddress: headerAddrInput })
|
| 283 |
});
|
| 284 |
if (res.ok) {
|
|
@@ -293,6 +485,20 @@ export default function App() {
|
|
| 293 |
}
|
| 294 |
};
|
| 295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
return (
|
| 297 |
<div className="flex h-screen bg-[#09090b] text-zinc-100 font-sans selection:bg-emerald-500/30">
|
| 298 |
{/* Sidebar */}
|
|
@@ -350,7 +556,7 @@ export default function App() {
|
|
| 350 |
active={activeTab === 'properties'}
|
| 351 |
onClick={() => setActiveTab('properties')}
|
| 352 |
/>
|
| 353 |
-
<div className="mt-
|
| 354 |
<div className="flex items-center justify-between text-xs text-zinc-500 mb-2">
|
| 355 |
<span>Node: Localhost</span>
|
| 356 |
<span className="text-emerald-500">Active</span>
|
|
@@ -359,9 +565,21 @@ export default function App() {
|
|
| 359 |
<div className="h-full bg-emerald-500 w-full rounded-full" />
|
| 360 |
</div>
|
| 361 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
</div>
|
| 363 |
</aside>
|
| 364 |
|
|
|
|
| 365 |
{/* Main Content */}
|
| 366 |
<main className="flex-1 flex flex-col overflow-hidden relative">
|
| 367 |
{/* Status Header */}
|
|
@@ -1011,9 +1229,9 @@ function PlayersComponent({ serverId, onlinePlayers, executeCommand }: { serverI
|
|
| 1011 |
setSyncing(true);
|
| 1012 |
addLog(`[SYSTEM] Manual Firebase cloud backup triggered...`);
|
| 1013 |
try {
|
| 1014 |
-
const res = await
|
| 1015 |
if (res.ok) {
|
| 1016 |
-
addLog(`[SYSTEM] Manual backup successfully uploaded to
|
| 1017 |
alert('Backup created successfully on Firebase Storage!');
|
| 1018 |
} else {
|
| 1019 |
const err = await res.json();
|
|
@@ -1041,10 +1259,10 @@ function PlayersComponent({ serverId, onlinePlayers, executeCommand }: { serverI
|
|
| 1041 |
setSyncing(true);
|
| 1042 |
addLog(`[SYSTEM] Manual Firebase cloud restore triggered...`);
|
| 1043 |
try {
|
| 1044 |
-
const res = await
|
| 1045 |
if (res.ok) {
|
| 1046 |
addLog(`[SYSTEM] Manual restore successfully completed.`);
|
| 1047 |
-
alert('Server files restored successfully from latest
|
| 1048 |
} else {
|
| 1049 |
const err = await res.json();
|
| 1050 |
addLog(`[SYSTEM] Restore failed: ${err.error}`);
|
|
@@ -1062,7 +1280,7 @@ function PlayersComponent({ serverId, onlinePlayers, executeCommand }: { serverI
|
|
| 1062 |
setSyncing(true);
|
| 1063 |
addLog(`[SYSTEM] Triggering git pull updates manually...`);
|
| 1064 |
try {
|
| 1065 |
-
const res = await
|
| 1066 |
if (res.ok) {
|
| 1067 |
alert('Git pull completed successfully.');
|
| 1068 |
} else {
|
|
@@ -1080,7 +1298,7 @@ function PlayersComponent({ serverId, onlinePlayers, executeCommand }: { serverI
|
|
| 1080 |
setSyncing(true);
|
| 1081 |
addLog(`[SYSTEM] Triggering git backup push manually...`);
|
| 1082 |
try {
|
| 1083 |
-
const res = await
|
| 1084 |
if (res.ok) {
|
| 1085 |
alert('Git push backup completed successfully.');
|
| 1086 |
} else {
|
|
@@ -1251,7 +1469,7 @@ function ServerPropertiesComponent({ server }: { server: ServerInstance }) {
|
|
| 1251 |
useEffect(() => {
|
| 1252 |
const fetchProps = async () => {
|
| 1253 |
try {
|
| 1254 |
-
const res = await
|
| 1255 |
if (res.ok) {
|
| 1256 |
const data = await res.json();
|
| 1257 |
setProperties(data);
|
|
@@ -1270,9 +1488,8 @@ function ServerPropertiesComponent({ server }: { server: ServerInstance }) {
|
|
| 1270 |
const handleSave = async () => {
|
| 1271 |
setSaving(true);
|
| 1272 |
try {
|
| 1273 |
-
await
|
| 1274 |
method: 'POST',
|
| 1275 |
-
headers: { 'Content-Type': 'application/json' },
|
| 1276 |
body: JSON.stringify(properties)
|
| 1277 |
});
|
| 1278 |
alert('Properties saved successfully! Please restart the server for changes to take effect.');
|
|
@@ -1427,9 +1644,8 @@ function CreateServerWizard({ onCreated }: { onCreated: (newId: string) => void
|
|
| 1427 |
}
|
| 1428 |
setCreating(true);
|
| 1429 |
try {
|
| 1430 |
-
const res = await
|
| 1431 |
method: 'POST',
|
| 1432 |
-
headers: { 'Content-Type': 'application/json' },
|
| 1433 |
body: JSON.stringify({
|
| 1434 |
name,
|
| 1435 |
software,
|
|
@@ -1616,7 +1832,7 @@ function MarketplaceComponent({ serverId }: { serverId: string }) {
|
|
| 1616 |
|
| 1617 |
const fetchInstalled = async () => {
|
| 1618 |
try {
|
| 1619 |
-
const res = await
|
| 1620 |
if (res.ok) {
|
| 1621 |
const data = await res.json();
|
| 1622 |
setInstalled(data);
|
|
@@ -1629,7 +1845,7 @@ function MarketplaceComponent({ serverId }: { serverId: string }) {
|
|
| 1629 |
const executeSearch = async () => {
|
| 1630 |
setLoading(true);
|
| 1631 |
try {
|
| 1632 |
-
const res = await
|
| 1633 |
if (res.ok) {
|
| 1634 |
const data = await res.json();
|
| 1635 |
setResults(data);
|
|
@@ -1672,9 +1888,8 @@ function MarketplaceComponent({ serverId }: { serverId: string }) {
|
|
| 1672 |
const handleInstall = async (project: any) => {
|
| 1673 |
setInstallingId(project.project_id);
|
| 1674 |
try {
|
| 1675 |
-
const res = await
|
| 1676 |
method: 'POST',
|
| 1677 |
-
headers: { 'Content-Type': 'application/json' },
|
| 1678 |
body: JSON.stringify({
|
| 1679 |
projectId: project.project_id,
|
| 1680 |
projectType: projectType
|
|
@@ -1697,9 +1912,8 @@ function MarketplaceComponent({ serverId }: { serverId: string }) {
|
|
| 1697 |
const handleUninstall = async (project: any, filename: string) => {
|
| 1698 |
setInstallingId(project.project_id);
|
| 1699 |
try {
|
| 1700 |
-
const res = await
|
| 1701 |
method: 'POST',
|
| 1702 |
-
headers: { 'Content-Type': 'application/json' },
|
| 1703 |
body: JSON.stringify({
|
| 1704 |
filename,
|
| 1705 |
projectType: projectType
|
|
|
|
| 27 |
Download,
|
| 28 |
Trash2,
|
| 29 |
CheckCircle2,
|
| 30 |
+
HardDrive,
|
| 31 |
+
Lock,
|
| 32 |
+
LogOut,
|
| 33 |
+
KeyRound,
|
| 34 |
+
Eye,
|
| 35 |
+
EyeOff,
|
| 36 |
+
ServerCrash
|
| 37 |
} from 'lucide-react';
|
| 38 |
import { motion, AnimatePresence } from 'motion/react';
|
| 39 |
|
|
|
|
| 91 |
? 'ws://localhost:3001'
|
| 92 |
: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`;
|
| 93 |
|
| 94 |
+
// --- Auth Helper ---
|
| 95 |
+
function getToken() { return localStorage.getItem('mc_panel_token') || ''; }
|
| 96 |
+
function setToken(t: string) { localStorage.setItem('mc_panel_token', t); }
|
| 97 |
+
function clearToken() { localStorage.removeItem('mc_panel_token'); }
|
| 98 |
+
function authHeaders(): Record<string, string> {
|
| 99 |
+
const t = getToken();
|
| 100 |
+
return t ? { 'Content-Type': 'application/json', 'Authorization': `Bearer ${t}` } : { 'Content-Type': 'application/json' };
|
| 101 |
+
}
|
| 102 |
+
function authFetch(url: string, opts: RequestInit = {}) {
|
| 103 |
+
const t = getToken();
|
| 104 |
+
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...(opts.headers as Record<string, string> || {}) };
|
| 105 |
+
if (t) headers['Authorization'] = `Bearer ${t}`;
|
| 106 |
+
return fetch(url, { ...opts, headers });
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// --- Login Screen Component ---
|
| 110 |
+
function AuthScreen({ onAuth }: { onAuth: () => void }) {
|
| 111 |
+
const [mode, setMode] = useState<'checking' | 'login' | 'setup'>('checking');
|
| 112 |
+
const [password, setPassword] = useState('');
|
| 113 |
+
const [confirmPwd, setConfirmPwd] = useState('');
|
| 114 |
+
const [showPwd, setShowPwd] = useState(false);
|
| 115 |
+
const [error, setError] = useState('');
|
| 116 |
+
const [loading, setLoading] = useState(false);
|
| 117 |
+
|
| 118 |
+
useEffect(() => {
|
| 119 |
+
fetch(`${API_BASE}/api/auth/status`)
|
| 120 |
+
.then(r => r.json())
|
| 121 |
+
.then(data => setMode(data.setupRequired ? 'setup' : 'login'))
|
| 122 |
+
.catch(() => setMode('login'));
|
| 123 |
+
}, []);
|
| 124 |
+
|
| 125 |
+
const handleSubmit = async (e: FormEvent) => {
|
| 126 |
+
e.preventDefault();
|
| 127 |
+
setError('');
|
| 128 |
+
if (mode === 'setup' && password !== confirmPwd) {
|
| 129 |
+
setError('Passwords do not match.');
|
| 130 |
+
return;
|
| 131 |
+
}
|
| 132 |
+
if (password.length < 6) {
|
| 133 |
+
setError('Password must be at least 6 characters.');
|
| 134 |
+
return;
|
| 135 |
+
}
|
| 136 |
+
setLoading(true);
|
| 137 |
+
try {
|
| 138 |
+
const endpoint = mode === 'setup' ? '/api/auth/setup' : '/api/auth/login';
|
| 139 |
+
const res = await fetch(`${API_BASE}${endpoint}`, {
|
| 140 |
+
method: 'POST',
|
| 141 |
+
headers: { 'Content-Type': 'application/json' },
|
| 142 |
+
body: JSON.stringify({ password })
|
| 143 |
+
});
|
| 144 |
+
const data = await res.json();
|
| 145 |
+
if (res.ok && data.token) {
|
| 146 |
+
setToken(data.token);
|
| 147 |
+
onAuth();
|
| 148 |
+
} else {
|
| 149 |
+
setError(data.error || 'Authentication failed.');
|
| 150 |
+
}
|
| 151 |
+
} catch (e) {
|
| 152 |
+
setError('Failed to connect to server. Please try again.');
|
| 153 |
+
} finally {
|
| 154 |
+
setLoading(false);
|
| 155 |
+
}
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
if (mode === 'checking') {
|
| 159 |
+
return (
|
| 160 |
+
<div className="flex h-screen bg-[#09090b] items-center justify-center">
|
| 161 |
+
<div className="w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
| 162 |
+
</div>
|
| 163 |
+
);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
return (
|
| 167 |
+
<div className="flex h-screen bg-[#09090b] items-center justify-center relative overflow-hidden">
|
| 168 |
+
{/* Background grid */}
|
| 169 |
+
<div className="absolute inset-0" style={{ backgroundImage: 'radial-gradient(circle at 25% 50%, rgba(16,185,129,0.06) 0%, transparent 50%), radial-gradient(circle at 75% 20%, rgba(99,102,241,0.06) 0%, transparent 50%)' }} />
|
| 170 |
+
<div className="absolute inset-0" style={{ backgroundImage: 'linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
|
| 171 |
+
|
| 172 |
+
<div className="relative z-10 w-full max-w-md px-6">
|
| 173 |
+
{/* Logo */}
|
| 174 |
+
<div className="text-center mb-8">
|
| 175 |
+
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 mb-4">
|
| 176 |
+
<ServerCrash className="w-8 h-8 text-emerald-400" />
|
| 177 |
+
</div>
|
| 178 |
+
<h1 className="text-3xl font-bold text-white tracking-tight">MC Hoster</h1>
|
| 179 |
+
<p className="text-zinc-400 mt-1 text-sm">Minecraft Server Management Panel</p>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
{/* Card */}
|
| 183 |
+
<div className="bg-zinc-900/80 backdrop-blur-xl border border-zinc-800 rounded-2xl p-8 shadow-2xl">
|
| 184 |
+
<div className="flex items-center gap-3 mb-6">
|
| 185 |
+
<div className="w-9 h-9 rounded-xl bg-zinc-800 flex items-center justify-center">
|
| 186 |
+
{mode === 'setup' ? <KeyRound className="w-4 h-4 text-amber-400" /> : <Lock className="w-4 h-4 text-emerald-400" />}
|
| 187 |
+
</div>
|
| 188 |
+
<div>
|
| 189 |
+
<h2 className="text-lg font-semibold text-white">
|
| 190 |
+
{mode === 'setup' ? 'Create Admin Password' : 'Admin Login'}
|
| 191 |
+
</h2>
|
| 192 |
+
<p className="text-xs text-zinc-500">
|
| 193 |
+
{mode === 'setup' ? 'First time setup — set your secure password' : 'Enter your admin password to continue'}
|
| 194 |
+
</p>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 199 |
+
<div>
|
| 200 |
+
<label className="block text-xs font-medium text-zinc-400 mb-1.5">Password</label>
|
| 201 |
+
<div className="relative">
|
| 202 |
+
<input
|
| 203 |
+
type={showPwd ? 'text' : 'password'}
|
| 204 |
+
value={password}
|
| 205 |
+
onChange={e => setPassword(e.target.value)}
|
| 206 |
+
placeholder="Enter password..."
|
| 207 |
+
className="w-full bg-zinc-800/80 border border-zinc-700 rounded-xl px-4 py-3 text-white placeholder-zinc-500 text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500/30 transition-all pr-10"
|
| 208 |
+
required
|
| 209 |
+
autoFocus
|
| 210 |
+
/>
|
| 211 |
+
<button type="button" onClick={() => setShowPwd(p => !p)}
|
| 212 |
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300">
|
| 213 |
+
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 214 |
+
</button>
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
|
| 218 |
+
{mode === 'setup' && (
|
| 219 |
+
<div>
|
| 220 |
+
<label className="block text-xs font-medium text-zinc-400 mb-1.5">Confirm Password</label>
|
| 221 |
+
<input
|
| 222 |
+
type={showPwd ? 'text' : 'password'}
|
| 223 |
+
value={confirmPwd}
|
| 224 |
+
onChange={e => setConfirmPwd(e.target.value)}
|
| 225 |
+
placeholder="Confirm password..."
|
| 226 |
+
className="w-full bg-zinc-800/80 border border-zinc-700 rounded-xl px-4 py-3 text-white placeholder-zinc-500 text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500/30 transition-all"
|
| 227 |
+
required
|
| 228 |
+
/>
|
| 229 |
+
</div>
|
| 230 |
+
)}
|
| 231 |
+
|
| 232 |
+
{error && (
|
| 233 |
+
<div className="flex items-center gap-2 bg-red-500/10 border border-red-500/30 rounded-xl px-4 py-3">
|
| 234 |
+
<ShieldAlert className="w-4 h-4 text-red-400 shrink-0" />
|
| 235 |
+
<p className="text-sm text-red-400">{error}</p>
|
| 236 |
+
</div>
|
| 237 |
+
)}
|
| 238 |
+
|
| 239 |
+
<button
|
| 240 |
+
type="submit"
|
| 241 |
+
disabled={loading}
|
| 242 |
+
className="w-full py-3 rounded-xl bg-emerald-500 hover:bg-emerald-400 text-black font-semibold text-sm transition-all flex items-center justify-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
| 243 |
+
>
|
| 244 |
+
{loading ? (
|
| 245 |
+
<div className="w-4 h-4 border-2 border-black border-t-transparent rounded-full animate-spin" />
|
| 246 |
+
) : (
|
| 247 |
+
mode === 'setup' ? <><KeyRound className="w-4 h-4" /> Set Password & Enter</> : <><Lock className="w-4 h-4" /> Login</>
|
| 248 |
+
)}
|
| 249 |
+
</button>
|
| 250 |
+
</form>
|
| 251 |
+
|
| 252 |
+
{mode === 'setup' && (
|
| 253 |
+
<p className="text-center text-xs text-zinc-600 mt-4">
|
| 254 |
+
Password is hashed securely and stored in Firestore.
|
| 255 |
+
</p>
|
| 256 |
+
)}
|
| 257 |
+
</div>
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
);
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
export default function App() {
|
| 264 |
+
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
| 265 |
const [activeTab, setActiveTab] = useState<Tab>('dashboard');
|
| 266 |
const [servers, setServers] = useState<ServerInstance[]>([]);
|
| 267 |
const [currentServerId, setCurrentServerId] = useState<string | null>(null);
|
|
|
|
| 280 |
const consoleEndRef = useRef<HTMLDivElement>(null);
|
| 281 |
const wsRef = useRef<WebSocket | null>(null);
|
| 282 |
|
| 283 |
+
// Check if already authenticated on mount
|
| 284 |
+
useEffect(() => {
|
| 285 |
+
const token = getToken();
|
| 286 |
+
if (!token) { setIsAuthenticated(false); return; }
|
| 287 |
+
// Verify token is still valid
|
| 288 |
+
fetch(`${API_BASE}/api/servers`, { headers: { 'Authorization': `Bearer ${token}` } })
|
| 289 |
+
.then(r => { setIsAuthenticated(r.status !== 401); })
|
| 290 |
+
.catch(() => setIsAuthenticated(false));
|
| 291 |
+
}, []);
|
| 292 |
+
|
| 293 |
+
const handleLogout = async () => {
|
| 294 |
+
await authFetch(`${API_BASE}/api/auth/logout`, { method: 'POST' });
|
| 295 |
+
clearToken();
|
| 296 |
+
setIsAuthenticated(false);
|
| 297 |
+
};
|
| 298 |
+
|
| 299 |
// Fetch servers from API
|
| 300 |
const fetchServers = async (selectNewId?: string) => {
|
| 301 |
try {
|
| 302 |
+
const res = await authFetch(`${API_BASE}/api/servers`);
|
| 303 |
+
if (res.status === 401) { clearToken(); setIsAuthenticated(false); return; }
|
| 304 |
if (res.ok) {
|
| 305 |
const data = await res.json();
|
| 306 |
setServers(data);
|
|
|
|
| 321 |
};
|
| 322 |
|
| 323 |
useEffect(() => {
|
| 324 |
+
if (isAuthenticated) fetchServers();
|
| 325 |
+
}, [isAuthenticated]);
|
| 326 |
|
| 327 |
// Sync state if active server changes
|
| 328 |
const activeServer = servers.find((s) => s.id === currentServerId);
|
|
|
|
| 347 |
|
| 348 |
setLogs([]);
|
| 349 |
|
| 350 |
+
const token = getToken();
|
| 351 |
+
const ws = new WebSocket(`${WS_BASE}${WS_BASE.includes('?') ? '&' : '?'}token=${token}`);
|
| 352 |
wsRef.current = ws;
|
| 353 |
|
| 354 |
ws.onopen = () => {
|
|
|
|
| 402 |
if (!currentServerId) return;
|
| 403 |
setStatus('starting');
|
| 404 |
try {
|
| 405 |
+
await authFetch(`${API_BASE}/api/servers/${currentServerId}/start`, { method: 'POST' });
|
| 406 |
fetchServers();
|
| 407 |
} catch (e) {
|
| 408 |
console.error(e);
|
|
|
|
| 413 |
if (!currentServerId) return;
|
| 414 |
setStatus('stopping');
|
| 415 |
try {
|
| 416 |
+
await authFetch(`${API_BASE}/api/servers/${currentServerId}/stop`, { method: 'POST' });
|
| 417 |
fetchServers();
|
| 418 |
} catch (e) {
|
| 419 |
console.error(e);
|
|
|
|
| 424 |
if (!currentServerId) return;
|
| 425 |
setStatus('starting');
|
| 426 |
try {
|
| 427 |
+
await authFetch(`${API_BASE}/api/servers/${currentServerId}/restart`, { method: 'POST' });
|
| 428 |
fetchServers();
|
| 429 |
} catch (e) {
|
| 430 |
console.error(e);
|
|
|
|
| 436 |
if (!confirm('Are you sure you want to delete this server instance permanently? This will erase all server files!')) return;
|
| 437 |
|
| 438 |
try {
|
| 439 |
+
const res = await authFetch(`${API_BASE}/api/servers/${currentServerId}`, { method: 'DELETE' });
|
| 440 |
if (res.ok) {
|
| 441 |
fetchServers();
|
| 442 |
} else {
|
|
|
|
| 451 |
const handleUpdateSettings = async (settings: { customAddress: string }) => {
|
| 452 |
if (!currentServerId) return;
|
| 453 |
try {
|
| 454 |
+
const res = await authFetch(`${API_BASE}/api/servers/${currentServerId}/settings`, {
|
| 455 |
method: 'POST',
|
|
|
|
| 456 |
body: JSON.stringify(settings)
|
| 457 |
});
|
| 458 |
if (res.ok) {
|
|
|
|
| 469 |
const handleSaveHeaderAddr = async () => {
|
| 470 |
if (!currentServerId) return;
|
| 471 |
try {
|
| 472 |
+
const res = await authFetch(`${API_BASE}/api/servers/${currentServerId}/settings`, {
|
| 473 |
method: 'POST',
|
|
|
|
| 474 |
body: JSON.stringify({ customAddress: headerAddrInput })
|
| 475 |
});
|
| 476 |
if (res.ok) {
|
|
|
|
| 485 |
}
|
| 486 |
};
|
| 487 |
|
| 488 |
+
// Show loading spinner while auth state unknown
|
| 489 |
+
if (isAuthenticated === null) {
|
| 490 |
+
return (
|
| 491 |
+
<div className="flex h-screen bg-[#09090b] items-center justify-center">
|
| 492 |
+
<div className="w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
| 493 |
+
</div>
|
| 494 |
+
);
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
// Show auth screen if not logged in
|
| 498 |
+
if (!isAuthenticated) {
|
| 499 |
+
return <AuthScreen onAuth={() => setIsAuthenticated(true)} />;
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
return (
|
| 503 |
<div className="flex h-screen bg-[#09090b] text-zinc-100 font-sans selection:bg-emerald-500/30">
|
| 504 |
{/* Sidebar */}
|
|
|
|
| 556 |
active={activeTab === 'properties'}
|
| 557 |
onClick={() => setActiveTab('properties')}
|
| 558 |
/>
|
| 559 |
+
<div className="mt-3 p-3 bg-zinc-900/50 rounded-xl border border-zinc-800">
|
| 560 |
<div className="flex items-center justify-between text-xs text-zinc-500 mb-2">
|
| 561 |
<span>Node: Localhost</span>
|
| 562 |
<span className="text-emerald-500">Active</span>
|
|
|
|
| 565 |
<div className="h-full bg-emerald-500 w-full rounded-full" />
|
| 566 |
</div>
|
| 567 |
</div>
|
| 568 |
+
<div className="mt-2 p-2.5 bg-blue-500/5 rounded-xl border border-blue-500/20 text-[10px] text-blue-400 flex items-center gap-2">
|
| 569 |
+
<Database className="w-3 h-3 shrink-0" />
|
| 570 |
+
<span>Firestore Backups Active</span>
|
| 571 |
+
</div>
|
| 572 |
+
<button
|
| 573 |
+
onClick={handleLogout}
|
| 574 |
+
className="mt-3 w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-all text-sm"
|
| 575 |
+
>
|
| 576 |
+
<LogOut className="w-4 h-4" />
|
| 577 |
+
<span>Logout</span>
|
| 578 |
+
</button>
|
| 579 |
</div>
|
| 580 |
</aside>
|
| 581 |
|
| 582 |
+
|
| 583 |
{/* Main Content */}
|
| 584 |
<main className="flex-1 flex flex-col overflow-hidden relative">
|
| 585 |
{/* Status Header */}
|
|
|
|
| 1229 |
setSyncing(true);
|
| 1230 |
addLog(`[SYSTEM] Manual Firebase cloud backup triggered...`);
|
| 1231 |
try {
|
| 1232 |
+
const res = await authFetch(`${API_BASE}/api/servers/${server.id}/backup`, { method: 'POST' });
|
| 1233 |
if (res.ok) {
|
| 1234 |
+
addLog(`[SYSTEM] Manual backup successfully uploaded to Firestore.`);
|
| 1235 |
alert('Backup created successfully on Firebase Storage!');
|
| 1236 |
} else {
|
| 1237 |
const err = await res.json();
|
|
|
|
| 1259 |
setSyncing(true);
|
| 1260 |
addLog(`[SYSTEM] Manual Firebase cloud restore triggered...`);
|
| 1261 |
try {
|
| 1262 |
+
const res = await authFetch(`${API_BASE}/api/servers/${server.id}/restore`, { method: 'POST' });
|
| 1263 |
if (res.ok) {
|
| 1264 |
addLog(`[SYSTEM] Manual restore successfully completed.`);
|
| 1265 |
+
alert('Server files restored successfully from latest Firestore backup!');
|
| 1266 |
} else {
|
| 1267 |
const err = await res.json();
|
| 1268 |
addLog(`[SYSTEM] Restore failed: ${err.error}`);
|
|
|
|
| 1280 |
setSyncing(true);
|
| 1281 |
addLog(`[SYSTEM] Triggering git pull updates manually...`);
|
| 1282 |
try {
|
| 1283 |
+
const res = await authFetch(`${API_BASE}/api/servers/${server.id}/git/pull`, { method: 'POST' });
|
| 1284 |
if (res.ok) {
|
| 1285 |
alert('Git pull completed successfully.');
|
| 1286 |
} else {
|
|
|
|
| 1298 |
setSyncing(true);
|
| 1299 |
addLog(`[SYSTEM] Triggering git backup push manually...`);
|
| 1300 |
try {
|
| 1301 |
+
const res = await authFetch(`${API_BASE}/api/servers/${server.id}/git/push`, { method: 'POST' });
|
| 1302 |
if (res.ok) {
|
| 1303 |
alert('Git push backup completed successfully.');
|
| 1304 |
} else {
|
|
|
|
| 1469 |
useEffect(() => {
|
| 1470 |
const fetchProps = async () => {
|
| 1471 |
try {
|
| 1472 |
+
const res = await authFetch(`${API_BASE}/api/servers/${server.id}/properties`);
|
| 1473 |
if (res.ok) {
|
| 1474 |
const data = await res.json();
|
| 1475 |
setProperties(data);
|
|
|
|
| 1488 |
const handleSave = async () => {
|
| 1489 |
setSaving(true);
|
| 1490 |
try {
|
| 1491 |
+
await authFetch(`${API_BASE}/api/servers/${server.id}/properties`, {
|
| 1492 |
method: 'POST',
|
|
|
|
| 1493 |
body: JSON.stringify(properties)
|
| 1494 |
});
|
| 1495 |
alert('Properties saved successfully! Please restart the server for changes to take effect.');
|
|
|
|
| 1644 |
}
|
| 1645 |
setCreating(true);
|
| 1646 |
try {
|
| 1647 |
+
const res = await authFetch(`${API_BASE}/api/servers`, {
|
| 1648 |
method: 'POST',
|
|
|
|
| 1649 |
body: JSON.stringify({
|
| 1650 |
name,
|
| 1651 |
software,
|
|
|
|
| 1832 |
|
| 1833 |
const fetchInstalled = async () => {
|
| 1834 |
try {
|
| 1835 |
+
const res = await authFetch(`${API_BASE}/api/servers/${serverId}/modrinth/installed`);
|
| 1836 |
if (res.ok) {
|
| 1837 |
const data = await res.json();
|
| 1838 |
setInstalled(data);
|
|
|
|
| 1845 |
const executeSearch = async () => {
|
| 1846 |
setLoading(true);
|
| 1847 |
try {
|
| 1848 |
+
const res = await authFetch(`${API_BASE}/api/modrinth/search?query=${encodeURIComponent(query)}&project_type=${projectType}`);
|
| 1849 |
if (res.ok) {
|
| 1850 |
const data = await res.json();
|
| 1851 |
setResults(data);
|
|
|
|
| 1888 |
const handleInstall = async (project: any) => {
|
| 1889 |
setInstallingId(project.project_id);
|
| 1890 |
try {
|
| 1891 |
+
const res = await authFetch(`${API_BASE}/api/servers/${serverId}/modrinth/install`, {
|
| 1892 |
method: 'POST',
|
|
|
|
| 1893 |
body: JSON.stringify({
|
| 1894 |
projectId: project.project_id,
|
| 1895 |
projectType: projectType
|
|
|
|
| 1912 |
const handleUninstall = async (project: any, filename: string) => {
|
| 1913 |
setInstallingId(project.project_id);
|
| 1914 |
try {
|
| 1915 |
+
const res = await authFetch(`${API_BASE}/api/servers/${serverId}/modrinth/uninstall`, {
|
| 1916 |
method: 'POST',
|
|
|
|
| 1917 |
body: JSON.stringify({
|
| 1918 |
filename,
|
| 1919 |
projectType: projectType
|