From e8dbefde433ac2b2440fb007f1f37ae452133dee Mon Sep 17 00:00:00 2001 From: Yuzu Date: Fri, 13 Feb 2026 15:52:13 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20initial=20prototype?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .devcontainer/post-create.sh | 3 + .devcontainer/setup-k8s-token.sh | 103 ++ apps/backend/package.json | 60 +- apps/backend/src/application/di-container.ts | 20 + .../services/reverse-proxy.service.ts | 80 + .../application/services/server.service.ts | 85 + .../src/application/services/user.service.ts | 64 + apps/backend/src/config/constants.ts | 36 + apps/backend/src/domain/entities/enums.ts | 31 + apps/backend/src/domain/errors/base.error.ts | 75 + .../backend/src/domain/events/domain-event.ts | 9 + .../events/reverse-proxy-lifecycle.events.ts | 28 + .../domain/events/server-lifecycle.events.ts | 43 + .../repositories/reverse-proxy.repository.ts | 22 + .../domain/repositories/server.repository.ts | 19 + .../domain/repositories/user.repository.ts | 11 + .../src/domain/value-objects/api-key.vo.ts | 33 + .../value-objects/k8s-connection-info.vo.ts | 15 + .../domain/value-objects/server-config.vo.ts | 39 + apps/backend/src/index.ts | 671 +------- .../src/infrastructure/api-key-generator.ts | 26 + apps/backend/src/infrastructure/event-bus.ts | 45 + .../infrastructure/event-handlers/index.ts | 4 + .../event-handlers/server-event.handler.ts | 22 + .../event-handlers/user-event.handler.ts | 19 + .../prisma/reverse-proxy.repository.impl.ts | 184 +++ .../prisma/server.repository.impl.ts | 212 +++ .../prisma/user.repository.impl.ts | 47 + apps/backend/src/lib/auth-plugin.ts | 44 + apps/backend/src/lib/auth.ts | 20 + apps/backend/src/lib/authorization.ts | 57 + apps/backend/src/lib/error-handler.ts | 33 + apps/backend/src/lib/errors.ts | 9 + apps/backend/src/lib/fetch-patch.ts | 52 + apps/backend/src/lib/kube-auth.ts | 107 ++ apps/backend/src/lib/middleware.ts | 29 + apps/backend/src/lib/service-utils.ts | 33 + apps/backend/src/lib/zod-validator.ts | 25 + apps/backend/src/routes/bootstrap.ts | 51 + apps/backend/src/routes/k8s.ts | 135 ++ .../src/routes/reverse-proxy.routes.ts | 51 + apps/backend/src/routes/servers.ts | 69 + apps/backend/src/routes/terminal.ts | 351 ++++ apps/backend/src/routes/users.ts | 30 + apps/backend/src/schemas/bootstrap.schema.ts | 9 + apps/backend/src/schemas/server.schema.ts | 143 ++ apps/backend/src/schemas/user.schema.ts | 19 + .../src/services/__tests__/session.test.ts | 235 +++ .../src/services/__tests__/user.test.ts | 272 ++++ apps/backend/src/services/k8s.ts | 455 ++++++ apps/backend/src/services/k8s/resources.ts | 271 ++++ apps/backend/src/services/server.ts | 276 ---- apps/backend/src/services/session.ts | 126 -- apps/backend/src/services/user.ts | 9 - apps/backend/src/services/websocket.ts | 54 + apps/web/app/bootstrap/page.tsx | 161 ++ apps/web/app/dashboard/k8s/page.tsx | 549 +++++++ apps/web/app/dashboard/layout.tsx | 5 + apps/web/app/dashboard/page.tsx | 5 + .../web/app/dashboard/servers/create/page.tsx | 184 +++ .../app/dashboard/servers/edit/[id]/page.tsx | 460 ++++++ apps/web/app/dashboard/servers/page.tsx | 474 ++++++ apps/web/app/dashboard/users/page.tsx | 348 ++++ apps/web/app/globals.css | 150 ++ apps/web/app/layout.tsx | 16 +- apps/web/app/login/page.tsx | 106 ++ apps/web/app/page.tsx | 31 +- apps/web/components.json | 22 + apps/web/components/dashboard-layout.tsx | 177 ++ apps/web/components/server-form.tsx | 1428 +++++++++++++++++ apps/web/components/terminal.tsx | 314 ++++ apps/web/components/ui/accordion.tsx | 58 + apps/web/components/ui/avatar.tsx | 41 + apps/web/components/ui/badge.tsx | 39 + apps/web/components/ui/button.tsx | 60 + apps/web/components/ui/card.tsx | 75 + apps/web/components/ui/checkbox.tsx | 30 + apps/web/components/ui/dialog.tsx | 129 ++ apps/web/components/ui/dropdown-menu.tsx | 228 +++ apps/web/components/ui/form.tsx | 152 ++ apps/web/components/ui/input.tsx | 21 + apps/web/components/ui/label.tsx | 21 + apps/web/components/ui/select.tsx | 175 ++ apps/web/components/ui/separator.tsx | 28 + apps/web/components/ui/sheet.tsx | 130 ++ apps/web/components/ui/sidebar.tsx | 682 ++++++++ apps/web/components/ui/skeleton.tsx | 13 + apps/web/components/ui/table.tsx | 92 ++ apps/web/components/ui/tabs.tsx | 66 + apps/web/components/ui/textarea.tsx | 24 + apps/web/components/ui/tooltip.tsx | 57 + apps/web/hooks/use-k8s-resources.ts | 112 ++ apps/web/hooks/use-server-list.ts | 61 + apps/web/hooks/use-server-logs.ts | 95 ++ apps/web/lib/api-helpers.ts | 24 + apps/web/lib/api.ts | 12 + apps/web/lib/auth-client.ts | 10 + apps/web/lib/utils.ts | 6 + apps/web/next-env.d.ts | 3 +- apps/web/package.json | 85 +- apps/web/postcss.config.mjs | 5 + apps/web/tsconfig.json | 25 +- apps/web/types/index.ts | 4 + biome.json | 88 +- bun.lockb | Bin 105200 -> 232096 bytes k8s/backend-rbac.yaml | 82 + k8s/rbac/dev-rbac.yaml | 48 + k8s/rbac/operator-rbac.yaml | 55 + package.json | 66 +- packages/api/package.json | 32 +- packages/api/src/constants.ts | 4 + packages/api/src/index.ts | 9 +- packages/api/src/labels.ts | 10 + packages/api/src/types.ts | 209 +++ packages/db/.gitignore | 1 + packages/db/package.json | 48 +- packages/db/prisma.config.ts | 9 + packages/db/prisma/schema.prisma | 159 +- packages/db/src/index.ts | 34 +- packages/db/src/models/reverse-proxy.ts | 15 + packages/db/src/models/server.ts | 17 + packages/db/src/models/session.ts | 11 + packages/db/src/models/user.ts | 21 + packages/k8s-operator/package.json | 27 +- .../src/controllers/server-controller.ts | 2 +- .../src/resources/reverseProxyServer.ts | 25 +- packages/k8s-operator/src/resources/server.ts | 45 +- .../src/services/notification.service.ts | 83 + .../k8s-operator/src/utils/crd-registrar.ts | 6 +- packages/k8s-operator/src/utils/errors.ts | 9 + packages/k8s-operator/src/utils/kube-auth.ts | 108 ++ packages/k8s-operator/src/utils/memory.ts | 34 +- .../k8s-operator/src/utils/rbac-registrar.ts | 30 +- .../k8s-operator/src/utils/service-type.ts | 19 + scripts/install.sh | 118 ++ scripts/refresh-k8s-token.sh | 74 + scripts/setup-db-notifications.sql | 76 + scripts/setup-rbac.sh | 101 ++ scripts/show-k8s-tokens.sh | 76 + turbo.json | 44 +- 140 files changed, 12390 insertions(+), 1369 deletions(-) create mode 100755 .devcontainer/setup-k8s-token.sh create mode 100644 apps/backend/src/application/di-container.ts create mode 100644 apps/backend/src/application/services/reverse-proxy.service.ts create mode 100644 apps/backend/src/application/services/server.service.ts create mode 100644 apps/backend/src/application/services/user.service.ts create mode 100644 apps/backend/src/config/constants.ts create mode 100644 apps/backend/src/domain/entities/enums.ts create mode 100644 apps/backend/src/domain/errors/base.error.ts create mode 100644 apps/backend/src/domain/events/domain-event.ts create mode 100644 apps/backend/src/domain/events/reverse-proxy-lifecycle.events.ts create mode 100644 apps/backend/src/domain/events/server-lifecycle.events.ts create mode 100644 apps/backend/src/domain/repositories/reverse-proxy.repository.ts create mode 100644 apps/backend/src/domain/repositories/server.repository.ts create mode 100644 apps/backend/src/domain/repositories/user.repository.ts create mode 100644 apps/backend/src/domain/value-objects/api-key.vo.ts create mode 100644 apps/backend/src/domain/value-objects/k8s-connection-info.vo.ts create mode 100644 apps/backend/src/domain/value-objects/server-config.vo.ts create mode 100644 apps/backend/src/infrastructure/api-key-generator.ts create mode 100644 apps/backend/src/infrastructure/event-bus.ts create mode 100644 apps/backend/src/infrastructure/event-handlers/index.ts create mode 100644 apps/backend/src/infrastructure/event-handlers/server-event.handler.ts create mode 100644 apps/backend/src/infrastructure/event-handlers/user-event.handler.ts create mode 100644 apps/backend/src/infrastructure/repositories/prisma/reverse-proxy.repository.impl.ts create mode 100644 apps/backend/src/infrastructure/repositories/prisma/server.repository.impl.ts create mode 100644 apps/backend/src/infrastructure/repositories/prisma/user.repository.impl.ts create mode 100644 apps/backend/src/lib/auth-plugin.ts create mode 100644 apps/backend/src/lib/auth.ts create mode 100644 apps/backend/src/lib/authorization.ts create mode 100644 apps/backend/src/lib/error-handler.ts create mode 100644 apps/backend/src/lib/errors.ts create mode 100644 apps/backend/src/lib/fetch-patch.ts create mode 100644 apps/backend/src/lib/kube-auth.ts create mode 100644 apps/backend/src/lib/middleware.ts create mode 100644 apps/backend/src/lib/service-utils.ts create mode 100644 apps/backend/src/lib/zod-validator.ts create mode 100644 apps/backend/src/routes/bootstrap.ts create mode 100644 apps/backend/src/routes/k8s.ts create mode 100644 apps/backend/src/routes/reverse-proxy.routes.ts create mode 100644 apps/backend/src/routes/servers.ts create mode 100644 apps/backend/src/routes/terminal.ts create mode 100644 apps/backend/src/routes/users.ts create mode 100644 apps/backend/src/schemas/bootstrap.schema.ts create mode 100644 apps/backend/src/schemas/server.schema.ts create mode 100644 apps/backend/src/schemas/user.schema.ts create mode 100644 apps/backend/src/services/__tests__/session.test.ts create mode 100644 apps/backend/src/services/__tests__/user.test.ts create mode 100644 apps/backend/src/services/k8s.ts create mode 100644 apps/backend/src/services/k8s/resources.ts delete mode 100644 apps/backend/src/services/server.ts delete mode 100644 apps/backend/src/services/session.ts delete mode 100644 apps/backend/src/services/user.ts create mode 100644 apps/backend/src/services/websocket.ts create mode 100644 apps/web/app/bootstrap/page.tsx create mode 100644 apps/web/app/dashboard/k8s/page.tsx create mode 100644 apps/web/app/dashboard/layout.tsx create mode 100644 apps/web/app/dashboard/page.tsx create mode 100644 apps/web/app/dashboard/servers/create/page.tsx create mode 100644 apps/web/app/dashboard/servers/edit/[id]/page.tsx create mode 100644 apps/web/app/dashboard/servers/page.tsx create mode 100644 apps/web/app/dashboard/users/page.tsx create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/login/page.tsx create mode 100644 apps/web/components.json create mode 100644 apps/web/components/dashboard-layout.tsx create mode 100644 apps/web/components/server-form.tsx create mode 100644 apps/web/components/terminal.tsx create mode 100644 apps/web/components/ui/accordion.tsx create mode 100644 apps/web/components/ui/avatar.tsx create mode 100644 apps/web/components/ui/badge.tsx create mode 100644 apps/web/components/ui/button.tsx create mode 100644 apps/web/components/ui/card.tsx create mode 100644 apps/web/components/ui/checkbox.tsx create mode 100644 apps/web/components/ui/dialog.tsx create mode 100644 apps/web/components/ui/dropdown-menu.tsx create mode 100644 apps/web/components/ui/form.tsx create mode 100644 apps/web/components/ui/input.tsx create mode 100644 apps/web/components/ui/label.tsx create mode 100644 apps/web/components/ui/select.tsx create mode 100644 apps/web/components/ui/separator.tsx create mode 100644 apps/web/components/ui/sheet.tsx create mode 100644 apps/web/components/ui/sidebar.tsx create mode 100644 apps/web/components/ui/skeleton.tsx create mode 100644 apps/web/components/ui/table.tsx create mode 100644 apps/web/components/ui/tabs.tsx create mode 100644 apps/web/components/ui/textarea.tsx create mode 100644 apps/web/components/ui/tooltip.tsx create mode 100644 apps/web/hooks/use-k8s-resources.ts create mode 100644 apps/web/hooks/use-server-list.ts create mode 100644 apps/web/hooks/use-server-logs.ts create mode 100644 apps/web/lib/api-helpers.ts create mode 100644 apps/web/lib/api.ts create mode 100644 apps/web/lib/auth-client.ts create mode 100644 apps/web/lib/utils.ts create mode 100644 apps/web/postcss.config.mjs create mode 100644 apps/web/types/index.ts mode change 100644 => 100755 bun.lockb create mode 100644 k8s/backend-rbac.yaml create mode 100644 k8s/rbac/dev-rbac.yaml create mode 100644 k8s/rbac/operator-rbac.yaml create mode 100644 packages/api/src/constants.ts create mode 100644 packages/api/src/labels.ts create mode 100644 packages/api/src/types.ts create mode 100644 packages/db/prisma.config.ts create mode 100644 packages/db/src/models/reverse-proxy.ts create mode 100644 packages/db/src/models/server.ts create mode 100644 packages/db/src/models/session.ts create mode 100644 packages/db/src/models/user.ts create mode 100644 packages/k8s-operator/src/services/notification.service.ts create mode 100644 packages/k8s-operator/src/utils/errors.ts create mode 100644 packages/k8s-operator/src/utils/kube-auth.ts create mode 100644 packages/k8s-operator/src/utils/service-type.ts create mode 100755 scripts/install.sh create mode 100755 scripts/refresh-k8s-token.sh create mode 100644 scripts/setup-db-notifications.sql create mode 100755 scripts/setup-rbac.sh create mode 100755 scripts/show-k8s-tokens.sh diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 420b71e..689ad99 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -36,6 +36,9 @@ done # Create namespace kubectl create namespace minikura --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true +# Uncomment the line below if you need service account token in .env +# bash /workspace/.devcontainer/setup-k8s-token.sh + # Install dependencies echo "==> Installing dependencies..." cd /workspace diff --git a/.devcontainer/setup-k8s-token.sh b/.devcontainer/setup-k8s-token.sh new file mode 100755 index 0000000..c554352 --- /dev/null +++ b/.devcontainer/setup-k8s-token.sh @@ -0,0 +1,103 @@ +#!/bin/bash +set -e + +NAMESPACE="minikura" +SERVICE_ACCOUNT="minikura-backend" +SECRET_NAME="minikura-backend-token" + +echo "==> Setting up Kubernetes service account..." + +# Create service account if it doesn't exist +kubectl create serviceaccount $SERVICE_ACCOUNT -n $NAMESPACE --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true + +# Create RBAC role +cat < Waiting for token to be generated..." +sleep 3 + +# Get the token +TOKEN=$(kubectl get secret $SECRET_NAME -n $NAMESPACE -o jsonpath='{.data.token}' 2>/dev/null | base64 -d) + +if [ -z "$TOKEN" ]; then + echo "[ERROR] Failed to retrieve service account token" + exit 1 +fi + +echo "" +echo "==============================================" +echo "[OK] Service Account Token Retrieved" +echo "==============================================" +echo "Service Account: $SERVICE_ACCOUNT" +echo "Namespace: $NAMESPACE" +echo "Token: ${TOKEN:0:50}...${TOKEN: -20}" +echo "" + +# Update .env file with the new token +ENV_FILE="/workspace/.env" + +if [ -f "$ENV_FILE" ]; then + # Check if token line exists + if grep -q "^KUBERNETES_SERVICE_ACCOUNT_TOKEN=" "$ENV_FILE"; then + # Update existing token + sed -i "s|^KUBERNETES_SERVICE_ACCOUNT_TOKEN=.*|KUBERNETES_SERVICE_ACCOUNT_TOKEN=\"$TOKEN\"|" "$ENV_FILE" + echo "[OK] Updated KUBERNETES_SERVICE_ACCOUNT_TOKEN in .env" + else + # Add token to end of file + echo "KUBERNETES_SERVICE_ACCOUNT_TOKEN=\"$TOKEN\"" >> "$ENV_FILE" + echo "[OK] Added KUBERNETES_SERVICE_ACCOUNT_TOKEN to .env" + fi +else + echo "[WARNING] .env file not found at $ENV_FILE" +fi + +echo "==============================================" +echo "" diff --git a/apps/backend/package.json b/apps/backend/package.json index ea7bd47..8d5f37e 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,25 +1,39 @@ { - "name": "@minikura/backend", - "module": "src/index.ts", - "type": "module", - "exports": "./src/index.ts", - "scripts": { - "dev": "bun --watch src/index.ts", - "build": "bun build src/index.ts --target bun --outdir ./dist", - "start": "NODE_ENV=production bun dist/index.js", - "test": "bun test" - }, - "devDependencies": { - "@types/bun": "^1.1.9" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "dependencies": { - "@elysiajs/swagger": "^1.1.1", - "@minikura/db": "workspace:*", - "argon2": "^0.41.1", - "dotenv": "^16.4.5", - "elysia": "^1.1.13" - } + "name": "@minikura/backend", + "module": "src/index.ts", + "type": "module", + "exports": "./src/index.ts", + "scripts": { + "dev": "bun --watch src/index.ts", + "build": "bun build src/index.ts --target bun --outdir ./dist", + "start": "NODE_ENV=production bun dist/index.js", + "test": "bun test", + "typecheck": "tsc --noEmit", + "lint": "biome lint .", + "format": "biome format --write ." + }, + "devDependencies": { + "@types/bcryptjs": "^3.0.0", + "@types/bun": "^1.3.6" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@elysiajs/cors": "1.1.1", + "@elysiajs/swagger": "1.1.3", + "@kubernetes/client-node": "^1.4.0", + "@minikura/db": "workspace:*", + "@sinclair/typebox": "^0.34.47", + "@types/ws": "^8.18.1", + "argon2": "^0.44.0", + "bcryptjs": "^3.0.3", + "better-auth": "^1.4.13", + "dotenv": "^17.2.3", + "elysia": "^1.4.22", + "undici": "^7.18.2", + "ws": "^8.19.0", + "yaml": "^2.8.2", + "zod": "^4.3.5" + } } diff --git a/apps/backend/src/application/di-container.ts b/apps/backend/src/application/di-container.ts new file mode 100644 index 0000000..afccc58 --- /dev/null +++ b/apps/backend/src/application/di-container.ts @@ -0,0 +1,20 @@ +import { PrismaReverseProxyRepository } from "../infrastructure/repositories/prisma/reverse-proxy.repository.impl"; +import { PrismaServerRepository } from "../infrastructure/repositories/prisma/server.repository.impl"; +import { PrismaUserRepository } from "../infrastructure/repositories/prisma/user.repository.impl"; +import { K8sService } from "../services/k8s"; +import { WebSocketService } from "../services/websocket"; +import { ReverseProxyService } from "./services/reverse-proxy.service"; +import { ServerService } from "./services/server.service"; +import { UserService } from "./services/user.service"; + +// Infrastructure layer +const userRepo = new PrismaUserRepository(); +const serverRepo = new PrismaServerRepository(); +const reverseProxyRepo = new PrismaReverseProxyRepository(); +const webSocketService = new WebSocketService(); + +// Application layer +export const userService = new UserService(userRepo); +export const serverService = new ServerService(serverRepo, K8sService.getInstance()); +export const reverseProxyService = new ReverseProxyService(reverseProxyRepo); +export const wsService = webSocketService; diff --git a/apps/backend/src/application/services/reverse-proxy.service.ts b/apps/backend/src/application/services/reverse-proxy.service.ts new file mode 100644 index 0000000..25e7326 --- /dev/null +++ b/apps/backend/src/application/services/reverse-proxy.service.ts @@ -0,0 +1,80 @@ +import type { EnvVariable, ReverseProxyWithEnvVars } from "@minikura/db"; +import { ConflictError, NotFoundError } from "../../domain/errors/base.error"; +import { + ReverseProxyCreatedEvent, + ReverseProxyDeletedEvent, + ReverseProxyUpdatedEvent, +} from "../../domain/events/reverse-proxy-lifecycle.events"; +import type { + ReverseProxyCreateInput, + ReverseProxyRepository, + ReverseProxyUpdateInput, +} from "../../domain/repositories/reverse-proxy.repository"; +import { eventBus } from "../../infrastructure/event-bus"; + +export class ReverseProxyService { + constructor(private reverseProxyRepo: ReverseProxyRepository) {} + + async getAllReverseProxies( + omitSensitive = false, + ): Promise { + return this.reverseProxyRepo.findAll(omitSensitive); + } + + async getReverseProxyById( + id: string, + omitSensitive = false, + ): Promise { + const proxy = await this.reverseProxyRepo.findById(id, omitSensitive); + if (!proxy) { + throw new NotFoundError("ReverseProxyServer", id); + } + return proxy; + } + + async createReverseProxy( + input: ReverseProxyCreateInput, + ): Promise { + const id = typeof input.id === "string" ? input.id : String(input.id); + const existing = await this.reverseProxyRepo.exists(id); + if (existing) { + throw new ConflictError("ReverseProxyServer", id); + } + + const proxy = await this.reverseProxyRepo.create(input); + await eventBus.publish( + new ReverseProxyCreatedEvent(proxy.id, proxy.type, input), + ); + return proxy; + } + + async updateReverseProxy( + id: string, + input: ReverseProxyUpdateInput, + ): Promise { + const proxy = await this.reverseProxyRepo.update(id, input); + await eventBus.publish(new ReverseProxyUpdatedEvent(id, input)); + return proxy; + } + + async deleteReverseProxy(id: string): Promise { + await this.reverseProxyRepo.delete(id); + await eventBus.publish(new ReverseProxyDeletedEvent(id)); + } + + async setEnvVariable( + proxyId: string, + key: string, + value: string, + ): Promise { + await this.reverseProxyRepo.setEnvVariable(proxyId, key, value); + } + + async getEnvVariables(proxyId: string): Promise { + return this.reverseProxyRepo.getEnvVariables(proxyId); + } + + async deleteEnvVariable(proxyId: string, key: string): Promise { + await this.reverseProxyRepo.deleteEnvVariable(proxyId, key); + } +} diff --git a/apps/backend/src/application/services/server.service.ts b/apps/backend/src/application/services/server.service.ts new file mode 100644 index 0000000..af83457 --- /dev/null +++ b/apps/backend/src/application/services/server.service.ts @@ -0,0 +1,85 @@ +import type { EnvVariable, ServerWithEnvVars } from "@minikura/db"; +import { ConflictError, NotFoundError } from "../../domain/errors/base.error"; +import { + ServerCreatedEvent, + ServerDeletedEvent, + ServerUpdatedEvent, +} from "../../domain/events/server-lifecycle.events"; +import type { + ServerCreateInput, + ServerRepository, + ServerUpdateInput, +} from "../../domain/repositories/server.repository"; +import { eventBus } from "../../infrastructure/event-bus"; +import type { K8sService } from "../../services/k8s"; + +export class ServerService { + constructor( + private serverRepo: ServerRepository, + private k8sService: K8sService, + ) {} + + async getAllServers(omitSensitive = false): Promise { + return this.serverRepo.findAll(omitSensitive); + } + + async getServerById( + id: string, + omitSensitive = false, + ): Promise { + const server = await this.serverRepo.findById(id, omitSensitive); + if (!server) { + throw new NotFoundError("Server", id); + } + return server; + } + + async createServer(input: ServerCreateInput): Promise { + const existing = await this.serverRepo.exists(input.id); + if (existing) { + throw new ConflictError("Server", input.id); + } + + const server = await this.serverRepo.create(input); + await eventBus.publish( + new ServerCreatedEvent(server.id, server.type, input), + ); + return server; + } + + async updateServer( + id: string, + input: ServerUpdateInput, + ): Promise { + const server = await this.serverRepo.update(id, input); + await eventBus.publish(new ServerUpdatedEvent(id, input)); + return server; + } + + async deleteServer(id: string): Promise { + await this.serverRepo.delete(id); + await eventBus.publish(new ServerDeletedEvent(id)); + } + + async setEnvVariable( + serverId: string, + key: string, + value: string, + ): Promise { + await this.serverRepo.setEnvVariable(serverId, key, value); + } + + async getEnvVariables(serverId: string): Promise { + return this.serverRepo.getEnvVariables(serverId); + } + + async deleteEnvVariable(serverId: string, key: string): Promise { + await this.serverRepo.deleteEnvVariable(serverId, key); + } + + async getConnectionInfo(serverId: string) { + await this.getServerById(serverId); + const serviceName = `minecraft-${serverId}`; + return this.k8sService.getServerConnectionInfo(serviceName); + } +} diff --git a/apps/backend/src/application/services/user.service.ts b/apps/backend/src/application/services/user.service.ts new file mode 100644 index 0000000..c3738de --- /dev/null +++ b/apps/backend/src/application/services/user.service.ts @@ -0,0 +1,64 @@ +import type { UpdateSuspensionInput, UpdateUserInput, User } from "@minikura/db"; +import { BusinessRuleError, NotFoundError } from "../../domain/errors/base.error"; +import { + UserSuspendedEvent, + UserUnsuspendedEvent, +} from "../../domain/events/server-lifecycle.events"; +import type { UserRepository } from "../../domain/repositories/user.repository"; +import { eventBus } from "../../infrastructure/event-bus"; + +export class UserService { + constructor(private userRepo: UserRepository) {} + + async getUserById(id: string): Promise { + const user = await this.userRepo.findById(id); + if (!user) { + throw new NotFoundError("User", id); + } + return user; + } + + async getUserByEmail(email: string): Promise { + return this.userRepo.findByEmail(email); + } + + async getAllUsers(): Promise { + return this.userRepo.findAll(); + } + + async updateUser(id: string, input: UpdateUserInput): Promise { + return this.userRepo.update(id, input); + } + + async updateSuspension(id: string, input: UpdateSuspensionInput): Promise { + const user = await this.userRepo.updateSuspension(id, input); + if (input.isSuspended) { + const suspendedUntil = input.suspendedUntil instanceof Date ? input.suspendedUntil : null; + await eventBus.publish(new UserSuspendedEvent(id, suspendedUntil)); + } else { + await eventBus.publish(new UserUnsuspendedEvent(id)); + } + return user; + } + + async suspendUser(id: string, suspendedUntil?: Date | null): Promise { + return this.updateSuspension(id, { + isSuspended: true, + suspendedUntil: suspendedUntil ?? null, + }); + } + + async unsuspendUser(id: string): Promise { + return this.updateSuspension(id, { + isSuspended: false, + suspendedUntil: null, + }); + } + + async deleteUser(requestingUserId: string, targetUserId: string): Promise { + if (requestingUserId === targetUserId) { + throw new BusinessRuleError("Cannot delete yourself"); + } + await this.userRepo.delete(targetUserId); + } +} diff --git a/apps/backend/src/config/constants.ts b/apps/backend/src/config/constants.ts new file mode 100644 index 0000000..91ab7fa --- /dev/null +++ b/apps/backend/src/config/constants.ts @@ -0,0 +1,36 @@ +export const API_KEY_PREFIXES = { + SERVER: "minikura_server_api_key_", + REVERSE_PROXY: "minikura_reverse_proxy_server_api_key_", +} as const; + +export const DEFAULT_PORTS = { + MINECRAFT: 25565, +} as const; + +export const DEFAULT_MEMORY = { + SERVER: 2048, // MB + REVERSE_PROXY: 512, // MB +} as const; + +export const DEFAULT_MEMORY_REQUEST = { + SERVER: 1024, // MB + REVERSE_PROXY: 512, // MB +} as const; + +export const DEFAULT_CPU = { + SERVER: { + REQUEST: "500m", + LIMIT: "2", + }, + REVERSE_PROXY: { + REQUEST: "250m", + LIMIT: "500m", + }, +} as const; + +export const VALIDATION = { + ID_PATTERN: /^[a-zA-Z0-9-_]+$/, + ID_ERROR_MESSAGE: "ID must be alphanumeric with - or _", + PORT_MIN: 1, + PORT_MAX: 65535, +} as const; diff --git a/apps/backend/src/domain/entities/enums.ts b/apps/backend/src/domain/entities/enums.ts new file mode 100644 index 0000000..6298b5b --- /dev/null +++ b/apps/backend/src/domain/entities/enums.ts @@ -0,0 +1,31 @@ +import { + MinecraftServerJarType, + GameMode as PrismaGameMode, + ServerDifficulty as PrismaServerDifficulty, + ServerType as PrismaServerType, + ServiceType as PrismaServiceType, + ReverseProxyServerType, +} from "@minikura/db"; + +export enum UserRole { + ADMIN = "admin", + USER = "user", +} + +export const MinecraftJarType = MinecraftServerJarType; +export type MinecraftJarType = (typeof MinecraftJarType)[keyof typeof MinecraftJarType]; + +export const ReverseProxyType = ReverseProxyServerType; +export type ReverseProxyType = (typeof ReverseProxyType)[keyof typeof ReverseProxyType]; + +export const ServerType = PrismaServerType; +export type ServerType = (typeof ServerType)[keyof typeof ServerType]; + +export const ServiceType = PrismaServiceType; +export type ServiceType = (typeof ServiceType)[keyof typeof ServiceType]; + +export const ServerDifficulty = PrismaServerDifficulty; +export type ServerDifficulty = (typeof ServerDifficulty)[keyof typeof ServerDifficulty]; + +export const GameMode = PrismaGameMode; +export type GameMode = (typeof GameMode)[keyof typeof GameMode]; diff --git a/apps/backend/src/domain/errors/base.error.ts b/apps/backend/src/domain/errors/base.error.ts new file mode 100644 index 0000000..ac6d6fd --- /dev/null +++ b/apps/backend/src/domain/errors/base.error.ts @@ -0,0 +1,75 @@ +export abstract class DomainError extends Error { + abstract readonly code: string; + abstract readonly statusCode: number; + + constructor(message: string) { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } + + toJSON() { + return { + name: this.name, + code: this.code, + message: this.message, + statusCode: this.statusCode, + }; + } +} + +export class NotFoundError extends DomainError { + readonly code = "NOT_FOUND"; + readonly statusCode = 404; + + constructor(resource: string, identifier?: string) { + super(identifier ? `${resource} not found: ${identifier}` : `${resource} not found`); + } +} + +export class ConflictError extends DomainError { + readonly code = "CONFLICT"; + readonly statusCode = 409; + + constructor(resource: string, identifier?: string) { + super( + identifier ? `${resource} already exists: ${identifier}` : `${resource} already exists` + ); + } +} + +export class UnauthorizedError extends DomainError { + readonly code = "UNAUTHORIZED"; + readonly statusCode = 401; + + constructor(message = "Unauthorized access") { + super(message); + } +} + +export class ForbiddenError extends DomainError { + readonly code = "FORBIDDEN"; + readonly statusCode = 403; + + constructor(message = "Forbidden access") { + super(message); + } +} + +export class ValidationError extends DomainError { + readonly code = "VALIDATION_ERROR"; + readonly statusCode = 400; + + constructor(message: string) { + super(message); + } +} + +export class BusinessRuleError extends DomainError { + readonly code = "BUSINESS_RULE_VIOLATION"; + readonly statusCode = 422; + + constructor(message: string) { + super(message); + } +} diff --git a/apps/backend/src/domain/events/domain-event.ts b/apps/backend/src/domain/events/domain-event.ts new file mode 100644 index 0000000..0969934 --- /dev/null +++ b/apps/backend/src/domain/events/domain-event.ts @@ -0,0 +1,9 @@ +export abstract class DomainEvent { + readonly occurredAt: Date; + readonly eventId: string; + + constructor() { + this.occurredAt = new Date(); + this.eventId = crypto.randomUUID(); + } +} diff --git a/apps/backend/src/domain/events/reverse-proxy-lifecycle.events.ts b/apps/backend/src/domain/events/reverse-proxy-lifecycle.events.ts new file mode 100644 index 0000000..14c7492 --- /dev/null +++ b/apps/backend/src/domain/events/reverse-proxy-lifecycle.events.ts @@ -0,0 +1,28 @@ +import { DomainEvent } from "./domain-event"; +import type { ReverseProxyType } from "../entities/enums"; +import type { ReverseProxyCreateInput, ReverseProxyUpdateInput } from "../repositories/reverse-proxy.repository"; + +export class ReverseProxyCreatedEvent extends DomainEvent { + constructor( + public readonly proxyId: string, + public readonly proxyType: ReverseProxyType, + public readonly config: ReverseProxyCreateInput + ) { + super(); + } +} + +export class ReverseProxyUpdatedEvent extends DomainEvent { + constructor( + public readonly proxyId: string, + public readonly changes: ReverseProxyUpdateInput + ) { + super(); + } +} + +export class ReverseProxyDeletedEvent extends DomainEvent { + constructor(public readonly proxyId: string) { + super(); + } +} diff --git a/apps/backend/src/domain/events/server-lifecycle.events.ts b/apps/backend/src/domain/events/server-lifecycle.events.ts new file mode 100644 index 0000000..ed48fd2 --- /dev/null +++ b/apps/backend/src/domain/events/server-lifecycle.events.ts @@ -0,0 +1,43 @@ +import type { ServerType } from "../entities/enums"; +import type { ServerCreateInput, ServerUpdateInput } from "../repositories/server.repository"; +import { DomainEvent } from "./domain-event"; + +export class ServerCreatedEvent extends DomainEvent { + constructor( + public readonly serverId: string, + public readonly serverType: ServerType, + public readonly config: ServerCreateInput + ) { + super(); + } +} + +export class ServerUpdatedEvent extends DomainEvent { + constructor( + public readonly serverId: string, + public readonly changes: ServerUpdateInput + ) { + super(); + } +} + +export class ServerDeletedEvent extends DomainEvent { + constructor(public readonly serverId: string) { + super(); + } +} + +export class UserSuspendedEvent extends DomainEvent { + constructor( + public readonly userId: string, + public readonly suspendedUntil: Date | null + ) { + super(); + } +} + +export class UserUnsuspendedEvent extends DomainEvent { + constructor(public readonly userId: string) { + super(); + } +} diff --git a/apps/backend/src/domain/repositories/reverse-proxy.repository.ts b/apps/backend/src/domain/repositories/reverse-proxy.repository.ts new file mode 100644 index 0000000..d3f8133 --- /dev/null +++ b/apps/backend/src/domain/repositories/reverse-proxy.repository.ts @@ -0,0 +1,22 @@ +import type { EnvVariable, ReverseProxyWithEnvVars } from "@minikura/db"; +import type { z } from "zod"; +import type { + createReverseProxySchema, + updateReverseProxySchema, +} from "../../schemas/server.schema"; + +export type ReverseProxyCreateInput = z.infer; +export type ReverseProxyUpdateInput = z.infer; + +export interface ReverseProxyRepository { + findById(id: string, omitSensitive?: boolean): Promise; + findAll(omitSensitive?: boolean): Promise; + exists(id: string): Promise; + create(input: ReverseProxyCreateInput): Promise; + update(id: string, input: ReverseProxyUpdateInput): Promise; + delete(id: string): Promise; + setEnvVariable(proxyId: string, key: string, value: string): Promise; + getEnvVariables(proxyId: string): Promise; + deleteEnvVariable(proxyId: string, key: string): Promise; + replaceEnvVariables(proxyId: string, envVars: EnvVariable[]): Promise; +} diff --git a/apps/backend/src/domain/repositories/server.repository.ts b/apps/backend/src/domain/repositories/server.repository.ts new file mode 100644 index 0000000..9714843 --- /dev/null +++ b/apps/backend/src/domain/repositories/server.repository.ts @@ -0,0 +1,19 @@ +import type { EnvVariable, Server, ServerWithEnvVars } from "@minikura/db"; +import type { z } from "zod"; +import type { createServerSchema, updateServerSchema } from "../../schemas/server.schema"; + +export type ServerCreateInput = z.infer; +export type ServerUpdateInput = z.infer; + +export interface ServerRepository { + findById(id: string, omitSensitive?: boolean): Promise; + findAll(omitSensitive?: boolean): Promise; + exists(id: string): Promise; + create(input: ServerCreateInput): Promise; + update(id: string, input: ServerUpdateInput): Promise; + delete(id: string): Promise; + setEnvVariable(serverId: string, key: string, value: string): Promise; + getEnvVariables(serverId: string): Promise; + deleteEnvVariable(serverId: string, key: string): Promise; + replaceEnvVariables(serverId: string, envVars: EnvVariable[]): Promise; +} diff --git a/apps/backend/src/domain/repositories/user.repository.ts b/apps/backend/src/domain/repositories/user.repository.ts new file mode 100644 index 0000000..f9a25ea --- /dev/null +++ b/apps/backend/src/domain/repositories/user.repository.ts @@ -0,0 +1,11 @@ +import type { UpdateSuspensionInput, UpdateUserInput, User } from "@minikura/db"; + +export interface UserRepository { + findById(id: string): Promise; + findByEmail(email: string): Promise; + findAll(): Promise; + update(id: string, input: UpdateUserInput): Promise; + updateSuspension(id: string, input: UpdateSuspensionInput): Promise; + delete(id: string): Promise; + count(): Promise; +} diff --git a/apps/backend/src/domain/value-objects/api-key.vo.ts b/apps/backend/src/domain/value-objects/api-key.vo.ts new file mode 100644 index 0000000..1eecf93 --- /dev/null +++ b/apps/backend/src/domain/value-objects/api-key.vo.ts @@ -0,0 +1,33 @@ +export class ApiKey { + private static readonly SERVER_PREFIX = "minikura_srv_"; + private static readonly REVERSE_PROXY_PREFIX = "minikura_proxy_"; + private static readonly TOKEN_BYTES = 32; + + private constructor(private readonly value: string) {} + + static generate(type: "server" | "reverse-proxy"): ApiKey { + const prefix = type === "server" ? ApiKey.SERVER_PREFIX : ApiKey.REVERSE_PROXY_PREFIX; + const token = Buffer.from(crypto.randomUUID()) + .toString("base64") + .replace(/[^a-zA-Z0-9]/g, "") + .substring(0, ApiKey.TOKEN_BYTES); + return new ApiKey(`${prefix}${token}`); + } + + static validate(value: string): boolean { + const patterns = [ + new RegExp(`^${ApiKey.SERVER_PREFIX}[a-zA-Z0-9]{32}$`), + new RegExp(`^${ApiKey.REVERSE_PROXY_PREFIX}[a-zA-Z0-9]{32}$`), + ]; + return patterns.some((pattern) => pattern.test(value)); + } + + toString(): string { + return this.value; + } + + getType(): "server" | "reverse-proxy" { + if (this.value.startsWith(ApiKey.SERVER_PREFIX)) return "server"; + return "reverse-proxy"; + } +} diff --git a/apps/backend/src/domain/value-objects/k8s-connection-info.vo.ts b/apps/backend/src/domain/value-objects/k8s-connection-info.vo.ts new file mode 100644 index 0000000..c2f6cc3 --- /dev/null +++ b/apps/backend/src/domain/value-objects/k8s-connection-info.vo.ts @@ -0,0 +1,15 @@ +export class K8sConnectionInfo { + constructor( + public readonly host: string, + public readonly port: number, + public readonly namespace: string + ) {} + + toUrl(): string { + return `${this.host}:${this.port}`; + } + + toConnectionString(): string { + return `Host: ${this.host}, Port: ${this.port}, Namespace: ${this.namespace}`; + } +} diff --git a/apps/backend/src/domain/value-objects/server-config.vo.ts b/apps/backend/src/domain/value-objects/server-config.vo.ts new file mode 100644 index 0000000..3dcfccc --- /dev/null +++ b/apps/backend/src/domain/value-objects/server-config.vo.ts @@ -0,0 +1,39 @@ +export class ServerConfig { + constructor( + public readonly memory: number, + public readonly memoryRequest: number, + public readonly cpuRequest: string, + public readonly cpuLimit: string, + public readonly jvmOpts: string | null + ) {} + + static fromDefaults(): ServerConfig { + return new ServerConfig(2048, 1024, "250m", "500m", null); + } + + static fromInput(input: { + memory?: number; + memoryRequest?: number; + cpuRequest?: string; + cpuLimit?: string; + jvmOpts?: string; + }): ServerConfig { + return new ServerConfig( + input.memory ?? 2048, + input.memoryRequest ?? 1024, + input.cpuRequest ?? "250m", + input.cpuLimit ?? "500m", + input.jvmOpts ?? null + ); + } + + getJvmArgs(): string { + const args: string[] = ["-Xmx" + this.memory + "M"]; + + if (this.jvmOpts) { + args.push(this.jvmOpts); + } + + return args.join(" "); + } +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 0fbcd20..6589d83 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,650 +1,41 @@ import { dotenvLoad } from "dotenv-mono"; -const dotenv = dotenvLoad(); -import { Elysia, error, t } from "elysia"; -import { swagger } from "@elysiajs/swagger"; -import { prisma, ServerType } from "@minikura/db"; +dotenvLoad(); -import { ServerService } from "./services/server"; -import { UserService } from "./services/user"; -import argon2 from "argon2"; -import { SessionService } from "./services/session"; +import { Elysia } from "elysia"; +import { auth } from "./lib/auth"; +import { authPlugin } from "./lib/auth-plugin"; +import { errorHandler } from "./lib/error-handler"; +import { bootstrapRoutes } from "./routes/bootstrap"; +import { k8sRoutes } from "./routes/k8s"; +import { reverseProxyRoutes } from "./routes/reverse-proxy.routes"; +import { serverRoutes } from "./routes/servers"; +import { terminalRoutes } from "./routes/terminal"; +import { userRoutes } from "./routes/users"; -enum ReturnError { - INVALID_USERNAME_OR_PASSWORD = "INVALID_USERNAME_OR_PASSWORD", - MISSING_TOKEN = "MISSING_TOKEN", - REVOKED_TOKEN = "REVOKED_TOKEN", - EXPIRED_TOKEN = "EXPIRED_TOKEN", - INVALID_TOKEN = "INVALID_TOKEN", - SERVER_NAME_IN_USE = "SERVER_NAME_IN_USE", - SERVER_NOT_FOUND = "SERVER_NOT_FOUND", -} - -const bootstrap = async () => { - const users = await prisma.user.findMany(); - if (users.length !== 0) { - return; - } - - await prisma.user.create({ - data: { - username: "admin", - password: await argon2.hash("admin"), - }, - }); - - console.log("Default user created"); -}; - - -const connectedClients = new Set(); -const broadcastServerChange = (action: string, serverType: string, serverId: string) => { - const message = { - type: "SERVER_CHANGE", - action, - serverType, - serverId, - timestamp: new Date().toISOString(), - }; - - connectedClients.forEach(client => { - try { - client.send(JSON.stringify(message)); - } catch (error) { - console.error("Error sending WebSocket message:", error); - connectedClients.delete(client); - } - }); - console.log(`Notified Velocity proxy: ${action} ${serverType} ${serverId}`); -}; +// Register event handlers +import "./infrastructure/event-handlers"; const app = new Elysia() - .use(swagger({ - path: '/swagger', - documentation: { - info: { - title: 'Minikura API Documentation', - version: '1.0.0' - } - } - })) - .ws("/ws", { - open(ws) { - const apiKey = ws.data.query.apiKey; - if (!apiKey) { - console.log("apiKey required"); - ws.close(); - return; - } - - connectedClients.add(ws); - console.log("Velocity proxy connected via WebSocket"); - }, - close(ws) { - connectedClients.delete(ws); - console.log("Velocity proxy disconnected from WebSocket"); - }, - message(ws, message) { - console.log("Received message from Velocity proxy:", message); - }, + .use(errorHandler) + .onRequest(({ set }) => { + const origin = process.env.WEB_URL || "http://localhost:3001"; + set.headers["Access-Control-Allow-Origin"] = origin; + set.headers["Access-Control-Allow-Credentials"] = "true"; + set.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"; + set.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, Cookie"; }) - .group('/api', app => app - .derive(async ({ headers, cookie: { session_token }, path }) => { - // Skip token validation for login route - if (path === '/api/login') { - return { - server: null, - session: null, - }; - } - - const auth = session_token.value; - const token = headers.authorization?.split(" ")[1]; - - if (!auth && !token) - return error("Unauthorized", { - success: false, - message: ReturnError.MISSING_TOKEN, - }); - - if (auth) { - const session = await SessionService.validate(auth); - if (session.status === SessionService.SESSION_STATUS.REVOKED) { - return error("Unauthorized", { - success: false, - message: ReturnError.REVOKED_TOKEN, - }); - } - if (session.status === SessionService.SESSION_STATUS.EXPIRED) { - return error("Unauthorized", { - success: false, - message: ReturnError.EXPIRED_TOKEN, - }); - } - if ( - session.status === SessionService.SESSION_STATUS.INVALID || - session.status !== SessionService.SESSION_STATUS.VALID || - !session.session - ) { - return error("Unauthorized", { - success: false, - message: ReturnError.INVALID_TOKEN, - }); - } - - return { - server: null, - session: session.session, - }; - } - - if (token) { - const session = await SessionService.validateApiKey(token); - if (session.status === SessionService.SESSION_STATUS.INVALID) { - return error("Unauthorized", { - success: false, - message: ReturnError.INVALID_TOKEN, - }); - } - if ( - session.status === SessionService.SESSION_STATUS.VALID && - session.server - ) { - return { - session: null, - server: session.server, - }; - } - } - - // Should never reach here - return error("Unauthorized", { - success: false, - 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 }), - }), - } - ) - .post("/logout", async ({ session, cookie: { session_token } }) => { - if (!session) return { success: true }; - - await SessionService.revoke(session.token); - - session_token.remove(); - - return { - success: true, - }; - }) - .get("/servers", async ({ session }) => { - // Broadcast to all connected WebSocket clients - const message = { - type: "test", - endpoint: "/servers", - timestamp: new Date().toISOString(), - }; - - connectedClients.forEach(client => { - try { - client.send(JSON.stringify(message)); - } catch (error) { - console.error("Error sending WebSocket message:", error); - connectedClients.delete(client); - } - }); - - console.log(`/servers API called, notified ${connectedClients.size} WebSocket clients`); - - return await ServerService.getAllServers(!session); - }) - .get("/servers/:id", async ({ session, params: { id } }) => { - return await ServerService.getServerById(id, !session); - }) - .post( - "/servers", - async ({ body, error }) => { - // 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.getServerById(body.id); - if (_server) { - return error("Conflict", { - success: false, - message: ReturnError.SERVER_NAME_IN_USE, - }); - } - - const server = await ServerService.createServer({ - id: body.id, - description: body.description, - listen_port: body.listen_port, - type: body.type, - env_variables: body.env_variables, - memory: body.memory, - }); - - broadcastServerChange("CREATE", "SERVER", server.id,); - - return { - success: true, - data: { - server, - }, - }; - }, - { - body: t.Object({ - id: t.String({ minLength: 1 }), - description: t.Nullable(t.String({ minLength: 1 })), - listen_port: t.Integer({ minimum: 1, maximum: 65535 }), - type: t.Enum(ServerType), - env_variables: t.Optional(t.Array(t.Object({ - key: t.String({ minLength: 1 }), - value: t.String(), - }))), - memory: t.Optional(t.String({ minLength: 1 })), - }), - } - ) - .patch( - "/servers/:id", - async ({ session, params: { id }, body }) => { - const server = await ServerService.getServerById(id); - if (!server) { - return error("Not Found", { - success: false, - message: ReturnError.SERVER_NOT_FOUND, - }); - } - - // Create update data with only fields that exist in the model - const data: any = {}; - - 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 }, - data, - }); - - const newServer = await ServerService.getServerById(id, !session); - - broadcastServerChange("UPDATE", "SERVER", server.id); - - return { - success: true, - data: { - server: newServer, - }, - }; - }, - { - body: t.Object({ - description: t.Optional(t.Nullable(t.String({ minLength: 1 }))), - listen_port: t.Optional(t.Integer({ minimum: 1, maximum: 65535 })), - memory: t.Optional(t.String({ minLength: 1 })), - }), - } - ) - .delete("/servers/:id", async ({ params: { id } }) => { - const server = await ServerService.getServerById(id); - if (!server) { - return error("Not Found", { - success: false, - message: ReturnError.SERVER_NOT_FOUND, - }); - } - - await prisma.server.delete({ - where: { id }, - }); - - broadcastServerChange("DELETE", "SERVER", server.id); - - return { - success: true, - }; - }) - .get("/reverse_proxy_servers", async ({ session }) => { - return await ServerService.getAllReverseProxyServers(!session); - }) - .post( - "/reverse_proxy_servers", - async ({ body, error }) => { - // 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.getReverseProxyServerById(body.id); - if (_server) { - return error("Conflict", { - success: false, - message: ReturnError.SERVER_NAME_IN_USE, - }); - } - - const server = await ServerService.createReverseProxyServer({ - 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, - }); - - broadcastServerChange("CREATE", "REVERSE_PROXY_SERVER", server.id); - - return { - success: true, - data: { - server, - }, - }; - }, - { - body: t.Object({ - id: t.String({ minLength: 1 }), - description: t.Nullable(t.String({ minLength: 1 })), - 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 })), - }), - } - ) - .patch( - "/reverse_proxy_servers/:id", - async ({ session, params: { id }, body }) => { - const server = await prisma.reverseProxyServer.findUnique({ - where: { id }, - }); - if (!server) { - return error("Not Found", { - success: false, - message: ReturnError.SERVER_NOT_FOUND, - }); - } - - // Create update data with only fields that exist in the model - const data: any = {}; - - 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 }, - data, - }); - - const newServer = await ServerService.getReverseProxyServerById( - id, - !session - ); - - broadcastServerChange("UPDATE", "REVERSE_PROXY_SERVER", server.id); - - return { - success: true, - data: { - server: newServer, - }, - }; - }, - { - body: t.Object({ - description: t.Optional(t.Nullable(t.String({ minLength: 1 }))), - 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 })), - }), - } - ) - .delete("/reverse_proxy_servers/:id", async ({ params: { id } }) => { - const server = await prisma.reverseProxyServer.findUnique({ - where: { id }, - }); - if (!server) { - return error("Not Found", { - success: false, - message: ReturnError.SERVER_NOT_FOUND, - }); - } - - await prisma.reverseProxyServer.delete({ - where: { id }, - }); - - broadcastServerChange("DELETE", "REVERSE_PROXY_SERVER", server.id); - - return { - 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", - }); - } - }) + .options("/*", () => new Response(null, { status: 204 })) + .all("/auth/*", ({ request }) => auth.handler(request)) + .use(bootstrapRoutes) + .use(authPlugin) + .group("/api", (app) => + app.use(userRoutes).use(serverRoutes).use(reverseProxyRoutes).use(k8sRoutes).use(terminalRoutes) ) - .listen(3000, async () => { - console.log("Server is running on port 3000"); - bootstrap(); - }); + .get("/health", () => ({ status: "ok" })); export type App = typeof app; + +app.listen(3000, () => { + console.log("Server running on http://localhost:3000"); +}); diff --git a/apps/backend/src/infrastructure/api-key-generator.ts b/apps/backend/src/infrastructure/api-key-generator.ts new file mode 100644 index 0000000..f5a471a --- /dev/null +++ b/apps/backend/src/infrastructure/api-key-generator.ts @@ -0,0 +1,26 @@ +export interface ApiKeyGenerator { + generateServerApiKey(): string; + generateReverseProxyApiKey(): string; +} + +export class ApiKeyGeneratorImpl implements ApiKeyGenerator { + private readonly SERVER_PREFIX = "minikura_srv_"; + private readonly REVERSE_PROXY_PREFIX = "minikura_proxy_"; + private readonly TOKEN_LENGTH = 32; + + generateServerApiKey(): string { + const token = Buffer.from(crypto.randomUUID()) + .toString("base64") + .replace(/[^a-zA-Z0-9]/g, "") + .substring(0, this.TOKEN_LENGTH); + return `${this.SERVER_PREFIX}${token}`; + } + + generateReverseProxyApiKey(): string { + const token = Buffer.from(crypto.randomUUID()) + .toString("base64") + .replace(/[^a-zA-Z0-9]/g, "") + .substring(0, this.TOKEN_LENGTH); + return `${this.REVERSE_PROXY_PREFIX}${token}`; + } +} diff --git a/apps/backend/src/infrastructure/event-bus.ts b/apps/backend/src/infrastructure/event-bus.ts new file mode 100644 index 0000000..45ba608 --- /dev/null +++ b/apps/backend/src/infrastructure/event-bus.ts @@ -0,0 +1,45 @@ +import type { DomainEvent } from "../domain/events/domain-event"; + +type EventHandler = (event: T) => void | Promise; + +export class EventBus { + private handlers = new Map>(); + private eventHistory: DomainEvent[] = []; + + subscribe( + eventClass: { new (...args: any[]): T }, + handler: EventHandler + ): () => void { + const eventName = eventClass.name; + if (!this.handlers.has(eventName)) { + this.handlers.set(eventName, new Set()); + } + this.handlers.get(eventName)!.add(handler as EventHandler); + return () => { + this.handlers.get(eventName)?.delete(handler as EventHandler); + }; + } + + async publish(event: T): Promise { + this.eventHistory.push(event); + const eventName = event.constructor.name; + const handlers = this.handlers.get(eventName) || []; + for (const handler of handlers) { + try { + await handler(event); + } catch (error) { + console.error(`[EventBus] Error in handler for ${eventName}:`, error); + } + } + } + + getHistory(): DomainEvent[] { + return [...this.eventHistory]; + } + + clearHistory(): void { + this.eventHistory = []; + } +} + +export const eventBus = new EventBus(); diff --git a/apps/backend/src/infrastructure/event-handlers/index.ts b/apps/backend/src/infrastructure/event-handlers/index.ts new file mode 100644 index 0000000..66b2a9b --- /dev/null +++ b/apps/backend/src/infrastructure/event-handlers/index.ts @@ -0,0 +1,4 @@ +import "./server-event.handler"; +import "./user-event.handler"; + +console.log("[EventBus] All event handlers registered"); diff --git a/apps/backend/src/infrastructure/event-handlers/server-event.handler.ts b/apps/backend/src/infrastructure/event-handlers/server-event.handler.ts new file mode 100644 index 0000000..e3b6322 --- /dev/null +++ b/apps/backend/src/infrastructure/event-handlers/server-event.handler.ts @@ -0,0 +1,22 @@ +import { + ServerCreatedEvent, + ServerDeletedEvent, + ServerUpdatedEvent, +} from "../../domain/events/server-lifecycle.events"; +import { eventBus } from "../event-bus"; +import { wsService } from "../../application/di-container"; + +eventBus.subscribe(ServerCreatedEvent, async (event) => { + console.log(`[Event] Server created: ${event.serverId} (${event.serverType})`); + wsService.broadcast("create", event.serverType, event.serverId); +}); + +eventBus.subscribe(ServerUpdatedEvent, async (event) => { + console.log(`[Event] Server updated: ${event.serverId}`); + wsService.broadcast("update", "server", event.serverId); +}); + +eventBus.subscribe(ServerDeletedEvent, async (event) => { + console.log(`[Event] Server deleted: ${event.serverId}`); + wsService.broadcast("delete", "server", event.serverId); +}); diff --git a/apps/backend/src/infrastructure/event-handlers/user-event.handler.ts b/apps/backend/src/infrastructure/event-handlers/user-event.handler.ts new file mode 100644 index 0000000..0373795 --- /dev/null +++ b/apps/backend/src/infrastructure/event-handlers/user-event.handler.ts @@ -0,0 +1,19 @@ +import { + UserSuspendedEvent, + UserUnsuspendedEvent, +} from "../../domain/events/server-lifecycle.events"; +import { eventBus } from "../event-bus"; + +eventBus.subscribe(UserSuspendedEvent, async (event) => { + if (event.suspendedUntil) { + console.log( + `[Event] User suspended: ${event.userId} until ${event.suspendedUntil.toISOString()}` + ); + } else { + console.log(`[Event] User suspended: ${event.userId} indefinitely`); + } +}); + +eventBus.subscribe(UserUnsuspendedEvent, async (event) => { + console.log(`[Event] User unsuspended: ${event.userId}`); +}); diff --git a/apps/backend/src/infrastructure/repositories/prisma/reverse-proxy.repository.impl.ts b/apps/backend/src/infrastructure/repositories/prisma/reverse-proxy.repository.impl.ts new file mode 100644 index 0000000..fa6df35 --- /dev/null +++ b/apps/backend/src/infrastructure/repositories/prisma/reverse-proxy.repository.impl.ts @@ -0,0 +1,184 @@ +import { type EnvVariable, prisma, type ReverseProxyWithEnvVars } from "@minikura/db"; +import { ConflictError, NotFoundError } from "../../../domain/errors/base.error"; +import type { + ReverseProxyCreateInput, + ReverseProxyRepository, + ReverseProxyUpdateInput, +} from "../../../domain/repositories/reverse-proxy.repository"; +import { ApiKeyGeneratorImpl } from "../../api-key-generator"; + +export class PrismaReverseProxyRepository implements ReverseProxyRepository { + private apiKeyGenerator = new ApiKeyGeneratorImpl(); + + async findById(id: string, omitSensitive = false): Promise { + const proxy = await prisma.reverseProxyServer.findUnique({ + where: { id }, + include: { env_variables: true }, + }); + + if (!proxy) return null; + + if (omitSensitive) { + const { api_key, ...rest } = proxy; + return { ...rest, api_key: "" } as ReverseProxyWithEnvVars; + } + + return proxy; + } + + async findAll(omitSensitive = false): Promise { + const proxies = await prisma.reverseProxyServer.findMany({ + include: { env_variables: true }, + }); + + if (omitSensitive) { + return proxies.map((proxy) => { + const { api_key, ...rest } = proxy; + return { ...rest, api_key: "" } as ReverseProxyWithEnvVars; + }); + } + + return proxies; + } + + async exists(id: string): Promise { + const proxy = await prisma.reverseProxyServer.findUnique({ + where: { id }, + select: { id: true }, + }); + return proxy !== null; + } + + async create(input: ReverseProxyCreateInput): Promise { + const existing = await this.exists(input.id); + if (existing) { + throw new ConflictError("ReverseProxyServer", input.id); + } + + const token = this.apiKeyGenerator.generateReverseProxyApiKey(); + + const proxy = await prisma.reverseProxyServer.create({ + data: { + id: input.id, + type: input.type ?? "VELOCITY", + description: input.description ?? null, + external_address: input.external_address, + external_port: input.external_port, + listen_port: input.listen_port ?? 25577, + service_type: input.service_type ?? "LOAD_BALANCER", + node_port: input.node_port ?? null, + memory: input.memory ?? 512, + cpu_request: input.cpu_request ?? "100m", + cpu_limit: input.cpu_limit ?? "200m", + api_key: token, + env_variables: input.env_variables + ? { + create: input.env_variables.map((ev) => ({ + key: ev.key, + value: ev.value, + })), + } + : undefined, + }, + include: { env_variables: true }, + }); + + return proxy; + } + + async update(id: string, input: ReverseProxyUpdateInput): Promise { + const proxy = await prisma.reverseProxyServer.findUnique({ + where: { id }, + }); + + if (!proxy) { + throw new NotFoundError("ReverseProxyServer", id); + } + + // Update proxy fields + const updated = await prisma.reverseProxyServer.update({ + where: { id }, + data: { + description: input.description, + external_address: input.external_address, + external_port: input.external_port, + listen_port: input.listen_port, + type: input.type, + service_type: input.service_type, + node_port: input.node_port, + memory: input.memory, + cpu_request: input.cpu_request, + cpu_limit: input.cpu_limit, + }, + include: { env_variables: true }, + }); + + return updated; + } + + async delete(id: string): Promise { + await prisma.reverseProxyServer.delete({ + where: { id }, + }); + } + + async setEnvVariable(proxyId: string, key: string, value: string): Promise { + await prisma.customEnvironmentVariable.upsert({ + where: { + key_reverse_proxy_id: { + key, + reverse_proxy_id: proxyId, + }, + }, + update: { + value, + }, + create: { + key, + value, + reverse_proxy_id: proxyId, + }, + }); + } + + async getEnvVariables(proxyId: string): Promise { + const proxy = await prisma.reverseProxyServer.findUnique({ + where: { id: proxyId }, + include: { env_variables: true }, + }); + + if (!proxy) { + throw new NotFoundError("ReverseProxyServer", proxyId); + } + + return proxy.env_variables.map((ev) => ({ + key: ev.key, + value: ev.value, + })); + } + + async deleteEnvVariable(proxyId: string, key: string): Promise { + await prisma.customEnvironmentVariable.deleteMany({ + where: { + key, + reverse_proxy_id: proxyId, + }, + }); + } + + async replaceEnvVariables(proxyId: string, envVars: EnvVariable[]): Promise { + await prisma.customEnvironmentVariable.deleteMany({ + where: { reverse_proxy_id: proxyId }, + }); + + if (envVars.length > 0) { + await prisma.customEnvironmentVariable.createMany({ + data: envVars.map((envVar) => ({ + key: envVar.key, + value: envVar.value, + reverse_proxy_id: proxyId, + })), + }); + } + } +} diff --git a/apps/backend/src/infrastructure/repositories/prisma/server.repository.impl.ts b/apps/backend/src/infrastructure/repositories/prisma/server.repository.impl.ts new file mode 100644 index 0000000..b07fb13 --- /dev/null +++ b/apps/backend/src/infrastructure/repositories/prisma/server.repository.impl.ts @@ -0,0 +1,212 @@ +import { type EnvVariable, prisma, type ServerWithEnvVars } from "@minikura/db"; +import { ConflictError, NotFoundError } from "../../../domain/errors/base.error"; +import type { + ServerCreateInput, + ServerRepository, + ServerUpdateInput, +} from "../../../domain/repositories/server.repository"; +import { ApiKeyGeneratorImpl } from "../../api-key-generator"; + +export class PrismaServerRepository implements ServerRepository { + private apiKeyGenerator = new ApiKeyGeneratorImpl(); + + async findById(id: string, omitSensitive = false): Promise { + const server = await prisma.server.findUnique({ + where: { id }, + include: { env_variables: true }, + }); + + if (!server) return null; + + if (omitSensitive) { + const { api_key, ...rest } = server; + return { ...rest, api_key: "" } as ServerWithEnvVars; + } + + return server; + } + + async findAll(omitSensitive = false): Promise { + const servers = await prisma.server.findMany({ + include: { env_variables: true }, + }); + + if (omitSensitive) { + return servers.map((server) => { + const { api_key, ...rest } = server; + return { ...rest, api_key: "" } as ServerWithEnvVars; + }); + } + + return servers; + } + + async exists(id: string): Promise { + const server = await prisma.server.findUnique({ + where: { id }, + select: { id: true }, + }); + return server !== null; + } + + async create(input: ServerCreateInput): Promise { + const existing = await this.exists(input.id); + if (existing) { + throw new ConflictError("Server", input.id); + } + + const token = this.apiKeyGenerator.generateServerApiKey(); + + const server = await prisma.server.create({ + data: { + id: input.id, + type: input.type, + description: input.description ?? null, + listen_port: input.listen_port, + service_type: input.service_type ?? "CLUSTER_IP", + node_port: input.node_port ?? null, + memory: input.memory ?? 2048, + memory_request: input.memory_request ?? 1024, + cpu_request: input.cpu_request ?? "250m", + cpu_limit: input.cpu_limit ?? "500m", + jar_type: input.jar_type ?? "PAPER", + minecraft_version: input.minecraft_version ?? "LATEST", + jvm_opts: input.jvm_opts ?? null, + use_aikar_flags: input.use_aikar_flags ?? true, + use_meowice_flags: input.use_meowice_flags ?? false, + difficulty: input.difficulty ?? "EASY", + game_mode: input.game_mode ?? "SURVIVAL", + max_players: input.max_players ?? 20, + pvp: input.pvp ?? true, + online_mode: input.online_mode ?? true, + motd: input.motd ?? null, + level_seed: input.level_seed ?? null, + level_type: input.level_type ?? null, + api_key: token, + env_variables: input.env_variables + ? { + create: input.env_variables.map((ev) => ({ + key: ev.key, + value: ev.value, + })), + } + : undefined, + }, + include: { env_variables: true }, + }); + + return server; + } + + async update(id: string, input: ServerUpdateInput): Promise { + const server = await prisma.server.findUnique({ + where: { id }, + }); + + if (!server) { + throw new NotFoundError("Server", id); + } + + // Handle env variables separately + if (input.env_variables !== undefined) { + await this.replaceEnvVariables(id, input.env_variables); + } + + // Update server fields + const updated = await prisma.server.update({ + where: { id }, + data: { + description: input.description, + listen_port: input.listen_port, + service_type: input.service_type, + node_port: input.node_port, + memory: input.memory, + memory_request: input.memory_request, + cpu_request: input.cpu_request, + cpu_limit: input.cpu_limit, + jar_type: input.jar_type, + minecraft_version: input.minecraft_version, + jvm_opts: input.jvm_opts, + use_aikar_flags: input.use_aikar_flags, + use_meowice_flags: input.use_meowice_flags, + difficulty: input.difficulty, + game_mode: input.game_mode, + max_players: input.max_players, + pvp: input.pvp, + online_mode: input.online_mode, + motd: input.motd, + level_seed: input.level_seed, + level_type: input.level_type, + }, + include: { env_variables: true }, + }); + + return updated; + } + + async delete(id: string): Promise { + await prisma.server.delete({ + where: { id }, + }); + } + + async setEnvVariable(serverId: string, key: string, value: string): Promise { + await prisma.customEnvironmentVariable.upsert({ + where: { + key_server_id: { + key, + server_id: serverId, + }, + }, + update: { + value, + }, + create: { + key, + value, + server_id: serverId, + }, + }); + } + + async getEnvVariables(serverId: string): Promise { + const server = await prisma.server.findUnique({ + where: { id: serverId }, + include: { env_variables: true }, + }); + + if (!server) { + throw new NotFoundError("Server", serverId); + } + + return server.env_variables.map((ev) => ({ + key: ev.key, + value: ev.value, + })); + } + + async deleteEnvVariable(serverId: string, key: string): Promise { + await prisma.customEnvironmentVariable.deleteMany({ + where: { + key, + server_id: serverId, + }, + }); + } + + async replaceEnvVariables(serverId: string, envVars: EnvVariable[]): Promise { + await prisma.customEnvironmentVariable.deleteMany({ + where: { server_id: serverId }, + }); + + if (envVars.length > 0) { + await prisma.customEnvironmentVariable.createMany({ + data: envVars.map((envVar) => ({ + key: envVar.key, + value: envVar.value, + server_id: serverId, + })), + }); + } + } +} diff --git a/apps/backend/src/infrastructure/repositories/prisma/user.repository.impl.ts b/apps/backend/src/infrastructure/repositories/prisma/user.repository.impl.ts new file mode 100644 index 0000000..5655eb4 --- /dev/null +++ b/apps/backend/src/infrastructure/repositories/prisma/user.repository.impl.ts @@ -0,0 +1,47 @@ +import { prisma, type UpdateSuspensionInput, type UpdateUserInput, type User } from "@minikura/db"; +import { UserRole } from "../../../domain/entities/enums"; +import type { UserRepository } from "../../../domain/repositories/user.repository"; + +export class PrismaUserRepository implements UserRepository { + async findById(id: string): Promise { + return await prisma.user.findUnique({ + where: { id }, + }); + } + + async findByEmail(email: string): Promise { + return await prisma.user.findUnique({ + where: { email }, + }); + } + + async findAll(): Promise { + return await prisma.user.findMany({ + orderBy: { createdAt: "desc" }, + }); + } + + async update(id: string, input: UpdateUserInput): Promise { + return await prisma.user.update({ + where: { id }, + data: input, + }); + } + + async updateSuspension(id: string, input: UpdateSuspensionInput): Promise { + return await prisma.user.update({ + where: { id }, + data: input, + }); + } + + async delete(id: string): Promise { + await prisma.user.delete({ + where: { id }, + }); + } + + async count(): Promise { + return await prisma.user.count(); + } +} diff --git a/apps/backend/src/lib/auth-plugin.ts b/apps/backend/src/lib/auth-plugin.ts new file mode 100644 index 0000000..cec3804 --- /dev/null +++ b/apps/backend/src/lib/auth-plugin.ts @@ -0,0 +1,44 @@ +import { isUserSuspended } from "@minikura/db"; +import { Elysia } from "elysia"; +import { auth } from "./auth"; + +async function getSessionFromHeaders(headers: Headers | Record) { + const headersObj = + headers instanceof Headers ? headers : new Headers(headers as Record); + + return auth.api.getSession({ + headers: headersObj, + }); +} + +export const authPlugin = new Elysia({ name: "auth" }) + .mount(auth.handler) + .derive({ as: "scoped" }, async ({ request }) => { + const session = await getSessionFromHeaders(request.headers); + + if ( + session?.user && + isUserSuspended( + session.user as unknown as Pick< + { isSuspended: boolean; suspendedUntil: Date | null }, + "isSuspended" | "suspendedUntil" + > + ) + ) { + return { + user: null, + session: null, + isAuthenticated: false, + isSuspended: true, + }; + } + + return { + user: session?.user || null, + session: session?.session || null, + isAuthenticated: Boolean(session?.user), + isSuspended: false, + }; + }); + +export type AuthPlugin = typeof authPlugin; diff --git a/apps/backend/src/lib/auth.ts b/apps/backend/src/lib/auth.ts new file mode 100644 index 0000000..363a915 --- /dev/null +++ b/apps/backend/src/lib/auth.ts @@ -0,0 +1,20 @@ +import { betterAuth } from "better-auth"; +import { prismaAdapter } from "better-auth/adapters/prisma"; +import { prisma } from "@minikura/db"; +import { admin, openAPI } from "better-auth/plugins"; + +export const auth = betterAuth({ + database: prismaAdapter(prisma, { + provider: "postgresql", + usePlural: false, + }), + emailAndPassword: { enabled: true }, + plugins: [admin(), openAPI()], + trustedOrigins: [process.env.WEB_URL || "http://localhost:3001"], + session: { + cookieCache: { enabled: true, maxAge: 60 * 5 }, + }, + basePath: "/auth", +}); + +export type Auth = typeof auth; diff --git a/apps/backend/src/lib/authorization.ts b/apps/backend/src/lib/authorization.ts new file mode 100644 index 0000000..339dd39 --- /dev/null +++ b/apps/backend/src/lib/authorization.ts @@ -0,0 +1,57 @@ +import type { User } from "@minikura/db"; +import type { Elysia } from "elysia"; +import { ForbiddenError, UnauthorizedError } from "../domain/errors/base.error"; + +export const requireAuth = (app: Elysia) => { + return app.derive((ctx: any) => { + const { user, isSuspended } = ctx as { + user: User | null; + isSuspended: boolean; + }; + if (!user) { + throw new UnauthorizedError(); + } + if (isSuspended) { + throw new ForbiddenError("Account is suspended"); + } + return { user }; + }); +}; + +export const requireAdmin = (app: Elysia) => { + return app.derive((ctx: any) => { + const { user, isSuspended } = ctx as { + user: User | null; + isSuspended: boolean; + }; + if (!user) { + throw new UnauthorizedError(); + } + if (isSuspended) { + throw new ForbiddenError("Account is suspended"); + } + if (user.role !== "admin") { + throw new ForbiddenError("Admin access required"); + } + return { user }; + }); +}; + +export const requireRole = (role: string) => (app: Elysia) => { + return app.derive((ctx: any) => { + const { user, isSuspended } = ctx as { + user: User | null; + isSuspended: boolean; + }; + if (!user) { + throw new UnauthorizedError(); + } + if (isSuspended) { + throw new ForbiddenError("Account is suspended"); + } + if (user.role !== role) { + throw new ForbiddenError(`${role} access required`); + } + return { user }; + }); +}; diff --git a/apps/backend/src/lib/error-handler.ts b/apps/backend/src/lib/error-handler.ts new file mode 100644 index 0000000..d8e768f --- /dev/null +++ b/apps/backend/src/lib/error-handler.ts @@ -0,0 +1,33 @@ +import type { Elysia } from "elysia"; +import { DomainError } from "../domain/errors/base.error"; + +export const errorHandler = (app: Elysia) => { + return app.onError(({ error, set }) => { + if (error instanceof DomainError) { + set.status = error.statusCode; + return { + success: false, + code: error.code, + message: error.message, + }; + } + + if (error instanceof Error && (error.name === "ValidationError" || error.name === "ZodError")) { + set.status = 400; + return { + success: false, + code: "VALIDATION_ERROR", + message: error.message, + }; + } + + console.error("Unhandled error:", error); + + set.status = 500; + return { + success: false, + code: "INTERNAL_SERVER_ERROR", + message: "An unexpected error occurred", + }; + }); +}; diff --git a/apps/backend/src/lib/errors.ts b/apps/backend/src/lib/errors.ts new file mode 100644 index 0000000..8e75ef0 --- /dev/null +++ b/apps/backend/src/lib/errors.ts @@ -0,0 +1,9 @@ +export const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + return "Unknown error"; +}; diff --git a/apps/backend/src/lib/fetch-patch.ts b/apps/backend/src/lib/fetch-patch.ts new file mode 100644 index 0000000..c38c6c4 --- /dev/null +++ b/apps/backend/src/lib/fetch-patch.ts @@ -0,0 +1,52 @@ +import * as https from "node:https"; +import * as k8s from "@kubernetes/client-node"; +import { Agent as UndiciAgent } from "undici"; + +let clientCert: string | undefined; +let clientKey: string | undefined; +let caCert: string | undefined; + +try { + const kc = new k8s.KubeConfig(); + kc.loadFromDefault(); + const user = kc.getCurrentUser(); + const cluster = kc.getCurrentCluster(); + + if (user?.certData) { + clientCert = Buffer.from(user.certData, "base64").toString(); + } + if (user?.keyData) { + clientKey = Buffer.from(user.keyData, "base64").toString(); + } + if (cluster?.caData) { + caCert = Buffer.from(cluster.caData, "base64").toString(); + } + + if (clientCert && clientKey) { + const OriginalAgent = https.Agent; + type UndiciOptions = ConstructorParameters[0]; + const httpsModule = https as typeof https & { Agent: typeof https.Agent }; + + httpsModule.Agent = class PatchedAgent extends OriginalAgent { + constructor(options?: https.AgentOptions) { + super(options); + + const undiciOptions: UndiciOptions = { + connect: { + cert: clientCert, + key: clientKey, + ca: caCert, + rejectUnauthorized: process.env.KUBERNETES_SKIP_TLS_VERIFY !== "true", + }, + }; + + const patched = this as unknown as { _undiciAgent?: UndiciAgent }; + patched._undiciAgent = new UndiciAgent(undiciOptions); + } + }; + + console.log("Patched https.Agent to use undici with client certificates"); + } +} catch (err) { + console.warn("Failed to patch https.Agent for Kubernetes:", err); +} diff --git a/apps/backend/src/lib/kube-auth.ts b/apps/backend/src/lib/kube-auth.ts new file mode 100644 index 0000000..1ae269a --- /dev/null +++ b/apps/backend/src/lib/kube-auth.ts @@ -0,0 +1,107 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { KubeConfig } from "@kubernetes/client-node"; +import { spawnSync } from "bun"; +import YAML from "yaml"; + +type KubeConfigDoc = { + users?: Array<{ name: string; user: { token?: string } }>; + contexts?: Array<{ + name: string; + context: { cluster: string; user: string; namespace?: string }; + }>; + clusters?: Array<{ name: string }>; +}; + +const SA_NAME = process.env.K8S_SA_NAME || "minikura-backend"; +const NAMESPACE = process.env.KUBERNETES_NAMESPACE || "minikura"; +const TOKEN_DURATION_HOURS = Number(process.env.K8S_TOKEN_DURATION_HOURS || 24); +const TOKEN_REFRESH_MIN = Number(process.env.K8S_TOKEN_REFRESH_MIN || 60); + +function kubeconfigPath(): string { + return process.env.KUBECONFIG || `${process.env.HOME || process.env.USERPROFILE}/.kube/config`; +} + +function refreshSaToken(): void { + const duration = `${TOKEN_DURATION_HOURS}h`; + const args = ["kubectl", "-n", NAMESPACE, "create", "token", SA_NAME, "--duration", duration]; + + if (process.env.KUBERNETES_SKIP_TLS_VERIFY === "true") { + args.push("--insecure-skip-tls-verify"); + } + + const proc = spawnSync(args); + + if (proc.exitCode !== 0) { + console.error("[kube-auth] kubectl create token failed:", proc.stderr.toString()); + return; + } + + const token = proc.stdout.toString().trim(); + const kcPath = kubeconfigPath(); + + if (!existsSync(kcPath)) { + console.error("[kube-auth] kubeconfig not found at:", kcPath); + return; + } + + const doc = YAML.parse(readFileSync(kcPath, "utf8")) as KubeConfigDoc; + + let user = doc.users?.find((existingUser) => existingUser.name === SA_NAME); + if (!user) { + user = { name: SA_NAME, user: {} }; + if (!doc.users) doc.users = []; + doc.users.push(user); + } + user.user = { token }; + + let ctx = doc.contexts?.find((context) => context.name === "bun-local"); + if (!ctx) { + const clusterName = doc.clusters?.[0]?.name || "default"; + ctx = { + name: "bun-local", + context: { + cluster: clusterName, + user: SA_NAME, + namespace: NAMESPACE, + }, + }; + if (!doc.contexts) doc.contexts = []; + doc.contexts.push(ctx); + } else { + ctx.context.user = SA_NAME; + ctx.context.namespace = NAMESPACE; + } + + writeFileSync(kcPath, YAML.stringify(doc)); + console.log( + `[kube-auth] kubeconfig updated with fresh token for ${SA_NAME} (expires in ${duration})` + ); +} + +export function buildKubeConfig(): KubeConfig { + const kc = new KubeConfig(); + + const isInCluster = + process.env.KUBERNETES_SERVICE_HOST && + existsSync("/var/run/secrets/kubernetes.io/serviceaccount/token"); + + if (isInCluster) { + console.log("[kube-auth] Running in-cluster, loading from service account"); + kc.loadFromCluster(); + return kc; + } + + console.log("[kube-auth] Running locally, using ServiceAccount token auth"); + refreshSaToken(); + + setInterval(refreshSaToken, TOKEN_REFRESH_MIN * 60_000); + + kc.loadFromDefault(); + try { + kc.setCurrentContext("bun-local"); + } catch (_error) { + console.warn("[kube-auth] Could not set bun-local context, using default"); + } + + return kc; +} diff --git a/apps/backend/src/lib/middleware.ts b/apps/backend/src/lib/middleware.ts new file mode 100644 index 0000000..f6ede4b --- /dev/null +++ b/apps/backend/src/lib/middleware.ts @@ -0,0 +1,29 @@ +type AuthContext = { + user?: { role?: string | null } | null; + set: { status?: number | string; headers?: unknown }; + params: Record; + query: Record; + [key: string]: unknown; +}; + +type ErrorResponse = { error: string }; + +export const requireAuth = (handler: (context: T) => R) => { + return (context: T): R | ErrorResponse => { + if (!context.user) { + context.set.status = 401; + return { error: "Unauthorized" }; + } + return handler(context); + }; +}; + +export const requireAdmin = (handler: (context: T) => R) => { + return (context: T): R | ErrorResponse => { + if (!context.user || context.user.role !== "admin") { + context.set.status = 403; + return { error: "Admin access required" }; + } + return handler(context); + }; +}; diff --git a/apps/backend/src/lib/service-utils.ts b/apps/backend/src/lib/service-utils.ts new file mode 100644 index 0000000..c49fa48 --- /dev/null +++ b/apps/backend/src/lib/service-utils.ts @@ -0,0 +1,33 @@ +export function createSensitiveFieldSelector>( + fields: T +): T & { api_key?: false } { + return { + ...fields, + api_key: false, + } as T & { api_key?: false }; +} + +export function pickDefined, K extends keyof T>( + source: T, + keys: K[] +): Partial> { + return keys.reduce( + (result, key) => { + if (source[key] !== undefined) { + result[key] = source[key]; + } + return result; + }, + {} as Partial> + ); +} + +export function generateApiKey(prefix: string): string { + const crypto = require("node:crypto"); + let token = crypto.randomBytes(64).toString("hex"); + token = token + .split("") + .map((char: string) => (Math.random() > 0.5 ? char.toUpperCase() : char)) + .join(""); + return `${prefix}${token}`; +} diff --git a/apps/backend/src/lib/zod-validator.ts b/apps/backend/src/lib/zod-validator.ts new file mode 100644 index 0000000..2189e95 --- /dev/null +++ b/apps/backend/src/lib/zod-validator.ts @@ -0,0 +1,25 @@ +import type { z } from "zod"; + +type ErrorHandler = (code: number, value: unknown) => never; + +export function validateBody( + schema: T, + body: unknown, + error: ErrorHandler +): z.infer { + const result = schema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + const message = `${firstError.path.join(".")}: ${firstError.message}`; + throw error(400, { message }); + } + + return result.data; +} + +export function zodValidate(schema: T) { + return (context: { body: unknown; error: ErrorHandler }) => { + return validateBody(schema, context.body, context.error); + }; +} diff --git a/apps/backend/src/routes/bootstrap.ts b/apps/backend/src/routes/bootstrap.ts new file mode 100644 index 0000000..006d214 --- /dev/null +++ b/apps/backend/src/routes/bootstrap.ts @@ -0,0 +1,51 @@ +import { prisma } from "@minikura/db"; +import { Elysia } from "elysia"; +import { auth } from "../lib/auth"; +import { getErrorMessage } from "../lib/errors"; +import { bootstrapSchema } from "../schemas/bootstrap.schema"; + +export const bootstrapRoutes = new Elysia({ prefix: "/bootstrap" }) + .get("/status", async () => { + const userCount = await prisma.user.count(); + return { needsSetup: userCount === 0 }; + }) + .post("/setup", async ({ body, set }) => { + const userCount = await prisma.user.count(); + if (userCount > 0) { + set.status = 400; + return { message: "Setup already completed" }; + } + + const validated = bootstrapSchema.safeParse(body); + if (!validated.success) { + const firstError = validated.error.issues[0]; + set.status = 400; + return { + message: `${firstError.path.join(".")}: ${firstError.message}`, + }; + } + const data = validated.data; + + try { + const result = await auth.api.createUser({ + body: { + email: data.email, + password: data.password, + name: data.name, + role: "admin", + }, + }); + + if (!result.user) { + console.error("No user in response:", result); + set.status = 500; + return { message: "Failed to create user" }; + } + + return { success: true }; + } catch (err: unknown) { + console.error("Bootstrap setup error:", err); + set.status = 500; + return { message: getErrorMessage(err) }; + } + }); diff --git a/apps/backend/src/routes/k8s.ts b/apps/backend/src/routes/k8s.ts new file mode 100644 index 0000000..a1b93f8 --- /dev/null +++ b/apps/backend/src/routes/k8s.ts @@ -0,0 +1,135 @@ +import { Elysia } from "elysia"; +import { authPlugin } from "../lib/auth-plugin"; +import { requireAuth } from "../lib/middleware"; +import { K8sService } from "../services/k8s"; + +export const k8sRoutes = new Elysia({ prefix: "/k8s" }) + .use(authPlugin) + .get( + "/status", + requireAuth(async () => { + const k8sService = K8sService.getInstance(); + return k8sService.getConnectionInfo(); + }) + ) + .get( + "/pods", + requireAuth(async () => { + const k8sService = K8sService.getInstance(); + return await k8sService.getPods(); + }) + ) + .get( + "/deployments", + requireAuth(async () => { + const k8sService = K8sService.getInstance(); + return await k8sService.getDeployments(); + }) + ) + .get( + "/statefulsets", + requireAuth(async () => { + const k8sService = K8sService.getInstance(); + return await k8sService.getStatefulSets(); + }) + ) + .get( + "/services", + requireAuth(async () => { + const k8sService = K8sService.getInstance(); + return await k8sService.getServices(); + }) + ) + .get( + "/configmaps", + requireAuth(async () => { + const k8sService = K8sService.getInstance(); + return await k8sService.getConfigMaps(); + }) + ) + .get( + "/ingresses", + requireAuth(async () => { + const k8sService = K8sService.getInstance(); + return await k8sService.getIngresses(); + }) + ) + .get( + "/minecraft-servers", + requireAuth(async () => { + const k8sService = K8sService.getInstance(); + return await k8sService.getMinecraftServers(); + }) + ) + .get( + "/reverse-proxy-servers", + requireAuth(async () => { + const k8sService = K8sService.getInstance(); + return await k8sService.getReverseProxyServers(); + }) + ) + .get( + "/pods/:podName", + requireAuth(async ({ params }) => { + const k8sService = K8sService.getInstance(); + return await k8sService.getPodInfo(params.podName); + }) + ) + .get( + "/pods/:podName/logs", + requireAuth(async ({ params, query, set }) => { + const k8sService = K8sService.getInstance(); + const options = { + container: query.container as string | undefined, + tailLines: query.tailLines ? parseInt(query.tailLines as string, 10) : 1000, + timestamps: query.timestamps === "true", + sinceSeconds: query.sinceSeconds ? parseInt(query.sinceSeconds as string, 10) : undefined, + }; + const logs = await k8sService.getPodLogs(params.podName, options); + + // Return as plain text + const headers = (set.headers ?? {}) as Record; + headers["content-type"] = "text/plain"; + set.headers = headers; + return logs; + }) + ) + .get( + "/servers/:serverId/pods", + requireAuth(async ({ params }) => { + const k8sService = K8sService.getInstance(); + const labelSelector = `minikura.kirameki.cafe/server-id=${params.serverId}`; + return await k8sService.getPodsByLabel(labelSelector); + }) + ) + .get( + "/reverse-proxy/:serverId/pods", + requireAuth(async ({ params }) => { + const k8sService = K8sService.getInstance(); + // Reverse proxy servers use either 'velocity-{id}' or 'bungeecord-{id}' pattern + // We need to check both patterns or use the proxy-id label + const labelSelector = `minikura.kirameki.cafe/proxy-id=${params.serverId}`; + return await k8sService.getPodsByLabel(labelSelector); + }) + ) + .get( + "/services/:serviceName", + requireAuth(async ({ params }) => { + const k8sService = K8sService.getInstance(); + return await k8sService.getServiceInfo(params.serviceName); + }) + ) + .get( + "/services/:serviceName/connection-info", + requireAuth(async ({ params }) => { + const k8sService = K8sService.getInstance(); + return await k8sService.getServerConnectionInfo(params.serviceName); + }) + ) + .get( + "/nodes", + requireAuth(async () => { + const k8sService = K8sService.getInstance(); + return await k8sService.getNodes(); + }) + ); diff --git a/apps/backend/src/routes/reverse-proxy.routes.ts b/apps/backend/src/routes/reverse-proxy.routes.ts new file mode 100644 index 0000000..c865f34 --- /dev/null +++ b/apps/backend/src/routes/reverse-proxy.routes.ts @@ -0,0 +1,51 @@ +import { Elysia } from "elysia"; +import { z } from "zod"; +import { reverseProxyService } from "../application/di-container"; +import { createReverseProxySchema, updateReverseProxySchema } from "../schemas/server.schema"; + +const envVariableSchema = z.object({ + key: z.string(), + value: z.string(), +}); + +export const reverseProxyRoutes = new Elysia({ prefix: "/reverse-proxy" }) + .get("/", async () => { + return await reverseProxyService.getAllReverseProxies(false); + }) + + .get("/:id", async ({ params }) => { + return await reverseProxyService.getReverseProxyById(params.id, false); + }) + + .post("/", async ({ body }) => { + const payload = createReverseProxySchema.parse(body); + const proxy = await reverseProxyService.createReverseProxy(payload); + return proxy; + }) + + .patch("/:id", async ({ params, body }) => { + const payload = updateReverseProxySchema.parse(body); + const proxy = await reverseProxyService.updateReverseProxy(params.id, payload); + return proxy; + }) + + .delete("/:id", async ({ params }) => { + await reverseProxyService.deleteReverseProxy(params.id); + return { success: true }; + }) + + .get("/:id/env", async ({ params }) => { + const envVariables = await reverseProxyService.getEnvVariables(params.id); + return { env_variables: envVariables }; + }) + + .post("/:id/env", async ({ params, body }) => { + const payload = envVariableSchema.parse(body); + await reverseProxyService.setEnvVariable(params.id, payload.key, payload.value); + return { success: true }; + }) + + .delete("/:id/env/:key", async ({ params }) => { + await reverseProxyService.deleteEnvVariable(params.id, params.key); + return { success: true }; + }); diff --git a/apps/backend/src/routes/servers.ts b/apps/backend/src/routes/servers.ts new file mode 100644 index 0000000..f132d4b --- /dev/null +++ b/apps/backend/src/routes/servers.ts @@ -0,0 +1,69 @@ +import { Elysia } from "elysia"; +import { z } from "zod"; +import { serverService, wsService } from "../application/di-container"; +import { createServerSchema, updateServerSchema } from "../schemas/server.schema"; +import type { WebSocketClient } from "../services/websocket"; + +const envVariableSchema = z.object({ + key: z.string(), + value: z.string(), +}); + +export const serverRoutes = new Elysia({ prefix: "/servers" }) + .ws("/ws", { + open(ws: WebSocketClient & { data?: { query?: Record }; close: () => void }) { + if (!ws.data?.query?.apiKey) { + ws.close(); + return; + } + wsService.addClient(ws); + }, + close(ws: WebSocketClient) { + wsService.removeClient(ws); + }, + message() {}, + }) + .get("/", async () => { + return await serverService.getAllServers(false); + }) + + .get("/:id", async ({ params }) => { + return await serverService.getServerById(params.id, false); + }) + + .get("/:id/connection-info", async ({ params }) => { + return await serverService.getConnectionInfo(params.id); + }) + + .post("/", async ({ body }) => { + const payload = createServerSchema.parse(body); + const server = await serverService.createServer(payload); + return server; + }) + + .patch("/:id", async ({ params, body }) => { + const payload = updateServerSchema.parse(body); + const server = await serverService.updateServer(params.id, payload); + return server; + }) + + .delete("/:id", async ({ params }) => { + await serverService.deleteServer(params.id); + return { success: true }; + }) + + .get("/:id/env", async ({ params }) => { + const envVariables = await serverService.getEnvVariables(params.id); + return { env_variables: envVariables }; + }) + + .post("/:id/env", async ({ params, body }) => { + const payload = envVariableSchema.parse(body); + await serverService.setEnvVariable(params.id, payload.key, payload.value); + return { success: true }; + }) + + .delete("/:id/env/:key", async ({ params }) => { + await serverService.deleteEnvVariable(params.id, params.key); + return { success: true }; + }); diff --git a/apps/backend/src/routes/terminal.ts b/apps/backend/src/routes/terminal.ts new file mode 100644 index 0000000..d8ecef9 --- /dev/null +++ b/apps/backend/src/routes/terminal.ts @@ -0,0 +1,351 @@ +import * as k8s from "@kubernetes/client-node"; +import { Elysia } from "elysia"; +import { getErrorMessage } from "../lib/errors"; +import { K8sService } from "../services/k8s"; + +type TerminalWsData = { + query?: Record; + k8sWs?: WebSocket; +}; + +type TerminalWs = { + data: TerminalWsData; + send: (message: string) => void; + close: () => void; +}; + +type TerminalMessage = + | { type: "input"; data: string } + | { type: "resize"; cols: number; rows: number }; + +type BunTlsOptions = { + rejectUnauthorized: boolean; + cert?: string; + key?: string; + ca?: string; +}; + +export const terminalRoutes = new Elysia({ prefix: "/terminal" }).ws("/exec", { + open: async (ws: TerminalWs) => { + const podName = ws.data.query?.podName; + const container = ws.data.query?.container; + const shell = ws.data.query?.shell || "/bin/sh"; + const mode = ws.data.query?.mode || "shell"; + + console.log( + `Opening terminal for pod: ${podName}, container: ${container}, shell: ${shell}, mode: ${mode}` + ); + + if (!podName) { + ws.send( + JSON.stringify({ + type: "error", + data: "Pod name is required", + }) + ); + ws.close(); + return; + } + + try { + const k8sService = K8sService.getInstance(); + if (!k8sService.isInitialized()) { + ws.send( + JSON.stringify({ + type: "error", + data: "Kubernetes client not initialized", + }) + ); + ws.close(); + return; + } + + const kc = k8sService.getKubeConfig(); + const namespace = k8sService.getNamespace(); + const cluster = kc.getCurrentCluster(); + const user = kc.getCurrentUser(); + + if (!cluster) { + throw new Error("No current cluster configured"); + } + + const server = cluster.server; + const isAttach = mode === "attach"; + const apiPath = isAttach + ? `/api/v1/namespaces/${namespace}/pods/${podName}/attach` + : `/api/v1/namespaces/${namespace}/pods/${podName}/exec`; + + const params = new URLSearchParams({ + stdout: "true", + stderr: "true", + stdin: "true", + tty: "true", + }); + + if (!isAttach) { + params.append("command", shell); + } + + if (container) { + params.append("container", container); + } + + const wsUrl = `${server}${apiPath}?${params.toString()}` + .replace("https://", "wss://") + .replace("http://", "ws://"); + + console.log(`Connecting to Kubernetes: ${wsUrl}`); + + const headers: Record = { + Connection: "Upgrade", + Upgrade: "websocket", + "Sec-WebSocket-Version": "13", + "Sec-WebSocket-Key": Buffer.from(Math.random().toString()) + .toString("base64") + .substring(0, 24), + "Sec-WebSocket-Protocol": "v4.channel.k8s.io", + }; + + if (user?.token) { + headers["Authorization"] = `Bearer ${user.token}`; + } else if (user?.username && user?.password) { + const auth = Buffer.from(`${user.username}:${user.password}`).toString("base64"); + headers["Authorization"] = `Basic ${auth}`; + } + + const tlsOptions: BunTlsOptions = { + rejectUnauthorized: cluster.skipTLSVerify !== true, + }; + + if (user?.certData) { + tlsOptions.cert = Buffer.from(user.certData, "base64").toString(); + } + if (user?.keyData) { + tlsOptions.key = Buffer.from(user.keyData, "base64").toString(); + } + if (cluster.caData) { + tlsOptions.ca = Buffer.from(cluster.caData, "base64").toString(); + } + + const wsOptions = { headers, tls: tlsOptions }; + const k8sWs = new WebSocket(wsUrl, wsOptions as unknown as string | string[]); + ws.data.k8sWs = k8sWs; + + k8sWs.onopen = async () => { + console.log(`Connected to Kubernetes ${isAttach ? "attach" : "exec"}`); + + if (isAttach) { + try { + const coreApi = k8sService.getCoreApi(); + const logs = await coreApi.readNamespacedPodLog({ + name: podName, + namespace: namespace, + container: container, + }); + + if (logs) { + const lines = logs.split("\n"); + for (const line of lines) { + ws.send( + JSON.stringify({ + type: "output", + data: line + "\r\n", + }) + ); + } + } + + ws.send( + JSON.stringify({ + type: "ready", + data: "Attached to container (showing logs since start)", + }) + ); + } catch (logError) { + console.error("Failed to fetch historical logs:", logError); + ws.send( + JSON.stringify({ + type: "ready", + data: "Attached to container", + }) + ); + } + } else { + ws.send( + JSON.stringify({ + type: "ready", + data: "Shell ready", + }) + ); + } + }; + + k8sWs.onmessage = (event: MessageEvent) => { + try { + const data = event.data; + + let buffer: Uint8Array; + + if (data instanceof Uint8Array) { + buffer = data; + } else if (data instanceof ArrayBuffer) { + buffer = new Uint8Array(data); + } else if (Buffer.isBuffer(data)) { + buffer = new Uint8Array(data); + } else if (data instanceof Blob) { + data.arrayBuffer().then((ab) => { + const uint8 = new Uint8Array(ab); + processBuffer(uint8); + }); + return; + } else if (typeof data === "string") { + ws.send(JSON.stringify({ type: "output", data })); + return; + } else { + console.log("Unknown data type:", typeof data, "constructor:", data?.constructor?.name); + buffer = new Uint8Array(data); + } + + processBuffer(buffer); + } catch (err) { + console.error("Error processing Kubernetes message:", err); + } + }; + + function processBuffer(buffer: Uint8Array): void { + if (buffer.length === 0) { + return; + } + + const channel = buffer[0]; + const message = new TextDecoder().decode(buffer.slice(1)); + + if (channel === 1 || channel === 2) { + ws.send(JSON.stringify({ type: "output", data: message })); + } else if (channel === 3) { + console.error("Kubernetes error channel:", message); + ws.send(JSON.stringify({ type: "error", data: message })); + } + } + + k8sWs.onerror = (error: Event) => { + console.error("Kubernetes WebSocket error:", error); + const message = getErrorMessage(error); + ws.send( + JSON.stringify({ + type: "error", + data: `Connection error: ${message}`, + }) + ); + }; + + k8sWs.onclose = (event: CloseEvent) => { + console.log(`Kubernetes WebSocket closed: ${event.code} ${event.reason}`); + ws.send( + JSON.stringify({ + type: "close", + data: event.reason || "Connection closed", + }) + ); + ws.close(); + }; + } catch (error: unknown) { + console.error("Error setting up terminal:", error); + if (error instanceof Error) { + console.error("Error stack:", error.stack); + } + ws.send( + JSON.stringify({ + type: "error", + data: `Failed to connect: ${getErrorMessage(error)}`, + }) + ); + ws.close(); + } + }, + + message: async (ws: TerminalWs, message: unknown) => { + try { + const data = parseTerminalMessage(message); + if (!data) { + return; + } + + const k8sWs = ws.data.k8sWs; + + if (!k8sWs || k8sWs.readyState !== WebSocket.OPEN) { + console.error("Kubernetes WebSocket not ready, state:", k8sWs?.readyState); + return; + } + + if (data.type === "input") { + console.log("Sending input to k8s:", data.data); + const encoder = new TextEncoder(); + const textData = encoder.encode(data.data); + const buffer = new Uint8Array(1 + textData.length); + buffer[0] = 0; + buffer.set(textData, 1); + k8sWs.send(buffer.buffer); + } else if (data.type === "resize") { + const resizeMsg = JSON.stringify({ + Width: data.cols, + Height: data.rows, + }); + const encoder = new TextEncoder(); + const textData = encoder.encode(resizeMsg); + const buffer = new Uint8Array(1 + textData.length); + buffer[0] = 4; + buffer.set(textData, 1); + k8sWs.send(buffer.buffer); + } + } catch (error: unknown) { + console.error("Error handling terminal message:", error); + ws.send( + JSON.stringify({ + type: "error", + data: `Error: ${getErrorMessage(error)}`, + }) + ); + } + }, + + close: (ws: TerminalWs) => { + console.log("Client WebSocket closed"); + const k8sWs = ws.data.k8sWs; + if (k8sWs && k8sWs.readyState === WebSocket.OPEN) { + k8sWs.close(); + } + }, +}); + +function parseTerminalMessage(message: unknown): TerminalMessage | null { + if (typeof message === "string") { + try { + const parsed = JSON.parse(message) as unknown; + return isTerminalMessage(parsed) ? parsed : null; + } catch { + console.error("Failed to parse message as JSON:", message); + return null; + } + } + return null; +} + +function isTerminalMessage(value: unknown): value is TerminalMessage { + if (!value || typeof value !== "object") { + return false; + } + if (!("type" in value)) { + return false; + } + const type = (value as { type?: unknown }).type; + if (type === "input") { + return typeof (value as { data?: unknown }).data === "string"; + } + if (type === "resize") { + const cols = (value as { cols?: unknown }).cols; + const rows = (value as { rows?: unknown }).rows; + return typeof cols === "number" && typeof rows === "number"; + } + return false; +} diff --git a/apps/backend/src/routes/users.ts b/apps/backend/src/routes/users.ts new file mode 100644 index 0000000..1ccac36 --- /dev/null +++ b/apps/backend/src/routes/users.ts @@ -0,0 +1,30 @@ +import type { UpdateUserInput } from "@minikura/db"; +import { Elysia } from "elysia"; +import { userService } from "../application/di-container"; +import { requireAdmin, requireAuth } from "../lib/authorization"; + +export const userRoutes = new Elysia({ prefix: "/users" }) + .use(requireAdmin) + .get("/", async () => { + const users = await userService.getAllUsers(); + return users; + }) + + .use(requireAuth) + .get("/:id", async ({ params }) => { + const foundUser = await userService.getUserById(params.id); + return foundUser; + }) + + .use(requireAdmin) + .patch("/:id", async ({ params, body }) => { + const input = body as UpdateUserInput; + const updatedUser = await userService.updateUser(params.id, input); + return updatedUser; + }) + + .use(requireAuth) + .delete("/:id", async ({ params, user }) => { + await userService.deleteUser(user.id, params.id); + return { success: true }; + }); diff --git a/apps/backend/src/schemas/bootstrap.schema.ts b/apps/backend/src/schemas/bootstrap.schema.ts new file mode 100644 index 0000000..06b6e9d --- /dev/null +++ b/apps/backend/src/schemas/bootstrap.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const bootstrapSchema = z.object({ + name: z.string().min(1, "Name is required"), + email: z.string().email("Valid email is required"), + password: z.string().min(8, "Password must be at least 8 characters"), +}); + +export type BootstrapInput = z.infer; diff --git a/apps/backend/src/schemas/server.schema.ts b/apps/backend/src/schemas/server.schema.ts new file mode 100644 index 0000000..ab37d14 --- /dev/null +++ b/apps/backend/src/schemas/server.schema.ts @@ -0,0 +1,143 @@ +import { MinecraftServerJarType, ReverseProxyServerType, ServerType, ServiceType } from "@minikura/db"; +import { z } from "zod"; +import { GameMode, ServerDifficulty } from "../domain/entities/enums"; + +export const serverIdSchema = z.object({ + id: z + .string() + .min(1, "Server ID is required") + .regex(/^[a-zA-Z0-9-_]+$/, "ID must be alphanumeric with - or _"), +}); + +export const createServerSchema = z.object({ + id: z + .string() + .min(1, "Server ID is required") + .regex(/^[a-zA-Z0-9-_]+$/, "ID must be alphanumeric with - or _"), + description: z.string().nullable().optional(), + listen_port: z.number().int().min(1).max(65535), + type: z.nativeEnum(ServerType), + service_type: z.nativeEnum(ServiceType).optional(), + node_port: z.union([z.number().int().min(30000).max(32767), z.null()]).optional(), + env_variables: z + .array( + z.object({ + key: z.string().min(1), + value: z.string(), + }) + ) + .optional(), + memory: z.number().int().min(256).optional(), + memory_request: z.number().int().min(256).optional(), + cpu_request: z.string().optional(), + cpu_limit: z.string().optional(), + + jar_type: z.nativeEnum(MinecraftServerJarType).optional(), + minecraft_version: z.string().optional(), + + jvm_opts: z.string().optional(), + use_aikar_flags: z.boolean().optional(), + use_meowice_flags: z.boolean().optional(), + + difficulty: z.nativeEnum(ServerDifficulty).optional(), + game_mode: z.nativeEnum(GameMode).optional(), + max_players: z.number().int().min(1).max(1000).optional(), + pvp: z.boolean().optional(), + online_mode: z.boolean().optional(), + motd: z.string().optional(), + level_seed: z.string().optional(), + level_type: z.string().optional(), +}); + +export const updateServerSchema = z.object({ + description: z.string().nullable().optional(), + listen_port: z.number().int().min(1).max(65535).optional(), + service_type: z.nativeEnum(ServiceType).optional(), + node_port: z + .union([ + z + .number() + .int() + .min(30000, "Node port must be at least 30000") + .max(32767, "Node port must be at most 32767"), + z.null(), + ]) + .optional(), + env_variables: z + .array( + z.object({ + key: z.string().min(1), + value: z.string(), + }) + ) + .optional(), + memory: z.number().int().min(256).optional(), + memory_request: z.number().int().min(256).optional(), + cpu_request: z.string().optional(), + cpu_limit: z.string().optional(), + + jar_type: z.nativeEnum(MinecraftServerJarType).optional(), + minecraft_version: z.string().optional(), + + jvm_opts: z.string().optional(), + use_aikar_flags: z.boolean().optional(), + use_meowice_flags: z.boolean().optional(), + + difficulty: z.nativeEnum(ServerDifficulty).optional(), + game_mode: z.nativeEnum(GameMode).optional(), + max_players: z.number().int().min(1).max(1000).optional(), + pvp: z.boolean().optional(), + online_mode: z.boolean().optional(), + motd: z.string().optional(), + level_seed: z.string().optional(), + level_type: z.string().optional(), +}); + +export const createReverseProxySchema = z.object({ + id: z + .string() + .min(1, "Server ID is required") + .regex(/^[a-zA-Z0-9-_]+$/, "ID must be alphanumeric with - or _"), + description: z.string().nullable().optional(), + external_address: z.string().min(1, "External address is required"), + external_port: z.number().int().min(1).max(65535), + listen_port: z.number().int().min(1).max(65535).optional(), + type: z.nativeEnum(ReverseProxyServerType).optional(), + service_type: z.nativeEnum(ServiceType).optional(), + node_port: z.union([z.number().int().min(30000).max(32767), z.null()]).optional(), + env_variables: z + .array( + z.object({ + key: z.string().min(1), + value: z.string(), + }) + ) + .optional(), + memory: z.number().int().min(256).optional(), + cpu_request: z.string().optional(), + cpu_limit: z.string().optional(), +}); + +export const updateReverseProxySchema = z.object({ + description: z.string().nullable().optional(), + external_address: z.string().optional(), + external_port: z.number().int().min(1).max(65535).optional(), + listen_port: z.number().int().min(1).max(65535).optional(), + type: z.nativeEnum(ReverseProxyServerType).optional(), + service_type: z.nativeEnum(ServiceType).optional(), + node_port: z.union([z.number().int().min(30000).max(32767), z.null()]).optional(), + memory: z.number().int().min(256).optional(), + cpu_request: z.string().optional(), + cpu_limit: z.string().optional(), +}); + +export const envVariableSchema = z.object({ + key: z.string().min(1, "Key is required"), + value: z.string(), +}); + +export type CreateServerInput = z.infer; +export type UpdateServerInput = z.infer; +export type CreateReverseProxyInput = z.infer; +export type UpdateReverseProxyInput = z.infer; +export type EnvVariableInput = z.infer; diff --git a/apps/backend/src/schemas/user.schema.ts b/apps/backend/src/schemas/user.schema.ts new file mode 100644 index 0000000..4fbede8 --- /dev/null +++ b/apps/backend/src/schemas/user.schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const updateUserSchema = z.object({ + name: z.string().min(1).optional(), + role: z.enum(["admin", "user"]).optional(), +}); + +export const updateSuspensionSchema = z.object({ + isSuspended: z.boolean(), + suspendedUntil: z.string().nullable().optional(), +}); + +export const suspendUserSchema = z.object({ + suspendedUntil: z.string().nullable().optional(), +}); + +export type UpdateUserInput = z.infer; +export type UpdateSuspensionInput = z.infer; +export type SuspendUserInput = z.infer; diff --git a/apps/backend/src/services/__tests__/session.test.ts b/apps/backend/src/services/__tests__/session.test.ts new file mode 100644 index 0000000..2f9b7f1 --- /dev/null +++ b/apps/backend/src/services/__tests__/session.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, beforeEach, mock } from "bun:test"; +import { SessionService } from "../session"; + +// Create mock functions +const mockSessionFindUnique = mock(); +const mockServerFindUnique = mock(); +const mockReverseProxyFindUnique = mock(); +const mockIsUserSuspended = mock(); + +// Mock the prisma client +mock.module("@minikura/db", () => ({ + prisma: { + session: { + findUnique: mockSessionFindUnique, + }, + server: { + findUnique: mockServerFindUnique, + }, + reverseProxyServer: { + findUnique: mockReverseProxyFindUnique, + }, + }, + isUserSuspended: mockIsUserSuspended, +})); + +// Import mocked modules +await import("@minikura/db"); + +describe("SessionService", () => { + beforeEach(() => { + mockSessionFindUnique.mockClear(); + mockServerFindUnique.mockClear(); + mockReverseProxyFindUnique.mockClear(); + mockIsUserSuspended.mockClear(); + }); + + describe("validate", () => { + it("should return INVALID for non-existent session", async () => { + mockSessionFindUnique.mockResolvedValue(null); + + const result = await SessionService.validate("invalid-token"); + + expect(result.status).toBe(SessionService.SESSION_STATUS.INVALID); + expect(result.session).toBeNull(); + }); + + it("should return EXPIRED for expired session", async () => { + const expiredDate = new Date(Date.now() - 1000); + mockSessionFindUnique.mockResolvedValue({ + id: "session-1", + token: "token-1", + expiresAt: expiredDate, + createdAt: new Date(), + updatedAt: new Date(), + ipAddress: null, + userAgent: null, + userId: "user-1", + user: { + id: "user-1", + name: "Test User", + email: "test@example.com", + emailVerified: true, + image: null, + role: "user", + isSuspended: false, + suspendedUntil: null, + }, + }); + + const result = await SessionService.validate("expired-token"); + + expect(result.status).toBe(SessionService.SESSION_STATUS.EXPIRED); + }); + + it("should return USER_SUSPENDED for suspended user", async () => { + const futureDate = new Date(Date.now() + 1000000); + mockIsUserSuspended.mockReturnValue(true); + + mockSessionFindUnique.mockResolvedValue({ + id: "session-1", + token: "token-1", + expiresAt: futureDate, + createdAt: new Date(), + updatedAt: new Date(), + ipAddress: null, + userAgent: null, + userId: "user-1", + user: { + id: "user-1", + name: "Test User", + email: "test@example.com", + emailVerified: true, + image: null, + role: "user", + isSuspended: true, + suspendedUntil: null, + }, + }); + + const result = await SessionService.validate("valid-token"); + + expect(result.status).toBe(SessionService.SESSION_STATUS.USER_SUSPENDED); + }); + + it("should return VALID for valid session with active user", async () => { + const futureDate = new Date(Date.now() + 1000000); + mockIsUserSuspended.mockReturnValue(false); + + mockSessionFindUnique.mockResolvedValue({ + id: "session-1", + token: "token-1", + expiresAt: futureDate, + createdAt: new Date(), + updatedAt: new Date(), + ipAddress: null, + userAgent: null, + userId: "user-1", + user: { + id: "user-1", + name: "Test User", + email: "test@example.com", + emailVerified: true, + image: null, + role: "user", + isSuspended: false, + suspendedUntil: null, + }, + }); + + const result = await SessionService.validate("valid-token"); + + expect(result.status).toBe(SessionService.SESSION_STATUS.VALID); + expect(result.session).toBeDefined(); + }); + + it("should handle temporary suspension correctly", async () => { + const futureDate = new Date(Date.now() + 1000000); + const pastSuspensionDate = new Date(Date.now() - 1000); + + // User was suspended but suspension has expired + mockIsUserSuspended.mockReturnValue(false); + + mockSessionFindUnique.mockResolvedValue({ + id: "session-1", + token: "token-1", + expiresAt: futureDate, + createdAt: new Date(), + updatedAt: new Date(), + ipAddress: null, + userAgent: null, + userId: "user-1", + user: { + id: "user-1", + name: "Test User", + email: "test@example.com", + emailVerified: true, + image: null, + role: "user", + isSuspended: true, + suspendedUntil: pastSuspensionDate, + }, + }); + + const result = await SessionService.validate("valid-token"); + + expect(result.status).toBe(SessionService.SESSION_STATUS.VALID); + }); + }); + + describe("validateApiKey", () => { + it("should return INVALID for invalid API key format", async () => { + const result = await SessionService.validateApiKey("invalid-key"); + + expect(result.status).toBe(SessionService.SESSION_STATUS.INVALID); + expect(result.server).toBeNull(); + }); + + it("should return INVALID when reverse proxy server not found", async () => { + mockReverseProxyFindUnique.mockResolvedValue(null); + + const result = await SessionService.validateApiKey( + "minikura_reverse_proxy_server_api_key_invalid" + ); + + expect(result.status).toBe(SessionService.SESSION_STATUS.INVALID); + expect(result.server).toBeNull(); + }); + + it("should return valid reverse proxy server for valid API key", async () => { + const mockServer = { + id: "server-1", + subdomain: "test", + api_key: "minikura_reverse_proxy_server_api_key_valid", + type: "VELOCITY", + description: null, + memory: "1G", + external_address: "test.example.com", + external_port: 25565, + listen_port: 25577, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockReverseProxyFindUnique.mockResolvedValue(mockServer); + + const result = await SessionService.validateApiKey( + "minikura_reverse_proxy_server_api_key_valid" + ); + + expect(result.status).toBe(SessionService.SESSION_STATUS.VALID); + expect(result.server?.id).toBe(mockServer.id); + }); + + it("should return valid server for valid server API key", async () => { + const mockServer = { + id: "server-1", + name: "Test Server", + api_key: "minikura_server_api_key_valid", + type: "STATEFUL", + description: null, + memory: "2G", + listen_port: 25565, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockServerFindUnique.mockResolvedValue(mockServer); + + const result = await SessionService.validateApiKey("minikura_server_api_key_valid"); + + expect(result.status).toBe(SessionService.SESSION_STATUS.VALID); + expect(result.server?.id).toBe(mockServer.id); + }); + }); +}); diff --git a/apps/backend/src/services/__tests__/user.test.ts b/apps/backend/src/services/__tests__/user.test.ts new file mode 100644 index 0000000..74adc6e --- /dev/null +++ b/apps/backend/src/services/__tests__/user.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { UserService } from "../user"; + +// Create mock functions +const mockFindUnique = mock(); +const mockFindMany = mock(); +const mockUpdate = mock(); +const mockDelete = mock(); +const mockIsUserSuspended = mock(); + +// Mock the prisma client +mock.module("@minikura/db", () => ({ + prisma: { + user: { + findUnique: mockFindUnique, + findMany: mockFindMany, + update: mockUpdate, + delete: mockDelete, + }, + }, + isUserSuspended: mockIsUserSuspended, +})); + +// Import mocked modules +await import("@minikura/db"); + +describe("UserService", () => { + beforeEach(() => { + mockFindUnique.mockClear(); + mockFindMany.mockClear(); + mockUpdate.mockClear(); + mockDelete.mockClear(); + mockIsUserSuspended.mockClear(); + }); + + describe("getUserByEmail", () => { + it("should return user when found", async () => { + const mockUser = { + id: "user-1", + name: "Test User", + email: "test@example.com", + emailVerified: true, + image: null, + role: "user", + banned: false, + isSuspended: false, + suspendedUntil: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockFindUnique.mockResolvedValue(mockUser); + + const result = await UserService.getUserByEmail("test@example.com"); + + expect(mockFindUnique).toHaveBeenCalledWith({ + where: { email: "test@example.com" }, + }); + expect(result).toEqual(mockUser); + }); + + it("should return null when user not found", async () => { + mockFindUnique.mockResolvedValue(null); + + const result = await UserService.getUserByEmail("notfound@example.com"); + + expect(result).toBeNull(); + }); + }); + + describe("getUserById", () => { + it("should return user when found", async () => { + const mockUser = { + id: "user-1", + name: "Test User", + email: "test@example.com", + emailVerified: true, + image: null, + role: "user", + banned: false, + isSuspended: false, + suspendedUntil: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockFindUnique.mockResolvedValue(mockUser); + + const result = await UserService.getUserById("user-1"); + + expect(result).toEqual(mockUser); + }); + }); + + describe("getAllUsersWithSuspension", () => { + it("should return all users with suspension info", async () => { + const mockUsers = [ + { + id: "user-1", + name: "User 1", + email: "user1@example.com", + emailVerified: true, + image: null, + role: "user", + banned: false, + isSuspended: false, + suspendedUntil: null, + createdAt: new Date(), + }, + { + id: "user-2", + name: "User 2", + email: "user2@example.com", + emailVerified: true, + image: null, + role: "user", + banned: false, + isSuspended: true, + suspendedUntil: new Date(Date.now() + 86400000), + createdAt: new Date(), + }, + ]; + + mockFindMany.mockResolvedValue(mockUsers); + + const result = await UserService.getAllUsersWithSuspension(); + + expect(result).toEqual(mockUsers); + expect(result).toHaveLength(2); + }); + }); + + describe("updateUser", () => { + it("should update user basic information", async () => { + const mockUpdatedUser = { + id: "user-1", + name: "Updated Name", + email: "test@example.com", + emailVerified: true, + image: null, + role: "admin", + createdAt: new Date(), + }; + + mockUpdate.mockResolvedValue(mockUpdatedUser); + + const result = await UserService.updateUser("user-1", { + name: "Updated Name", + role: "admin", + }); + + expect(result).toEqual(mockUpdatedUser); + }); + }); + + describe("suspendUser", () => { + it("should suspend user indefinitely when no expiration provided", async () => { + const mockSuspendedUser = { + id: "user-1", + name: "Test User", + email: "test@example.com", + emailVerified: true, + image: null, + role: "user", + isSuspended: true, + suspendedUntil: null, + createdAt: new Date(), + }; + + mockUpdate.mockResolvedValue(mockSuspendedUser); + + const result = await UserService.suspendUser("user-1"); + + expect(result.isSuspended).toBe(true); + expect(result.suspendedUntil).toBeNull(); + }); + + it("should suspend user until specific date", async () => { + const suspendUntil = new Date(Date.now() + 86400000); // 1 day from now + + const mockSuspendedUser = { + id: "user-1", + name: "Test User", + email: "test@example.com", + emailVerified: true, + image: null, + role: "user", + isSuspended: true, + suspendedUntil: suspendUntil, + createdAt: new Date(), + }; + + mockUpdate.mockResolvedValue(mockSuspendedUser); + + const result = await UserService.suspendUser("user-1", suspendUntil); + + expect(result.isSuspended).toBe(true); + expect(result.suspendedUntil).toEqual(suspendUntil); + }); + }); + + describe("unsuspendUser", () => { + it("should unsuspend a user", async () => { + const mockUnsuspendedUser = { + id: "user-1", + name: "Test User", + email: "test@example.com", + emailVerified: true, + image: null, + role: "user", + isSuspended: false, + suspendedUntil: null, + createdAt: new Date(), + }; + + mockUpdate.mockResolvedValue(mockUnsuspendedUser); + + const result = await UserService.unsuspendUser("user-1"); + + expect(result.isSuspended).toBe(false); + expect(result.suspendedUntil).toBeNull(); + }); + }); + + describe("deleteUser", () => { + it("should delete a user", async () => { + mockDelete.mockResolvedValue({}); + + await UserService.deleteUser("user-1"); + + expect(mockDelete).toHaveBeenCalledWith({ + where: { id: "user-1" }, + }); + }); + }); + + describe("checkSuspension", () => { + it("should return true when user is suspended", async () => { + const mockUser = { + isSuspended: true, + suspendedUntil: null, + }; + + mockFindUnique.mockResolvedValue(mockUser); + mockIsUserSuspended.mockReturnValue(true); + + const result = await UserService.checkSuspension("user-1"); + + expect(result).toBe(true); + }); + + it("should return false when user is not suspended", async () => { + const mockUser = { + isSuspended: false, + suspendedUntil: null, + }; + + mockFindUnique.mockResolvedValue(mockUser); + mockIsUserSuspended.mockReturnValue(false); + + const result = await UserService.checkSuspension("user-1"); + + expect(result).toBe(false); + }); + + it("should throw error when user not found", async () => { + mockFindUnique.mockResolvedValue(null); + + await expect(UserService.checkSuspension("user-1")).rejects.toThrow("User not found"); + }); + }); +}); diff --git a/apps/backend/src/services/k8s.ts b/apps/backend/src/services/k8s.ts new file mode 100644 index 0000000..6ab6fc4 --- /dev/null +++ b/apps/backend/src/services/k8s.ts @@ -0,0 +1,455 @@ +import * as k8s from "@kubernetes/client-node"; +import type { CustomResourceSummary } from "@minikura/api"; +import { K8sResources } from "./k8s/resources"; + +const CUSTOM_RESOURCE_GROUP = "minikura.kirameki.cafe"; +const CUSTOM_RESOURCE_VERSION = "v1alpha1"; + +export class K8sService { + private static instance: K8sService; + private kc: k8s.KubeConfig; + private coreApi!: k8s.CoreV1Api; + private appsApi!: k8s.AppsV1Api; + private customObjectsApi!: k8s.CustomObjectsApi; + private networkingApi!: k8s.NetworkingV1Api; + private namespace: string; + private initialized: boolean = false; + private resources!: K8sResources; + + private constructor() { + this.kc = new k8s.KubeConfig(); + this.namespace = process.env.KUBERNETES_NAMESPACE || "minikura"; + + try { + this.setupConfig(); + this.initializeClients(); + this.resources = new K8sResources( + this.coreApi, + this.appsApi, + this.networkingApi, + this.namespace, + ); + this.initialized = true; + } catch (_error) { + this.initialized = false; + } + } + + private setupConfig(): void { + const isBun = typeof Bun !== "undefined"; + + if (isBun) { + const { buildKubeConfig } = require("../lib/kube-auth"); + this.kc = buildKubeConfig(); + return; + } + + try { + this.kc.loadFromDefault(); + console.log("Loaded Kubernetes config from default location"); + } catch (err) { + console.warn( + "Failed to load Kubernetes config from default location:", + err, + ); + } + + if (!this.kc.getCurrentContext()) { + try { + this.kc.loadFromCluster(); + console.log("Loaded Kubernetes config from cluster"); + } catch (err) { + console.warn("Failed to load Kubernetes config from cluster:", err); + } + } + + if (!this.kc.getCurrentContext()) { + throw new Error( + "Failed to setup Kubernetes client - no valid configuration found", + ); + } + + const currentCluster = this.kc.getCurrentCluster(); + if (currentCluster) { + console.log(`Connecting to Kubernetes server: ${currentCluster.server}`); + } + } + + private initializeClients(): void { + this.coreApi = this.kc.makeApiClient(k8s.CoreV1Api); + this.appsApi = this.kc.makeApiClient(k8s.AppsV1Api); + this.customObjectsApi = this.kc.makeApiClient(k8s.CustomObjectsApi); + this.networkingApi = this.kc.makeApiClient(k8s.NetworkingV1Api); + } + + static getInstance(): K8sService { + if (!K8sService.instance) { + K8sService.instance = new K8sService(); + } + return K8sService.instance; + } + + isInitialized(): boolean { + return this.initialized; + } + + getConnectionInfo(): { + initialized: boolean; + currentContext?: string; + cluster?: string; + namespace: string; + } { + if (!this.initialized) { + return { initialized: false, namespace: this.namespace }; + } + + try { + const currentContext = this.kc.getCurrentContext(); + const cluster = this.kc.getCurrentCluster()?.name; + return { + initialized: true, + currentContext, + cluster, + namespace: this.namespace, + }; + } catch (_error) { + return { initialized: false, namespace: this.namespace }; + } + } + + async getPods() { + if (!this.initialized) { + throw new Error("Kubernetes client not initialized"); + } + + try { + return await this.resources.listPods(); + } catch (error: unknown) { + console.error("Error fetching pods:", error); + throw new Error(`Failed to fetch pods: ${getErrorMessage(error)}`); + } + } + + async getDeployments() { + if (!this.initialized) { + throw new Error("Kubernetes client not initialized"); + } + + try { + return await this.resources.listDeployments(); + } catch (error: unknown) { + console.error("Error fetching deployments:", error); + throw new Error(`Failed to fetch deployments: ${getErrorMessage(error)}`); + } + } + + async getStatefulSets() { + if (!this.initialized) { + throw new Error("Kubernetes client not initialized"); + } + + try { + return await this.resources.listStatefulSets(); + } catch (error: unknown) { + console.error("Error fetching statefulsets:", error); + throw new Error( + `Failed to fetch statefulsets: ${getErrorMessage(error)}`, + ); + } + } + + async getServices() { + if (!this.initialized) { + throw new Error("Kubernetes client not initialized"); + } + + try { + return await this.resources.listServices(); + } catch (error: unknown) { + console.error("Error fetching services:", error); + throw new Error(`Failed to fetch services: ${getErrorMessage(error)}`); + } + } + + async getConfigMaps() { + if (!this.initialized) { + throw new Error("Kubernetes client not initialized"); + } + + try { + return await this.resources.listConfigMaps(); + } catch (error: unknown) { + console.error("Error fetching configmaps:", error); + throw new Error(`Failed to fetch configmaps: ${getErrorMessage(error)}`); + } + } + + async getIngresses() { + if (!this.initialized) { + throw new Error("Kubernetes client not initialized"); + } + + try { + return await this.resources.listIngresses(); + } catch (error: unknown) { + console.error("Error fetching ingresses:", error); + throw new Error(`Failed to fetch ingresses: ${getErrorMessage(error)}`); + } + } + + async getCustomResources( + group: string, + version: string, + plural: string, + ): Promise { + if (!this.initialized) { + throw new Error("Kubernetes client not initialized"); + } + + try { + type CustomResourceItem = { + metadata?: { + name?: string; + namespace?: string; + creationTimestamp?: string; + labels?: Record; + }; + spec?: Record; + status?: { phase?: string; [key: string]: unknown }; + }; + + const response = await this.customObjectsApi.listNamespacedCustomObject({ + group, + version, + namespace: this.namespace, + plural, + }); + const body = response as unknown as { + items?: CustomResourceItem[]; + body?: { items?: CustomResourceItem[] }; + }; + const items = body.items ?? body.body?.items ?? []; + return items.map((item) => ({ + name: item.metadata?.name, + namespace: item.metadata?.namespace, + age: getAge(item.metadata?.creationTimestamp), + labels: item.metadata?.labels, + spec: item.spec, + status: item.status, + })); + } catch (error: unknown) { + console.error(`Error fetching custom resources ${plural}:`, error); + throw new Error( + `Failed to fetch custom resources: ${getErrorMessage(error)}`, + ); + } + } + + async getMinecraftServers() { + return this.getCustomResources( + CUSTOM_RESOURCE_GROUP, + CUSTOM_RESOURCE_VERSION, + "minecraftservers", + ); + } + + async getReverseProxyServers() { + return this.getCustomResources( + CUSTOM_RESOURCE_GROUP, + CUSTOM_RESOURCE_VERSION, + "reverseproxyservers", + ); + } + + async getPodLogs( + podName: string, + options?: { + container?: string; + tailLines?: number; + timestamps?: boolean; + sinceSeconds?: number; + }, + ) { + if (!this.initialized) { + throw new Error("Kubernetes client not initialized"); + } + + try { + const response = await this.coreApi.readNamespacedPodLog({ + name: podName, + namespace: this.namespace, + container: options?.container, + tailLines: options?.tailLines, + timestamps: options?.timestamps, + sinceSeconds: options?.sinceSeconds, + }); + return response; + } catch (error: unknown) { + console.error(`Error fetching logs for pod ${podName}:`, error); + throw new Error(`Failed to fetch pod logs: ${getErrorMessage(error)}`); + } + } + + async getPodsByLabel(labelSelector: string) { + if (!this.initialized) { + throw new Error("Kubernetes client not initialized"); + } + + try { + return await this.resources.listPodsByLabel(labelSelector); + } catch (error: unknown) { + console.error("Error fetching pods by label:", error); + throw new Error( + `Failed to fetch pods by label: ${getErrorMessage(error)}`, + ); + } + } + + async getPodInfo(podName: string) { + if (!this.initialized) { + throw new Error("Kubernetes client not initialized"); + } + + try { + return await this.resources.getPodInfo(podName); + } catch (error: unknown) { + console.error(`Error fetching pod ${podName}:`, error); + throw new Error(`Failed to fetch pod info: ${getErrorMessage(error)}`); + } + } + + async getServiceInfo(serviceName: string) { + if (!this.initialized) { + throw new Error("Kubernetes client not initialized"); + } + + try { + return await this.resources.getServiceInfo(serviceName); + } catch (error: unknown) { + console.error(`Error fetching service ${serviceName}:`, error); + throw new Error( + `Failed to fetch service info: ${getErrorMessage(error)}`, + ); + } + } + + async getNodes() { + if (!this.initialized) { + throw new Error("Kubernetes client not initialized"); + } + + try { + return await this.resources.listNodes(); + } catch (error: unknown) { + console.error("Error fetching nodes:", error); + throw new Error(`Failed to fetch nodes: ${getErrorMessage(error)}`); + } + } + + async getServerConnectionInfo(serviceName: string) { + if (!this.initialized) { + throw new Error("Kubernetes client not initialized"); + } + + try { + const service = await this.getServiceInfo(serviceName); + const nodes = await this.getNodes(); + + if (service.type === "ClusterIP") { + return { + type: "ClusterIP", + ip: service.clusterIP, + port: service.ports[0]?.port || null, + connectionString: + service.clusterIP && service.ports[0]?.port + ? `${service.clusterIP}:${service.ports[0].port}` + : null, + note: "Only accessible within the cluster", + }; + } + + if (service.type === "NodePort") { + const nodeIP = nodes[0]?.externalIP || nodes[0]?.internalIP; + const nodePort = service.ports[0]?.nodePort; + return { + type: "NodePort", + nodeIP, + nodePort, + port: service.ports[0]?.port || null, + connectionString: nodeIP && nodePort ? `${nodeIP}:${nodePort}` : null, + note: + nodeIP && !nodes[0]?.externalIP + ? "Using internal IP (may not be accessible from outside the cluster network)" + : "Accessible from any node in the cluster", + }; + } + + if (service.type === "LoadBalancer") { + const externalIP = + service.loadBalancerIP || service.loadBalancerHostname; + return { + type: "LoadBalancer", + externalIP, + port: service.ports[0]?.port || null, + connectionString: + externalIP && service.ports[0]?.port + ? `${externalIP}:${service.ports[0].port}` + : null, + note: !externalIP ? "LoadBalancer IP pending" : null, + }; + } + + return { + type: service.type, + note: "Unknown service type", + }; + } catch (error: unknown) { + console.error( + `Error fetching connection info for service ${serviceName}:`, + error, + ); + throw new Error( + `Failed to fetch connection info: ${getErrorMessage(error)}`, + ); + } + } + + getKubeConfig(): k8s.KubeConfig { + return this.kc; + } + + getCoreApi(): k8s.CoreV1Api { + return this.coreApi; + } + + getNamespace(): string { + return this.namespace; + } +} + +function getAge(timestamp: Date | string | undefined): string { + if (!timestamp) return "unknown"; + const now = new Date(); + const created = new Date(timestamp); + const diff = now.getTime() - created.getTime(); + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d`; + if (hours > 0) return `${hours}h`; + if (minutes > 0) return `${minutes}m`; + return `${seconds}s`; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + return "Unknown error"; +} diff --git a/apps/backend/src/services/k8s/resources.ts b/apps/backend/src/services/k8s/resources.ts new file mode 100644 index 0000000..352fcbb --- /dev/null +++ b/apps/backend/src/services/k8s/resources.ts @@ -0,0 +1,271 @@ +import type * as k8s from "@kubernetes/client-node"; +import type { + DeploymentInfo, + K8sConfigMapSummary, + K8sIngressSummary, + K8sNodeSummary, + K8sServiceInfo, + K8sServicePort, + K8sServiceSummary, + PodDetails, + PodInfo, + StatefulSetInfo, +} from "@minikura/api"; + +export class K8sResources { + constructor( + private readonly coreApi: k8s.CoreV1Api, + private readonly appsApi: k8s.AppsV1Api, + private readonly networkingApi: k8s.NetworkingV1Api, + private readonly namespace: string + ) {} + + async listPods(): Promise { + const response = await this.coreApi.listNamespacedPod({ namespace: this.namespace }); + return response.items.map((pod) => mapPodInfo(pod)); + } + + async listPodsByLabel(labelSelector: string): Promise { + const response = await this.coreApi.listNamespacedPod({ + namespace: this.namespace, + labelSelector, + }); + return response.items.map((pod) => ({ + ...mapPodInfo(pod), + containers: pod.spec?.containers?.map((container) => container.name ?? "") || [], + })); + } + + async getPodInfo(podName: string): Promise { + const response = await this.coreApi.readNamespacedPod({ + name: podName, + namespace: this.namespace, + }); + const pod = response; + return { + ...mapPodInfo(pod), + containers: pod.spec?.containers?.map((container) => container.name ?? "") || [], + ip: pod.status?.podIP, + conditions: + pod.status?.conditions?.map((condition) => ({ + type: condition.type, + status: condition.status, + lastTransitionTime: condition.lastTransitionTime + ? condition.lastTransitionTime.toISOString() + : undefined, + })) || [], + containerStatuses: + pod.status?.containerStatuses?.map((status) => ({ + name: status.name, + ready: status.ready, + restartCount: status.restartCount, + state: status.state + ? { + waiting: status.state.waiting + ? { + reason: status.state.waiting.reason, + message: status.state.waiting.message, + } + : undefined, + running: status.state.running + ? { + startedAt: status.state.running.startedAt, + } + : undefined, + terminated: status.state.terminated + ? { + reason: status.state.terminated.reason, + exitCode: status.state.terminated.exitCode, + finishedAt: status.state.terminated.finishedAt, + } + : undefined, + } + : undefined, + })) || [], + }; + } + + async listDeployments(): Promise { + const response = await this.appsApi.listNamespacedDeployment({ namespace: this.namespace }); + return response.items.map((deployment) => ({ + name: deployment.metadata?.name ?? "", + namespace: deployment.metadata?.namespace, + ready: `${deployment.status?.readyReplicas ?? 0}/${deployment.status?.replicas ?? 0}`, + desired: deployment.status?.replicas ?? 0, + current: deployment.status?.replicas ?? 0, + updated: deployment.status?.updatedReplicas ?? 0, + upToDate: deployment.status?.updatedReplicas ?? 0, + available: deployment.status?.availableReplicas ?? 0, + age: getAge(deployment.metadata?.creationTimestamp), + labels: deployment.metadata?.labels, + })); + } + + async listStatefulSets(): Promise { + const response = await this.appsApi.listNamespacedStatefulSet({ namespace: this.namespace }); + return response.items.map((statefulSet) => ({ + name: statefulSet.metadata?.name ?? "", + namespace: statefulSet.metadata?.namespace, + ready: `${statefulSet.status?.readyReplicas ?? 0}/${statefulSet.spec?.replicas ?? 0}`, + desired: statefulSet.spec?.replicas ?? 0, + current: statefulSet.status?.currentReplicas ?? 0, + updated: statefulSet.status?.updatedReplicas ?? 0, + age: getAge(statefulSet.metadata?.creationTimestamp), + labels: statefulSet.metadata?.labels, + })); + } + + async listServices(): Promise { + const response = await this.coreApi.listNamespacedService({ namespace: this.namespace }); + return response.items.map((service) => { + const ports = service.spec?.ports ?? []; + const portSummary = ports + .map((port) => `${port.port}${port.nodePort ? `:${port.nodePort}` : ""}/${port.protocol}`) + .join(", "); + + return { + name: service.metadata?.name ?? "", + namespace: service.metadata?.namespace, + type: service.spec?.type, + clusterIP: service.spec?.clusterIP ?? null, + externalIP: + service.status?.loadBalancer?.ingress?.[0]?.ip || + service.spec?.externalIPs?.join(", ") || + "", + ports: portSummary, + age: getAge(service.metadata?.creationTimestamp), + labels: service.metadata?.labels, + }; + }); + } + + async listConfigMaps(): Promise { + const response = await this.coreApi.listNamespacedConfigMap({ namespace: this.namespace }); + return response.items.map((configMap) => ({ + name: configMap.metadata?.name ?? "", + namespace: configMap.metadata?.namespace, + data: Object.keys(configMap.data ?? {}).length, + age: getAge(configMap.metadata?.creationTimestamp), + labels: configMap.metadata?.labels, + })); + } + + async listIngresses(): Promise { + const response = await this.networkingApi.listNamespacedIngress({ namespace: this.namespace }); + return response.items.map((ingress) => { + const hosts = + ingress.spec?.rules + ?.map((rule) => rule.host) + .filter((host): host is string => Boolean(host)) + .join(", ") || ""; + const addresses = + ingress.status?.loadBalancer?.ingress + ?.map((item) => item.ip || item.hostname) + .filter((entry): entry is string => Boolean(entry)) + .join(", ") || ""; + + return { + name: ingress.metadata?.name ?? "", + namespace: ingress.metadata?.namespace, + className: ingress.spec?.ingressClassName ?? null, + hosts, + address: addresses, + age: getAge(ingress.metadata?.creationTimestamp), + labels: ingress.metadata?.labels, + }; + }); + } + + async getServiceInfo(serviceName: string): Promise { + const response = await this.coreApi.readNamespacedService({ + name: serviceName, + namespace: this.namespace, + }); + const service = response; + const ports: K8sServicePort[] = + service.spec?.ports?.map((port) => ({ + name: port.name ?? null, + protocol: port.protocol ?? null, + port: port.port, + targetPort: port.targetPort, + nodePort: port.nodePort ?? null, + })) || []; + + return { + name: service.metadata?.name, + namespace: service.metadata?.namespace, + type: service.spec?.type, + clusterIP: service.spec?.clusterIP ?? null, + externalIPs: service.spec?.externalIPs || [], + loadBalancerIP: service.status?.loadBalancer?.ingress?.[0]?.ip || null, + loadBalancerHostname: service.status?.loadBalancer?.ingress?.[0]?.hostname || null, + ports, + selector: service.spec?.selector, + }; + } + + async listNodes(): Promise { + const response = await this.coreApi.listNode(); + return response.items.map((node) => { + const labels = node.metadata?.labels ?? {}; + const roles = Object.keys(labels) + .filter((label) => label.startsWith("node-role.kubernetes.io/")) + .map((label) => label.replace("node-role.kubernetes.io/", "")) + .join(","); + const addresses = node.status?.addresses ?? []; + const internalIP = addresses.find((address) => address.type === "InternalIP")?.address; + const externalIP = addresses.find((address) => address.type === "ExternalIP")?.address; + const hostname = addresses.find((address) => address.type === "Hostname")?.address; + const readyCondition = node.status?.conditions?.find((condition) => condition.type === "Ready"); + + return { + name: node.metadata?.name, + status: readyCondition?.status === "True" ? "Ready" : "NotReady", + roles: roles || "", + age: getAge(node.metadata?.creationTimestamp), + version: node.status?.nodeInfo?.kubeletVersion, + internalIP, + externalIP, + hostname, + }; + }); + } +} + +function mapPodInfo(pod: k8s.V1Pod): PodInfo { + const containerStatuses = pod.status?.containerStatuses ?? []; + const readyCount = containerStatuses.filter((status) => status.ready).length; + const totalCount = containerStatuses.length; + const restarts = containerStatuses.reduce( + (accumulator, status) => accumulator + (status.restartCount ?? 0), + 0 + ); + + return { + name: pod.metadata?.name ?? "", + namespace: pod.metadata?.namespace, + status: pod.status?.phase ?? "Unknown", + ready: `${readyCount}/${totalCount}`, + restarts, + age: getAge(pod.metadata?.creationTimestamp), + labels: pod.metadata?.labels, + nodeName: pod.spec?.nodeName, + }; +} + +function getAge(timestamp: Date | string | undefined): string { + if (!timestamp) return "unknown"; + const now = new Date(); + const created = new Date(timestamp); + const diff = now.getTime() - created.getTime(); + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d`; + if (hours > 0) return `${hours}h`; + if (minutes > 0) return `${minutes}m`; + return `${seconds}s`; +} diff --git a/apps/backend/src/services/server.ts b/apps/backend/src/services/server.ts deleted file mode 100644 index 5b7eaf7..0000000 --- a/apps/backend/src/services/server.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { prisma } from "@minikura/db"; -import type { ServerType } from "@minikura/db"; -import crypto from "node:crypto"; - -export namespace ServerService { - export async function getAllServers(omitSensitive = false) { - if (omitSensitive) { - return await prisma.server.findMany({ - 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({ - 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 }, - 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 }, - 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.findUnique({ - where: { id }, - include: { - env_variables: true, - }, - }); - } - } - - export async function createReverseProxyServer({ - id, - description, - external_address, - external_port, - listen_port, - type, - env_variables, - memory, - }: { - id: string; - description: string | null; - 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 - .split("") - .map((char) => (Math.random() > 0.5 ? char.toUpperCase() : char)) - .join(""); - token = `minikura_reverse_proxy_server_api_key_${token}`; - - return await prisma.reverseProxyServer.create({ - data: { - id, - description, - 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({ - id, - description, - type, - listen_port, - env_variables, - memory, - }: { - id: string; - description: string | null; - type: ServerType; - listen_port: number; - env_variables?: { key: string; value: string }[]; - memory?: string; - }) { - let token = crypto.randomBytes(64).toString("hex"); - token = token - .split("") - .map((char) => (Math.random() > 0.5 ? char.toUpperCase() : char)) - .join(""); - token = `minikura_server_api_key_${token}`; - - return await prisma.server.create({ - data: { - id, - description, - type, - listen_port, - api_key: token, - 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, - }, - }, - }); - } -} diff --git a/apps/backend/src/services/session.ts b/apps/backend/src/services/session.ts deleted file mode 100644 index 17f7aa0..0000000 --- a/apps/backend/src/services/session.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { prisma } from "@minikura/db"; -import crypto from "node:crypto"; - -export namespace SessionService { - export enum SESSION_STATUS { - VALID = "VALID", - INVALID = "INVALID", - REVOKED = "REVOKED", - EXPIRED = "EXPIRED", - } - - export async function validate(token: string) { - const session = await prisma.session.findUnique({ - where: { - token, - }, - }); - - if (!session) { - return { - status: SESSION_STATUS.INVALID, - session: null, - }; - } - - if (session.revoked) { - return { - status: SESSION_STATUS.REVOKED, - session, - }; - } - - if (session.expires_at < new Date()) { - return { - status: SESSION_STATUS.EXPIRED, - session, - }; - } - - return { - status: SESSION_STATUS.VALID, - session, - }; - } - - export async function validateApiKey(apiKey: string) { - // If starts with "minikura_reverse_proxy_server_api_key_" - if (apiKey.startsWith("minikura_reverse_proxy_server_api_key_")) { - const reverseProxyServer = await prisma.reverseProxyServer.findUnique({ - where: { - api_key: apiKey, - }, - }); - - if (!reverseProxyServer) { - return { - status: SESSION_STATUS.INVALID, - session: null, - }; - } - - return { - status: SESSION_STATUS.VALID, - server: reverseProxyServer, - }; - } - - if (apiKey.startsWith("minikura_server_api_key_")) { - const server = await prisma.server.findUnique({ - where: { - api_key: apiKey, - }, - }); - - if (!server) { - return { - status: SESSION_STATUS.INVALID, - session: null, - }; - } - - return { - status: SESSION_STATUS.VALID, - server: server, - }; - } - - return { - status: SESSION_STATUS.INVALID, - session: null, - }; - } - - export async function create(userId: string) { - let token = crypto.randomBytes(64).toString("hex"); - token = token - .split("") - .map((char) => (Math.random() > 0.5 ? char.toUpperCase() : char)) - .join(""); - token = `minikura_user_session_${token}`; - - return await prisma.session.create({ - data: { - token, - user: { - connect: { - id: userId, - }, - }, - // Expires in 48 hours - expires_at: new Date(Date.now() + 48 * 60 * 60 * 1000), - }, - }); - } - - export async function revoke(token: string) { - return await prisma.session.update({ - where: { - token, - }, - data: { - revoked: true, - }, - }); - } -} diff --git a/apps/backend/src/services/user.ts b/apps/backend/src/services/user.ts deleted file mode 100644 index f2f94cc..0000000 --- a/apps/backend/src/services/user.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { prisma } from "@minikura/db"; - -export namespace UserService { - export async function getUserByUsername(username: string) { - return await prisma.user.findUnique({ - where: { username }, - }); - } -} diff --git a/apps/backend/src/services/websocket.ts b/apps/backend/src/services/websocket.ts new file mode 100644 index 0000000..87e60ba --- /dev/null +++ b/apps/backend/src/services/websocket.ts @@ -0,0 +1,54 @@ +export type WebSocketClient = { + send: (message: string) => void; +}; + +export interface IWebSocketService { + addClient(client: WebSocketClient): void; + removeClient(client: WebSocketClient): void; + broadcast(action: string, serverType: string, serverId: string): void; + getClientCount(): number; +} + +export class WebSocketService implements IWebSocketService { + private clients = new Set(); + + addClient(client: WebSocketClient): void { + this.clients.add(client); + console.log(`[WebSocket] Client connected (total: ${this.clients.size})`); + } + + removeClient(client: WebSocketClient): void { + this.clients.delete(client); + console.log( + `[WebSocket] Client disconnected (total: ${this.clients.size})`, + ); + } + + broadcast(action: string, serverType: string, serverId: string): void { + const message = JSON.stringify({ + type: "SERVER_CHANGE", + action, + serverType, + serverId, + timestamp: new Date().toISOString(), + }); + + let failedClients = 0; + this.clients.forEach((client) => { + try { + client.send(message); + } catch { + failedClients++; + this.clients.delete(client); + } + }); + + if (failedClients > 0) { + console.log(`[WebSocket] Removed ${failedClients} failed clients`); + } + } + + getClientCount(): number { + return this.clients.size; + } +} diff --git a/apps/web/app/bootstrap/page.tsx b/apps/web/app/bootstrap/page.tsx new file mode 100644 index 0000000..d3827d3 --- /dev/null +++ b/apps/web/app/bootstrap/page.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/lib/api"; + +export default function BootstrapPage() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [checkingStatus, setCheckingStatus] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + const checkStatus = async () => { + try { + const { data } = await api.bootstrap.status.get(); + + if (data && !data.needsSetup) { + router.replace("/login"); + } + } catch (err) { + console.error("Failed to check bootstrap status:", err); + } finally { + setCheckingStatus(false); + } + }; + + checkStatus(); + }, [router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(""); + + const formData = new FormData(e.currentTarget); + const email = formData.get("email") as string; + const password = formData.get("password") as string; + const confirmPassword = formData.get("confirmPassword") as string; + const name = formData.get("name") as string; + + if (password !== confirmPassword) { + setError("Passwords do not match"); + setLoading(false); + return; + } + + try { + const { data, error: apiError } = await api.bootstrap.setup.post({ + email, + password, + name, + }); + + if (apiError) { + const errorMessage = + "value" in apiError && + typeof apiError.value === "object" && + apiError.value && + "message" in apiError.value + ? String(apiError.value.message) + : "Failed to create admin user"; + setError(errorMessage); + } else if (data?.success) { + router.push("/login"); + } else { + setError("Failed to create admin user"); + } + } catch (err) { + setError("Failed to connect to server"); + } finally { + setLoading(false); + } + }; + + if (checkingStatus) { + return ( +
+ +
+ ); + } + + return ( +
+ + + + Welcome to Minikura + + + Create your admin account to get started + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {error && ( +
{error}
+ )} + +
+
+
+
+ ); +} diff --git a/apps/web/app/dashboard/k8s/page.tsx b/apps/web/app/dashboard/k8s/page.tsx new file mode 100644 index 0000000..865e53e --- /dev/null +++ b/apps/web/app/dashboard/k8s/page.tsx @@ -0,0 +1,549 @@ +"use client"; + +import { AlertCircle, CheckCircle2, XCircle } from "lucide-react"; +import type { + CustomResourceSummary, + DeploymentInfo, + K8sConfigMapSummary, + K8sServiceSummary, + K8sStatus, + PodInfo, + StatefulSetInfo, +} from "@minikura/api"; +import { useEffect, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { api } from "@/lib/api"; + +export default function K8sResourcesPage() { + const [status, setStatus] = useState(null); + const [pods, setPods] = useState([]); + const [deployments, setDeployments] = useState([]); + const [statefulSets, setStatefulSets] = useState([]); + const [services, setServices] = useState([]); + const [configMaps, setConfigMaps] = useState([]); + const [minecraftServers, setMinecraftServers] = useState([]); + const [reverseProxyServers, setReverseProxyServers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = async () => { + try { + setLoading(true); + setError(null); + + const [ + statusRes, + podsRes, + deploymentsRes, + statefulSetsRes, + servicesRes, + configMapsRes, + minecraftServersRes, + reverseProxyServersRes, + ] = await Promise.allSettled([ + api.api.k8s.status.get(), + api.api.k8s.pods.get(), + api.api.k8s.deployments.get(), + api.api.k8s.statefulsets.get(), + api.api.k8s.services.get(), + api.api.k8s.configmaps.get(), + api.api.k8s["minecraft-servers"].get(), + api.api.k8s["reverse-proxy-servers"].get(), + ]); + + if (statusRes.status === "fulfilled" && statusRes.value.data) { + setStatus(statusRes.value.data as K8sStatus); + } else if (statusRes.status === "rejected") { + console.error("Failed to fetch status:", statusRes.reason); + } + + if (podsRes.status === "fulfilled" && podsRes.value.data) { + setPods(podsRes.value.data as PodInfo[]); + } else if (podsRes.status === "rejected") { + console.error("Failed to fetch pods:", podsRes.reason); + } + + if (deploymentsRes.status === "fulfilled" && deploymentsRes.value.data) { + setDeployments(deploymentsRes.value.data as DeploymentInfo[]); + } else if (deploymentsRes.status === "rejected") { + console.error("Failed to fetch deployments:", deploymentsRes.reason); + } + + if (statefulSetsRes.status === "fulfilled" && statefulSetsRes.value.data) { + setStatefulSets(statefulSetsRes.value.data as StatefulSetInfo[]); + } else if (statefulSetsRes.status === "rejected") { + console.error("Failed to fetch statefulsets:", statefulSetsRes.reason); + } + + if (servicesRes.status === "fulfilled" && servicesRes.value.data) { + setServices(servicesRes.value.data as K8sServiceSummary[]); + } else if (servicesRes.status === "rejected") { + console.error("Failed to fetch services:", servicesRes.reason); + } + + if (configMapsRes.status === "fulfilled" && configMapsRes.value.data) { + setConfigMaps(configMapsRes.value.data as K8sConfigMapSummary[]); + } else if (configMapsRes.status === "rejected") { + console.error("Failed to fetch configmaps:", configMapsRes.reason); + } + + if (minecraftServersRes.status === "fulfilled" && minecraftServersRes.value.data) { + setMinecraftServers(minecraftServersRes.value.data as CustomResourceSummary[]); + } else if (minecraftServersRes.status === "rejected") { + console.error("Failed to fetch minecraft servers:", minecraftServersRes.reason); + } + + if (reverseProxyServersRes.status === "fulfilled" && reverseProxyServersRes.value.data) { + setReverseProxyServers(reverseProxyServersRes.value.data as CustomResourceSummary[]); + } else if (reverseProxyServersRes.status === "rejected") { + console.error("Failed to fetch reverse proxy servers:", reverseProxyServersRes.reason); + } + } catch (err: unknown) { + const errorMessage = + err instanceof Error ? err.message : "Failed to fetch Kubernetes resources"; + setError(errorMessage); + } finally { + setLoading(false); + } + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: fetchData intentionally omitted to avoid infinite loop + useEffect(() => { + fetchData(); + const interval = setInterval(fetchData, 30000); + return () => clearInterval(interval); + }, []); + + const getStatusBadge = (phase: string) => { + const variants: Record< + string, + { + icon: React.ComponentType<{ className?: string }>; + variant: "default" | "destructive" | "secondary"; + } + > = { + Running: { icon: CheckCircle2, variant: "default" }, + Succeeded: { icon: CheckCircle2, variant: "default" }, + Failed: { icon: XCircle, variant: "destructive" }, + Pending: { icon: AlertCircle, variant: "secondary" }, + Unknown: { icon: AlertCircle, variant: "secondary" }, + }; + + const status = variants[phase] || variants.Unknown; + const Icon = status.icon; + + return ( + + + {phase} + + ); + }; + + if (loading && !status) { + return ( +
+
+

Kubernetes Resources

+

View and monitor your Kubernetes resources

+
+ + + + + + + + +
+ ); + } + + if (error) { + return ( +
+
+

Kubernetes Resources

+

View and monitor your Kubernetes resources

+
+ + + + + Error + + + +

{error}

+
+
+
+ ); + } + + if (!status?.initialized) { + return ( +
+
+

Kubernetes Resources

+

View and monitor your Kubernetes resources

+
+ + + + + Kubernetes Not Connected + + + The Kubernetes client is not initialized. Please ensure the operator is running with + proper Kubernetes configuration. + + + +

+ Set{" "} + KUBERNETES_SKIP_TLS_VERIFY=true{" "} + if using self-signed certificates. +

+
+
+
+ ); + } + + return ( +
+
+

Kubernetes Resources

+

View and monitor your Kubernetes resources

+
+ +
+ + Connected to Kubernetes +
+ + + + Pods ({pods.length}) + Deployments ({deployments.length}) + StatefulSets ({statefulSets.length}) + Services ({services.length}) + ConfigMaps ({configMaps.length}) + Minecraft Servers ({minecraftServers.length}) + + Reverse Proxies ({reverseProxyServers.length}) + + + + + + + Pods + Running pods in the minikura namespace + + + {pods.length === 0 ? ( +

No pods found

+ ) : ( +
+ + + + Name + Status + Ready + Restarts + Node + Age + + + + {pods.map((pod) => ( + + {pod.name} + {getStatusBadge(pod.status)} + {pod.ready} + {pod.restarts} + + {pod.nodeName || "-"} + + {pod.age} + + ))} + +
+
+ )} +
+
+
+ + + + + Deployments + Deployments in the minikura namespace + + + {deployments.length === 0 ? ( +

No deployments found

+ ) : ( +
+ + + + Name + Ready + Up-to-date + Available + Age + + + + {deployments.map((deployment) => ( + + {deployment.name} + {deployment.ready} + {deployment.upToDate ?? deployment.updated} + {deployment.available ?? 0} + + {deployment.age} + + + ))} + +
+
+ )} +
+
+
+ + + + + StatefulSets + StatefulSets in the minikura namespace + + + {statefulSets.length === 0 ? ( +

No statefulsets found

+ ) : ( +
+ + + + Name + Ready + Desired + Current + Age + + + + {statefulSets.map((statefulSet) => ( + + {statefulSet.name} + {statefulSet.ready} + {statefulSet.desired} + {statefulSet.current} + + {statefulSet.age} + + + ))} + +
+
+ )} +
+
+
+ + + + + Services + Services in the minikura namespace + + + {services.length === 0 ? ( +

No services found

+ ) : ( +
+ + + + Name + Type + Cluster IP + External IP + Ports + Age + + + + {services.map((service) => ( + + {service.name} + + {service.type} + + + {service.clusterIP} + + + {service.externalIP} + + + {service.ports} + + + {service.age} + + + ))} + +
+
+ )} +
+
+
+ + + + + ConfigMaps + ConfigMaps in the minikura namespace + + + {configMaps.length === 0 ? ( +

No configmaps found

+ ) : ( +
+ + + + Name + Data Keys + Age + + + + {configMaps.map((cm) => ( + + {cm.name} + {cm.data} + {cm.age} + + ))} + +
+
+ )} +
+
+
+ + + + + Minecraft Servers + Custom Minecraft server resources + + + {minecraftServers.length === 0 ? ( +

No Minecraft servers found

+ ) : ( +
+ + + + Name + Status + Age + + + + {minecraftServers.map((server) => ( + + {server.name} + + {server.status?.phase ? ( + getStatusBadge(server.status.phase) + ) : ( + Unknown + )} + + + {server.age} + + + ))} + +
+
+ )} +
+
+
+ + + + + Reverse Proxy Servers + Custom reverse proxy server resources + + + {reverseProxyServers.length === 0 ? ( +

No reverse proxy servers found

+ ) : ( +
+ + + + Name + Status + Age + + + + {reverseProxyServers.map((server) => ( + + {server.name} + + {server.status?.phase ? ( + getStatusBadge(server.status.phase) + ) : ( + Unknown + )} + + + {server.age} + + + ))} + +
+
+ )} +
+
+
+
+
+ ); +} diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx new file mode 100644 index 0000000..a1df0f3 --- /dev/null +++ b/apps/web/app/dashboard/layout.tsx @@ -0,0 +1,5 @@ +import { DashboardLayout } from "@/components/dashboard-layout"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx new file mode 100644 index 0000000..05a3770 --- /dev/null +++ b/apps/web/app/dashboard/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function DashboardPage() { + redirect("/dashboard/users"); +} diff --git a/apps/web/app/dashboard/servers/create/page.tsx b/apps/web/app/dashboard/servers/create/page.tsx new file mode 100644 index 0000000..3c2e370 --- /dev/null +++ b/apps/web/app/dashboard/servers/create/page.tsx @@ -0,0 +1,184 @@ +"use client"; + +import type { CreateServerRequest } from "@minikura/api"; +import { ArrowLeft } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { ServerForm, type ServerFormData } from "@/components/server-form"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { api } from "@/lib/api"; + +export default function CreateServerPage() { + const router = useRouter(); + + const handleSubmit = async (data: ServerFormData) => { + const filteredEnvVars = data.envVars.filter((ev) => ev.key && ev.value); + + const envVariables: Record = {}; + + if (data.allowFlight) envVariables.ALLOW_FLIGHT = String(data.allowFlight); + if (data.enableCommandBlock) + envVariables.ENABLE_COMMAND_BLOCK = String(data.enableCommandBlock); + if (data.spawnProtection) envVariables.SPAWN_PROTECTION = data.spawnProtection; + if (data.viewDistance) envVariables.VIEW_DISTANCE = data.viewDistance; + if (data.simulationDistance) envVariables.SIMULATION_DISTANCE = data.simulationDistance; + + if (data.levelName) envVariables.LEVEL = data.levelName; + if (data.levelSeed) envVariables.SEED = data.levelSeed; + if (data.levelType) envVariables.LEVEL_TYPE = data.levelType; + if (data.generatorSettings) envVariables.GENERATOR_SETTINGS = data.generatorSettings; + if (data.hardcore) envVariables.HARDCORE = String(data.hardcore); + if (data.spawnAnimals !== undefined) envVariables.SPAWN_ANIMALS = String(data.spawnAnimals); + if (data.spawnMonsters !== undefined) envVariables.SPAWN_MONSTERS = String(data.spawnMonsters); + if (data.spawnNpcs !== undefined) envVariables.SPAWN_NPCS = String(data.spawnNpcs); + + if (data.enableWhitelist) envVariables.ENABLE_WHITELIST = String(data.enableWhitelist); + if (data.whitelist) envVariables.WHITELIST = data.whitelist; + if (data.whitelistFile) envVariables.WHITELIST_FILE = data.whitelistFile; + if (data.ops) envVariables.OPS = data.ops; + if (data.opsFile) envVariables.OPS_FILE = data.opsFile; + + if (data.jvmXxOpts) envVariables.JVM_XX_OPTS = data.jvmXxOpts; + if (data.jvmDdOpts) envVariables.JVM_DD_OPTS = data.jvmDdOpts; + if (data.enableJmx) envVariables.ENABLE_JMX = String(data.enableJmx); + + if (data.resourcePack) envVariables.RESOURCE_PACK = data.resourcePack; + if (data.resourcePackSha1) envVariables.RESOURCE_PACK_SHA1 = data.resourcePackSha1; + if (data.resourcePackEnforce) + envVariables.RESOURCE_PACK_ENFORCE = String(data.resourcePackEnforce); + + if (data.enableRcon !== undefined) envVariables.ENABLE_RCON = String(data.enableRcon); + if (data.rconPassword) envVariables.RCON_PASSWORD = data.rconPassword; + if (data.rconPort) envVariables.RCON_PORT = data.rconPort; + if (data.rconCmdsStartup) envVariables.RCON_CMDS_STARTUP = data.rconCmdsStartup; + if (data.rconCmdsOnConnect) envVariables.RCON_CMDS_ON_CONNECT = data.rconCmdsOnConnect; + if (data.rconCmdsFirstConnect) envVariables.RCON_CMDS_FIRST_CONNECT = data.rconCmdsFirstConnect; + if (data.rconCmdsOnDisconnect) envVariables.RCON_CMDS_ON_DISCONNECT = data.rconCmdsOnDisconnect; + if (data.rconCmdsLastDisconnect) + envVariables.RCON_CMDS_LAST_DISCONNECT = data.rconCmdsLastDisconnect; + + if (data.enableQuery !== undefined) envVariables.ENABLE_QUERY = String(data.enableQuery); + if (data.queryPort) envVariables.QUERY_PORT = data.queryPort; + + if (data.enableAutopause) envVariables.ENABLE_AUTOPAUSE = String(data.enableAutopause); + if (data.autopauseTimeoutEst) envVariables.AUTOPAUSE_TIMEOUT_EST = data.autopauseTimeoutEst; + if (data.autopauseTimeoutInit) envVariables.AUTOPAUSE_TIMEOUT_INIT = data.autopauseTimeoutInit; + if (data.autopauseTimeoutKn) envVariables.AUTOPAUSE_TIMEOUT_KN = data.autopauseTimeoutKn; + if (data.autopausePeriod) envVariables.AUTOPAUSE_PERIOD = data.autopausePeriod; + if (data.autopauseKnockInterface) + envVariables.AUTOPAUSE_KNOCK_INTERFACE = data.autopauseKnockInterface; + + if (data.enableAutostop) envVariables.ENABLE_AUTOSTOP = String(data.enableAutostop); + if (data.autostopTimeoutEst) envVariables.AUTOSTOP_TIMEOUT_EST = data.autostopTimeoutEst; + if (data.autostopTimeoutInit) envVariables.AUTOSTOP_TIMEOUT_INIT = data.autostopTimeoutInit; + if (data.autostopPeriod) envVariables.AUTOSTOP_PERIOD = data.autostopPeriod; + + if (data.plugins) envVariables.PLUGINS = data.plugins; + if (data.removeOldPlugins) envVariables.REMOVE_OLD_PLUGINS = String(data.removeOldPlugins); + if (data.spigetResources) envVariables.SPIGET_RESOURCES = data.spigetResources; + + if (data.paperBuild) envVariables.PAPER_BUILD = data.paperBuild; + + if (data.type === "CUSTOM" && data.customJarUrl) { + envVariables.CUSTOM_SERVER = data.customJarUrl; + envVariables.VERSION = ""; + } + + if (data.timezone) envVariables.TZ = data.timezone; + if (data.uid) envVariables.UID = data.uid; + if (data.gid) envVariables.GID = data.gid; + if (data.stopDuration) envVariables.STOP_DURATION = data.stopDuration; + if (data.serverIcon) envVariables.ICON = data.serverIcon; + + envVariables.EULA = String(data.eula); + + envVariables.TYPE = data.type; + if (data.type !== "CUSTOM" && data.version) { + envVariables.VERSION = data.version; + } + if (data.type === "CUSTOM" && !data.customJarUrl) { + throw new Error("Custom jar URL is required for custom servers"); + } + + for (const envVar of filteredEnvVars) { + envVariables[envVar.key] = envVar.value; + } + + const payload: CreateServerRequest = { + id: data.id.trim(), + description: data.description.trim() || null, + listen_port: Number(data.listenPort), + type: "STATEFUL", + service_type: data.serviceType, + node_port: data.serviceType === "NODE_PORT" && data.nodePort ? Number(data.nodePort) : null, + env_variables: Object.entries(envVariables).map(([key, value]) => ({ key, value })), + memory: data.memoryLimit ? Number(data.memoryLimit) : undefined, + memory_request: data.memoryRequest ? Number(data.memoryRequest) : undefined, + cpu_request: data.cpuRequest || undefined, + cpu_limit: data.cpuLimit || undefined, + jar_type: data.type === "CUSTOM" ? "VANILLA" : data.type, + minecraft_version: data.type === "CUSTOM" ? undefined : data.version || "LATEST", + jvm_opts: data.jvmOpts || undefined, + use_aikar_flags: data.useAikarFlags || undefined, + use_meowice_flags: data.useMeowiceFlags || undefined, + difficulty: data.difficulty, + game_mode: data.mode, + max_players: data.maxPlayers ? Number(data.maxPlayers) : undefined, + pvp: data.pvp, + online_mode: data.onlineMode, + motd: data.motd, + level_seed: data.levelSeed, + level_type: data.levelType, + }; + + const response = await api.api.servers.post(payload); + + if (response.error) { + const errorMsg = + typeof response.error === "object" && + response.error && + "value" in response.error && + typeof response.error.value === "object" && + response.error.value && + "message" in response.error.value + ? String(response.error.value.message) + : "Failed to create server"; + throw new Error(errorMsg); + } + + router.push("/dashboard/servers"); + }; + + return ( +
+
+ +
+

Create Minecraft Server

+

+ Configure your new Minecraft server with comprehensive settings +

+
+
+ + + + Server Configuration + + Complete configuration for itzg/minecraft-server Docker image with all environment + variables + + + + router.push("/dashboard/servers")} + submitLabel="Create Server" + /> + + +
+ ); +} diff --git a/apps/web/app/dashboard/servers/edit/[id]/page.tsx b/apps/web/app/dashboard/servers/edit/[id]/page.tsx new file mode 100644 index 0000000..20ca62e --- /dev/null +++ b/apps/web/app/dashboard/servers/edit/[id]/page.tsx @@ -0,0 +1,460 @@ +"use client"; + +import type { NormalServer, UpdateServerRequest } from "@minikura/api"; +import { ArrowLeft } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { ServerForm, type ServerFormData, type ServerType } from "@/components/server-form"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { api } from "@/lib/api"; +import { getReverseProxyApi } from "@/lib/api-helpers"; + +export default function EditServerPage() { + const router = useRouter(); + const params = useParams(); + const serverId = params.id as string; + + const [loading, setLoading] = useState(true); + const [serverData, setServerData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchServer = async () => { + try { + setLoading(true); + + const normalResponse = await api.api.servers.get(); + if (normalResponse.data) { + const servers = normalResponse.data as unknown as NormalServer[]; + const server = servers.find((s) => s.id === serverId); + if (server) { + setServerData(server); + setLoading(false); + return; + } + } + + const proxyResponse = await getReverseProxyApi().get(); + if (proxyResponse.data) { + const proxies = proxyResponse.data as unknown as NormalServer[]; + const proxy = proxies.find((p) => p.id === serverId); + if (proxy) { + setServerData(proxy); + setLoading(false); + return; + } + } + + setError("Server not found"); + setLoading(false); + } catch (err) { + console.error("Failed to fetch server:", err); + setError("Failed to load server data"); + setLoading(false); + } + }; + + if (serverId) { + fetchServer(); + } + }, [serverId]); + + const toServiceType = (value?: string | null): ServerFormData["serviceType"] => { + if (value === "NODE_PORT" || value === "LOAD_BALANCER") { + return value; + } + return "CLUSTER_IP"; + }; + + const toDifficulty = (value?: string | null): ServerFormData["difficulty"] => { + if (value === "peaceful" || value === "easy" || value === "normal" || value === "hard") { + return value; + } + return "easy"; + }; + + const toMode = (value?: string | null): ServerFormData["mode"] => { + if (value === "creative" || value === "adventure" || value === "spectator") { + return value; + } + return "survival"; + }; + + const toServerType = (value?: string | null): ServerType => { + if (value === "VANILLA" || value === "CUSTOM") { + return value; + } + return "PAPER"; + }; + + const parseEnvVariables = (envVars?: Array<{ key: string; value: string }>) => { + if (!envVars) return {}; + + const parsed: Record = {}; + for (const { key, value } of envVars) { + parsed[key] = value; + } + return parsed; + }; + + const handleSubmit = async (data: ServerFormData) => { + if (!serverData) return; + + const filteredEnvVars = data.envVars.filter((ev) => ev.key && ev.value); + + const envVariables: Record = {}; + if (data.allowFlight) envVariables.ALLOW_FLIGHT = String(data.allowFlight); + if (data.enableCommandBlock) + envVariables.ENABLE_COMMAND_BLOCK = String(data.enableCommandBlock); + if (data.spawnProtection) envVariables.SPAWN_PROTECTION = data.spawnProtection; + if (data.viewDistance) envVariables.VIEW_DISTANCE = data.viewDistance; + if (data.simulationDistance) envVariables.SIMULATION_DISTANCE = data.simulationDistance; + + if (data.levelSeed) envVariables.SEED = data.levelSeed; + if (data.levelType) envVariables.LEVEL_TYPE = data.levelType; + if (data.generatorSettings) envVariables.GENERATOR_SETTINGS = data.generatorSettings; + if (data.hardcore) envVariables.HARDCORE = String(data.hardcore); + if (data.spawnAnimals !== undefined) envVariables.SPAWN_ANIMALS = String(data.spawnAnimals); + if (data.spawnMonsters !== undefined) envVariables.SPAWN_MONSTERS = String(data.spawnMonsters); + if (data.spawnNpcs !== undefined) envVariables.SPAWN_NPCS = String(data.spawnNpcs); + + if (data.enableWhitelist) envVariables.ENABLE_WHITELIST = String(data.enableWhitelist); + if (data.whitelist) envVariables.WHITELIST = data.whitelist; + if (data.whitelistFile) envVariables.WHITELIST_FILE = data.whitelistFile; + if (data.ops) envVariables.OPS = data.ops; + if (data.opsFile) envVariables.OPS_FILE = data.opsFile; + + if (data.jvmXxOpts) envVariables.JVM_XX_OPTS = data.jvmXxOpts; + if (data.jvmDdOpts) envVariables.JVM_DD_OPTS = data.jvmDdOpts; + if (data.enableJmx) envVariables.ENABLE_JMX = String(data.enableJmx); + + if (data.resourcePack) envVariables.RESOURCE_PACK = data.resourcePack; + if (data.resourcePackSha1) envVariables.RESOURCE_PACK_SHA1 = data.resourcePackSha1; + if (data.resourcePackEnforce) + envVariables.RESOURCE_PACK_ENFORCE = String(data.resourcePackEnforce); + + if (data.enableRcon !== undefined) envVariables.ENABLE_RCON = String(data.enableRcon); + if (data.rconPassword) envVariables.RCON_PASSWORD = data.rconPassword; + if (data.rconPort) envVariables.RCON_PORT = data.rconPort; + if (data.rconCmdsStartup) envVariables.RCON_CMDS_STARTUP = data.rconCmdsStartup; + if (data.rconCmdsOnConnect) envVariables.RCON_CMDS_ON_CONNECT = data.rconCmdsOnConnect; + if (data.rconCmdsFirstConnect) envVariables.RCON_CMDS_FIRST_CONNECT = data.rconCmdsFirstConnect; + if (data.rconCmdsOnDisconnect) envVariables.RCON_CMDS_ON_DISCONNECT = data.rconCmdsOnDisconnect; + if (data.rconCmdsLastDisconnect) + envVariables.RCON_CMDS_LAST_DISCONNECT = data.rconCmdsLastDisconnect; + + if (data.enableQuery !== undefined) envVariables.ENABLE_QUERY = String(data.enableQuery); + if (data.queryPort) envVariables.QUERY_PORT = data.queryPort; + + if (data.enableAutopause) envVariables.ENABLE_AUTOPAUSE = String(data.enableAutopause); + if (data.autopauseTimeoutEst) envVariables.AUTOPAUSE_TIMEOUT_EST = data.autopauseTimeoutEst; + if (data.autopauseTimeoutInit) envVariables.AUTOPAUSE_TIMEOUT_INIT = data.autopauseTimeoutInit; + if (data.autopauseTimeoutKn) envVariables.AUTOPAUSE_TIMEOUT_KN = data.autopauseTimeoutKn; + if (data.autopausePeriod) envVariables.AUTOPAUSE_PERIOD = data.autopausePeriod; + if (data.autopauseKnockInterface) + envVariables.AUTOPAUSE_KNOCK_INTERFACE = data.autopauseKnockInterface; + + if (data.enableAutostop) envVariables.ENABLE_AUTOSTOP = String(data.enableAutostop); + if (data.autostopTimeoutEst) envVariables.AUTOSTOP_TIMEOUT_EST = data.autostopTimeoutEst; + if (data.autostopTimeoutInit) envVariables.AUTOSTOP_TIMEOUT_INIT = data.autostopTimeoutInit; + if (data.autostopPeriod) envVariables.AUTOSTOP_PERIOD = data.autostopPeriod; + + if (data.plugins) envVariables.PLUGINS = data.plugins; + if (data.removeOldPlugins) envVariables.REMOVE_OLD_PLUGINS = String(data.removeOldPlugins); + if (data.spigetResources) envVariables.SPIGET_RESOURCES = data.spigetResources; + + if (data.paperBuild) envVariables.PAPER_BUILD = data.paperBuild; + + if (data.type === "CUSTOM" && data.customJarUrl) { + envVariables.CUSTOM_SERVER = data.customJarUrl; + envVariables.VERSION = ""; + } + + if (data.timezone) envVariables.TZ = data.timezone; + if (data.uid) envVariables.UID = data.uid; + if (data.gid) envVariables.GID = data.gid; + if (data.stopDuration) envVariables.STOP_DURATION = data.stopDuration; + if (data.serverIcon) envVariables.ICON = data.serverIcon; + + envVariables.EULA = String(data.eula); + envVariables.TYPE = data.type; + if (data.type !== "CUSTOM" && data.version) { + envVariables.VERSION = data.version; + } + if (data.type === "CUSTOM" && !data.customJarUrl) { + throw new Error("Custom jar URL is required for custom servers"); + } + + for (const envVar of filteredEnvVars) { + envVariables[envVar.key] = envVar.value; + } + + const payload: UpdateServerRequest = { + description: data.description.trim() || null, + listen_port: Number(data.listenPort), + service_type: data.serviceType, + node_port: data.serviceType === "NODE_PORT" && data.nodePort ? Number(data.nodePort) : null, + env_variables: Object.entries(envVariables).map(([key, value]) => ({ key, value })), + memory: data.memoryLimit ? Number(data.memoryLimit) : undefined, + memory_request: data.memoryRequest ? Number(data.memoryRequest) : undefined, + cpu_request: data.cpuRequest || undefined, + cpu_limit: data.cpuLimit || undefined, + jar_type: data.type === "CUSTOM" ? "VANILLA" : data.type, + minecraft_version: data.type === "CUSTOM" ? undefined : data.version || "LATEST", + jvm_opts: data.jvmOpts || undefined, + use_aikar_flags: data.useAikarFlags || undefined, + use_meowice_flags: data.useMeowiceFlags || undefined, + difficulty: data.difficulty, + game_mode: data.mode, + max_players: data.maxPlayers ? Number(data.maxPlayers) : undefined, + pvp: data.pvp, + online_mode: data.onlineMode, + motd: data.motd, + level_seed: data.levelSeed, + level_type: data.levelType, + }; + + const response = await api.api.servers({ id: serverId }).patch(payload); + + if (response.error) { + const errorMsg = + typeof response.error === "object" && + response.error && + "value" in response.error && + typeof response.error.value === "object" && + response.error.value && + "message" in response.error.value + ? String(response.error.value.message) + : "Failed to update server"; + throw new Error(errorMsg); + } + + router.push("/dashboard/servers"); + }; + + if (loading) { + return ( +
+
+
+

Loading server data...

+
+
+ ); + } + + if (error || !serverData) { + return ( +
+
+
+ {error || "Server not found"} +
+ +
+
+ ); + } + + const envVars = parseEnvVariables(serverData.env_variables); + + const dockerManagedKeys = new Set([ + "EULA", + "TYPE", + "VERSION", + "CUSTOM_SERVER", + "MOTD", + "DIFFICULTY", + "MODE", + "MAX_PLAYERS", + "PVP", + "ONLINE_MODE", + "ALLOW_FLIGHT", + "ENABLE_COMMAND_BLOCK", + "SPAWN_PROTECTION", + "VIEW_DISTANCE", + "SIMULATION_DISTANCE", + "LEVEL", + "SEED", + "LEVEL_TYPE", + "GENERATOR_SETTINGS", + "HARDCORE", + "SPAWN_ANIMALS", + "SPAWN_MONSTERS", + "SPAWN_NPCS", + "ENABLE_WHITELIST", + "WHITELIST", + "WHITELIST_FILE", + "OPS", + "OPS_FILE", + "USE_AIKAR_FLAGS", + "USE_MEOWICE_FLAGS", + "JVM_OPTS", + "JVM_XX_OPTS", + "JVM_DD_OPTS", + "ENABLE_JMX", + "RESOURCE_PACK", + "RESOURCE_PACK_SHA1", + "RESOURCE_PACK_ENFORCE", + "ENABLE_RCON", + "RCON_PASSWORD", + "RCON_PORT", + "RCON_CMDS_STARTUP", + "RCON_CMDS_ON_CONNECT", + "RCON_CMDS_FIRST_CONNECT", + "RCON_CMDS_ON_DISCONNECT", + "RCON_CMDS_LAST_DISCONNECT", + "ENABLE_QUERY", + "QUERY_PORT", + "ENABLE_AUTOPAUSE", + "AUTOPAUSE_TIMEOUT_EST", + "AUTOPAUSE_TIMEOUT_INIT", + "AUTOPAUSE_TIMEOUT_KN", + "AUTOPAUSE_PERIOD", + "AUTOPAUSE_KNOCK_INTERFACE", + "ENABLE_AUTOSTOP", + "AUTOSTOP_TIMEOUT_EST", + "AUTOSTOP_TIMEOUT_INIT", + "AUTOSTOP_PERIOD", + "PLUGINS", + "REMOVE_OLD_PLUGINS", + "SPIGET_RESOURCES", + "PAPER_BUILD", + "TZ", + "UID", + "GID", + "STOP_DURATION", + "ICON", + ]); + + const customEnvVars = Object.entries(envVars) + .filter(([key]) => !dockerManagedKeys.has(key)) + .map(([key, value]) => ({ id: crypto.randomUUID(), key, value })); + + const initialData: Partial = { + id: serverData.id, + description: serverData.description || "", + memoryLimit: String(serverData.memory || 2048), + memoryRequest: String(serverData.memory_request ?? 1024), + cpuRequest: serverData.cpu_request || "500m", + cpuLimit: serverData.cpu_limit || "2", + type: (envVars.TYPE || serverData.jar_type || "PAPER") as ServerType, + version: envVars.VERSION || serverData.minecraft_version || "", + customJarUrl: envVars.CUSTOM_SERVER || undefined, + eula: envVars.EULA === "true", + listenPort: String(serverData.listen_port || 25565), + serviceType: toServiceType(serverData.service_type), + nodePort: serverData.node_port ? String(serverData.node_port) : undefined, + + motd: envVars.MOTD || serverData.motd || undefined, + difficulty: toDifficulty(envVars.DIFFICULTY || serverData.difficulty), + mode: toMode(envVars.MODE || serverData.game_mode), + maxPlayers: envVars.MAX_PLAYERS || String(serverData.max_players || 20), + pvp: envVars.PVP ? envVars.PVP === "true" : (serverData.pvp ?? true), + onlineMode: envVars.ONLINE_MODE + ? envVars.ONLINE_MODE === "true" + : (serverData.online_mode ?? true), + allowFlight: envVars.ALLOW_FLIGHT === "true", + enableCommandBlock: envVars.ENABLE_COMMAND_BLOCK === "true", + spawnProtection: envVars.SPAWN_PROTECTION || "16", + viewDistance: envVars.VIEW_DISTANCE || "10", + simulationDistance: envVars.SIMULATION_DISTANCE || "10", + + levelName: envVars.LEVEL || "world", + levelSeed: envVars.SEED || serverData.level_seed || undefined, + levelType: envVars.LEVEL_TYPE || serverData.level_type || undefined, + generatorSettings: envVars.GENERATOR_SETTINGS || undefined, + hardcore: envVars.HARDCORE === "true", + spawnAnimals: envVars.SPAWN_ANIMALS !== "false", + spawnMonsters: envVars.SPAWN_MONSTERS !== "false", + spawnNpcs: envVars.SPAWN_NPCS !== "false", + + enableWhitelist: envVars.ENABLE_WHITELIST === "true", + whitelist: envVars.WHITELIST || undefined, + whitelistFile: envVars.WHITELIST_FILE || undefined, + ops: envVars.OPS || undefined, + opsFile: envVars.OPS_FILE || undefined, + + useAikarFlags: envVars.USE_AIKAR_FLAGS === "true" || serverData.use_aikar_flags || false, + useMeowiceFlags: envVars.USE_MEOWICE_FLAGS === "true" || serverData.use_meowice_flags || false, + jvmOpts: envVars.JVM_OPTS || serverData.jvm_opts || undefined, + jvmXxOpts: envVars.JVM_XX_OPTS || undefined, + jvmDdOpts: envVars.JVM_DD_OPTS || undefined, + enableJmx: envVars.ENABLE_JMX === "true", + + resourcePack: envVars.RESOURCE_PACK || undefined, + resourcePackSha1: envVars.RESOURCE_PACK_SHA1 || undefined, + resourcePackEnforce: envVars.RESOURCE_PACK_ENFORCE === "true", + + enableRcon: envVars.ENABLE_RCON !== "false", + rconPassword: envVars.RCON_PASSWORD || undefined, + rconPort: envVars.RCON_PORT || "25575", + rconCmdsStartup: envVars.RCON_CMDS_STARTUP || undefined, + rconCmdsOnConnect: envVars.RCON_CMDS_ON_CONNECT || undefined, + rconCmdsFirstConnect: envVars.RCON_CMDS_FIRST_CONNECT || undefined, + rconCmdsOnDisconnect: envVars.RCON_CMDS_ON_DISCONNECT || undefined, + rconCmdsLastDisconnect: envVars.RCON_CMDS_LAST_DISCONNECT || undefined, + + enableQuery: envVars.ENABLE_QUERY === "true", + queryPort: envVars.QUERY_PORT || "25565", + + enableAutopause: envVars.ENABLE_AUTOPAUSE === "true", + autopauseTimeoutEst: envVars.AUTOPAUSE_TIMEOUT_EST || "3600", + autopauseTimeoutInit: envVars.AUTOPAUSE_TIMEOUT_INIT || "600", + autopauseTimeoutKn: envVars.AUTOPAUSE_TIMEOUT_KN || "120", + autopausePeriod: envVars.AUTOPAUSE_PERIOD || "10", + autopauseKnockInterface: envVars.AUTOPAUSE_KNOCK_INTERFACE || "eth0", + + enableAutostop: envVars.ENABLE_AUTOSTOP === "true", + autostopTimeoutEst: envVars.AUTOSTOP_TIMEOUT_EST || "3600", + autostopTimeoutInit: envVars.AUTOSTOP_TIMEOUT_INIT || "1800", + autostopPeriod: envVars.AUTOSTOP_PERIOD || "10", + + plugins: envVars.PLUGINS || undefined, + removeOldPlugins: envVars.REMOVE_OLD_PLUGINS === "true", + spigetResources: envVars.SPIGET_RESOURCES || undefined, + + paperBuild: envVars.PAPER_BUILD || undefined, + + serverIcon: envVars.ICON || undefined, + + envVars: customEnvVars, + }; + + return ( +
+
+ +
+

Edit Server: {serverData.id}

+

Update your Minecraft server configuration

+
+
+ + + + Server Configuration + Modify settings for your Minecraft server + + + router.push("/dashboard/servers")} + submitLabel="Save Changes" + /> + + +
+ ); +} diff --git a/apps/web/app/dashboard/servers/page.tsx b/apps/web/app/dashboard/servers/page.tsx new file mode 100644 index 0000000..fd3f11c --- /dev/null +++ b/apps/web/app/dashboard/servers/page.tsx @@ -0,0 +1,474 @@ +"use client"; + +import { + AlertCircle, + Check, + CheckCircle2, + Copy, + FileText, + Globe, + Pencil, + Plus, + Server, + Trash2, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import type { ConnectionInfo, NormalServer, PodInfo, ReverseProxyServer } from "@minikura/api"; +import { getReverseProxyApi } from "@/lib/api-helpers"; +import { api } from "@/lib/api"; + +// Server status component +function ServerStatusCell({ serverId, type }: { serverId: string; type: "normal" | "proxy" }) { + const [pods, setPods] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchPods = async () => { + try { + const endpoint = + type === "normal" + ? api.api.k8s["servers"]({ serverId }).pods.get + : api.api.k8s["reverse-proxy"]({ serverId }).pods.get; + const res = await endpoint(); + if (res.data) { + setPods(res.data as PodInfo[]); + } + } catch (error) { + console.error("Failed to fetch pods:", error); + } finally { + setLoading(false); + } + }; + fetchPods(); + }, [serverId, type]); + + if (loading) { + return ( +
+
+ Loading... +
+ ); + } + + if (pods.length === 0) { + return ( +
+ + No pods +
+ ); + } + + const allRunning = pods.every((pod) => pod.status === "Running"); + const readyCount = pods.filter( + (pod) => pod.ready === "1/1" || pod.ready === "1/1" || pod.ready === "1/1" + ).length; + + return ( +
+ {allRunning ? ( + + ) : ( + + )} + + {readyCount}/{pods.length} Ready + +
+ ); +} + +// Connection info component +function ConnectionInfoCell({ serverId, type }: { serverId: string; type: "normal" | "proxy" }) { + const [connectionInfo, setConnectionInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [copied, setCopied] = useState(false); + + useEffect(() => { + const fetchConnectionInfo = async () => { + try { + const reverseProxyApi = getReverseProxyApi(); + const endpoint = + type === "normal" + ? api.api.servers({ id: serverId })["connection-info"] + : reverseProxyApi({ id: serverId })["connection-info"]; + const res = await endpoint.get(); + if (res.data) { + setConnectionInfo(res.data as ConnectionInfo); + } + } catch (error) { + console.error("Failed to fetch connection info:", error); + } finally { + setLoading(false); + } + }; + fetchConnectionInfo(); + }, [serverId, type]); + + const handleCopy = async () => { + if (connectionInfo?.connectionString) { + await navigator.clipboard.writeText(connectionInfo.connectionString); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + if (loading) { + return Loading...; + } + + if (!connectionInfo) { + return N/A; + } + + return ( + +
+ + {connectionInfo.type} + + {connectionInfo.connectionString && ( +
+ + {connectionInfo.connectionString} + + + + + + +

{copied ? "Copied!" : "Copy to clipboard"}

+
+
+
+ )} + {connectionInfo.note && ( +

{connectionInfo.note}

+ )} +
+
+ ); +} + +export default function ServersPage() { + const router = useRouter(); + const [normalServers, setNormalServers] = useState([]); + const [reverseProxies, setReverseProxies] = useState([]); + const [loading, setLoading] = useState(true); + const [deleteTarget, setDeleteTarget] = useState<{ + id: string; + type: "normal" | "proxy"; + } | null>(null); + + const fetchServers = useCallback(async () => { + try { + const reverseProxyApi = getReverseProxyApi(); + const [normalRes, proxyRes] = await Promise.all([ + api.api.servers.get(), + reverseProxyApi.get(), + ]); + + if (normalRes.data) { + setNormalServers(normalRes.data as unknown as NormalServer[]); + } + if (proxyRes.data) { + setReverseProxies(proxyRes.data as unknown as ReverseProxyServer[]); + } + } catch (error) { + console.error("Failed to fetch servers:", error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchServers(); + }, [fetchServers]); + + const handleDelete = async () => { + if (!deleteTarget) return; + + try { + const reverseProxyApi = getReverseProxyApi(); + if (deleteTarget.type === "normal") { + await api.api.servers({ id: deleteTarget.id }).delete(); + } else { + await reverseProxyApi({ id: deleteTarget.id }).delete(); + } + await fetchServers(); + setDeleteTarget(null); + } catch (error) { + console.error("Failed to delete server:", error); + } + }; + + if (loading) { + return ( +
+
+

Server Management

+

Manage your Minecraft servers

+
+
+

Loading...

+
+
+ ); + } + + return ( +
+
+
+

Server Management

+

+ Manage your Minecraft servers and reverse proxies +

+
+ +
+ + {/* Normal Servers */} + + +
+ + Minecraft Servers +
+ Manage your normal Minecraft server instances +
+ + {normalServers.length === 0 ? ( +
+

No servers created yet

+
+ ) : ( +
+ + + + ID + Status + Storage + Software + Version + Memory (MB) + Network + Description + Actions + + + + {normalServers.map((server) => ( + + {server.id} + + + + + {server.type} + + + {server.jar_type || "VANILLA"} + + + {server.minecraft_version || "LATEST"} + + {server.memory || 1024} + + + + + {server.description || "-"} + + +
+ + + +
+
+
+ ))} +
+
+
+ )} +
+
+ + {/* Reverse Proxy Servers */} + + +
+ + Reverse Proxy Servers +
+ Manage your Velocity and BungeeCord proxy servers +
+ + {reverseProxies.length === 0 ? ( +
+

No reverse proxies created yet

+
+ ) : ( +
+ + + + ID + Status + Type + External + Listen Port + Memory (MB) + Network + Description + Actions + + + + {reverseProxies.map((proxy) => ( + + {proxy.id} + + + + + {proxy.type} + + + {proxy.external_address}:{proxy.external_port} + + {proxy.listen_port} + {proxy.memory} + + + + + {proxy.description || "-"} + + +
+ + + +
+
+
+ ))} +
+
+
+ )} +
+
+ + {/* Delete Confirmation Dialog */} + setDeleteTarget(null)}> + + + Delete Server + + Are you sure you want to delete this{" "} + {deleteTarget?.type === "normal" ? "server" : "reverse proxy"}? This action cannot be + undone. + + + + + + + + +
+ ); +} diff --git a/apps/web/app/dashboard/users/page.tsx b/apps/web/app/dashboard/users/page.tsx new file mode 100644 index 0000000..76f0dea --- /dev/null +++ b/apps/web/app/dashboard/users/page.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { Ban, CheckCircle, Edit, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { getUserApi } from "@/lib/api-helpers"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/lib/api"; +import { useSession } from "@/lib/auth-client"; + +type User = { + id: string; + name: string; + email: string; + role: string; + createdAt: string; + emailVerified: boolean; + isSuspended: boolean; + suspendedUntil: string | null; +}; + +export default function UsersPage() { + const { data: session } = useSession(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [editingUser, setEditingUser] = useState(null); + const [suspendingUser, setSuspendingUser] = useState(null); + const [deleteUser, setDeleteUser] = useState(null); + + const fetchUsers = useCallback(async () => { + try { + const { data } = await api.api.users.get(); + if (data && typeof data === "object" && "users" in data) { + setUsers(data.users as User[]); + } + } catch (error) { + console.error("Failed to fetch users:", error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchUsers(); + }, [fetchUsers]); + + const handleEdit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editingUser) return; + + const formData = new FormData(e.currentTarget); + const name = formData.get("name") as string; + const role = formData.get("role") as string; + + try { + const { error } = await api.api.users({ id: editingUser.id }).patch({ + name, + role: role as "admin" | "user", + }); + + if (!error) { + await fetchUsers(); + setEditingUser(null); + } + } catch (error) { + console.error("Failed to update user:", error); + } + }; + + const handleSuspend = async (e: React.FormEvent) => { + e.preventDefault(); + if (!suspendingUser) return; + + const formData = new FormData(e.currentTarget); + const suspendedUntil = formData.get("suspendedUntil") as string; + + try { + const { error } = await getUserApi(suspendingUser.id).suspension.patch({ + isSuspended: true, + suspendedUntil: suspendedUntil || null, + }); + + if (!error) { + await fetchUsers(); + setSuspendingUser(null); + } + } catch (error) { + console.error("Failed to suspend user:", error); + } + }; + + const handleUnsuspend = async (userId: string) => { + try { + const { error } = await getUserApi(userId).suspension.patch({ + isSuspended: false, + suspendedUntil: null, + }); + + if (!error) { + await fetchUsers(); + } + } catch (error) { + console.error("Failed to unsuspend user:", error); + } + }; + + const handleDelete = async () => { + if (!deleteUser) return; + + try { + const { error } = await api.api.users({ id: deleteUser.id }).delete(); + + if (!error) { + await fetchUsers(); + setDeleteUser(null); + } + } catch (error) { + console.error("Failed to delete user:", error); + } + }; + + const isUserSuspended = (user: User): boolean => { + if (!user.isSuspended) return false; + if (user.suspendedUntil && new Date(user.suspendedUntil) <= new Date()) { + return false; + } + return true; + }; + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+
+

User Management

+

Manage user accounts and permissions

+
+ + + + Users + All registered users in the system + + +
+ + + + Name + Email + Role + Status + Created + Actions + + + + {users.map((user) => ( + + {user.name} + {user.email} + + + {user.role} + + + +
+ {isUserSuspended(user) ? ( + + Suspended + {user.suspendedUntil && + ` until ${new Date(user.suspendedUntil).toLocaleDateString()}`} + + ) : ( + + {user.emailVerified ? "Active" : "Unverified"} + + )} +
+
+ {new Date(user.createdAt).toLocaleDateString()} + +
+ + {isUserSuspended(user) ? ( + + ) : ( + + )} + +
+
+
+ ))} +
+
+
+
+
+ + {/* Edit Dialog */} + setEditingUser(null)}> + + + Edit User + Update user information and role + +
+
+
+ + +
+
+ + +
+
+ + + + +
+
+
+ + {/* Suspend Dialog */} + setSuspendingUser(null)}> + + + Suspend User + + Suspend {suspendingUser?.name} from accessing the system + + +
+
+
+ + +

