Initial push
This commit is contained in:
71
app/api/accept-invite/route.ts
Normal file
71
app/api/accept-invite/route.ts
Normal 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
48
app/api/audit/route.ts
Normal 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 });
|
||||
}
|
||||
56
app/api/backups/[id]/route.ts
Normal file
56
app/api/backups/[id]/route.ts
Normal 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
48
app/api/backups/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
47
app/api/files/delete/route.ts
Normal file
47
app/api/files/delete/route.ts
Normal 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 });
|
||||
}
|
||||
37
app/api/files/download/route.ts
Normal file
37
app/api/files/download/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
60
app/api/files/list/route.ts
Normal file
60
app/api/files/list/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
71
app/api/files/upload/route.ts
Normal file
71
app/api/files/upload/route.ts
Normal 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
9
app/api/health/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
66
app/api/monitoring/route.ts
Normal file
66
app/api/monitoring/route.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
133
app/api/players/[id]/route.ts
Normal file
133
app/api/players/[id]/route.ts
Normal 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
42
app/api/players/route.ts
Normal 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
88
app/api/plugins/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
69
app/api/scheduler/[id]/route.ts
Normal file
69
app/api/scheduler/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
67
app/api/scheduler/route.ts
Normal file
67
app/api/scheduler/route.ts
Normal 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 });
|
||||
}
|
||||
74
app/api/server/control/route.ts
Normal file
74
app/api/server/control/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
66
app/api/server/settings/route.ts
Normal file
66
app/api/server/settings/route.ts
Normal 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 });
|
||||
}
|
||||
20
app/api/server/status/route.ts
Normal file
20
app/api/server/status/route.ts
Normal 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);
|
||||
}
|
||||
35
app/api/server/versions/route.ts
Normal file
35
app/api/server/versions/route.ts
Normal 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
91
app/api/team/route.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user