Initial push

This commit is contained in:
2026-03-08 15:49:34 +01:00
parent 8da12bb7d1
commit 47127f276d
101 changed files with 13844 additions and 8 deletions

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { invitations, users } from "@/lib/db/schema";
import { auth } from "@/lib/auth";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { nanoid } from "nanoid";
const AcceptSchema = z.object({
token: z.string().min(1),
name: z.string().min(2).max(100),
password: z.string().min(8).max(128),
});
export async function POST(req: NextRequest) {
let body: z.infer<typeof AcceptSchema>;
try {
body = AcceptSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const invitation = await db
.select()
.from(invitations)
.where(eq(invitations.token, body.token))
.get();
if (!invitation) {
return NextResponse.json({ error: "Invalid invitation token" }, { status: 404 });
}
if (invitation.acceptedAt) {
return NextResponse.json({ error: "Invitation already used" }, { status: 409 });
}
if (Number(invitation.expiresAt) < Date.now()) {
return NextResponse.json({ error: "Invitation has expired" }, { status: 410 });
}
// Check if email already registered
const existing = await db.select().from(users).where(eq(users.email, invitation.email)).get();
if (existing) {
return NextResponse.json({ error: "Email already registered" }, { status: 409 });
}
// Create user via Better Auth
try {
await auth.api.signUpEmail({
body: {
email: invitation.email,
password: body.password,
name: body.name,
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create account";
return NextResponse.json({ error: message }, { status: 500 });
}
// Mark invitation as accepted and set role
await db
.update(invitations)
.set({ acceptedAt: Date.now() })
.where(eq(invitations.token, body.token));
await db
.update(users)
.set({ role: invitation.role })
.where(eq(users.email, invitation.email));
return NextResponse.json({ success: true });
}

48
app/api/audit/route.ts Normal file
View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { auditLogs, users } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { desc, eq, like, and, gte, lte } from "drizzle-orm";
export async function GET(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const { searchParams } = new URL(req.url);
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1"));
const limit = Math.min(100, parseInt(searchParams.get("limit") ?? "50"));
const offset = (page - 1) * limit;
const userId = searchParams.get("userId");
const action = searchParams.get("action");
const from = searchParams.get("from");
const to = searchParams.get("to");
const conditions = [];
if (userId) conditions.push(eq(auditLogs.userId, userId));
if (action) conditions.push(like(auditLogs.action, `${action}%`));
if (from) conditions.push(gte(auditLogs.createdAt, parseInt(from)));
if (to) conditions.push(lte(auditLogs.createdAt, parseInt(to)));
const logs = await db
.select({
log: auditLogs,
userName: users.name,
userEmail: users.email,
})
.from(auditLogs)
.leftJoin(users, eq(auditLogs.userId, users.id))
.where(conditions.length ? and(...conditions) : undefined)
.orderBy(desc(auditLogs.createdAt))
.limit(limit)
.offset(offset);
return NextResponse.json({ logs, page, limit });
}

View File

@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { deleteBackup } from "@/lib/backup/manager";
import { db } from "@/lib/db";
import { backups } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import * as fs from "node:fs";
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
try {
await deleteBackup(id);
return NextResponse.json({ success: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const backup = await db.select().from(backups).where(eq(backups.id, id)).get();
if (!backup) return NextResponse.json({ error: "Backup not found" }, { status: 404 });
if (backup.status !== "completed") {
return NextResponse.json({ error: "Backup not ready" }, { status: 400 });
}
if (!backup.path || !fs.existsSync(backup.path)) {
return NextResponse.json({ error: "Backup file not found on disk" }, { status: 404 });
}
const fileBuffer = fs.readFileSync(backup.path);
return new NextResponse(fileBuffer, {
headers: {
"Content-Disposition": `attachment; filename="${encodeURIComponent(backup.name)}"`,
"Content-Type": "application/zip",
"Content-Length": String(fileBuffer.length),
"X-Content-Type-Options": "nosniff",
},
});
}

48
app/api/backups/route.ts Normal file
View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { createBackup, listBackups, BackupType } from "@/lib/backup/manager";
import { z } from "zod";
const CreateBackupSchema = z.object({
type: z.enum(["worlds", "plugins", "config", "full"]),
});
export async function GET(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const backups = await listBackups();
return NextResponse.json({ backups });
}
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip, 5); // Strict limit for backup creation
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
let body: z.infer<typeof CreateBackupSchema>;
try {
body = CreateBackupSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
try {
const id = await createBackup(body.type as BackupType, session.user.id);
return NextResponse.json({ success: true, id }, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : "Backup failed";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { sanitizeFilePath } from "@/lib/security/sanitize";
import { db } from "@/lib/db";
import { auditLogs } from "@/lib/db/schema";
import { nanoid } from "nanoid";
import { getClientIp } from "@/lib/security/rateLimit";
import * as fs from "node:fs";
import * as path from "node:path";
export async function DELETE(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
const { filePath } = await req.json();
let resolvedPath: string;
try {
resolvedPath = sanitizeFilePath(filePath, mcBase);
} catch {
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
}
if (!fs.existsSync(resolvedPath)) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
const stat = fs.statSync(resolvedPath);
if (stat.isDirectory()) {
fs.rmSync(resolvedPath, { recursive: true });
} else {
fs.unlinkSync(resolvedPath);
}
const ip = getClientIp(req);
await db.insert(auditLogs).values({
id: nanoid(), userId: session.user.id,
action: "file.delete", target: "file", targetId: path.relative(mcBase, resolvedPath),
details: null, ipAddress: ip, createdAt: Date.now(),
});
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { sanitizeFilePath } from "@/lib/security/sanitize";
import * as fs from "node:fs";
import * as path from "node:path";
export async function GET(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
const filePath = req.nextUrl.searchParams.get("path") ?? "";
let resolvedPath: string;
try {
resolvedPath = sanitizeFilePath(filePath, mcBase);
} catch {
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
}
if (!fs.existsSync(resolvedPath) || fs.statSync(resolvedPath).isDirectory()) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
const fileName = path.basename(resolvedPath);
const fileBuffer = fs.readFileSync(resolvedPath);
return new NextResponse(fileBuffer, {
headers: {
"Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`,
"Content-Type": "application/octet-stream",
"Content-Length": String(fileBuffer.length),
// Prevent XSS via content sniffing
"X-Content-Type-Options": "nosniff",
},
});
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { sanitizeFilePath } from "@/lib/security/sanitize";
import * as fs from "node:fs";
import * as path from "node:path";
export async function GET(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
const relativePath = new URL(req.url).searchParams.get("path") ?? "/";
let resolvedPath: string;
try {
resolvedPath = sanitizeFilePath(relativePath, mcBase);
} catch {
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
}
try {
const entries = fs.readdirSync(resolvedPath, { withFileTypes: true });
const files = entries.map((entry) => {
const fullPath = path.join(resolvedPath, entry.name);
let size = 0;
let modifiedAt = 0;
try {
const stat = fs.statSync(fullPath);
size = stat.size;
modifiedAt = stat.mtimeMs;
} catch {}
return {
name: entry.name,
path: path.relative(mcBase, fullPath),
isDirectory: entry.isDirectory(),
size,
modifiedAt,
};
});
// Sort: directories first, then files, alphabetically
files.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
return NextResponse.json({
path: path.relative(mcBase, resolvedPath) || "/",
entries: files,
});
} catch (err) {
return NextResponse.json({ error: "Cannot read directory" }, { status: 500 });
}
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { sanitizeFilePath } from "@/lib/security/sanitize";
import { db } from "@/lib/db";
import { auditLogs } from "@/lib/db/schema";
import { nanoid } from "nanoid";
import * as fs from "node:fs";
import * as path from "node:path";
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
const BLOCKED_EXTENSIONS = new Set([".exe", ".bat", ".cmd", ".sh", ".ps1"]);
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip, 20);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
const targetDir = req.nextUrl.searchParams.get("path") ?? "/";
let resolvedDir: string;
try {
resolvedDir = sanitizeFilePath(targetDir, mcBase);
} catch {
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
}
const formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
// Check file size
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: "File too large (max 500 MB)" }, { status: 413 });
}
// Check extension
const ext = path.extname(file.name).toLowerCase();
if (BLOCKED_EXTENSIONS.has(ext)) {
return NextResponse.json({ error: "File type not allowed" }, { status: 400 });
}
// Sanitize filename: allow alphanumeric, dots, dashes, underscores, spaces
const safeName = file.name.replace(/[^a-zA-Z0-9._\- ]/g, "_");
const destPath = path.join(resolvedDir, safeName);
// Ensure destination is still within base
if (!destPath.startsWith(mcBase)) {
return NextResponse.json({ error: "Invalid destination" }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
fs.mkdirSync(resolvedDir, { recursive: true });
fs.writeFileSync(destPath, buffer);
await db.insert(auditLogs).values({
id: nanoid(), userId: session.user.id,
action: "file.upload", target: "file", targetId: path.relative(mcBase, destPath),
details: JSON.stringify({ size: file.size, name: safeName }), ipAddress: ip, createdAt: Date.now(),
});
return NextResponse.json({ success: true, path: path.relative(mcBase, destPath) });
}

9
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
/** Health check endpoint used by Docker and monitoring. */
export async function GET() {
return NextResponse.json(
{ status: "ok", timestamp: Date.now() },
{ status: 200 },
);
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { mcProcessManager } from "@/lib/minecraft/process";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import * as os from "node:os";
export async function GET(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const totalMemMb = Math.round(os.totalmem() / 1024 / 1024);
const freeMemMb = Math.round(os.freemem() / 1024 / 1024);
const usedMemMb = totalMemMb - freeMemMb;
// CPU usage (average across all cores, sampled over 100ms)
const cpuPercent = await getCpuPercent();
const serverStatus = mcProcessManager.getStatus();
return NextResponse.json({
system: {
cpuPercent,
totalMemMb,
usedMemMb,
freeMemMb,
loadAvg: os.loadavg(),
uptime: os.uptime(),
platform: os.platform(),
arch: os.arch(),
},
server: serverStatus,
timestamp: Date.now(),
});
}
function getCpuPercent(): Promise<number> {
return new Promise((resolve) => {
const cpus1 = os.cpus();
setTimeout(() => {
const cpus2 = os.cpus();
let totalIdle = 0;
let totalTick = 0;
for (let i = 0; i < cpus1.length; i++) {
const cpu1 = cpus1[i].times;
const cpu2 = cpus2[i].times;
const idle = cpu2.idle - cpu1.idle;
const total =
(cpu2.user - cpu1.user) +
(cpu2.nice - cpu1.nice) +
(cpu2.sys - cpu1.sys) +
(cpu2.idle - cpu1.idle) +
((cpu2.irq ?? 0) - (cpu1.irq ?? 0));
totalIdle += idle;
totalTick += total;
}
const percent = totalTick === 0 ? 0 : Math.round(((totalTick - totalIdle) / totalTick) * 100);
resolve(percent);
}, 100);
});
}

View File

@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { mcPlayers, playerBans, playerChatHistory, playerSpawnPoints, auditLogs } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { eq, desc } from "drizzle-orm";
import { rconClient } from "@/lib/minecraft/rcon";
import { sanitizeRconCommand } from "@/lib/security/sanitize";
import { z } from "zod";
import { nanoid } from "nanoid";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const player = await db.select().from(mcPlayers).where(eq(mcPlayers.id, id)).get();
if (!player) return NextResponse.json({ error: "Player not found" }, { status: 404 });
const [bans, chatHistory, spawnPoints] = await Promise.all([
db.select().from(playerBans).where(eq(playerBans.playerId, id)).orderBy(desc(playerBans.bannedAt)),
db.select().from(playerChatHistory).where(eq(playerChatHistory.playerId, id)).orderBy(desc(playerChatHistory.timestamp)).limit(200),
db.select().from(playerSpawnPoints).where(eq(playerSpawnPoints.playerId, id)),
]);
return NextResponse.json({ player, bans, chatHistory, spawnPoints });
}
const BanSchema = z.object({
reason: z.string().min(1).max(500),
expiresAt: z.number().optional(),
});
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin", "moderator"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const { id } = await params;
const { searchParams } = new URL(req.url);
const action = searchParams.get("action");
const player = await db.select().from(mcPlayers).where(eq(mcPlayers.id, id)).get();
if (!player) return NextResponse.json({ error: "Player not found" }, { status: 404 });
if (action === "ban") {
let body: z.infer<typeof BanSchema>;
try {
body = BanSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
// Insert ban record
await db.insert(playerBans).values({
id: nanoid(),
playerId: id,
reason: body.reason,
bannedBy: session.user.id,
bannedAt: Date.now(),
expiresAt: body.expiresAt ?? null,
isActive: true,
});
await db.update(mcPlayers).set({ isBanned: true }).where(eq(mcPlayers.id, id));
// Execute ban via RCON
try {
const cmd = sanitizeRconCommand(`ban ${player.username} ${body.reason}`);
await rconClient.sendCommand(cmd);
} catch { /* RCON might be unavailable */ }
// Audit
await db.insert(auditLogs).values({
id: nanoid(),
userId: session.user.id,
action: "player.ban",
target: "player",
targetId: id,
details: JSON.stringify({ reason: body.reason }),
ipAddress: ip,
createdAt: Date.now(),
});
return NextResponse.json({ success: true });
}
if (action === "unban") {
await db.update(playerBans).set({ isActive: false, unbannedBy: session.user.id, unbannedAt: Date.now() }).where(eq(playerBans.playerId, id));
await db.update(mcPlayers).set({ isBanned: false }).where(eq(mcPlayers.id, id));
try {
const cmd = sanitizeRconCommand(`pardon ${player.username}`);
await rconClient.sendCommand(cmd);
} catch { /* RCON might be unavailable */ }
await db.insert(auditLogs).values({
id: nanoid(), userId: session.user.id, action: "player.unban",
target: "player", targetId: id, details: null, ipAddress: ip, createdAt: Date.now(),
});
return NextResponse.json({ success: true });
}
if (action === "kick") {
const { reason } = await req.json();
try {
const cmd = sanitizeRconCommand(`kick ${player.username} ${reason ?? "Kicked by admin"}`);
await rconClient.sendCommand(cmd);
} catch (err) {
return NextResponse.json({ error: "RCON unavailable" }, { status: 503 });
}
await db.insert(auditLogs).values({
id: nanoid(), userId: session.user.id, action: "player.kick",
target: "player", targetId: id, details: JSON.stringify({ reason }), ipAddress: ip, createdAt: Date.now(),
});
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
}

42
app/api/players/route.ts Normal file
View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { mcPlayers } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { desc, like, or, eq } from "drizzle-orm";
export async function GET(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const { searchParams } = new URL(req.url);
const search = searchParams.get("q")?.trim() ?? "";
const onlineOnly = searchParams.get("online") === "true";
const bannedOnly = searchParams.get("banned") === "true";
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1"));
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "50")));
const offset = (page - 1) * limit;
let query = db.select().from(mcPlayers);
const conditions = [];
if (search) {
conditions.push(like(mcPlayers.username, `%${search}%`));
}
if (onlineOnly) conditions.push(eq(mcPlayers.isOnline, true));
if (bannedOnly) conditions.push(eq(mcPlayers.isBanned, true));
const rows = await db
.select()
.from(mcPlayers)
.where(conditions.length ? (conditions.length === 1 ? conditions[0] : or(...conditions)) : undefined)
.orderBy(desc(mcPlayers.lastSeen))
.limit(limit)
.offset(offset);
return NextResponse.json({ players: rows, page, limit });
}

88
app/api/plugins/route.ts Normal file
View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { plugins, auditLogs } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { rconClient } from "@/lib/minecraft/rcon";
import { sanitizeRconCommand } from "@/lib/security/sanitize";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import * as fs from "node:fs";
import * as path from "node:path";
export async function GET(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
// Read plugins from DB + sync with filesystem
const mcPath = process.env.MC_SERVER_PATH ?? "/opt/minecraft/server";
const pluginsDir = path.join(mcPath, "plugins");
const dbPlugins = await db.select().from(plugins);
// Try to read actual plugin jars from filesystem
let jarFiles: string[] = [];
try {
jarFiles = fs
.readdirSync(pluginsDir)
.filter((f) => f.endsWith(".jar"));
} catch { /* plugins dir might not exist */ }
return NextResponse.json({ plugins: dbPlugins, jarFiles });
}
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const { searchParams } = new URL(req.url);
const action = searchParams.get("action");
const pluginName = searchParams.get("name");
if (!pluginName || !/^[a-zA-Z0-9_\-]+$/.test(pluginName)) {
return NextResponse.json({ error: "Invalid plugin name" }, { status: 400 });
}
try {
if (action === "enable" || action === "disable") {
const cmd = sanitizeRconCommand(
`plugman ${action} ${pluginName}`,
);
const result = await rconClient.sendCommand(cmd);
await db
.update(plugins)
.set({ isEnabled: action === "enable" })
.where(eq(plugins.name, pluginName));
await db.insert(auditLogs).values({
id: nanoid(), userId: session.user.id,
action: `plugin.${action}`, target: "plugin", targetId: pluginName,
details: null, ipAddress: ip, createdAt: Date.now(),
});
return NextResponse.json({ success: true, result });
}
if (action === "reload") {
const cmd = sanitizeRconCommand(`plugman reload ${pluginName}`);
const result = await rconClient.sendCommand(cmd);
return NextResponse.json({ success: true, result });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 503 });
}
}

View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { scheduledTasks } from "@/lib/db/schema";
import { scheduleTask, stopTask } from "@/lib/scheduler";
import { eq } from "drizzle-orm";
import { z } from "zod";
import cron from "node-cron";
const UpdateSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
cronExpression: z.string().max(100).optional(),
command: z.string().min(1).max(500).optional(),
isEnabled: z.boolean().optional(),
});
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const task = await db.select().from(scheduledTasks).where(eq(scheduledTasks.id, id)).get();
if (!task) return NextResponse.json({ error: "Task not found" }, { status: 404 });
let body: z.infer<typeof UpdateSchema>;
try {
body = UpdateSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
if (body.cronExpression && !cron.validate(body.cronExpression)) {
return NextResponse.json({ error: "Invalid cron expression" }, { status: 400 });
}
const updated = { ...task, ...body, updatedAt: Date.now() };
await db.update(scheduledTasks).set(updated).where(eq(scheduledTasks.id, id));
// Reschedule
stopTask(id);
if (updated.isEnabled) {
scheduleTask(id, updated.cronExpression, updated.command);
}
return NextResponse.json({ success: true });
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
stopTask(id);
await db.delete(scheduledTasks).where(eq(scheduledTasks.id, id));
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { scheduledTasks } from "@/lib/db/schema";
import { scheduleTask, stopTask } from "@/lib/scheduler";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import { z } from "zod";
import cron from "node-cron";
const TaskSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
cronExpression: z.string().max(100),
command: z.string().min(1).max(500),
isEnabled: z.boolean().default(true),
});
export async function GET(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const tasks = await db.select().from(scheduledTasks).orderBy(scheduledTasks.createdAt);
return NextResponse.json({ tasks });
}
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
let body: z.infer<typeof TaskSchema>;
try {
body = TaskSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
if (!cron.validate(body.cronExpression)) {
return NextResponse.json({ error: "Invalid cron expression" }, { status: 400 });
}
const id = nanoid();
await db.insert(scheduledTasks).values({
id,
name: body.name,
description: body.description ?? null,
cronExpression: body.cronExpression,
command: body.command,
isEnabled: body.isEnabled,
createdAt: Date.now(),
updatedAt: Date.now(),
});
if (body.isEnabled) {
scheduleTask(id, body.cronExpression, body.command);
}
return NextResponse.json({ success: true, id }, { status: 201 });
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { mcProcessManager } from "@/lib/minecraft/process";
import { db } from "@/lib/db";
import { auditLogs } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { z } from "zod";
import { nanoid } from "nanoid";
const ActionSchema = z.object({
action: z.enum(["start", "stop", "restart"]),
force: z.boolean().optional().default(false),
});
export async function POST(req: NextRequest) {
// Auth
const session = await auth.api.getSession({ headers: req.headers });
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Role check — only admin+
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Rate limiting
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip, 20); // stricter limit for control actions
if (!allowed) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
// Validate body
let body: z.infer<typeof ActionSchema>;
try {
body = ActionSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { action, force } = body;
try {
switch (action) {
case "start":
await mcProcessManager.start();
break;
case "stop":
await mcProcessManager.stop(force);
break;
case "restart":
await mcProcessManager.restart(force);
break;
}
// Audit log
await db.insert(auditLogs).values({
id: nanoid(),
userId: session.user.id,
action: `server.${action}${force ? ".force" : ""}`,
target: "server",
targetId: null,
details: JSON.stringify({ action, force }),
ipAddress: ip,
createdAt: Date.now(),
});
return NextResponse.json({ success: true, action });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { serverSettings } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { z } from "zod";
const UpdateSettingsSchema = z.object({
minecraftPath: z.string().optional(),
serverJar: z.string().optional(),
serverVersion: z.string().optional(),
serverType: z.enum(["vanilla", "paper", "spigot", "bukkit", "fabric", "forge", "bedrock"]).optional(),
maxRam: z.number().min(512).max(32768).optional(),
minRam: z.number().min(256).max(32768).optional(),
rconEnabled: z.boolean().optional(),
rconPort: z.number().min(1).max(65535).optional(),
rconPassword: z.string().min(8).optional(),
javaArgs: z.string().max(1000).optional(),
autoStart: z.boolean().optional(),
restartOnCrash: z.boolean().optional(),
backupEnabled: z.boolean().optional(),
backupSchedule: z.string().optional(),
bluemapEnabled: z.boolean().optional(),
bluemapUrl: z.string().url().optional().or(z.literal("")),
});
export async function GET(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const settings = await db.select().from(serverSettings).get();
// Never return RCON password
if (settings) {
const { rconPassword: _, ...safe } = settings;
return NextResponse.json({ settings: safe });
}
return NextResponse.json({ settings: null });
}
export async function PATCH(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (session.user.role !== "superadmin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
let body: z.infer<typeof UpdateSettingsSchema>;
try {
body = UpdateSettingsSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const existing = await db.select().from(serverSettings).get();
if (existing) {
await db.update(serverSettings).set({ ...body, updatedAt: Date.now() });
} else {
await db.insert(serverSettings).values({ id: 1, ...body, updatedAt: Date.now() });
}
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { mcProcessManager } from "@/lib/minecraft/process";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
export async function GET(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
const status = mcProcessManager.getStatus();
return NextResponse.json(status);
}

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { fetchVanillaVersions, fetchPaperVersions, fetchFabricVersions, type VersionInfo } from "@/lib/minecraft/versions";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
export async function GET(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip, 20);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const type = req.nextUrl.searchParams.get("type") ?? "vanilla";
try {
let versionInfos: VersionInfo[];
switch (type) {
case "paper":
versionInfos = await fetchPaperVersions();
break;
case "fabric":
versionInfos = await fetchFabricVersions();
break;
case "vanilla":
default:
versionInfos = await fetchVanillaVersions();
}
const versions = versionInfos.map((v) => v.id);
return NextResponse.json({ versions, type });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch versions";
return NextResponse.json({ error: message }, { status: 503 });
}
}

91
app/api/team/route.ts Normal file
View File

@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { users, invitations } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { sendInvitationEmail } from "@/lib/email";
import { z } from "zod";
import { nanoid } from "nanoid";
import { eq, ne } from "drizzle-orm";
const InviteSchema = z.object({
email: z.string().email().max(254),
role: z.enum(["admin", "moderator"]),
playerUuid: z.string().optional(),
});
export async function GET(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (session.user.role !== "superadmin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const teamUsers = await db
.select()
.from(users)
.where(ne(users.id, session.user.id));
const pendingInvites = await db
.select()
.from(invitations)
.where(eq(invitations.acceptedAt, null as unknown as number));
return NextResponse.json({ users: teamUsers, pendingInvites });
}
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (session.user.role !== "superadmin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip, 10);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
let body: z.infer<typeof InviteSchema>;
try {
body = InviteSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
// Check if user already exists
const existing = await db.select().from(users).where(eq(users.email, body.email)).get();
if (existing) {
return NextResponse.json({ error: "User already exists" }, { status: 409 });
}
// Create invitation
const token = nanoid(48);
const expiresAt = Date.now() + 48 * 60 * 60 * 1000; // 48 hours
await db.insert(invitations).values({
id: nanoid(),
email: body.email,
role: body.role,
invitedBy: session.user.id,
token,
expiresAt,
createdAt: Date.now(),
});
const baseUrl = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`;
try {
await sendInvitationEmail({
to: body.email,
invitedByName: session.user.name ?? "An admin",
inviteUrl,
role: body.role,
});
} catch (err) {
// Log but don't fail — admin can resend
console.error("[Team] Failed to send invitation email:", err);
}
return NextResponse.json({ success: true, inviteUrl }, { status: 201 });
}