+ Leave empty for indefinite suspension +

+
+
+ + + + +
+
+
+ + {/* Delete Dialog */} + setDeleteUser(null)}> + + + Delete User + + Are you sure you want to delete {deleteUser?.name}? This action cannot be undone. + + + + + + + + +
+ ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..de45600 --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,150 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +@keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } +} + +@keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } +} + +@layer utilities { + .animate-accordion-down { + animation: accordion-down 0.2s ease-out; + } + .animate-accordion-up { + animation: accordion-up 0.2s ease-out; + } +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 225b603..e164233 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,11 +1,15 @@ -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Minikura - Minecraft Server Manager", + description: "Manage your Minecraft servers with ease", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + {children} ); } diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx new file mode 100644 index 0000000..edd936f --- /dev/null +++ b/apps/web/app/login/page.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { signIn, useSession } from "@/lib/auth-client"; + +export default function LoginPage() { + const router = useRouter(); + const { data: session, isPending } = useSession(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!isPending && session?.user) { + router.replace("/dashboard"); + } + }, [session, isPending, router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(""); + + const formData = new FormData(e.currentTarget); + const email = formData.get("email") as string; + const password = formData.get("password") as string; + + try { + const result = await signIn.email({ + email, + password, + }); + + if (result.error) { + setError(result.error.message || "Invalid email or password"); + } else { + router.push("/dashboard"); + } + } catch (err) { + setError("Failed to connect to server"); + } finally { + setLoading(false); + } + }; + + if (isPending) { + return ( +
+ +
+ ); + } + + if (session?.user) { + return ( +
+ +
+ ); + } + + return ( +
+ + + Minikura + Sign in to your account + + +
+
+ + +
+
+ + +
+ {error &&
{error}
} + +
+
+
+
+ ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 3834edb..52dd7a5 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,17 +1,18 @@ -import { api } from "@minikura/api"; +import { treaty } from "@elysiajs/eden"; +import type { App } from "@minikura/backend"; +import { redirect } from "next/navigation"; -async function fetchData() { - const response = await api.index.get(); - return response; -} - -export default async function Page() { - const data = await fetchData(); - - return ( -
-

Hello React

-
{JSON.stringify(data, null, 2)}
-
- ); +export const dynamic = "force-dynamic"; + +const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000"; +const api = treaty(apiUrl); + +export default async function HomePage() { + const { data } = await api.bootstrap.status.get(); + + if (data?.needsSetup) { + redirect("/bootstrap"); + } else { + redirect("/login"); + } } diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..b7b9791 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/apps/web/components/dashboard-layout.tsx b/apps/web/components/dashboard-layout.tsx new file mode 100644 index 0000000..1642f50 --- /dev/null +++ b/apps/web/components/dashboard-layout.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { + Loader2, + LogOut, + Network, + Server, + Settings, + Users, +} from "lucide-react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import { signOut, useSession } from "@/lib/auth-client"; + +const menuItems = [ + { href: "/dashboard/users", icon: Users, label: "Users" }, + { href: "/dashboard/servers", icon: Server, label: "Servers" }, +]; + +const k8sMenuItems = [ + { href: "/dashboard/k8s", icon: Network, label: "Resources" }, +]; + +export function DashboardLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + const { data: session, isPending } = useSession(); + + useEffect(() => { + if (!isPending && !session?.user) { + router.replace("/login"); + } + }, [session, isPending, router]); + + if (isPending) { + return ( +
+ +
+ ); + } + + if (!session?.user) { + return ( +
+ +
+ ); + } + + const handleSignOut = async () => { + await signOut(); + window.location.href = "/login"; + }; + + const userInitials = + session?.user?.name + ?.split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() || "U"; + + return ( + + + +

Minikura

+
+ + + + + {menuItems.map((item) => ( + + + + + {item.label} + + + + ))} + + + + + Kubernetes + + + {k8sMenuItems.map((item) => ( + + + + + {item.label} + + + + ))} + + + + +
+ + + + + + + + + Settings + + + + + + Sign Out + + + +
+
+ +
+ +
+
+
{children}
+
+
+ ); +} diff --git a/apps/web/components/server-form.tsx b/apps/web/components/server-form.tsx new file mode 100644 index 0000000..4cecf65 --- /dev/null +++ b/apps/web/components/server-form.tsx @@ -0,0 +1,1428 @@ +"use client"; + +import type { GameMode, ServiceType as PrismaServiceType, ServerDifficulty } from "@minikura/db"; +import { Info, Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; + +export type ServerType = "VANILLA" | "PAPER" | "SPIGOT" | "PURPUR" | "FABRIC" | "CUSTOM"; +export type ServiceType = PrismaServiceType; +export type Difficulty = ServerDifficulty; +export type Mode = GameMode; + +export interface EnvVar { + id?: string; + key: string; + value: string; +} + +export interface ServerFormData { + // Basic Configuration + id: string; + description: string; + memoryLimit: string; + memoryRequest: string; + cpuRequest: string; + cpuLimit: string; + + // Server Type & Version + type: ServerType; + version?: string; + customJarUrl?: string; + eula: boolean; + + // Network Configuration + listenPort: string; + serviceType: ServiceType; + nodePort?: string; + + // Server Properties + motd?: string; + difficulty: "peaceful" | "easy" | "normal" | "hard"; + mode: "survival" | "creative" | "adventure" | "spectator"; + maxPlayers: string; + pvp: boolean; + onlineMode: boolean; + allowFlight: boolean; + enableCommandBlock: boolean; + spawnProtection: string; + viewDistance: string; + simulationDistance: string; + + // World Configuration + levelName: string; + levelSeed?: string; + levelType?: string; + generatorSettings?: string; + hardcore: boolean; + spawnAnimals: boolean; + spawnMonsters: boolean; + spawnNpcs: boolean; + + // Player Management + enableWhitelist: boolean; + whitelist?: string; + whitelistFile?: string; + ops?: string; + opsFile?: string; + + // JVM & Performance + useAikarFlags: boolean; + useMeowiceFlags: boolean; + jvmOpts?: string; + jvmXxOpts?: string; + jvmDdOpts?: string; + + // Resource Pack + + resourcePack?: string; + resourcePackSha1?: string; + resourcePackEnforce: boolean; + + // RCON Configuration + enableRcon: boolean; + rconPassword?: string; + rconPort: string; + rconCmdsStartup?: string; + rconCmdsOnConnect?: string; + rconCmdsFirstConnect?: string; + rconCmdsOnDisconnect?: string; + rconCmdsLastDisconnect?: string; + + // Query Protocol + enableQuery: boolean; + queryPort: string; + + // Auto-Pause + enableAutopause: boolean; + autopauseTimeoutEst: string; + autopauseTimeoutInit: string; + autopauseTimeoutKn: string; + autopausePeriod: string; + autopauseKnockInterface: string; + + // Auto-Stop + enableAutostop: boolean; + autostopTimeoutEst: string; + autostopTimeoutInit: string; + autostopPeriod: string; + + // Mods & Plugins + plugins?: string; + removeOldPlugins: boolean; + spigetResources?: string; + + // Type-Specific Options + paperBuild?: string; + + // Advanced + timezone: string; + uid: string; + gid: string; + enableJmx: boolean; + stopDuration: string; + serverIcon?: string; + + // Environment Variables + envVars: EnvVar[]; +} + +const toMode = (value: string): ServerFormData["mode"] => { + if (value === "creative" || value === "adventure" || value === "spectator") { + return value; + } + return "survival"; +}; + +const toDifficulty = (value: string): ServerFormData["difficulty"] => { + if (value === "peaceful" || value === "normal" || value === "hard") { + return value; + } + return "easy"; +}; + +interface ServerFormProps { + initialData?: Partial; + onSubmit: (data: ServerFormData) => Promise; + onCancel: () => void; + submitLabel?: string; + loading?: boolean; +} + +const InfoTooltip = ({ text }: { text: string }) => ( +
+ + + {text} + +
+); + +export function ServerForm({ + initialData, + onSubmit, + onCancel, + submitLabel = "Create Server", + loading = false, +}: ServerFormProps) { + const defaultType = initialData?.type || "PAPER"; + const defaultVersion = defaultType === "CUSTOM" ? "" : initialData?.version || "LATEST"; + + const [formData, setFormData] = useState({ + id: initialData?.id || "", + description: initialData?.description || "", + memoryLimit: initialData?.memoryLimit || "2048", + memoryRequest: initialData?.memoryRequest || "1024", + cpuRequest: initialData?.cpuRequest || "500m", + cpuLimit: initialData?.cpuLimit || "2", + type: defaultType, + version: defaultVersion, + eula: initialData?.eula ?? true, + listenPort: initialData?.listenPort || "25565", + serviceType: initialData?.serviceType || "CLUSTER_IP", + difficulty: initialData?.difficulty || "easy", + mode: initialData?.mode || "survival", + maxPlayers: initialData?.maxPlayers || "20", + pvp: initialData?.pvp ?? true, + onlineMode: initialData?.onlineMode ?? true, + allowFlight: initialData?.allowFlight ?? false, + enableCommandBlock: initialData?.enableCommandBlock ?? false, + spawnProtection: initialData?.spawnProtection || "16", + viewDistance: initialData?.viewDistance || "10", + simulationDistance: initialData?.simulationDistance || "10", + levelName: initialData?.levelName || "world", + hardcore: initialData?.hardcore ?? false, + spawnAnimals: initialData?.spawnAnimals ?? true, + spawnMonsters: initialData?.spawnMonsters ?? true, + spawnNpcs: initialData?.spawnNpcs ?? true, + enableWhitelist: initialData?.enableWhitelist ?? false, + useAikarFlags: initialData?.useAikarFlags ?? false, + useMeowiceFlags: initialData?.useMeowiceFlags ?? false, + resourcePackEnforce: initialData?.resourcePackEnforce ?? false, + enableRcon: initialData?.enableRcon ?? true, + rconPort: initialData?.rconPort || "25575", + enableQuery: initialData?.enableQuery ?? false, + queryPort: initialData?.queryPort || "25565", + enableAutopause: initialData?.enableAutopause ?? false, + autopauseTimeoutEst: initialData?.autopauseTimeoutEst || "3600", + autopauseTimeoutInit: initialData?.autopauseTimeoutInit || "600", + autopauseTimeoutKn: initialData?.autopauseTimeoutKn || "120", + autopausePeriod: initialData?.autopausePeriod || "10", + autopauseKnockInterface: initialData?.autopauseKnockInterface || "eth0", + enableAutostop: initialData?.enableAutostop ?? false, + autostopTimeoutEst: initialData?.autostopTimeoutEst || "3600", + autostopTimeoutInit: initialData?.autostopTimeoutInit || "1800", + autostopPeriod: initialData?.autostopPeriod || "10", + removeOldPlugins: initialData?.removeOldPlugins ?? false, + timezone: initialData?.timezone || "UTC", + uid: initialData?.uid || "1000", + gid: initialData?.gid || "1000", + enableJmx: initialData?.enableJmx ?? false, + stopDuration: initialData?.stopDuration || "60", + customJarUrl: initialData?.customJarUrl || "", + envVars: (initialData?.envVars || []).map((envVar) => ({ + id: envVar.id || crypto.randomUUID(), + key: envVar.key, + value: envVar.value, + })), + }); + + const [error, setError] = useState(null); + + const updateField = (key: K, value: ServerFormData[K]) => { + setFormData((prev) => ({ ...prev, [key]: value })); + }; + + const addEnvVar = () => { + setFormData((prev) => ({ + ...prev, + envVars: [...prev.envVars, { id: crypto.randomUUID(), key: "", value: "" }], + })); + }; + + const removeEnvVar = (index: number) => { + setFormData((prev) => ({ + ...prev, + envVars: prev.envVars.filter((_, i) => i !== index), + })); + }; + + const updateEnvVar = (index: number, field: "key" | "value", value: string) => { + setFormData((prev) => { + const updated = [...prev.envVars]; + updated[index] = { ...updated[index], [field]: value }; + return { ...prev, envVars: updated }; + }); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + // Basic validation + if (!formData.id.trim()) { + setError("Server ID is required"); + return; + } + + if (!/^[a-zA-Z0-9-_]+$/.test(formData.id)) { + setError("ID must be alphanumeric with - or _"); + return; + } + + if (!formData.eula) { + setError("You must accept the Minecraft EULA to create a server"); + return; + } + + if (formData.type === "CUSTOM" && !formData.customJarUrl?.trim()) { + setError("Custom jar URL is required for custom servers"); + return; + } + + try { + await onSubmit(formData); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } + }; + + return ( +
+ + + Basic + Server + World + Players + Performance + Mods/Plugins + Resources + Automation + Network + Advanced + + + {/* Basic Configuration */} + +
+ + updateField("id", e.target.value)} + placeholder="my-server" + required + /> +
+ +
+ +