Initial push
This commit is contained in:
48
lib/auth/client.ts
Normal file
48
lib/auth/client.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { organizationClient } from "better-auth/client/plugins";
|
||||
import { magicLinkClient } from "better-auth/client/plugins";
|
||||
import type { Auth } from "./index";
|
||||
|
||||
/**
|
||||
* Better Auth client instance for use in React components and client-side
|
||||
* code. Mirrors the plugins registered on the server-side `auth` instance.
|
||||
*/
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL ?? "http://localhost:3000",
|
||||
|
||||
plugins: [
|
||||
// Enables organization.* methods (createOrganization, getActiveMember, etc.)
|
||||
organizationClient(),
|
||||
|
||||
// Enables signIn.magicLink and magicLink.verify
|
||||
magicLinkClient(),
|
||||
],
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience re-exports so consumers only need to import from this module
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const {
|
||||
signIn,
|
||||
signOut,
|
||||
signUp,
|
||||
useSession,
|
||||
getSession,
|
||||
} = authClient;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inferred client-side types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ClientSession = typeof authClient.$Infer.Session.session;
|
||||
export type ClientUser = typeof authClient.$Infer.Session.user;
|
||||
|
||||
/**
|
||||
* Infer server plugin types on the client side.
|
||||
* Provides full type safety for plugin-specific methods without importing
|
||||
* the server-only `auth` instance into a client bundle.
|
||||
*/
|
||||
export type { Auth };
|
||||
108
lib/auth/index.ts
Normal file
108
lib/auth/index.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { organization } from "better-auth/plugins";
|
||||
import { magicLink } from "better-auth/plugins/magic-link";
|
||||
import { db } from "@/lib/db";
|
||||
import * as schema from "@/lib/db/schema";
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
export const auth = betterAuth({
|
||||
// -------------------------------------------------------------------------
|
||||
// Core
|
||||
// -------------------------------------------------------------------------
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Database adapter (Drizzle + bun:sqlite)
|
||||
// -------------------------------------------------------------------------
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "sqlite",
|
||||
schema: {
|
||||
users: schema.users,
|
||||
sessions: schema.sessions,
|
||||
accounts: schema.accounts,
|
||||
verifications: schema.verifications,
|
||||
},
|
||||
usePlural: false,
|
||||
}),
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Custom user fields
|
||||
// -------------------------------------------------------------------------
|
||||
user: {
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
required: false,
|
||||
defaultValue: "moderator",
|
||||
input: false, // Not settable by the user directly
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Email + password authentication
|
||||
// -------------------------------------------------------------------------
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false,
|
||||
minPasswordLength: 8,
|
||||
maxPasswordLength: 128,
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Plugins
|
||||
// -------------------------------------------------------------------------
|
||||
plugins: [
|
||||
// Organization / role support
|
||||
organization(),
|
||||
|
||||
// Magic link — used for invitation acceptance flows
|
||||
magicLink({
|
||||
expiresIn: 60 * 60, // 1 hour
|
||||
disableSignUp: true, // magic links are only for invited users
|
||||
sendMagicLink: async ({ email, url, token }) => {
|
||||
// Delegate to the application's email module. The email module is
|
||||
// responsible for importing and calling Resend (or whichever mailer
|
||||
// is configured). We do a dynamic import so that this file does not
|
||||
// pull in email dependencies at auth-initialisation time on the edge.
|
||||
const { sendMagicLinkEmail } = await import("@/lib/email/index");
|
||||
await sendMagicLinkEmail({ email, url, token });
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Trusted origins — allow env-configured list plus localhost in dev
|
||||
// -------------------------------------------------------------------------
|
||||
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(",").map((o) => o.trim())
|
||||
: ["http://localhost:3000"],
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cookie / session security
|
||||
// -------------------------------------------------------------------------
|
||||
advanced: {
|
||||
useSecureCookies: isProduction,
|
||||
defaultCookieAttributes: {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type helpers for use across the application
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Auth = typeof auth;
|
||||
|
||||
/** The server-side session type returned by auth.api.getSession */
|
||||
export type Session = typeof auth.$Infer.Session.session;
|
||||
|
||||
/** The user type embedded in every session */
|
||||
export type User = typeof auth.$Infer.Session.user;
|
||||
141
lib/backup/manager.ts
Normal file
141
lib/backup/manager.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Backup manager: creates zip archives of worlds, plugins, or config.
|
||||
* Uses the `archiver` package.
|
||||
*/
|
||||
|
||||
import archiver from "archiver";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { db } from "@/lib/db";
|
||||
import { backups } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export type BackupType = "worlds" | "plugins" | "config" | "full";
|
||||
|
||||
const BACKUPS_DIR =
|
||||
process.env.BACKUPS_PATH ?? path.join(process.cwd(), "backups");
|
||||
|
||||
/** Ensure the backups directory exists. */
|
||||
function ensureBackupsDir(): void {
|
||||
fs.mkdirSync(BACKUPS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup archive of the specified type.
|
||||
* Returns the backup record ID.
|
||||
*/
|
||||
export async function createBackup(
|
||||
type: BackupType,
|
||||
triggeredBy: string,
|
||||
): Promise<string> {
|
||||
ensureBackupsDir();
|
||||
|
||||
const mcPath = process.env.MC_SERVER_PATH ?? "/opt/minecraft/server";
|
||||
const id = nanoid();
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const fileName = `backup-${type}-${timestamp}.zip`;
|
||||
const filePath = path.join(BACKUPS_DIR, fileName);
|
||||
|
||||
// Insert pending record
|
||||
await db.insert(backups).values({
|
||||
id,
|
||||
name: fileName,
|
||||
type,
|
||||
size: 0,
|
||||
path: filePath,
|
||||
createdAt: Date.now(),
|
||||
status: "running",
|
||||
triggeredBy,
|
||||
});
|
||||
|
||||
try {
|
||||
await archiveBackup(type, mcPath, filePath);
|
||||
|
||||
const stat = fs.statSync(filePath);
|
||||
await db
|
||||
.update(backups)
|
||||
.set({ status: "completed", size: stat.size })
|
||||
.where(eq(backups.id, id));
|
||||
} catch (err) {
|
||||
await db
|
||||
.update(backups)
|
||||
.set({ status: "failed" })
|
||||
.where(eq(backups.id, id));
|
||||
throw err;
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function archiveBackup(
|
||||
type: BackupType,
|
||||
mcPath: string,
|
||||
outPath: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const output = fs.createWriteStream(outPath);
|
||||
const archive = archiver("zip", { zlib: { level: 6 } });
|
||||
|
||||
output.on("close", resolve);
|
||||
archive.on("error", reject);
|
||||
archive.pipe(output);
|
||||
|
||||
const dirsToArchive: { src: string; name: string }[] = [];
|
||||
|
||||
if (type === "worlds" || type === "full") {
|
||||
// Common world directory names
|
||||
for (const dir of ["world", "world_nether", "world_the_end"]) {
|
||||
const p = path.join(mcPath, dir);
|
||||
if (fs.existsSync(p)) dirsToArchive.push({ src: p, name: dir });
|
||||
}
|
||||
}
|
||||
if (type === "plugins" || type === "full") {
|
||||
const p = path.join(mcPath, "plugins");
|
||||
if (fs.existsSync(p)) dirsToArchive.push({ src: p, name: "plugins" });
|
||||
}
|
||||
if (type === "config" || type === "full") {
|
||||
// Config files at server root
|
||||
for (const file of [
|
||||
"server.properties",
|
||||
"ops.json",
|
||||
"whitelist.json",
|
||||
"banned-players.json",
|
||||
"banned-ips.json",
|
||||
"eula.txt",
|
||||
"spigot.yml",
|
||||
"paper.yml",
|
||||
"bukkit.yml",
|
||||
]) {
|
||||
const p = path.join(mcPath, file);
|
||||
if (fs.existsSync(p)) archive.file(p, { name: `config/${file}` });
|
||||
}
|
||||
}
|
||||
|
||||
for (const { src, name } of dirsToArchive) {
|
||||
archive.directory(src, name);
|
||||
}
|
||||
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a backup file and its DB record. */
|
||||
export async function deleteBackup(id: string): Promise<void> {
|
||||
const record = await db
|
||||
.select()
|
||||
.from(backups)
|
||||
.where(eq(backups.id, id))
|
||||
.get();
|
||||
if (!record) throw new Error("Backup not found");
|
||||
|
||||
if (record.path && fs.existsSync(record.path)) {
|
||||
fs.unlinkSync(record.path);
|
||||
}
|
||||
await db.delete(backups).where(eq(backups.id, id));
|
||||
}
|
||||
|
||||
/** List all backups from the DB. */
|
||||
export async function listBackups() {
|
||||
return db.select().from(backups).orderBy(backups.createdAt);
|
||||
}
|
||||
102
lib/db/index.ts
Normal file
102
lib/db/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import * as schema from "./schema";
|
||||
|
||||
const DB_PATH = "./data/cubeadmin.db";
|
||||
|
||||
// Ensure the data directory exists before opening the database
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||||
|
||||
const sqlite = new Database(DB_PATH, { create: true });
|
||||
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
sqlite.exec("PRAGMA journal_mode = WAL;");
|
||||
sqlite.exec("PRAGMA foreign_keys = ON;");
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
|
||||
export type DB = typeof db;
|
||||
|
||||
// Re-export all schema tables for convenient imports from a single location
|
||||
export {
|
||||
users,
|
||||
sessions,
|
||||
accounts,
|
||||
verifications,
|
||||
invitations,
|
||||
mcPlayers,
|
||||
playerBans,
|
||||
playerChatHistory,
|
||||
playerSpawnPoints,
|
||||
plugins,
|
||||
backups,
|
||||
scheduledTasks,
|
||||
auditLogs,
|
||||
serverSettings,
|
||||
} from "./schema";
|
||||
|
||||
// Re-export Zod schemas
|
||||
export {
|
||||
insertUserSchema,
|
||||
selectUserSchema,
|
||||
insertSessionSchema,
|
||||
selectSessionSchema,
|
||||
insertAccountSchema,
|
||||
selectAccountSchema,
|
||||
insertVerificationSchema,
|
||||
selectVerificationSchema,
|
||||
insertInvitationSchema,
|
||||
selectInvitationSchema,
|
||||
insertMcPlayerSchema,
|
||||
selectMcPlayerSchema,
|
||||
insertPlayerBanSchema,
|
||||
selectPlayerBanSchema,
|
||||
insertPlayerChatHistorySchema,
|
||||
selectPlayerChatHistorySchema,
|
||||
insertPlayerSpawnPointSchema,
|
||||
selectPlayerSpawnPointSchema,
|
||||
insertPluginSchema,
|
||||
selectPluginSchema,
|
||||
insertBackupSchema,
|
||||
selectBackupSchema,
|
||||
insertScheduledTaskSchema,
|
||||
selectScheduledTaskSchema,
|
||||
insertAuditLogSchema,
|
||||
selectAuditLogSchema,
|
||||
insertServerSettingsSchema,
|
||||
selectServerSettingsSchema,
|
||||
} from "./schema";
|
||||
|
||||
// Re-export inferred types
|
||||
export type {
|
||||
User,
|
||||
NewUser,
|
||||
Session,
|
||||
NewSession,
|
||||
Account,
|
||||
NewAccount,
|
||||
Verification,
|
||||
NewVerification,
|
||||
Invitation,
|
||||
NewInvitation,
|
||||
McPlayer,
|
||||
NewMcPlayer,
|
||||
PlayerBan,
|
||||
NewPlayerBan,
|
||||
PlayerChatHistory,
|
||||
NewPlayerChatHistory,
|
||||
PlayerSpawnPoint,
|
||||
NewPlayerSpawnPoint,
|
||||
Plugin,
|
||||
NewPlugin,
|
||||
Backup,
|
||||
NewBackup,
|
||||
ScheduledTask,
|
||||
NewScheduledTask,
|
||||
AuditLog,
|
||||
NewAuditLog,
|
||||
ServerSettings,
|
||||
NewServerSettings,
|
||||
} from "./schema";
|
||||
31
lib/db/migrate.ts
Normal file
31
lib/db/migrate.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { db } from "./index";
|
||||
|
||||
const MIGRATIONS_FOLDER = resolve(process.cwd(), "drizzle");
|
||||
|
||||
/**
|
||||
* Run all pending Drizzle migrations at startup.
|
||||
*
|
||||
* If the migrations folder does not exist yet (e.g. fresh clone before the
|
||||
* first `bun run db:generate` has been executed) the function exits silently
|
||||
* so that the application can still start in development without crashing.
|
||||
*/
|
||||
export function runMigrations(): void {
|
||||
if (!existsSync(MIGRATIONS_FOLDER)) {
|
||||
console.warn(
|
||||
`[migrate] Migrations folder not found at "${MIGRATIONS_FOLDER}". ` +
|
||||
"Skipping migrations — run `bun run db:generate` to create migration files.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
|
||||
console.log("[migrate] Database migrations applied successfully.");
|
||||
} catch (err) {
|
||||
console.error("[migrate] Failed to apply database migrations:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
359
lib/db/schema.ts
Normal file
359
lib/db/schema.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import {
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
real,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Better Auth core tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: integer("email_verified", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
image: text("image"),
|
||||
role: text("role", {
|
||||
enum: ["superadmin", "admin", "moderator"],
|
||||
})
|
||||
.notNull()
|
||||
.default("moderator"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable("sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
token: text("token").notNull().unique(),
|
||||
expiresAt: integer("expires_at").notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const accounts = sqliteTable("accounts", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
expiresAt: integer("expires_at"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const verifications = sqliteTable("verifications", {
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: integer("expires_at").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invitation system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const invitations = sqliteTable("invitations", {
|
||||
id: text("id").primaryKey(),
|
||||
email: text("email").notNull(),
|
||||
role: text("role", {
|
||||
enum: ["superadmin", "admin", "moderator"],
|
||||
})
|
||||
.notNull()
|
||||
.default("moderator"),
|
||||
invitedBy: text("invited_by")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
token: text("token").notNull().unique(),
|
||||
expiresAt: integer("expires_at").notNull(),
|
||||
acceptedAt: integer("accepted_at"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minecraft player management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const mcPlayers = sqliteTable(
|
||||
"mc_players",
|
||||
{
|
||||
id: text("id").primaryKey(), // uuid
|
||||
uuid: text("uuid").notNull(), // Minecraft UUID
|
||||
username: text("username").notNull(),
|
||||
firstSeen: integer("first_seen"),
|
||||
lastSeen: integer("last_seen"),
|
||||
isOnline: integer("is_online", { mode: "boolean" }).notNull().default(false),
|
||||
playTime: integer("play_time").notNull().default(0), // minutes
|
||||
role: text("role"),
|
||||
isBanned: integer("is_banned", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
notes: text("notes"),
|
||||
},
|
||||
(table) => [uniqueIndex("mc_players_uuid_idx").on(table.uuid)],
|
||||
);
|
||||
|
||||
export const playerBans = sqliteTable("player_bans", {
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => mcPlayers.id, { onDelete: "cascade" }),
|
||||
reason: text("reason"),
|
||||
bannedBy: text("banned_by").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
bannedAt: integer("banned_at").notNull(),
|
||||
expiresAt: integer("expires_at"),
|
||||
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||
unbannedBy: text("unbanned_by").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
unbannedAt: integer("unbanned_at"),
|
||||
});
|
||||
|
||||
export const playerChatHistory = sqliteTable("player_chat_history", {
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => mcPlayers.id, { onDelete: "cascade" }),
|
||||
message: text("message").notNull(),
|
||||
channel: text("channel"),
|
||||
timestamp: integer("timestamp").notNull(),
|
||||
serverId: text("server_id"),
|
||||
});
|
||||
|
||||
export const playerSpawnPoints = sqliteTable("player_spawn_points", {
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => mcPlayers.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
world: text("world").notNull(),
|
||||
x: real("x").notNull(),
|
||||
y: real("y").notNull(),
|
||||
z: real("z").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const plugins = sqliteTable("plugins", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
version: text("version"),
|
||||
description: text("description"),
|
||||
isEnabled: integer("is_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
jarFile: text("jar_file"),
|
||||
config: text("config"), // JSON blob
|
||||
installedAt: integer("installed_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backup management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const backups = sqliteTable("backups", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
type: text("type", {
|
||||
enum: ["worlds", "plugins", "config", "full"],
|
||||
}).notNull(),
|
||||
size: integer("size"), // bytes
|
||||
path: text("path"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
status: text("status", {
|
||||
enum: ["pending", "running", "completed", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
triggeredBy: text("triggered_by").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduled tasks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const scheduledTasks = sqliteTable("scheduled_tasks", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
cronExpression: text("cron_expression").notNull(),
|
||||
command: text("command").notNull(), // MC command to run
|
||||
isEnabled: integer("is_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
lastRun: integer("last_run"),
|
||||
nextRun: integer("next_run"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit logs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const auditLogs = sqliteTable("audit_logs", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
action: text("action").notNull(),
|
||||
target: text("target"),
|
||||
targetId: text("target_id"),
|
||||
details: text("details"), // JSON blob
|
||||
ipAddress: text("ip_address"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server settings (singleton row, id = 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const serverSettings = sqliteTable("server_settings", {
|
||||
id: integer("id").primaryKey().default(1),
|
||||
minecraftPath: text("minecraft_path"),
|
||||
serverJar: text("server_jar"),
|
||||
serverVersion: text("server_version"),
|
||||
serverType: text("server_type", {
|
||||
enum: ["vanilla", "paper", "spigot", "bukkit", "fabric", "forge", "bedrock"],
|
||||
}),
|
||||
maxRam: integer("max_ram").default(4096),
|
||||
minRam: integer("min_ram").default(1024),
|
||||
rconEnabled: integer("rcon_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
rconPort: integer("rcon_port").default(25575),
|
||||
rconPassword: text("rcon_password"), // stored encrypted
|
||||
javaArgs: text("java_args"),
|
||||
autoStart: integer("auto_start", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
restartOnCrash: integer("restart_on_crash", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
backupEnabled: integer("backup_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
backupSchedule: text("backup_schedule"),
|
||||
bluemapEnabled: integer("bluemap_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
bluemapUrl: text("bluemap_url"),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zod schemas (insert + select) for each table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const insertUserSchema = createInsertSchema(users);
|
||||
export const selectUserSchema = createSelectSchema(users);
|
||||
|
||||
export const insertSessionSchema = createInsertSchema(sessions);
|
||||
export const selectSessionSchema = createSelectSchema(sessions);
|
||||
|
||||
export const insertAccountSchema = createInsertSchema(accounts);
|
||||
export const selectAccountSchema = createSelectSchema(accounts);
|
||||
|
||||
export const insertVerificationSchema = createInsertSchema(verifications);
|
||||
export const selectVerificationSchema = createSelectSchema(verifications);
|
||||
|
||||
export const insertInvitationSchema = createInsertSchema(invitations);
|
||||
export const selectInvitationSchema = createSelectSchema(invitations);
|
||||
|
||||
export const insertMcPlayerSchema = createInsertSchema(mcPlayers);
|
||||
export const selectMcPlayerSchema = createSelectSchema(mcPlayers);
|
||||
|
||||
export const insertPlayerBanSchema = createInsertSchema(playerBans);
|
||||
export const selectPlayerBanSchema = createSelectSchema(playerBans);
|
||||
|
||||
export const insertPlayerChatHistorySchema =
|
||||
createInsertSchema(playerChatHistory);
|
||||
export const selectPlayerChatHistorySchema =
|
||||
createSelectSchema(playerChatHistory);
|
||||
|
||||
export const insertPlayerSpawnPointSchema =
|
||||
createInsertSchema(playerSpawnPoints);
|
||||
export const selectPlayerSpawnPointSchema =
|
||||
createSelectSchema(playerSpawnPoints);
|
||||
|
||||
export const insertPluginSchema = createInsertSchema(plugins);
|
||||
export const selectPluginSchema = createSelectSchema(plugins);
|
||||
|
||||
export const insertBackupSchema = createInsertSchema(backups);
|
||||
export const selectBackupSchema = createSelectSchema(backups);
|
||||
|
||||
export const insertScheduledTaskSchema = createInsertSchema(scheduledTasks);
|
||||
export const selectScheduledTaskSchema = createSelectSchema(scheduledTasks);
|
||||
|
||||
export const insertAuditLogSchema = createInsertSchema(auditLogs);
|
||||
export const selectAuditLogSchema = createSelectSchema(auditLogs);
|
||||
|
||||
export const insertServerSettingsSchema = createInsertSchema(serverSettings);
|
||||
export const selectServerSettingsSchema = createSelectSchema(serverSettings);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inferred TypeScript types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
|
||||
export type Session = typeof sessions.$inferSelect;
|
||||
export type NewSession = typeof sessions.$inferInsert;
|
||||
|
||||
export type Account = typeof accounts.$inferSelect;
|
||||
export type NewAccount = typeof accounts.$inferInsert;
|
||||
|
||||
export type Verification = typeof verifications.$inferSelect;
|
||||
export type NewVerification = typeof verifications.$inferInsert;
|
||||
|
||||
export type Invitation = typeof invitations.$inferSelect;
|
||||
export type NewInvitation = typeof invitations.$inferInsert;
|
||||
|
||||
export type McPlayer = typeof mcPlayers.$inferSelect;
|
||||
export type NewMcPlayer = typeof mcPlayers.$inferInsert;
|
||||
|
||||
export type PlayerBan = typeof playerBans.$inferSelect;
|
||||
export type NewPlayerBan = typeof playerBans.$inferInsert;
|
||||
|
||||
export type PlayerChatHistory = typeof playerChatHistory.$inferSelect;
|
||||
export type NewPlayerChatHistory = typeof playerChatHistory.$inferInsert;
|
||||
|
||||
export type PlayerSpawnPoint = typeof playerSpawnPoints.$inferSelect;
|
||||
export type NewPlayerSpawnPoint = typeof playerSpawnPoints.$inferInsert;
|
||||
|
||||
export type Plugin = typeof plugins.$inferSelect;
|
||||
export type NewPlugin = typeof plugins.$inferInsert;
|
||||
|
||||
export type Backup = typeof backups.$inferSelect;
|
||||
export type NewBackup = typeof backups.$inferInsert;
|
||||
|
||||
export type ScheduledTask = typeof scheduledTasks.$inferSelect;
|
||||
export type NewScheduledTask = typeof scheduledTasks.$inferInsert;
|
||||
|
||||
export type AuditLog = typeof auditLogs.$inferSelect;
|
||||
export type NewAuditLog = typeof auditLogs.$inferInsert;
|
||||
|
||||
export type ServerSettings = typeof serverSettings.$inferSelect;
|
||||
export type NewServerSettings = typeof serverSettings.$inferInsert;
|
||||
53
lib/email/index.ts
Normal file
53
lib/email/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Resend } from "resend";
|
||||
import { render } from "@react-email/render";
|
||||
import { InvitationEmail } from "./templates/invitation";
|
||||
|
||||
function getResend(): Resend {
|
||||
const key = process.env.RESEND_API_KEY;
|
||||
if (!key) throw new Error("RESEND_API_KEY is not configured");
|
||||
return new Resend(key);
|
||||
}
|
||||
|
||||
export async function sendMagicLinkEmail({
|
||||
email,
|
||||
url,
|
||||
token: _token,
|
||||
}: {
|
||||
email: string;
|
||||
url: string;
|
||||
token: string;
|
||||
}): Promise<void> {
|
||||
const { error } = await getResend().emails.send({
|
||||
from: process.env.EMAIL_FROM ?? "CubeAdmin <noreply@example.com>",
|
||||
to: email,
|
||||
subject: "Your CubeAdmin sign-in link",
|
||||
html: `<p>Click the link below to sign in to CubeAdmin. This link expires in 1 hour.</p><p><a href="${url}">${url}</a></p>`,
|
||||
});
|
||||
|
||||
if (error) throw new Error(`Failed to send magic link email: ${error.message}`);
|
||||
}
|
||||
|
||||
export async function sendInvitationEmail({
|
||||
to,
|
||||
invitedByName,
|
||||
inviteUrl,
|
||||
role,
|
||||
}: {
|
||||
to: string;
|
||||
invitedByName: string;
|
||||
inviteUrl: string;
|
||||
role: string;
|
||||
}): Promise<void> {
|
||||
const html = await render(
|
||||
InvitationEmail({ invitedByName, inviteUrl, role }),
|
||||
);
|
||||
|
||||
const { error } = await getResend().emails.send({
|
||||
from: process.env.EMAIL_FROM ?? "CubeAdmin <noreply@example.com>",
|
||||
to,
|
||||
subject: `You've been invited to CubeAdmin`,
|
||||
html,
|
||||
});
|
||||
|
||||
if (error) throw new Error(`Failed to send email: ${error.message}`);
|
||||
}
|
||||
78
lib/email/templates/invitation.tsx
Normal file
78
lib/email/templates/invitation.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Tailwind,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
interface InvitationEmailProps {
|
||||
invitedByName: string;
|
||||
inviteUrl: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function InvitationEmail({
|
||||
invitedByName,
|
||||
inviteUrl,
|
||||
role,
|
||||
}: InvitationEmailProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>
|
||||
{invitedByName} invited you to manage a Minecraft server on CubeAdmin
|
||||
</Preview>
|
||||
<Tailwind>
|
||||
<Body className="bg-zinc-950 font-sans">
|
||||
<Container className="mx-auto max-w-lg py-12 px-6">
|
||||
{/* Logo */}
|
||||
<Section className="mb-8 text-center">
|
||||
<Heading className="text-2xl font-bold text-emerald-500 m-0">
|
||||
⬛ CubeAdmin
|
||||
</Heading>
|
||||
</Section>
|
||||
|
||||
{/* Card */}
|
||||
<Section className="bg-zinc-900 rounded-xl border border-zinc-800 p-8">
|
||||
<Heading className="text-xl font-semibold text-white mt-0 mb-2">
|
||||
You've been invited
|
||||
</Heading>
|
||||
<Text className="text-zinc-400 mt-0 mb-6">
|
||||
<strong className="text-white">{invitedByName}</strong> has
|
||||
invited you to join CubeAdmin as a{" "}
|
||||
<strong className="text-emerald-400">{role}</strong>. Click the
|
||||
button below to create your account and start managing the
|
||||
Minecraft server.
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
href={inviteUrl}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white font-semibold rounded-lg px-6 py-3 text-sm no-underline inline-block"
|
||||
>
|
||||
Accept Invitation
|
||||
</Button>
|
||||
|
||||
<Text className="text-zinc-500 text-xs mt-6 mb-0">
|
||||
This invitation link expires in 48 hours. If you didn't
|
||||
expect this email, you can safely ignore it.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text className="text-zinc-600 text-xs text-center mt-6">
|
||||
CubeAdmin — Minecraft Server Management
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
export default InvitationEmail;
|
||||
269
lib/minecraft/process.ts
Normal file
269
lib/minecraft/process.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { EventEmitter } from "node:events"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { serverSettings } from "@/lib/db/schema"
|
||||
import { rconClient } from "@/lib/minecraft/rcon"
|
||||
|
||||
// Maximum number of output lines kept in the ring buffer
|
||||
const RING_BUFFER_SIZE = 500
|
||||
|
||||
export interface ServerStatus {
|
||||
running: boolean
|
||||
pid?: number
|
||||
uptime?: number
|
||||
startedAt?: Date
|
||||
}
|
||||
|
||||
type OutputCallback = (line: string) => void
|
||||
|
||||
export class McProcessManager extends EventEmitter {
|
||||
private process: ReturnType<typeof Bun.spawn> | null = null
|
||||
private startedAt: Date | null = null
|
||||
private outputBuffer: string[] = []
|
||||
private outputCallbacks: Set<OutputCallback> = new Set()
|
||||
private restartOnCrash = false
|
||||
private isIntentionalStop = false
|
||||
private stdoutReader: Promise<void> | null = null
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start the Minecraft server process.
|
||||
* Reads java command configuration from the `server_settings` table.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.process !== null) {
|
||||
throw new Error("Server is already running")
|
||||
}
|
||||
|
||||
const settings = await this.loadSettings()
|
||||
const cmd = this.buildCommand(settings)
|
||||
|
||||
console.log(`[MC] Starting server: ${cmd.join(" ")}`)
|
||||
|
||||
this.isIntentionalStop = false
|
||||
this.restartOnCrash = settings.restartOnCrash ?? false
|
||||
|
||||
this.process = Bun.spawn(cmd, {
|
||||
cwd: settings.minecraftPath ?? process.env.MC_SERVER_PATH ?? process.cwd(),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
})
|
||||
|
||||
this.startedAt = new Date()
|
||||
|
||||
// Pipe stdout
|
||||
this.stdoutReader = this.readStream(
|
||||
this.process.stdout as ReadableStream<Uint8Array> | null ?? null,
|
||||
"stdout",
|
||||
)
|
||||
// Pipe stderr into the same output stream
|
||||
void this.readStream(
|
||||
this.process.stderr as ReadableStream<Uint8Array> | null ?? null,
|
||||
"stderr",
|
||||
)
|
||||
|
||||
this.emit("started", { pid: this.process.pid })
|
||||
console.log(`[MC] Server started with PID ${this.process.pid}`)
|
||||
|
||||
// Watch for exit
|
||||
void this.watchExit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the Minecraft server.
|
||||
* @param force - if true, kills the process immediately; if false, sends the
|
||||
* RCON `stop` command and waits for graceful shutdown.
|
||||
*/
|
||||
async stop(force = false): Promise<void> {
|
||||
if (this.process === null) {
|
||||
throw new Error("Server is not running")
|
||||
}
|
||||
|
||||
this.isIntentionalStop = true
|
||||
|
||||
if (force) {
|
||||
console.log("[MC] Force-killing server process")
|
||||
this.process.kill()
|
||||
} else {
|
||||
console.log("[MC] Sending RCON stop command")
|
||||
try {
|
||||
await rconClient.sendCommand("stop")
|
||||
} catch (err) {
|
||||
console.warn("[MC] RCON stop failed, killing process:", err)
|
||||
this.process.kill()
|
||||
}
|
||||
}
|
||||
|
||||
// Wait up to 30 s for the process to exit
|
||||
await Promise.race([
|
||||
this.process.exited,
|
||||
new Promise<void>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Server did not stop in 30 s")), 30_000),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the Minecraft server.
|
||||
* @param force - passed through to `stop()`
|
||||
*/
|
||||
async restart(force = false): Promise<void> {
|
||||
if (this.process !== null) {
|
||||
await this.stop(force)
|
||||
}
|
||||
await this.start()
|
||||
}
|
||||
|
||||
/** Returns current process status */
|
||||
getStatus(): ServerStatus {
|
||||
const running = this.process !== null
|
||||
if (!running) return { running: false }
|
||||
|
||||
return {
|
||||
running: true,
|
||||
pid: this.process!.pid,
|
||||
startedAt: this.startedAt ?? undefined,
|
||||
uptime: this.startedAt
|
||||
? Math.floor((Date.now() - this.startedAt.getTime()) / 1000)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the last RING_BUFFER_SIZE lines of console output */
|
||||
getOutput(): string[] {
|
||||
return [...this.outputBuffer]
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback that receives each new output line.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
onOutput(cb: OutputCallback): () => void {
|
||||
this.outputCallbacks.add(cb)
|
||||
return () => this.outputCallbacks.delete(cb)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a raw string to the server's stdin (for when RCON is unavailable).
|
||||
*/
|
||||
writeStdin(line: string): void {
|
||||
const stdin = this.process?.stdin
|
||||
if (!stdin) throw new Error("Server is not running")
|
||||
// Bun.spawn stdin is a FileSink (not a WritableStream)
|
||||
const fileSink = stdin as import("bun").FileSink
|
||||
fileSink.write(new TextEncoder().encode(line + "\n"))
|
||||
void fileSink.flush()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private async loadSettings() {
|
||||
const rows = await db.select().from(serverSettings).limit(1)
|
||||
const s = rows[0]
|
||||
if (!s) throw new Error("No server settings found in database")
|
||||
return s
|
||||
}
|
||||
|
||||
private buildCommand(settings: Awaited<ReturnType<typeof this.loadSettings>>): string[] {
|
||||
const jarPath = settings.serverJar ?? "server.jar"
|
||||
const minRam = settings.minRam ?? 1024
|
||||
const maxRam = settings.maxRam ?? 4096
|
||||
const extraArgs: string[] = settings.javaArgs
|
||||
? settings.javaArgs.split(/\s+/).filter(Boolean)
|
||||
: []
|
||||
|
||||
return [
|
||||
"java",
|
||||
`-Xms${minRam}M`,
|
||||
`-Xmx${maxRam}M`,
|
||||
...extraArgs,
|
||||
"-jar",
|
||||
jarPath,
|
||||
"--nogui",
|
||||
]
|
||||
}
|
||||
|
||||
private async readStream(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
_tag: string,
|
||||
): Promise<void> {
|
||||
if (!stream) return
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let partial = ""
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
partial += chunk
|
||||
|
||||
const lines = partial.split("\n")
|
||||
partial = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
this.pushLine(line)
|
||||
}
|
||||
}
|
||||
// Flush remaining partial content
|
||||
if (partial) this.pushLine(partial)
|
||||
} catch {
|
||||
// Stream closed - normal during shutdown
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
private pushLine(line: string): void {
|
||||
// Ring buffer
|
||||
this.outputBuffer.push(line)
|
||||
if (this.outputBuffer.length > RING_BUFFER_SIZE) {
|
||||
this.outputBuffer.shift()
|
||||
}
|
||||
|
||||
this.emit("output", line)
|
||||
for (const cb of this.outputCallbacks) {
|
||||
try {
|
||||
cb(line)
|
||||
} catch {
|
||||
// Ignore callback errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async watchExit(): Promise<void> {
|
||||
if (!this.process) return
|
||||
const exitCode = await this.process.exited
|
||||
|
||||
const wasRunning = this.process !== null
|
||||
this.process = null
|
||||
this.startedAt = null
|
||||
|
||||
await rconClient.disconnect().catch(() => {})
|
||||
|
||||
if (wasRunning) {
|
||||
this.emit("stopped", { exitCode })
|
||||
console.log(`[MC] Server stopped with exit code ${exitCode}`)
|
||||
|
||||
if (!this.isIntentionalStop && this.restartOnCrash) {
|
||||
this.emit("crash", { exitCode })
|
||||
console.warn(`[MC] Server crashed (exit ${exitCode}), restarting in 5 s…`)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000))
|
||||
try {
|
||||
await this.start()
|
||||
} catch (err) {
|
||||
console.error("[MC] Auto-restart failed:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mcProcessManager = new McProcessManager()
|
||||
129
lib/minecraft/rcon.ts
Normal file
129
lib/minecraft/rcon.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Rcon } from "rcon-client"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { serverSettings } from "@/lib/db/schema"
|
||||
|
||||
// Shell metacharacters that must never appear in RCON commands
|
||||
const SHELL_METACHAR_RE = /[;&|`$<>\\(){}\[\]!#~]/
|
||||
|
||||
export class RconManager {
|
||||
private client: Rcon | null = null
|
||||
private connecting = false
|
||||
private retryCount = 0
|
||||
private readonly maxRetries = 5
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** True when the underlying Rcon socket is open */
|
||||
isConnected(): boolean {
|
||||
return this.client !== null && (this.client as unknown as { authenticated: boolean }).authenticated === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the Minecraft RCON server using credentials stored in the DB.
|
||||
* Resolves when the handshake succeeds, rejects after maxRetries failures.
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.isConnected()) return
|
||||
if (this.connecting) return
|
||||
|
||||
this.connecting = true
|
||||
try {
|
||||
const settings = await db.select().from(serverSettings).limit(1)
|
||||
const cfg = settings[0]
|
||||
if (!cfg) throw new Error("No server settings found in database")
|
||||
|
||||
const rconHost = process.env.MC_RCON_HOST ?? "127.0.0.1"
|
||||
const rconPort = cfg.rconPort ?? 25575
|
||||
const rconPassword = cfg.rconPassword ?? process.env.MC_RCON_PASSWORD ?? ""
|
||||
|
||||
this.client = new Rcon({
|
||||
host: rconHost,
|
||||
port: rconPort,
|
||||
password: rconPassword,
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
await this.client.connect()
|
||||
this.retryCount = 0
|
||||
console.log(`[RCON] Connected to ${rconHost}:${rconPort}`)
|
||||
} finally {
|
||||
this.connecting = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Cleanly close the RCON socket */
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer)
|
||||
this.retryTimer = null
|
||||
}
|
||||
if (this.client) {
|
||||
try {
|
||||
await this.client.end()
|
||||
} catch {
|
||||
// ignore errors during disconnect
|
||||
}
|
||||
this.client = null
|
||||
}
|
||||
console.log("[RCON] Disconnected")
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to the Minecraft server via RCON.
|
||||
* Rejects if the command contains shell metacharacters to prevent injection.
|
||||
* Auto-reconnects if disconnected (up to maxRetries attempts with backoff).
|
||||
*/
|
||||
async sendCommand(command: string): Promise<string> {
|
||||
this.validateCommand(command)
|
||||
|
||||
if (!this.isConnected()) {
|
||||
await this.connectWithBackoff()
|
||||
}
|
||||
|
||||
if (!this.client) {
|
||||
throw new Error("RCON client is not connected")
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.send(command)
|
||||
return response
|
||||
} catch (err) {
|
||||
// Mark as disconnected and surface error
|
||||
this.client = null
|
||||
throw new Error(`RCON command failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private validateCommand(command: string): void {
|
||||
if (!command || typeof command !== "string") {
|
||||
throw new Error("Command must be a non-empty string")
|
||||
}
|
||||
if (command.length > 1024) {
|
||||
throw new Error("Command exceeds maximum length of 1024 characters")
|
||||
}
|
||||
if (SHELL_METACHAR_RE.test(command)) {
|
||||
throw new Error("Command contains disallowed characters")
|
||||
}
|
||||
}
|
||||
|
||||
private async connectWithBackoff(): Promise<void> {
|
||||
while (this.retryCount < this.maxRetries) {
|
||||
const delay = Math.min(1000 * 2 ** this.retryCount, 30_000)
|
||||
this.retryCount++
|
||||
console.warn(`[RCON] Reconnecting (attempt ${this.retryCount}/${this.maxRetries}) in ${delay}ms…`)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
try {
|
||||
await this.connect()
|
||||
return
|
||||
} catch (err) {
|
||||
console.error(`[RCON] Reconnect attempt ${this.retryCount} failed:`, err)
|
||||
}
|
||||
}
|
||||
throw new Error(`RCON failed to reconnect after ${this.maxRetries} attempts`)
|
||||
}
|
||||
}
|
||||
|
||||
export const rconClient = new RconManager()
|
||||
104
lib/minecraft/sync.ts
Normal file
104
lib/minecraft/sync.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Syncs Minecraft server data (players, plugins) into the local database
|
||||
* by parsing server logs and using RCON commands.
|
||||
*/
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { mcPlayers, plugins } from "@/lib/db/schema";
|
||||
import { rconClient } from "@/lib/minecraft/rcon";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
/** Parse the player list from the RCON "list" command response. */
|
||||
export async function syncOnlinePlayers(): Promise<void> {
|
||||
try {
|
||||
const response = await rconClient.sendCommand("list");
|
||||
// Response format: "There are X of a max of Y players online: player1, player2"
|
||||
const match = response.match(/players online: (.*)$/);
|
||||
if (!match) return;
|
||||
|
||||
const onlineNames = match[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Mark all players as offline first
|
||||
await db
|
||||
.update(mcPlayers)
|
||||
.set({ isOnline: false })
|
||||
.where(eq(mcPlayers.isOnline, true));
|
||||
|
||||
// Mark online players
|
||||
for (const name of onlineNames) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(mcPlayers)
|
||||
.where(eq(mcPlayers.username, name))
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(mcPlayers)
|
||||
.set({ isOnline: true, lastSeen: Date.now() })
|
||||
.where(eq(mcPlayers.username, name));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// RCON might not be connected — ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a log line and update player records accordingly. */
|
||||
export function parseLogLine(
|
||||
line: string,
|
||||
onPlayerJoin?: (name: string) => void,
|
||||
onPlayerLeave?: (name: string) => void,
|
||||
): void {
|
||||
// "[HH:MM:SS] [Server thread/INFO]: PlayerName joined the game"
|
||||
const joinMatch = line.match(/\[.*\]: (\w+) joined the game/);
|
||||
if (joinMatch) {
|
||||
const name = joinMatch[1];
|
||||
upsertPlayer(name, { isOnline: true, lastSeen: Date.now() });
|
||||
onPlayerJoin?.(name);
|
||||
return;
|
||||
}
|
||||
|
||||
// "[HH:MM:SS] [Server thread/INFO]: PlayerName left the game"
|
||||
const leaveMatch = line.match(/\[.*\]: (\w+) left the game/);
|
||||
if (leaveMatch) {
|
||||
const name = leaveMatch[1];
|
||||
upsertPlayer(name, { isOnline: false, lastSeen: Date.now() });
|
||||
onPlayerLeave?.(name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertPlayer(
|
||||
username: string,
|
||||
data: Partial<typeof mcPlayers.$inferInsert>,
|
||||
): Promise<void> {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(mcPlayers)
|
||||
.where(eq(mcPlayers.username, username))
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(mcPlayers)
|
||||
.set(data as Record<string, unknown>)
|
||||
.where(eq(mcPlayers.username, username));
|
||||
} else {
|
||||
await db.insert(mcPlayers).values({
|
||||
id: nanoid(),
|
||||
uuid: (data as { uuid?: string }).uuid ?? nanoid(), // placeholder until real UUID is known
|
||||
username,
|
||||
firstSeen: Date.now(),
|
||||
lastSeen: Date.now(),
|
||||
isOnline: false,
|
||||
playTime: 0,
|
||||
isBanned: false,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
}
|
||||
324
lib/minecraft/versions.ts
Normal file
324
lib/minecraft/versions.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
// ---- Types ------------------------------------------------------------------
|
||||
|
||||
export type ServerType = "vanilla" | "paper" | "spigot" | "fabric" | "forge" | "bedrock"
|
||||
|
||||
export interface VersionInfo {
|
||||
id: string
|
||||
type?: string
|
||||
releaseTime?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
// ---- In-memory cache --------------------------------------------------------
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
||||
const cache = new Map<string, CacheEntry<unknown>>()
|
||||
|
||||
function cacheGet<T>(key: string): T | null {
|
||||
const entry = cache.get(key)
|
||||
if (!entry) return null
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
cache.delete(key)
|
||||
return null
|
||||
}
|
||||
return entry.data as T
|
||||
}
|
||||
|
||||
function cacheSet<T>(key: string, data: T): void {
|
||||
cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS })
|
||||
}
|
||||
|
||||
// ---- Vanilla ----------------------------------------------------------------
|
||||
|
||||
interface MojangManifest {
|
||||
latest: { release: string; snapshot: string }
|
||||
versions: Array<{
|
||||
id: string
|
||||
type: string
|
||||
url: string
|
||||
releaseTime: string
|
||||
sha1: string
|
||||
}>
|
||||
}
|
||||
|
||||
/** Fetch all versions from the official Mojang version manifest. */
|
||||
export async function fetchVanillaVersions(): Promise<VersionInfo[]> {
|
||||
const key = "vanilla:versions"
|
||||
const cached = cacheGet<VersionInfo[]>(key)
|
||||
if (cached) return cached
|
||||
|
||||
const res = await fetch(
|
||||
"https://piston-meta.mojang.com/mc/game/version_manifest_v2.json",
|
||||
)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch Mojang manifest: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
const manifest: MojangManifest = await res.json()
|
||||
const versions: VersionInfo[] = manifest.versions.map((v) => ({
|
||||
id: v.id,
|
||||
type: v.type,
|
||||
releaseTime: v.releaseTime,
|
||||
url: v.url,
|
||||
}))
|
||||
|
||||
cacheSet(key, versions)
|
||||
return versions
|
||||
}
|
||||
|
||||
// ---- Paper ------------------------------------------------------------------
|
||||
|
||||
interface PaperBuildsResponse {
|
||||
project_id: string
|
||||
project_name: string
|
||||
version: string
|
||||
builds: number[]
|
||||
}
|
||||
|
||||
/** Fetch all Paper MC versions from the PaperMC API. */
|
||||
export async function fetchPaperVersions(): Promise<VersionInfo[]> {
|
||||
const key = "paper:versions"
|
||||
const cached = cacheGet<VersionInfo[]>(key)
|
||||
if (cached) return cached
|
||||
|
||||
const res = await fetch("https://api.papermc.io/v2/projects/paper")
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch Paper versions: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
const data: { versions: string[] } = await res.json()
|
||||
const versions: VersionInfo[] = data.versions.map((id) => ({ id }))
|
||||
|
||||
cacheSet(key, versions)
|
||||
return versions
|
||||
}
|
||||
|
||||
// ---- Fabric -----------------------------------------------------------------
|
||||
|
||||
interface FabricGameVersion {
|
||||
version: string
|
||||
stable: boolean
|
||||
}
|
||||
|
||||
/** Fetch all Fabric-supported Minecraft versions. */
|
||||
export async function fetchFabricVersions(): Promise<VersionInfo[]> {
|
||||
const key = "fabric:versions"
|
||||
const cached = cacheGet<VersionInfo[]>(key)
|
||||
if (cached) return cached
|
||||
|
||||
const res = await fetch("https://meta.fabricmc.net/v2/versions/game")
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch Fabric versions: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
const data: FabricGameVersion[] = await res.json()
|
||||
const versions: VersionInfo[] = data.map((v) => ({
|
||||
id: v.version,
|
||||
type: v.stable ? "release" : "snapshot",
|
||||
}))
|
||||
|
||||
cacheSet(key, versions)
|
||||
return versions
|
||||
}
|
||||
|
||||
// ---- Download URL resolution ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the direct download URL for a given server type + version.
|
||||
* Throws if the type/version combination cannot be resolved.
|
||||
*/
|
||||
export async function getDownloadUrl(
|
||||
type: ServerType,
|
||||
version: string,
|
||||
): Promise<string> {
|
||||
validateVersion(version)
|
||||
|
||||
switch (type) {
|
||||
case "vanilla":
|
||||
return getVanillaDownloadUrl(version)
|
||||
case "paper":
|
||||
return getPaperDownloadUrl(version)
|
||||
case "fabric":
|
||||
return getFabricDownloadUrl(version)
|
||||
case "spigot":
|
||||
throw new Error(
|
||||
"Spigot cannot be downloaded directly; use BuildTools instead.",
|
||||
)
|
||||
case "forge":
|
||||
throw new Error(
|
||||
"Forge installers must be downloaded from files.minecraftforge.net.",
|
||||
)
|
||||
case "bedrock":
|
||||
throw new Error(
|
||||
"Bedrock server downloads require manual acceptance of Microsoft's EULA.",
|
||||
)
|
||||
default:
|
||||
throw new Error(`Unsupported server type: ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function getVanillaDownloadUrl(version: string): Promise<string> {
|
||||
const versions = await fetchVanillaVersions()
|
||||
const entry = versions.find((v) => v.id === version)
|
||||
if (!entry?.url) throw new Error(`Vanilla version not found: ${version}`)
|
||||
|
||||
// The URL points to a version JSON; fetch it to get the server jar URL
|
||||
const cacheKey = `vanilla:jar-url:${version}`
|
||||
const cached = cacheGet<string>(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const res = await fetch(entry.url)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch version manifest for ${version}: ${res.status}`)
|
||||
}
|
||||
|
||||
const versionData: {
|
||||
downloads: { server?: { url: string } }
|
||||
} = await res.json()
|
||||
|
||||
const url = versionData.downloads.server?.url
|
||||
if (!url) throw new Error(`No server download available for vanilla ${version}`)
|
||||
|
||||
cacheSet(cacheKey, url)
|
||||
return url
|
||||
}
|
||||
|
||||
async function getPaperDownloadUrl(version: string): Promise<string> {
|
||||
const cacheKey = `paper:jar-url:${version}`
|
||||
const cached = cacheGet<string>(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
// Get the latest build number for this version
|
||||
const buildsRes = await fetch(
|
||||
`https://api.papermc.io/v2/projects/paper/versions/${encodeURIComponent(version)}`,
|
||||
)
|
||||
if (!buildsRes.ok) {
|
||||
throw new Error(`Paper version ${version} not found: ${buildsRes.status}`)
|
||||
}
|
||||
|
||||
const buildsData: PaperBuildsResponse = await buildsRes.json()
|
||||
const latestBuild = buildsData.builds.at(-1)
|
||||
if (latestBuild === undefined) {
|
||||
throw new Error(`No builds found for Paper ${version}`)
|
||||
}
|
||||
|
||||
const url = `https://api.papermc.io/v2/projects/paper/versions/${encodeURIComponent(version)}/builds/${latestBuild}/downloads/paper-${version}-${latestBuild}.jar`
|
||||
cacheSet(cacheKey, url)
|
||||
return url
|
||||
}
|
||||
|
||||
async function getFabricDownloadUrl(version: string): Promise<string> {
|
||||
const cacheKey = `fabric:jar-url:${version}`
|
||||
const cached = cacheGet<string>(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
// Get latest loader and installer versions
|
||||
const [loadersRes, installersRes] = await Promise.all([
|
||||
fetch("https://meta.fabricmc.net/v2/versions/loader"),
|
||||
fetch("https://meta.fabricmc.net/v2/versions/installer"),
|
||||
])
|
||||
|
||||
if (!loadersRes.ok || !installersRes.ok) {
|
||||
throw new Error("Failed to fetch Fabric loader/installer versions")
|
||||
}
|
||||
|
||||
const loaders: Array<{ version: string; stable: boolean }> = await loadersRes.json()
|
||||
const installers: Array<{ version: string; stable: boolean }> = await installersRes.json()
|
||||
|
||||
const latestLoader = loaders.find((l) => l.stable)
|
||||
const latestInstaller = installers.find((i) => i.stable)
|
||||
|
||||
if (!latestLoader || !latestInstaller) {
|
||||
throw new Error("Could not determine latest stable Fabric loader/installer")
|
||||
}
|
||||
|
||||
const url = `https://meta.fabricmc.net/v2/versions/loader/${encodeURIComponent(version)}/${encodeURIComponent(latestLoader.version)}/${encodeURIComponent(latestInstaller.version)}/server/jar`
|
||||
cacheSet(cacheKey, url)
|
||||
return url
|
||||
}
|
||||
|
||||
// ---- Download ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Download a server jar to `destPath`.
|
||||
* @param onProgress - optional callback receiving bytes downloaded and total bytes
|
||||
*/
|
||||
export async function downloadServer(
|
||||
type: ServerType,
|
||||
version: string,
|
||||
destPath: string,
|
||||
onProgress?: (downloaded: number, total: number) => void,
|
||||
): Promise<void> {
|
||||
validateDestPath(destPath)
|
||||
|
||||
const url = await getDownloadUrl(type, version)
|
||||
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download server jar: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
const contentLength = Number(res.headers.get("content-length") ?? "0")
|
||||
const body = res.body
|
||||
if (!body) throw new Error("Response body is empty")
|
||||
|
||||
const file = Bun.file(destPath)
|
||||
const writer = file.writer()
|
||||
|
||||
let downloaded = 0
|
||||
const reader = body.getReader()
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
writer.write(value)
|
||||
downloaded += value.byteLength
|
||||
|
||||
if (onProgress && contentLength > 0) {
|
||||
onProgress(downloaded, contentLength)
|
||||
}
|
||||
}
|
||||
await writer.end()
|
||||
} catch (err) {
|
||||
writer.end()
|
||||
throw err
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Versions] Downloaded ${type} ${version} → ${destPath} (${downloaded} bytes)`,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Input validation -------------------------------------------------------
|
||||
|
||||
/** Minecraft version strings are like "1.21.4", "24w44a" - allow alphanumeric + dots + dashes */
|
||||
function validateVersion(version: string): void {
|
||||
if (!version || typeof version !== "string") {
|
||||
throw new Error("Version must be a non-empty string")
|
||||
}
|
||||
if (!/^[\w.\-+]{1,64}$/.test(version)) {
|
||||
throw new Error(`Invalid version string: ${version}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Prevent path traversal in destination paths */
|
||||
function validateDestPath(destPath: string): void {
|
||||
if (!destPath || typeof destPath !== "string") {
|
||||
throw new Error("Destination path must be a non-empty string")
|
||||
}
|
||||
if (destPath.includes("..")) {
|
||||
throw new Error("Destination path must not contain '..'")
|
||||
}
|
||||
if (!destPath.endsWith(".jar")) {
|
||||
throw new Error("Destination path must end with .jar")
|
||||
}
|
||||
}
|
||||
75
lib/scheduler/index.ts
Normal file
75
lib/scheduler/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Task scheduler using node-cron.
|
||||
* Loads enabled tasks from the DB on startup and registers them.
|
||||
*/
|
||||
|
||||
import cron, { ScheduledTask } from "node-cron";
|
||||
import { db } from "@/lib/db";
|
||||
import { scheduledTasks } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { rconClient } from "@/lib/minecraft/rcon";
|
||||
import { sanitizeRconCommand } from "@/lib/security/sanitize";
|
||||
|
||||
const activeJobs = new Map<string, ScheduledTask>();
|
||||
|
||||
/** Load all enabled tasks from DB and schedule them. */
|
||||
export async function initScheduler(): Promise<void> {
|
||||
const tasks = await db
|
||||
.select()
|
||||
.from(scheduledTasks)
|
||||
.where(eq(scheduledTasks.isEnabled, true));
|
||||
|
||||
for (const task of tasks) {
|
||||
scheduleTask(task.id, task.cronExpression, task.command);
|
||||
}
|
||||
}
|
||||
|
||||
/** Schedule a single task. Returns false if cron expression is invalid. */
|
||||
export function scheduleTask(
|
||||
id: string,
|
||||
expression: string,
|
||||
command: string,
|
||||
): boolean {
|
||||
if (!cron.validate(expression)) return false;
|
||||
|
||||
// Stop existing job if any
|
||||
stopTask(id);
|
||||
|
||||
const job = cron.schedule(expression, async () => {
|
||||
try {
|
||||
const safeCmd = sanitizeRconCommand(command);
|
||||
await rconClient.sendCommand(safeCmd);
|
||||
await db
|
||||
.update(scheduledTasks)
|
||||
.set({ lastRun: Date.now() })
|
||||
.where(eq(scheduledTasks.id, id));
|
||||
} catch (err) {
|
||||
console.error(`[Scheduler] Task ${id} failed:`, err);
|
||||
}
|
||||
});
|
||||
|
||||
activeJobs.set(id, job);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Stop and remove a scheduled task. */
|
||||
export function stopTask(id: string): void {
|
||||
const existing = activeJobs.get(id);
|
||||
if (existing) {
|
||||
existing.stop();
|
||||
activeJobs.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop all active jobs. */
|
||||
export function stopAllTasks(): void {
|
||||
for (const [id, job] of activeJobs.entries()) {
|
||||
job.stop();
|
||||
activeJobs.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** List active job IDs. */
|
||||
export function getActiveTaskIds(): string[] {
|
||||
return Array.from(activeJobs.keys());
|
||||
}
|
||||
59
lib/security/rateLimit.ts
Normal file
59
lib/security/rateLimit.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* In-memory rate limiter (per IP, per minute window).
|
||||
* For production at scale, replace with Redis-backed solution.
|
||||
*/
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
windowStart: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, RateLimitEntry>();
|
||||
const WINDOW_MS = 60_000; // 1 minute
|
||||
|
||||
// Cleanup stale entries every 5 minutes to prevent memory leaks
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store.entries()) {
|
||||
if (now - entry.windowStart > WINDOW_MS * 2) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
export function checkRateLimit(
|
||||
ip: string,
|
||||
limit: number = parseInt(process.env.RATE_LIMIT_RPM ?? "100"),
|
||||
): { allowed: boolean; remaining: number; resetAt: number } {
|
||||
const now = Date.now();
|
||||
const entry = store.get(ip);
|
||||
|
||||
if (!entry || now - entry.windowStart > WINDOW_MS) {
|
||||
store.set(ip, { count: 1, windowStart: now });
|
||||
return { allowed: true, remaining: limit - 1, resetAt: now + WINDOW_MS };
|
||||
}
|
||||
|
||||
if (entry.count >= limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: entry.windowStart + WINDOW_MS,
|
||||
};
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: limit - entry.count,
|
||||
resetAt: entry.windowStart + WINDOW_MS,
|
||||
};
|
||||
}
|
||||
|
||||
/** Extract real IP from request (handles proxies). */
|
||||
export function getClientIp(request: Request): string {
|
||||
return (
|
||||
request.headers.get("x-real-ip") ??
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
60
lib/security/sanitize.ts
Normal file
60
lib/security/sanitize.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Input sanitization utilities.
|
||||
* Prevents XSS, command injection, and path traversal attacks.
|
||||
*/
|
||||
|
||||
/** Shell metacharacters that must not appear in RCON commands sent to the OS. */
|
||||
const SHELL_UNSAFE = /[;&|`$(){}[\]<>\\'"*?!#~]/;
|
||||
|
||||
/**
|
||||
* Validate a Minecraft RCON command.
|
||||
* Commands go to the MC RCON protocol (not a shell), but we still strip
|
||||
* shell metacharacters as a defense-in-depth measure.
|
||||
*/
|
||||
export function sanitizeRconCommand(cmd: string): string {
|
||||
if (typeof cmd !== "string") throw new Error("Command must be a string");
|
||||
const trimmed = cmd.trim();
|
||||
if (trimmed.length === 0) throw new Error("Command cannot be empty");
|
||||
if (trimmed.length > 32767) throw new Error("Command too long");
|
||||
// RCON commands start with / or a bare word — never allow OS-level metacharacters
|
||||
if (SHELL_UNSAFE.test(trimmed)) {
|
||||
throw new Error("Command contains forbidden characters");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize a file system path relative to a base directory.
|
||||
* Prevents path traversal (e.g. "../../etc/passwd").
|
||||
*/
|
||||
export function sanitizeFilePath(
|
||||
inputPath: string,
|
||||
baseDir: string,
|
||||
): string {
|
||||
const path = require("node:path");
|
||||
const resolved = path.resolve(baseDir, inputPath);
|
||||
if (!resolved.startsWith(path.resolve(baseDir))) {
|
||||
throw new Error("Path traversal detected");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags from user-provided strings to prevent stored XSS.
|
||||
* Use this before storing free-text fields in the database.
|
||||
*/
|
||||
export function stripHtml(input: string): string {
|
||||
return input.replace(/<[^>]*>/g, "");
|
||||
}
|
||||
|
||||
/** Validate a Minecraft UUID (8-4-4-4-12 hex). */
|
||||
export function isValidMcUuid(uuid: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
uuid,
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate a Minecraft username (3-16 chars, alphanumeric + underscore). */
|
||||
export function isValidMcUsername(name: string): boolean {
|
||||
return /^[a-zA-Z0-9_]{3,16}$/.test(name);
|
||||
}
|
||||
258
lib/socket/server.ts
Normal file
258
lib/socket/server.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { Server, Socket } from "socket.io"
|
||||
import os from "node:os"
|
||||
import type { auth as AuthType } from "@/lib/auth/index"
|
||||
import { mcProcessManager } from "@/lib/minecraft/process"
|
||||
import { rconClient } from "@/lib/minecraft/rcon"
|
||||
|
||||
// Shell metacharacters - same set as rcon.ts for defence-in-depth
|
||||
const SHELL_METACHAR_RE = /[;&|`$<>\\(){}\[\]!#~]/
|
||||
|
||||
// ---- Auth middleware ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a Socket.io middleware that validates the Better Auth session token
|
||||
* from the `better-auth.session_token` cookie (or the `auth` handshake header).
|
||||
*/
|
||||
function makeAuthMiddleware(auth: typeof AuthType) {
|
||||
return async (socket: Socket, next: (err?: Error) => void) => {
|
||||
try {
|
||||
// Prefer the cookie sent during the upgrade handshake
|
||||
const rawCookie: string =
|
||||
(socket.handshake.headers.cookie as string | undefined) ?? ""
|
||||
|
||||
const token = parseCookieToken(rawCookie) ?? socket.handshake.auth?.token
|
||||
|
||||
if (!token || typeof token !== "string") {
|
||||
return next(new Error("Authentication required"))
|
||||
}
|
||||
|
||||
// Use Better Auth's built-in session verification
|
||||
const session = await auth.api.getSession({
|
||||
headers: new Headers({
|
||||
cookie: `better-auth.session_token=${token}`,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!session?.user) {
|
||||
return next(new Error("Invalid or expired session"))
|
||||
}
|
||||
|
||||
// Attach user info to the socket for later use
|
||||
;(socket.data as Record<string, unknown>).user = session.user
|
||||
next()
|
||||
} catch (err) {
|
||||
next(
|
||||
new Error(
|
||||
`Auth error: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract the value of `better-auth.session_token` from a Cookie header string */
|
||||
function parseCookieToken(cookieHeader: string): string | null {
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const [rawKey, ...rest] = part.trim().split("=")
|
||||
if (rawKey?.trim() === "better-auth.session_token") {
|
||||
return decodeURIComponent(rest.join("=").trim())
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- /console namespace -----------------------------------------------------
|
||||
|
||||
function setupConsoleNamespace(io: Server): void {
|
||||
const consoleNsp = io.of("/console")
|
||||
|
||||
consoleNsp.on("connection", (socket: Socket) => {
|
||||
console.log(`[Socket /console] Client connected: ${socket.id}`)
|
||||
|
||||
// Send buffered output so the client gets historical lines immediately
|
||||
const history = mcProcessManager.getOutput()
|
||||
socket.emit("history", history)
|
||||
|
||||
// Stream live output
|
||||
const unsubscribe = mcProcessManager.onOutput((line: string) => {
|
||||
socket.emit("output", line)
|
||||
})
|
||||
|
||||
// Forward process lifecycle events to this client
|
||||
const onStarted = (data: unknown) => socket.emit("server:started", data)
|
||||
const onStopped = (data: unknown) => socket.emit("server:stopped", data)
|
||||
const onCrash = (data: unknown) => socket.emit("server:crash", data)
|
||||
|
||||
mcProcessManager.on("started", onStarted)
|
||||
mcProcessManager.on("stopped", onStopped)
|
||||
mcProcessManager.on("crash", onCrash)
|
||||
|
||||
// Handle commands sent by the client
|
||||
socket.on("command", async (rawCommand: unknown) => {
|
||||
if (typeof rawCommand !== "string" || !rawCommand.trim()) {
|
||||
socket.emit("error", { message: "Command must be a non-empty string" })
|
||||
return
|
||||
}
|
||||
|
||||
const command = rawCommand.trim()
|
||||
|
||||
if (SHELL_METACHAR_RE.test(command)) {
|
||||
socket.emit("error", { message: "Command contains disallowed characters" })
|
||||
return
|
||||
}
|
||||
|
||||
if (command.length > 1024) {
|
||||
socket.emit("error", { message: "Command too long" })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let response: string
|
||||
if (rconClient.isConnected()) {
|
||||
response = await rconClient.sendCommand(command)
|
||||
} else {
|
||||
// Fallback: write directly to stdin
|
||||
mcProcessManager.writeStdin(command)
|
||||
response = "(sent via stdin)"
|
||||
}
|
||||
socket.emit("command:response", { command, response })
|
||||
} catch (err) {
|
||||
socket.emit("error", {
|
||||
message: `Command failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
console.log(`[Socket /console] Client disconnected: ${socket.id}`)
|
||||
unsubscribe()
|
||||
mcProcessManager.off("started", onStarted)
|
||||
mcProcessManager.off("stopped", onStopped)
|
||||
mcProcessManager.off("crash", onCrash)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---- /monitoring namespace --------------------------------------------------
|
||||
|
||||
interface MonitoringStats {
|
||||
cpu: number
|
||||
ram: { usedMB: number; totalMB: number }
|
||||
uptime: number
|
||||
server: {
|
||||
running: boolean
|
||||
pid?: number
|
||||
uptimeSecs?: number
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute CPU usage as a percentage across all cores (averaged over a 100 ms window) */
|
||||
function getCpuPercent(): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const cpus1 = os.cpus()
|
||||
|
||||
setTimeout(() => {
|
||||
const cpus2 = os.cpus()
|
||||
let idleDiff = 0
|
||||
let totalDiff = 0
|
||||
|
||||
for (let i = 0; i < cpus1.length; i++) {
|
||||
const t1 = cpus1[i]!.times
|
||||
const t2 = cpus2[i]!.times
|
||||
|
||||
const idle = t2.idle - t1.idle
|
||||
const total =
|
||||
t2.user + t2.nice + t2.sys + t2.idle + t2.irq -
|
||||
(t1.user + t1.nice + t1.sys + t1.idle + t1.irq)
|
||||
|
||||
idleDiff += idle
|
||||
totalDiff += total
|
||||
}
|
||||
|
||||
const percent = totalDiff === 0 ? 0 : (1 - idleDiff / totalDiff) * 100
|
||||
resolve(Math.round(percent * 10) / 10)
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
function setupMonitoringNamespace(io: Server): void {
|
||||
const monitoringNsp = io.of("/monitoring")
|
||||
|
||||
// Interval for the polling loop; only active when at least one client is connected
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
let clientCount = 0
|
||||
|
||||
const startPolling = () => {
|
||||
if (interval) return
|
||||
interval = setInterval(async () => {
|
||||
try {
|
||||
const [cpuPercent] = await Promise.all([getCpuPercent()])
|
||||
|
||||
const totalMem = os.totalmem()
|
||||
const freeMem = os.freemem()
|
||||
const usedMem = totalMem - freeMem
|
||||
|
||||
const status = mcProcessManager.getStatus()
|
||||
|
||||
const stats: MonitoringStats = {
|
||||
cpu: cpuPercent,
|
||||
ram: {
|
||||
usedMB: Math.round(usedMem / 1024 / 1024),
|
||||
totalMB: Math.round(totalMem / 1024 / 1024),
|
||||
},
|
||||
uptime: os.uptime(),
|
||||
server: {
|
||||
running: status.running,
|
||||
pid: status.pid,
|
||||
uptimeSecs: status.uptime,
|
||||
},
|
||||
}
|
||||
|
||||
monitoringNsp.emit("stats", stats)
|
||||
} catch (err) {
|
||||
console.error("[Socket /monitoring] Stats collection error:", err)
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
}
|
||||
|
||||
monitoringNsp.on("connection", (socket: Socket) => {
|
||||
console.log(`[Socket /monitoring] Client connected: ${socket.id}`)
|
||||
clientCount++
|
||||
startPolling()
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
console.log(`[Socket /monitoring] Client disconnected: ${socket.id}`)
|
||||
clientCount--
|
||||
if (clientCount <= 0) {
|
||||
clientCount = 0
|
||||
stopPolling()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Public setup function --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Attach all Socket.io namespaces to the given `io` server.
|
||||
* Must be called after the HTTP server has been created.
|
||||
*/
|
||||
export function setupSocketServer(io: Server, auth: typeof AuthType): void {
|
||||
const authMiddleware = makeAuthMiddleware(auth)
|
||||
|
||||
// Apply auth middleware to both namespaces
|
||||
io.of("/console").use(authMiddleware)
|
||||
io.of("/monitoring").use(authMiddleware)
|
||||
|
||||
setupConsoleNamespace(io)
|
||||
setupMonitoringNamespace(io)
|
||||
|
||||
console.log("[Socket.io] Namespaces /console and /monitoring ready")
|
||||
}
|
||||
Reference in New Issue
Block a user