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() const app = new Elysia()
.use(swagger()) .use(swagger({
.ws("/ws", { path: '/swagger',
body: t.Object({ documentation: {
message: t.String(), info: {
}), title: 'Minikura API Documentation',
// query: t.Object({ version: '1.0.0'
// 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,
});
} }
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); .group('/api', app => app
.derive(async ({ headers, cookie: { session_token }, path }) => {
session_token.httpOnly = true; // Skip token validation for login route
session_token.value = session.token; if (path === '/api/login') {
return { 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 auth = session_token.value;
const token = headers.authorization?.split(" ")[1]; const token = headers.authorization?.split(" ")[1];
@@ -161,6 +121,48 @@ const app = new Elysia()
message: ReturnError.INVALID_TOKEN, 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 } }) => { .post("/logout", async ({ session, cookie: { session_token } }) => {
if (!session) return { success: true }; if (!session) return { success: true };
@@ -182,11 +184,11 @@ const app = new Elysia()
"/servers", "/servers",
async ({ body, error }) => { async ({ body, error }) => {
// Must be a-z, A-Z, 0-9, and -_ only // Must be a-z, A-Z, 0-9, and -_ only
if (!/^[a-zA-Z0-9-_]+$/.test(body.name)) { if (!/^[a-zA-Z0-9-_]+$/.test(body.id)) {
return error("Bad Request", "Name must be a-z, A-Z, 0-9, and -_ only"); 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) { if (_server) {
return error("Conflict", { return error("Conflict", {
success: false, success: false,
@@ -195,12 +197,12 @@ const app = new Elysia()
} }
const server = await ServerService.createServer({ const server = await ServerService.createServer({
name: body.name, id: body.id,
description: body.description, description: body.description,
address: body.address, listen_port: body.listen_port,
port: body.port,
type: body.type, type: body.type,
join_priority: body.join_priority, env_variables: body.env_variables,
memory: body.memory,
}); });
return { return {
@@ -212,12 +214,15 @@ const app = new Elysia()
}, },
{ {
body: t.Object({ body: t.Object({
name: t.String({ minLength: 1 }), id: t.String({ minLength: 1 }),
description: t.Nullable(t.String({ minLength: 1 })), description: t.Nullable(t.String({ minLength: 1 })),
address: t.String({ minLength: 1 }), listen_port: t.Integer({ minimum: 1, maximum: 65535 }),
port: t.Integer({ minimum: 1, maximum: 65535 }),
type: t.Enum(ServerType), 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) { // Create update data with only fields that exist in the model
const _server = await ServerService.getServerByName(body.name); const data: any = {};
if (_server && _server.id !== id) {
return error("Conflict", {
success: false,
message: ReturnError.SERVER_NAME_IN_USE,
});
}
}
const data = { if (body.description !== undefined) data.description = body.description;
name: body.name, if (body.listen_port !== undefined) data.listen_port = body.listen_port;
description: body.description, if (body.memory !== undefined) data.memory = body.memory;
address: body.address, // Don't allow service_type to be updated through API
port: body.port,
join_priority: body.join_priority,
};
await prisma.server.update({ await prisma.server.update({
where: { id }, where: { id },
@@ -266,11 +261,9 @@ const app = new Elysia()
}, },
{ {
body: t.Object({ body: t.Object({
name: t.Optional(t.String({ minLength: 1 })),
description: t.Optional(t.Nullable(t.String({ minLength: 1 }))), description: t.Optional(t.Nullable(t.String({ minLength: 1 }))),
address: t.Optional(t.String({ minLength: 1 })), listen_port: t.Optional(t.Integer({ minimum: 1, maximum: 65535 })),
port: t.Optional(t.Integer({ minimum: 1, maximum: 65535 })), memory: t.Optional(t.String({ minLength: 1 })),
join_priority: t.Optional(t.Integer({ minimum: 0 })),
}), }),
} }
) )
@@ -298,13 +291,11 @@ const app = new Elysia()
"/reverse_proxy_servers", "/reverse_proxy_servers",
async ({ body, error }) => { async ({ body, error }) => {
// Must be a-z, A-Z, 0-9, and -_ only // Must be a-z, A-Z, 0-9, and -_ only
if (!/^[a-zA-Z0-9-_]+$/.test(body.name)) { if (!/^[a-zA-Z0-9-_]+$/.test(body.id)) {
return error("Bad Request", "Name must be a-z, A-Z, 0-9, and -_ only"); return error("Bad Request", "ID must be a-z, A-Z, 0-9, and -_ only");
} }
const _server = await ServerService.getReverseProxyServerByName( const _server = await ServerService.getReverseProxyServerById(body.id);
body.name
);
if (_server) { if (_server) {
return error("Conflict", { return error("Conflict", {
success: false, success: false,
@@ -313,10 +304,14 @@ const app = new Elysia()
} }
const server = await ServerService.createReverseProxyServer({ const server = await ServerService.createReverseProxyServer({
name: body.name, id: body.id,
address: body.address,
port: body.port,
description: body.description, 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 { return {
@@ -328,10 +323,17 @@ const app = new Elysia()
}, },
{ {
body: t.Object({ body: t.Object({
name: t.String({ minLength: 1 }), id: t.String({ minLength: 1 }),
description: t.Nullable(t.String({ minLength: 1 })), description: t.Nullable(t.String({ minLength: 1 })),
address: t.String({ minLength: 1 }), external_address: t.String({ minLength: 1 }),
port: t.Integer({ minimum: 1, maximum: 65535 }), 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) { // Create update data with only fields that exist in the model
const _server = await ServerService.getServerByName(body.name); const data: any = {};
if (_server && _server.id !== id) {
return error("Conflict", {
success: false,
message: ReturnError.SERVER_NAME_IN_USE,
});
}
}
const data = { if (body.description !== undefined) data.description = body.description;
name: body.name, if (body.external_address !== undefined) data.external_address = body.external_address;
description: body.description, if (body.external_port !== undefined) data.external_port = body.external_port;
address: body.address, if (body.listen_port !== undefined) data.listen_port = body.listen_port;
port: body.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({ await prisma.reverseProxyServer.update({
where: { id }, where: { id },
@@ -384,10 +380,12 @@ const app = new Elysia()
}, },
{ {
body: t.Object({ body: t.Object({
name: t.Optional(t.String({ minLength: 1 })),
description: t.Optional(t.Nullable(t.String({ minLength: 1 }))), description: t.Optional(t.Nullable(t.String({ minLength: 1 }))),
address: t.Optional(t.String({ minLength: 1 })), external_address: t.Optional(t.String({ minLength: 1 })),
port: t.Optional(t.Integer({ minimum: 1, maximum: 65535 })), 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, 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 () => { .listen(3000, async () => {
console.log("Server is running on port 3000"); console.log("Server is running on port 3000");
bootstrap(); bootstrap();

View File

@@ -4,73 +4,126 @@ import crypto from "node:crypto";
export namespace ServerService { export namespace ServerService {
export async function getAllServers(omitSensitive = false) { export async function getAllServers(omitSensitive = false) {
if (omitSensitive) {
return await prisma.server.findMany({ return await prisma.server.findMany({
omit: { select: {
api_key: omitSensitive, 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) { export async function getAllReverseProxyServers(omitSensitive = false) {
if (omitSensitive) {
return await prisma.reverseProxyServer.findMany({ return await prisma.reverseProxyServer.findMany({
omit: { select: {
api_key: omitSensitive, 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) { export async function getServerById(id: string, omitSensitive = false) {
if (omitSensitive) {
return await prisma.server.findUnique({ return await prisma.server.findUnique({
where: { id }, where: { id },
omit: { select: {
api_key: omitSensitive, 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( export async function getReverseProxyServerById(
id: string, id: string,
omitSensitive = false omitSensitive = false
) { ) {
if (omitSensitive) {
return await prisma.reverseProxyServer.findUnique({ return await prisma.reverseProxyServer.findUnique({
where: { id }, where: { id },
omit: { select: {
api_key: omitSensitive, 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 {
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
) {
return await prisma.reverseProxyServer.findUnique({ return await prisma.reverseProxyServer.findUnique({
where: { name }, where: { id },
omit: { include: {
api_key: omitSensitive, env_variables: true,
}, },
}); });
} }
}
export async function createReverseProxyServer({ export async function createReverseProxyServer({
name, id,
description, description,
address, external_address,
port, external_port,
listen_port,
type,
env_variables,
memory,
}: { }: {
name: string; id: string;
description: string | null; description: string | null;
address: string; external_address: string;
port: number; external_port: number;
listen_port?: number;
type?: "VELOCITY" | "BUNGEECORD";
env_variables?: { key: string; value: string }[];
memory?: string;
}) { }) {
let token = crypto.randomBytes(64).toString("hex"); let token = crypto.randomBytes(64).toString("hex");
token = token token = token
@@ -81,29 +134,41 @@ export namespace ServerService {
return await prisma.reverseProxyServer.create({ return await prisma.reverseProxyServer.create({
data: { data: {
name, id,
description, description,
address, external_address,
port, external_port,
listen_port: listen_port || 25565,
type: type || "VELOCITY",
api_key: token, 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({ export async function createServer({
name, id,
description, description,
address,
port,
type, type,
join_priority, listen_port,
env_variables,
memory,
}: { }: {
name: string; id: string;
description: string | null; description: string | null;
address: string;
port: number;
type: ServerType; type: ServerType;
join_priority: number | null; listen_port: number;
env_variables?: { key: string; value: string }[];
memory?: string;
}) { }) {
let token = crypto.randomBytes(64).toString("hex"); let token = crypto.randomBytes(64).toString("hex");
token = token token = token
@@ -114,13 +179,97 @@ export namespace ServerService {
return await prisma.server.create({ return await prisma.server.create({
data: { data: {
name, id,
description, description,
address,
port,
type, type,
listen_port,
api_key: token, 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:generate": "bun --filter @minikura/db generate",
"db:studio": "bun --filter @minikura/db studio", "db:studio": "bun --filter @minikura/db studio",
"db:push": "bun --filter @minikura/db push", "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": { "devDependencies": {
"@biomejs/biome": "^1.9.1", "@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 { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
previewFeatures = ["omitApi"]
} }
datasource db { datasource db {
@@ -19,26 +12,33 @@ enum ServerType {
STATELESS STATELESS
} }
enum ReverseProxyServerType {
VELOCITY
BUNGEECORD
}
model ReverseProxyServer { model ReverseProxyServer {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique type ReverseProxyServerType
description String? description String?
external_address String
external_port Int
listen_port Int @default(25565)
memory String @default("512M")
api_key String @unique api_key String @unique
address String env_variables CustomEnvironmentVariable[] @relation("ReverseProxyServerEnvVars")
port Int
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
} }
model Server { model Server {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique
description String?
address String
port Int
type ServerType type ServerType
description String?
listen_port Int @default(25565)
memory String @default("1G")
env_variables CustomEnvironmentVariable[] @relation("ServerEnvVars")
api_key String @unique api_key String @unique
join_priority Int?
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
} }
@@ -62,3 +62,20 @@ model Session {
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt 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": { "tasks": {
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"] "outputs": [".next/**", "!.next/cache/**", "dist/**"]
}, },
"typecheck": { "typecheck": {
"dependsOn": ["^typecheck"] "dependsOn": ["^typecheck"]