feat: adds server management API

This commit is contained in:
2025-05-12 12:37:35 +07:00
parent 952928ffcb
commit 13068b6066
6 changed files with 753 additions and 410 deletions

View File

@@ -37,65 +37,25 @@ const bootstrap = async () => {
};
const app = new Elysia()
.use(swagger())
.ws("/ws", {
body: t.Object({
message: t.String(),
}),
// query: t.Object({
// id: t.String(),
// }),
message(ws, { message }) {
const { id } = ws.data.query;
ws.send({
id,
message,
time: Date.now(),
});
},
})
.post(
"/login",
async ({ body, cookie: { session_token } }) => {
const user = await UserService.getUserByUsername(body.username);
if (!user) {
// Fake hash to prevent timing attacks
const FAKE_HASH =
"$argon2id$v=19$m=65536,t=3,p=4$PMqZArEBmfkHPbPv7Z50KQ$miV6tIIXU6w2WWOGLbWrdan8TGMTUsS1A1hy9RrpS9Y";
await argon2.verify(FAKE_HASH, "fake");
return error("Unauthorized", {
success: false,
message: ReturnError.INVALID_USERNAME_OR_PASSWORD,
});
.use(swagger({
path: '/swagger',
documentation: {
info: {
title: 'Minikura API Documentation',
version: '1.0.0'
}
const valid = await argon2.verify(user.password, body.password);
if (!valid) {
return error("Unauthorized", {
success: false,
message: ReturnError.INVALID_USERNAME_OR_PASSWORD,
});
}
const session = await SessionService.create(user.id);
session_token.httpOnly = true;
session_token.value = session.token;
}))
.group('/api', app => app
.derive(async ({ headers, cookie: { session_token }, path }) => {
// Skip token validation for login route
if (path === '/api/login') {
return {
success: true,
server: null,
session: null,
};
},
{
body: t.Object({
username: t.String({ minLength: 1 }),
password: t.String({ minLength: 1 }),
}),
}
)
.derive(async ({ headers, cookie: { session_token } }) => {
const auth = session_token.value;
const token = headers.authorization?.split(" ")[1];
@@ -161,6 +121,48 @@ const app = new Elysia()
message: ReturnError.INVALID_TOKEN,
});
})
.post(
"/login",
async ({ body, cookie: { session_token } }) => {
const user = await UserService.getUserByUsername(body.username);
const valid = await argon2.verify(user?.password || "fake", body.password);
if (!user || !valid) {
return error("Unauthorized", {
success: false,
message: ReturnError.INVALID_USERNAME_OR_PASSWORD,
});
}
const session = await SessionService.create(user.id);
session_token.httpOnly = true;
session_token.value = session.token;
return {
success: true,
};
},
{
body: t.Object({
username: t.String({ minLength: 1 }),
password: t.String({ minLength: 1 }),
}),
}
)
.ws("/ws", {
body: t.Object({
message: t.String(),
}),
message(ws, { message }) {
const { id } = ws.data.query;
ws.send({
id,
message,
time: Date.now(),
});
},
})
.post("/logout", async ({ session, cookie: { session_token } }) => {
if (!session) return { success: true };
@@ -182,11 +184,11 @@ const app = new Elysia()
"/servers",
async ({ body, error }) => {
// Must be a-z, A-Z, 0-9, and -_ only
if (!/^[a-zA-Z0-9-_]+$/.test(body.name)) {
return error("Bad Request", "Name must be a-z, A-Z, 0-9, and -_ only");
if (!/^[a-zA-Z0-9-_]+$/.test(body.id)) {
return error("Bad Request", "ID must be a-z, A-Z, 0-9, and -_ only");
}
const _server = await ServerService.getServerByName(body.name);
const _server = await ServerService.getServerById(body.id);
if (_server) {
return error("Conflict", {
success: false,
@@ -195,12 +197,12 @@ const app = new Elysia()
}
const server = await ServerService.createServer({
name: body.name,
id: body.id,
description: body.description,
address: body.address,
port: body.port,
listen_port: body.listen_port,
type: body.type,
join_priority: body.join_priority,
env_variables: body.env_variables,
memory: body.memory,
});
return {
@@ -212,12 +214,15 @@ const app = new Elysia()
},
{
body: t.Object({
name: t.String({ minLength: 1 }),
id: t.String({ minLength: 1 }),
description: t.Nullable(t.String({ minLength: 1 })),
address: t.String({ minLength: 1 }),
port: t.Integer({ minimum: 1, maximum: 65535 }),
listen_port: t.Integer({ minimum: 1, maximum: 65535 }),
type: t.Enum(ServerType),
join_priority: t.Nullable(t.Integer({ minimum: 0 })),
env_variables: t.Optional(t.Array(t.Object({
key: t.String({ minLength: 1 }),
value: t.String(),
}))),
memory: t.Optional(t.String({ minLength: 1 })),
}),
}
)
@@ -232,23 +237,13 @@ const app = new Elysia()
});
}
if (body.name) {
const _server = await ServerService.getServerByName(body.name);
if (_server && _server.id !== id) {
return error("Conflict", {
success: false,
message: ReturnError.SERVER_NAME_IN_USE,
});
}
}
// Create update data with only fields that exist in the model
const data: any = {};
const data = {
name: body.name,
description: body.description,
address: body.address,
port: body.port,
join_priority: body.join_priority,
};
if (body.description !== undefined) data.description = body.description;
if (body.listen_port !== undefined) data.listen_port = body.listen_port;
if (body.memory !== undefined) data.memory = body.memory;
// Don't allow service_type to be updated through API
await prisma.server.update({
where: { id },
@@ -266,11 +261,9 @@ const app = new Elysia()
},
{
body: t.Object({
name: t.Optional(t.String({ minLength: 1 })),
description: t.Optional(t.Nullable(t.String({ minLength: 1 }))),
address: t.Optional(t.String({ minLength: 1 })),
port: t.Optional(t.Integer({ minimum: 1, maximum: 65535 })),
join_priority: t.Optional(t.Integer({ minimum: 0 })),
listen_port: t.Optional(t.Integer({ minimum: 1, maximum: 65535 })),
memory: t.Optional(t.String({ minLength: 1 })),
}),
}
)
@@ -298,13 +291,11 @@ const app = new Elysia()
"/reverse_proxy_servers",
async ({ body, error }) => {
// Must be a-z, A-Z, 0-9, and -_ only
if (!/^[a-zA-Z0-9-_]+$/.test(body.name)) {
return error("Bad Request", "Name must be a-z, A-Z, 0-9, and -_ only");
if (!/^[a-zA-Z0-9-_]+$/.test(body.id)) {
return error("Bad Request", "ID must be a-z, A-Z, 0-9, and -_ only");
}
const _server = await ServerService.getReverseProxyServerByName(
body.name
);
const _server = await ServerService.getReverseProxyServerById(body.id);
if (_server) {
return error("Conflict", {
success: false,
@@ -313,10 +304,14 @@ const app = new Elysia()
}
const server = await ServerService.createReverseProxyServer({
name: body.name,
address: body.address,
port: body.port,
id: body.id,
description: body.description,
external_address: body.external_address,
external_port: body.external_port,
listen_port: body.listen_port,
type: body.type,
env_variables: body.env_variables,
memory: body.memory,
});
return {
@@ -328,10 +323,17 @@ const app = new Elysia()
},
{
body: t.Object({
name: t.String({ minLength: 1 }),
id: t.String({ minLength: 1 }),
description: t.Nullable(t.String({ minLength: 1 })),
address: t.String({ minLength: 1 }),
port: t.Integer({ minimum: 1, maximum: 65535 }),
external_address: t.String({ minLength: 1 }),
external_port: t.Integer({ minimum: 1, maximum: 65535 }),
listen_port: t.Optional(t.Integer({ minimum: 1, maximum: 65535 })),
type: t.Optional(t.Enum({ VELOCITY: "VELOCITY", BUNGEECORD: "BUNGEECORD" })),
env_variables: t.Optional(t.Array(t.Object({
key: t.String({ minLength: 1 }),
value: t.String(),
}))),
memory: t.Optional(t.String({ minLength: 1 })),
}),
}
)
@@ -348,22 +350,16 @@ const app = new Elysia()
});
}
if (body.name) {
const _server = await ServerService.getServerByName(body.name);
if (_server && _server.id !== id) {
return error("Conflict", {
success: false,
message: ReturnError.SERVER_NAME_IN_USE,
});
}
}
// Create update data with only fields that exist in the model
const data: any = {};
const data = {
name: body.name,
description: body.description,
address: body.address,
port: body.port,
};
if (body.description !== undefined) data.description = body.description;
if (body.external_address !== undefined) data.external_address = body.external_address;
if (body.external_port !== undefined) data.external_port = body.external_port;
if (body.listen_port !== undefined) data.listen_port = body.listen_port;
if (body.type !== undefined) data.type = body.type;
if (body.memory !== undefined) data.memory = body.memory;
// Don't allow service_type to be updated through API
await prisma.reverseProxyServer.update({
where: { id },
@@ -384,10 +380,12 @@ const app = new Elysia()
},
{
body: t.Object({
name: t.Optional(t.String({ minLength: 1 })),
description: t.Optional(t.Nullable(t.String({ minLength: 1 }))),
address: t.Optional(t.String({ minLength: 1 })),
port: t.Optional(t.Integer({ minimum: 1, maximum: 65535 })),
external_address: t.Optional(t.String({ minLength: 1 })),
external_port: t.Optional(t.Integer({ minimum: 1, maximum: 65535 })),
listen_port: t.Optional(t.Integer({ minimum: 1, maximum: 65535 })),
type: t.Optional(t.Enum({ VELOCITY: "VELOCITY", BUNGEECORD: "BUNGEECORD" })),
memory: t.Optional(t.String({ minLength: 1 })),
}),
}
)
@@ -410,6 +408,181 @@ const app = new Elysia()
success: true,
};
})
.get("/servers/:id/env", async ({ params: { id } }) => {
const server = await ServerService.getServerById(id);
if (!server) {
return error("Not Found", {
success: false,
message: ReturnError.SERVER_NOT_FOUND,
});
}
return {
success: true,
data: {
env_variables: server.env_variables,
},
};
})
.post(
"/servers/:id/env",
async ({ params: { id }, body }) => {
const server = await ServerService.getServerById(id);
if (!server) {
return error("Not Found", {
success: false,
message: ReturnError.SERVER_NOT_FOUND,
});
}
const envVar = await prisma.customEnvironmentVariable.upsert({
where: {
key_server_id: {
key: body.key,
server_id: id,
},
},
update: {
value: body.value,
},
create: {
key: body.key,
value: body.value,
server_id: id,
},
});
return {
success: true,
data: {
env_var: envVar,
},
};
},
{
body: t.Object({
key: t.String({ minLength: 1 }),
value: t.String(),
}),
}
)
.delete("/servers/:id/env/:key", async ({ params: { id, key } }) => {
const server = await ServerService.getServerById(id);
if (!server) {
return error("Not Found", {
success: false,
message: ReturnError.SERVER_NOT_FOUND,
});
}
try {
await prisma.customEnvironmentVariable.delete({
where: {
key_server_id: {
key,
server_id: id,
},
},
});
return {
success: true,
};
} catch (err) {
return error("Not Found", {
success: false,
message: "Environment variable not found",
});
}
})
.get("/reverse_proxy_servers/:id/env", async ({ params: { id } }) => {
const server = await ServerService.getReverseProxyServerById(id);
if (!server) {
return error("Not Found", {
success: false,
message: ReturnError.SERVER_NOT_FOUND,
});
}
return {
success: true,
data: {
env_variables: server.env_variables,
},
};
})
.post(
"/reverse_proxy_servers/:id/env",
async ({ params: { id }, body }) => {
const server = await ServerService.getReverseProxyServerById(id);
if (!server) {
return error("Not Found", {
success: false,
message: ReturnError.SERVER_NOT_FOUND,
});
}
const envVar = await prisma.customEnvironmentVariable.upsert({
where: {
key_reverse_proxy_id: {
key: body.key,
reverse_proxy_id: id,
},
},
update: {
value: body.value,
},
create: {
key: body.key,
value: body.value,
reverse_proxy_id: id,
},
});
return {
success: true,
data: {
env_var: envVar,
},
};
},
{
body: t.Object({
key: t.String({ minLength: 1 }),
value: t.String(),
}),
}
)
.delete("/reverse_proxy_servers/:id/env/:key", async ({ params: { id, key } }) => {
const server = await ServerService.getReverseProxyServerById(id);
if (!server) {
return error("Not Found", {
success: false,
message: ReturnError.SERVER_NOT_FOUND,
});
}
try {
await prisma.customEnvironmentVariable.delete({
where: {
key_reverse_proxy_id: {
key,
reverse_proxy_id: id,
},
},
});
return {
success: true,
};
} catch (err) {
return error("Not Found", {
success: false,
message: "Environment variable not found",
});
}
})
)
.listen(3000, async () => {
console.log("Server is running on port 3000");
bootstrap();

View File

@@ -4,73 +4,126 @@ import crypto from "node:crypto";
export namespace ServerService {
export async function getAllServers(omitSensitive = false) {
if (omitSensitive) {
return await prisma.server.findMany({
omit: {
api_key: omitSensitive,
select: {
id: true,
type: true,
description: true,
listen_port: true,
memory: true,
created_at: true,
updated_at: true,
env_variables: true,
},
});
} else {
return await prisma.server.findMany({
include: {
env_variables: true,
},
});
}
}
export async function getAllReverseProxyServers(omitSensitive = false) {
if (omitSensitive) {
return await prisma.reverseProxyServer.findMany({
omit: {
api_key: omitSensitive,
select: {
id: true,
type: true,
description: true,
external_address: true,
external_port: true,
listen_port: true,
memory: true,
created_at: true,
updated_at: true,
env_variables: true,
},
});
} else {
return await prisma.reverseProxyServer.findMany({
include: {
env_variables: true,
},
});
}
}
export async function getServerById(id: string, omitSensitive = false) {
if (omitSensitive) {
return await prisma.server.findUnique({
where: { id },
omit: {
api_key: omitSensitive,
select: {
id: true,
type: true,
description: true,
listen_port: true,
memory: true,
created_at: true,
updated_at: true,
env_variables: true,
},
});
} else {
return await prisma.server.findUnique({
where: { id },
include: {
env_variables: true,
},
});
}
}
export async function getReverseProxyServerById(
id: string,
omitSensitive = false
) {
if (omitSensitive) {
return await prisma.reverseProxyServer.findUnique({
where: { id },
omit: {
api_key: omitSensitive,
select: {
id: true,
type: true,
description: true,
external_address: true,
external_port: true,
listen_port: true,
memory: true,
created_at: true,
updated_at: true,
env_variables: true,
},
});
}
export async function getServerByName(name: string, omitSensitive = false) {
return await prisma.server.findUnique({
where: { name },
omit: {
api_key: omitSensitive,
},
});
}
export async function getReverseProxyServerByName(
name: string,
omitSensitive = false
) {
} else {
return await prisma.reverseProxyServer.findUnique({
where: { name },
omit: {
api_key: omitSensitive,
where: { id },
include: {
env_variables: true,
},
});
}
}
export async function createReverseProxyServer({
name,
id,
description,
address,
port,
external_address,
external_port,
listen_port,
type,
env_variables,
memory,
}: {
name: string;
id: string;
description: string | null;
address: string;
port: number;
external_address: string;
external_port: number;
listen_port?: number;
type?: "VELOCITY" | "BUNGEECORD";
env_variables?: { key: string; value: string }[];
memory?: string;
}) {
let token = crypto.randomBytes(64).toString("hex");
token = token
@@ -81,29 +134,41 @@ export namespace ServerService {
return await prisma.reverseProxyServer.create({
data: {
name,
id,
description,
address,
port,
external_address,
external_port,
listen_port: listen_port || 25565,
type: type || "VELOCITY",
api_key: token,
memory: memory || "512M",
env_variables: env_variables ? {
create: env_variables.map(ev => ({
key: ev.key,
value: ev.value
}))
} : undefined,
},
include: {
env_variables: true,
}
});
}
export async function createServer({
name,
id,
description,
address,
port,
type,
join_priority,
listen_port,
env_variables,
memory,
}: {
name: string;
id: string;
description: string | null;
address: string;
port: number;
type: ServerType;
join_priority: number | null;
listen_port: number;
env_variables?: { key: string; value: string }[];
memory?: string;
}) {
let token = crypto.randomBytes(64).toString("hex");
token = token
@@ -114,13 +179,97 @@ export namespace ServerService {
return await prisma.server.create({
data: {
name,
id,
description,
address,
port,
type,
listen_port,
api_key: token,
join_priority,
memory: memory || "1G",
env_variables: env_variables ? {
create: env_variables.map(ev => ({
key: ev.key,
value: ev.value
}))
} : undefined,
},
include: {
env_variables: true,
}
});
}
export async function setServerEnvironmentVariable(
serverId: string,
key: string,
value: string
) {
// Upsert pattern - create if doesn't exist, update if it does
return await prisma.customEnvironmentVariable.upsert({
where: {
key_server_id: {
key,
server_id: serverId,
},
},
update: {
value,
},
create: {
key,
value,
server_id: serverId,
},
});
}
export async function setReverseProxyEnvironmentVariable(
proxyId: string,
key: string,
value: string
) {
// Upsert pattern - create if doesn't exist, update if it does
return await prisma.customEnvironmentVariable.upsert({
where: {
key_reverse_proxy_id: {
key,
reverse_proxy_id: proxyId,
},
},
update: {
value,
},
create: {
key,
value,
reverse_proxy_id: proxyId,
},
});
}
export async function deleteServerEnvironmentVariable(
serverId: string,
key: string
) {
return await prisma.customEnvironmentVariable.delete({
where: {
key_server_id: {
key,
server_id: serverId,
},
},
});
}
export async function deleteReverseProxyEnvironmentVariable(
proxyId: string,
key: string
) {
return await prisma.customEnvironmentVariable.delete({
where: {
key_reverse_proxy_id: {
key,
reverse_proxy_id: proxyId,
},
},
});
}

BIN
bun.lockb

Binary file not shown.

View File

@@ -13,7 +13,11 @@
"db:generate": "bun --filter @minikura/db generate",
"db:studio": "bun --filter @minikura/db studio",
"db:push": "bun --filter @minikura/db push",
"db:reset": "bun --filter @minikura/db reset"
"db:reset": "bun --filter @minikura/db reset",
"k8s:dev": "bun --filter @minikura/k8s-operator dev",
"k8s:build": "bun --filter @minikura/k8s-operator build",
"k8s:start": "bun --filter @minikura/k8s-operator start",
"k8s:crd": "bun --filter @minikura/k8s-operator apply-crds"
},
"devDependencies": {
"@biomejs/biome": "^1.9.1",

View File

@@ -1,12 +1,5 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
previewFeatures = ["omitApi"]
}
datasource db {
@@ -19,26 +12,33 @@ enum ServerType {
STATELESS
}
enum ReverseProxyServerType {
VELOCITY
BUNGEECORD
}
model ReverseProxyServer {
id String @id @default(cuid())
name String @unique
type ReverseProxyServerType
description String?
external_address String
external_port Int
listen_port Int @default(25565)
memory String @default("512M")
api_key String @unique
address String
port Int
env_variables CustomEnvironmentVariable[] @relation("ReverseProxyServerEnvVars")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
}
model Server {
id String @id @default(cuid())
name String @unique
description String?
address String
port Int
type ServerType
description String?
listen_port Int @default(25565)
memory String @default("1G")
env_variables CustomEnvironmentVariable[] @relation("ServerEnvVars")
api_key String @unique
join_priority Int?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
}
@@ -62,3 +62,20 @@ model Session {
created_at DateTime @default(now())
updated_at DateTime @updatedAt
}
model CustomEnvironmentVariable {
id String @id @default(cuid())
key String
value String
created_at DateTime @default(now())
updated_at DateTime @updatedAt
server_id String?
server Server? @relation("ServerEnvVars", fields: [server_id], references: [id], onDelete: Cascade)
reverse_proxy_id String?
reverse_proxy_server ReverseProxyServer? @relation("ReverseProxyServerEnvVars", fields: [reverse_proxy_id], references: [id], onDelete: Cascade)
@@unique([key, server_id])
@@unique([key, reverse_proxy_id])
}

View File

@@ -3,7 +3,7 @@
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"]
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"typecheck": {
"dependsOn": ["^typecheck"]