From 71a21b7b79005c0b59e1ca47ef1ec9536bbfe335 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Thu, 13 Aug 2026 17:37:18 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20plugin=20registry=20a?= =?UTF-8?q?nd=20server=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .devcontainer/docker-compose.yml | 39 ++ .devcontainer/post-create.sh | 29 + .env.example | 12 + apps/backend/package.json | 1 + apps/backend/src/application/di-container.ts | 4 +- .../interfaces/k8s.service.interface.ts | 1 + apps/backend/src/index.ts | 4 +- .../prisma/server.repository.impl.ts | 2 + apps/backend/src/routes/registry.ts | 140 +++++ apps/backend/src/routes/servers.ts | 28 +- apps/backend/src/routes/terminal.ts | 537 +++++++++-------- apps/backend/src/schemas/server.schema.ts | 1 + apps/backend/src/services/k8s.ts | 9 + .../src/services/operator-resource-sync.ts | 37 +- apps/backend/src/services/plugin-registry.ts | 541 +++++++++++++++++ apps/web/app/dashboard/plugins/page.tsx | 7 + .../web/app/dashboard/servers/create/page.tsx | 5 + .../app/dashboard/servers/edit/[id]/page.tsx | 7 + .../dashboard/servers/manage/[id]/page.tsx | 181 ++++++ apps/web/app/dashboard/servers/page.tsx | 3 + apps/web/components/dashboard-layout.tsx | 13 +- apps/web/components/plugin-registry.tsx | 557 ++++++++++++++++++ apps/web/components/server-form.tsx | 20 +- .../server-form/artifact-selector.tsx | 86 +++ .../web/components/server-form/mods-panel.tsx | 4 + apps/web/components/server-form/types.ts | 1 + apps/web/components/servers/server-table.tsx | 18 +- apps/web/components/terminal.tsx | 29 +- bun.lock | 69 ++- .../api/v1alpha1/minecraftserver_types.go | 5 +- .../api/v1alpha1/zz_generated.deepcopy.go | 5 + ...nikura.kirameki.cafe_minecraftservers.yaml | 7 +- ...ura.kirameki.cafe_reverseproxyservers.yaml | 2 +- operator/internal/resources/common.go | 2 +- operator/internal/resources/common_test.go | 6 +- .../internal/resources/minecraftserver.go | 20 +- .../resources/minecraftserver_test.go | 31 + packages/api/src/types.ts | 2 + packages/db/prisma/schema.prisma | 56 ++ 39 files changed, 2201 insertions(+), 320 deletions(-) create mode 100644 apps/backend/src/routes/registry.ts create mode 100644 apps/backend/src/services/plugin-registry.ts create mode 100644 apps/web/app/dashboard/plugins/page.tsx create mode 100644 apps/web/app/dashboard/servers/manage/[id]/page.tsx create mode 100644 apps/web/components/plugin-registry.tsx create mode 100644 apps/web/components/server-form/artifact-selector.tsx diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 25c40e5..89557fd 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -29,12 +29,21 @@ services: depends_on: db: condition: service_healthy + minio-init: + condition: service_completed_successfully environment: - KUBECONFIG=/home/dev/.kube/config - DATABASE_URL=postgresql://postgres:postgres@db:5432/minikura?sslmode=disable - WEB_URL=http://localhost:3001 - API_URL=http://localhost:3000 - KUBERNETES_NAMESPACE=minikura + - PLUGIN_REGISTRY_USER_AGENT=Minikura/1.0 (devcontainer) + - S3_ENDPOINT=http://minio:9000 + - S3_REGION=us-east-1 + - S3_BUCKET=minikura-plugins + - S3_ACCESS_KEY_ID=minikura + - S3_SECRET_ACCESS_KEY=minikura-development + - S3_FORCE_PATH_STYLE=true db: image: postgres:17 @@ -53,6 +62,36 @@ services: timeout: 5s retries: 5 + minio: + image: minio/minio:RELEASE.2025-07-23T15-54-02Z + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minikura + MINIO_ROOT_PASSWORD: minikura-development + volumes: + - minio-data:/data + ports: + - "9002:9000" + - "9003:9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 10 + + minio-init: + image: minio/mc:RELEASE.2025-07-21T05-28-08Z + depends_on: + minio: + condition: service_healthy + entrypoint: ["/bin/sh", "-c"] + command: + - >- + mc alias set local http://minio:9000 minikura minikura-development && + mc mb --ignore-existing local/minikura-plugins + volumes: postgres-data: + minio-data: vscode-server: diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 28203cb..8624f11 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -91,6 +91,35 @@ kubectl wait --for=condition=Ready node --all --timeout=120s \ echo "==> Creating minikura namespace..." kubectl create namespace minikura --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true +# Pods run inside k3s while the backend runs in the outer devcontainer. Expose +# the node address under the same service name used by production manifests. +echo "==> Exposing the development backend to k3s workloads..." +K3S_NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}') +kubectl apply -f - < Installing CRDs..." make -C /workspace/operator install-crds 2>/dev/null \ || echo "[WARN] CRD install failed; run 'bun run operator:crds'" diff --git a/.env.example b/.env.example index 9c12797..a13878f 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,15 @@ MINIKURA_OPERATOR_BACKEND_URL="http://minikura-backend:3000/api" # Comma-separated download URLs for RedisBungee and the shaded Minikura Velocity plugin JAR # MINIKURA_VELOCITY_PLUGIN_URL="https://example.com/redisbungee.jar,https://example.com/minikura-velocity.jar" + +# Plugin registry and S3-compatible artifact storage +PLUGIN_REGISTRY_USER_AGENT="Minikura/1.0 (https://github.com/YuzuZensai/Minikura)" +# The devcontainer supplies http://minio:9000 internally and exposes it on host port 9002. +S3_ENDPOINT="http://localhost:9000" +S3_REGION="us-east-1" +S3_BUCKET="minikura-plugins" +S3_ACCESS_KEY_ID="minikura" +S3_SECRET_ACCESS_KEY="minikura-development" +S3_FORCE_PATH_STYLE="true" +# URL reachable by Minecraft pods. Defaults to MINIKURA_OPERATOR_BACKEND_URL. +# MINIKURA_PLUGIN_DOWNLOAD_BASE_URL="http://minikura-backend:3000/api" diff --git a/apps/backend/package.json b/apps/backend/package.json index a7939b1..31a77db 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -22,6 +22,7 @@ "typescript": "^7.0.2" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1109.0", "@elysiajs/node": "^1.4.5", "@kubernetes/client-node": "^1.4.0", "@minikura/api": "workspace:*", diff --git a/apps/backend/src/application/di-container.ts b/apps/backend/src/application/di-container.ts index 8f0d7ce..ff3abda 100644 --- a/apps/backend/src/application/di-container.ts +++ b/apps/backend/src/application/di-container.ts @@ -3,6 +3,7 @@ import { PrismaServerRepository } from "../infrastructure/repositories/prisma/se import { PrismaUserRepository } from "../infrastructure/repositories/prisma/user.repository.impl"; import { K8sService } from "../services/k8s"; import { OperatorResourceSync } from "../services/operator-resource-sync"; +import { PluginRegistryService } from "../services/plugin-registry"; import { WebSocketService } from "../services/websocket"; import { ReverseProxyService } from "./services/reverse-proxy.service"; import { ServerService } from "./services/server.service"; @@ -14,9 +15,10 @@ const reverseProxyRepo = new PrismaReverseProxyRepository(); const webSocketService = new WebSocketService(); const k8sService = new K8sService(); const operatorResourceSync = new OperatorResourceSync(); +const pluginRegistryService = new PluginRegistryService(); export const userService = new UserService(userRepo); export const serverService = new ServerService(serverRepo, k8sService); export const reverseProxyService = new ReverseProxyService(reverseProxyRepo, k8sService); export const wsService = webSocketService; -export { k8sService, operatorResourceSync }; +export { k8sService, operatorResourceSync, pluginRegistryService }; diff --git a/apps/backend/src/application/interfaces/k8s.service.interface.ts b/apps/backend/src/application/interfaces/k8s.service.interface.ts index a388fa2..b2f207c 100644 --- a/apps/backend/src/application/interfaces/k8s.service.interface.ts +++ b/apps/backend/src/application/interfaces/k8s.service.interface.ts @@ -13,6 +13,7 @@ export interface IK8sService { getPods(): Promise; getPodsByLabel(labelSelector: string): Promise; getPodInfo(podName: string): Promise; + restartPod(podName: string): Promise; getPodLogs( podName: string, options?: { diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 1cc9c24..5feefb1 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -11,6 +11,7 @@ import { errorHandler } from "./middleware/error-handler"; import { bootstrapRoutes } from "./routes/bootstrap"; import { k8sRoutes } from "./routes/k8s"; import { pluginRoutes } from "./routes/plugin"; +import { registryDownloadRoutes, registryRoutes } from "./routes/registry"; import { reverseProxyRoutes } from "./routes/reverse-proxy"; import { serverRoutes } from "./routes/servers"; import { terminalRoutes } from "./routes/terminal"; @@ -37,15 +38,16 @@ const app = new Elysia({ adapter: node() }) }) .all("/auth/*", ({ request }) => auth.handler(request)) .use(bootstrapRoutes) + .group("/api", (app) => app.use(registryDownloadRoutes).use(terminalRoutes)) .use(authPlugin) .group("/api", (app) => app .use(userRoutes) .use(serverRoutes) .use(reverseProxyRoutes) + .use(registryRoutes) .use(pluginRoutes) .use(k8sRoutes) - .use(terminalRoutes) ); export type App = typeof app; diff --git a/apps/backend/src/infrastructure/repositories/prisma/server.repository.impl.ts b/apps/backend/src/infrastructure/repositories/prisma/server.repository.impl.ts index addf331..4b60284 100644 --- a/apps/backend/src/infrastructure/repositories/prisma/server.repository.impl.ts +++ b/apps/backend/src/infrastructure/repositories/prisma/server.repository.impl.ts @@ -83,6 +83,7 @@ export class PrismaServerRepository implements ServerRepository { level_seed: input.level_seed ?? null, level_type: input.level_type ?? null, api_key: token, + running: input.running ?? true, env_variables: input.env_variables ? { create: input.env_variables.map((ev) => ({ @@ -135,6 +136,7 @@ export class PrismaServerRepository implements ServerRepository { motd: input.motd, level_seed: input.level_seed, level_type: input.level_type, + running: input.running, }, include: { env_variables: true }, }); diff --git a/apps/backend/src/routes/registry.ts b/apps/backend/src/routes/registry.ts new file mode 100644 index 0000000..16879fa --- /dev/null +++ b/apps/backend/src/routes/registry.ts @@ -0,0 +1,140 @@ +import { Elysia, t } from "elysia"; +import { pluginRegistryService, operatorResourceSync } from "../application/di-container"; +import { assertAdmin, requireAuth } from "../middleware/auth-guards"; + +const providerSchema = t.Union([t.Literal("MODRINTH"), t.Literal("HANGAR")]); +const platformSchema = t.Union([t.Literal("PAPER"), t.Literal("FOLIA"), t.Literal("VELOCITY")]); +const versionSchema = t.Object({ + provider: providerSchema, + projectId: t.String({ minLength: 1 }), + projectName: t.Optional(t.String({ minLength: 1 })), + versionId: t.String({ minLength: 1 }), + version: t.String({ minLength: 1 }), + platform: platformSchema, + minecraftVersions: t.Array(t.String()), + filename: t.String({ minLength: 1 }), + size: t.Integer({ minimum: 1 }), + sha256: t.Nullable(t.String({ minLength: 64, maxLength: 64 })), + downloadUrl: t.String({ format: "uri" }), + license: t.Nullable(t.String()), + description: t.Optional(t.String()), + author: t.Optional(t.String()), + iconUrl: t.Optional(t.Nullable(t.String())), + projectUrl: t.Optional(t.String({ format: "uri" })), + categories: t.Optional(t.Array(t.String())), + updatedAt: t.Optional(t.Nullable(t.String())), +}); + +export const registryDownloadRoutes = new Elysia({ prefix: "/registry/artifacts" }).get( + "/:token/download", + async ({ params }) => { + const download = await pluginRegistryService.download(params.token); + if (download.response) return download.response; + if (download.redirect) return Response.redirect(download.redirect, 302); + throw new Error("Plugin artifact download is unavailable"); + } +); + +export const registryRoutes = new Elysia({ prefix: "/registry" }) + .use(requireAuth) + .get( + "/search", + async ({ query, user }) => { + assertAdmin(user); + return pluginRegistryService.search( + query.query ?? "", + query.platform, + query.minecraftVersion + ); + }, + { + query: t.Object({ + query: t.Optional(t.String({ default: "" })), + platform: platformSchema, + minecraftVersion: t.Optional(t.String()), + }), + } + ) + .get( + "/versions", + async ({ query, user }) => { + assertAdmin(user); + return pluginRegistryService.versions( + query.provider, + query.projectId, + query.platform, + query.minecraftVersion + ); + }, + { + query: t.Object({ + provider: providerSchema, + projectId: t.String({ minLength: 1 }), + platform: platformSchema, + minecraftVersion: t.Optional(t.String()), + }), + } + ) + .get("/artifacts", async ({ user }) => { + assertAdmin(user); + return pluginRegistryService.listArtifacts(); + }) + .post( + "/artifacts", + async ({ body, user }) => { + assertAdmin(user); + return pluginRegistryService.register(body); + }, + { body: versionSchema } + ) + .post( + "/artifacts/upload", + async ({ body, user }) => { + assertAdmin(user); + return pluginRegistryService.upload(body.file); + }, + { body: t.Object({ file: t.File({ type: "application/java-archive" }) }) } + ) + .delete("/library/:artifactId", async ({ params, user }) => { + assertAdmin(user); + const serverIds = await pluginRegistryService.deleteArtifact(params.artifactId); + await Promise.all(serverIds.map((serverId) => operatorResourceSync.syncServerById(serverId))); + return { success: true }; + }) + .get("/servers/:serverId/plugins", async ({ params, user }) => { + assertAdmin(user); + return pluginRegistryService.list(params.serverId); + }) + .put( + "/servers/:serverId/plugins", + async ({ params, body, user }) => { + assertAdmin(user); + await pluginRegistryService.reconcile(params.serverId, body.artifactIds); + await operatorResourceSync.syncServerById(params.serverId); + return { success: true }; + }, + { body: t.Object({ artifactIds: t.Array(t.String()) }) } + ) + .post( + "/servers/:serverId/plugins", + async ({ params, body, user }) => { + assertAdmin(user); + const registered = await pluginRegistryService.register(body); + const artifact = await pluginRegistryService.deploy(params.serverId, registered.id); + await operatorResourceSync.syncServerById(params.serverId); + return artifact; + }, + { body: versionSchema } + ) + .post("/servers/:serverId/deployments/:artifactId", async ({ params, user }) => { + assertAdmin(user); + const artifact = await pluginRegistryService.deploy(params.serverId, params.artifactId); + await operatorResourceSync.syncServerById(params.serverId); + return artifact; + }) + .delete("/servers/:serverId/installations/:installationId", async ({ params, user }) => { + assertAdmin(user); + await pluginRegistryService.remove(params.serverId, params.installationId); + await operatorResourceSync.syncServerById(params.serverId); + return { success: true }; + }); diff --git a/apps/backend/src/routes/servers.ts b/apps/backend/src/routes/servers.ts index d838a07..9165c12 100644 --- a/apps/backend/src/routes/servers.ts +++ b/apps/backend/src/routes/servers.ts @@ -1,5 +1,6 @@ +import { labelKeys } from "@minikura/api"; import { Elysia } from "elysia"; -import { serverService, wsService } from "../application/di-container"; +import { k8sService, serverService, wsService } from "../application/di-container"; import { bearerToken, findApiKeyOwner } from "../middleware/api-key"; import { assertAdmin, requireAuth } from "../middleware/auth-guards"; import { @@ -8,6 +9,7 @@ import { updateServerSchema, } from "../schemas/server.schema"; import type { WebSocketClient } from "../services/websocket"; +import { operatorResourceName } from "../services/operator-resource-sync"; export const serverRoutes = new Elysia({ prefix: "/servers" }) .ws("/ws", { @@ -79,4 +81,28 @@ export const serverRoutes = new Elysia({ prefix: "/servers" }) assertAdmin(user); await serverService.deleteEnvVariable(params.id, params.key); return { success: true }; + }) + + .post("/:id/actions/start", async ({ params, user }) => { + assertAdmin(user); + return await serverService.updateServer(params.id, { running: true }); + }) + + .post("/:id/actions/stop", async ({ params, user }) => { + assertAdmin(user); + return await serverService.updateServer(params.id, { running: false }); + }) + + .post("/:id/actions/restart", async ({ params, user }) => { + assertAdmin(user); + const server = await serverService.getServerById(params.id); + if (!server.running) { + await serverService.updateServer(params.id, { running: true }); + return { success: true }; + } + const pods = await k8sService.getPodsByLabel( + `${labelKeys.serverId}=${operatorResourceName(params.id)}` + ); + await Promise.all(pods.map((pod) => k8sService.restartPod(pod.name))); + return { success: true }; }); diff --git a/apps/backend/src/routes/terminal.ts b/apps/backend/src/routes/terminal.ts index 0a5063e..391fe96 100644 --- a/apps/backend/src/routes/terminal.ts +++ b/apps/backend/src/routes/terminal.ts @@ -1,12 +1,15 @@ +import { isUserSuspended } from "@minikura/db"; import { getErrorMessage } from "@minikura/shared/errors"; -import { Elysia } from "elysia"; +import type { Elysia } from "elysia"; +import WebSocket, { type RawData } from "ws"; import { k8sService } from "../application/di-container"; import { logger } from "../infrastructure/logger"; -import { requireAdmin } from "../middleware/auth-guards"; +import { auth } from "../middleware/auth"; type TerminalWsData = { query?: Record; k8sWs?: WebSocket; + request: Request; }; type TerminalWs = { @@ -19,307 +22,317 @@ type TerminalMessage = | { type: "input"; data: string } | { type: "resize"; cols: number; rows: number }; -type BunTlsOptions = { +type TlsOptions = { rejectUnauthorized: boolean; cert?: string; key?: string; ca?: string; }; -export const terminalRoutes = new Elysia({ prefix: "/terminal" }).use(requireAdmin).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"; +export const terminalRoutes = (app: Elysia) => + app.ws("/terminal/exec", { + open: async (ws: TerminalWs) => { + const session = await auth.api.getSession({ headers: ws.data.request.headers }); + const user = session?.user; + const suspended = + user && + ((user as unknown as { banned?: boolean }).banned === true || + isUserSuspended( + user as unknown as { isSuspended: boolean; suspendedUntil: Date | null } + )); - logger.debug( - `Opening terminal for pod: ${podName}, container: ${container}, shell: ${shell}, mode: ${mode}` - ); + if (user?.role !== "admin" || suspended) { + ws.send(JSON.stringify({ type: "error", data: "Admin access required" })); + ws.close(); + return; + } - if (!podName) { - ws.send( - JSON.stringify({ - type: "error", - data: "Pod name is required", - }) + 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"; + + logger.debug( + `Opening terminal for pod: ${podName}, container: ${container}, shell: ${shell}, mode: ${mode}` ); - ws.close(); - return; - } - try { - if (!k8sService.isInitialized()) { + if (!podName) { ws.send( JSON.stringify({ type: "error", - data: "Kubernetes client not initialized", + data: "Pod name is required", }) ); 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://"); - - logger.debug(`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 () => { - logger.debug(`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) { - logger.error({ err: logError }, "Failed to fetch historical logs"); - ws.send( - JSON.stringify({ - type: "ready", - data: "Attached to container", - }) - ); - } - } else { + try { + if (!k8sService.isInitialized()) { ws.send( JSON.stringify({ - type: "ready", - data: "Shell ready", + type: "error", + data: "Kubernetes client not initialized", }) ); - } - }; - - 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 { - logger.debug( - { dataType: typeof data, constructor: data?.constructor?.name }, - "Unknown data type" - ); - buffer = new Uint8Array(data); - } - - processBuffer(buffer); - } catch (err) { - logger.error({ err }, "Error processing Kubernetes message"); - } - }; - - function processBuffer(buffer: Uint8Array): void { - if (buffer.length === 0) { + ws.close(); return; } - const channel = buffer[0]; - const message = new TextDecoder().decode(buffer.slice(1)); + const kc = k8sService.getKubeConfig(); + const namespace = k8sService.getNamespace(); + const cluster = kc.getCurrentCluster(); + const user = kc.getCurrentUser(); - if (channel === 1 || channel === 2) { - ws.send(JSON.stringify({ type: "output", data: message })); - } else if (channel === 3) { - logger.error({ message }, "Kubernetes error channel"); - ws.send(JSON.stringify({ type: "error", data: message })); + if (!cluster) { + throw new Error("No current cluster configured"); } - } - k8sWs.onerror = (error: Event) => { - logger.error({ err: error }, "Kubernetes WebSocket error"); - const message = getErrorMessage(error); + const server = cluster.server; + const isConsole = mode === "console"; + const apiPath = `/api/v1/namespaces/${namespace}/pods/${podName}/exec`; + + const params = new URLSearchParams({ + stdout: "true", + stderr: "true", + stdin: "true", + tty: "true", + }); + + const command = isConsole + ? [ + "/bin/sh", + "-c", + 'tail -n 0 -F /data/logs/latest.log & tail_pid=$!; trap "kill $tail_pid" EXIT; cat > /tmp/minikura-console', + ] + : [shell]; + for (const part of command) { + params.append("command", part); + } + + if (container) { + params.append("container", container); + } + + const wsUrl = `${server}${apiPath}?${params.toString()}` + .replace("https://", "wss://") + .replace("http://", "ws://"); + + logger.debug(`Connecting to Kubernetes: ${wsUrl}`); + + const headers: Record = {}; + + 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: TlsOptions = { + 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 k8sWs = new WebSocket(wsUrl, "v4.channel.k8s.io", { + headers, + ...tlsOptions, + }); + ws.data.k8sWs = k8sWs; + + k8sWs.on("open", async () => { + logger.debug(`Connected to Kubernetes ${isConsole ? "console" : "shell"}`); + + if (isConsole) { + 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: "Minecraft console ready", + }) + ); + } catch (logError) { + logger.error({ err: logError }, "Failed to fetch historical logs"); + ws.send( + JSON.stringify({ + type: "ready", + data: "Minecraft console ready", + }) + ); + } + } else { + ws.send( + JSON.stringify({ + type: "ready", + data: "Shell ready", + }) + ); + } + }); + + k8sWs.on("message", (data: RawData) => { + try { + 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 (Array.isArray(data)) { + buffer = new Uint8Array(Buffer.concat(data)); + } else { + logger.debug({ dataType: typeof data }, "Unknown data type"); + return; + } + + processBuffer(buffer); + } catch (err) { + logger.error({ err }, "Error processing Kubernetes message"); + } + }); + + 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) { + try { + const status = JSON.parse(message) as { status?: string; message?: string }; + if (status.status !== "Success") { + ws.send(JSON.stringify({ type: "error", data: status.message || message })); + } + } catch { + ws.send(JSON.stringify({ type: "error", data: message })); + } + } + } + + k8sWs.on("error", (error: Error) => { + logger.error({ err: error }, "Kubernetes WebSocket error"); + const message = getErrorMessage(error); + ws.send( + JSON.stringify({ + type: "error", + data: `Connection error: ${message}`, + }) + ); + }); + + k8sWs.on("close", (code: number, reason: Buffer) => { + const closeReason = reason.toString(); + logger.debug(`Kubernetes WebSocket closed: ${code} ${closeReason}`); + ws.send( + JSON.stringify({ + type: "close", + data: closeReason || `Connection closed (${code})`, + }) + ); + ws.close(); + }); + } catch (error: unknown) { + logger.error({ err: error }, "Error setting up terminal"); + if (error instanceof Error) { + logger.error({ stack: error.stack }, "Error stack"); + } ws.send( JSON.stringify({ type: "error", - data: `Connection error: ${message}`, - }) - ); - }; - - k8sWs.onclose = (event: CloseEvent) => { - logger.debug(`Kubernetes WebSocket closed: ${event.code} ${event.reason}`); - ws.send( - JSON.stringify({ - type: "close", - data: event.reason || "Connection closed", + data: `Failed to connect: ${getErrorMessage(error)}`, }) ); ws.close(); - }; - } catch (error: unknown) { - logger.error({ err: error }, "Error setting up terminal"); - if (error instanceof Error) { - logger.error({ stack: 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; + 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) { + logger.error({ readyState: k8sWs?.readyState }, "Kubernetes WebSocket not ready"); + return; + } + + if (data.type === "input") { + logger.debug({ input: data.data }, "Sending input to k8s"); + 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) { + logger.error({ err: error }, "Error handling terminal message"); + ws.send( + JSON.stringify({ + type: "error", + data: `Error: ${getErrorMessage(error)}`, + }) + ); } + }, + close: (ws: TerminalWs) => { + logger.debug("Client WebSocket closed"); const k8sWs = ws.data.k8sWs; - - if (!k8sWs || k8sWs.readyState !== WebSocket.OPEN) { - logger.error({ readyState: k8sWs?.readyState }, "Kubernetes WebSocket not ready"); - return; + if (k8sWs && k8sWs.readyState === WebSocket.OPEN) { + k8sWs.close(); } - - if (data.type === "input") { - logger.debug({ input: data.data }, "Sending input to k8s"); - 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) { - logger.error({ err: error }, "Error handling terminal message"); - ws.send( - JSON.stringify({ - type: "error", - data: `Error: ${getErrorMessage(error)}`, - }) - ); - } - }, - - close: (ws: TerminalWs) => { - logger.debug("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") { diff --git a/apps/backend/src/schemas/server.schema.ts b/apps/backend/src/schemas/server.schema.ts index ead3bd0..75b249e 100644 --- a/apps/backend/src/schemas/server.schema.ts +++ b/apps/backend/src/schemas/server.schema.ts @@ -50,6 +50,7 @@ export const createServerSchema = z.object({ motd: z.string().optional(), level_seed: z.string().optional(), level_type: z.string().optional(), + running: z.boolean().optional(), }); export const updateServerSchema = createServerSchema.omit({ id: true, type: true }).partial(); diff --git a/apps/backend/src/services/k8s.ts b/apps/backend/src/services/k8s.ts index 800d4da..97dbd54 100644 --- a/apps/backend/src/services/k8s.ts +++ b/apps/backend/src/services/k8s.ts @@ -162,6 +162,15 @@ export class K8sService implements IK8sService { return this.resources.getPodInfo(podName); } + async restartPod(podName: string): Promise { + this.ensureInitialized(); + await this.coreApi.deleteNamespacedPod({ + name: podName, + namespace: this.namespace, + propagationPolicy: "Foreground", + }); + } + async getServiceInfo(serviceName: string) { this.ensureInitialized(); return this.resources.getServiceInfo(serviceName); diff --git a/apps/backend/src/services/operator-resource-sync.ts b/apps/backend/src/services/operator-resource-sync.ts index 01ceaf9..8c12ac0 100644 --- a/apps/backend/src/services/operator-resource-sync.ts +++ b/apps/backend/src/services/operator-resource-sync.ts @@ -3,6 +3,7 @@ import { API_GROUP } from "@minikura/api"; import { prisma, type ReverseProxyWithEnvVars, type ServerWithEnvVars } from "@minikura/db"; import { buildKubeConfig } from "@minikura/shared/kube-auth"; import { logger } from "../infrastructure/logger"; +import { PluginRegistryService } from "./plugin-registry"; const API_VERSION = "v1alpha1"; const FIELD_MANAGER = "minikura-backend"; @@ -50,6 +51,7 @@ export class OperatorResourceSync { private coreApi?: k8s.CoreV1Api; private customObjectsApi?: k8s.CustomObjectsApi; private syncing = false; + private readonly pluginRegistry = new PluginRegistryService(); constructor() { try { @@ -72,7 +74,9 @@ export class OperatorResourceSync { this.syncing = true; try { const [servers, proxies] = await Promise.all([ - prisma.server.findMany({ include: { env_variables: true } }), + prisma.server.findMany({ + include: { env_variables: true, plugins: { include: { artifact: true } } }, + }), prisma.reverseProxyServer.findMany({ include: { env_variables: true } }), ]); const syncResults = await Promise.allSettled([ @@ -106,10 +110,10 @@ export class OperatorResourceSync { } async syncServerById(id: string): Promise { - this.requireClients(); + if (!this.coreApi || !this.customObjectsApi) return; const server = await prisma.server.findUnique({ where: { id }, - include: { env_variables: true }, + include: { env_variables: true, plugins: { include: { artifact: true } } }, }); if (server) await this.syncServer(server); } @@ -133,7 +137,11 @@ export class OperatorResourceSync { await this.deleteResource("reverseproxyservers", operatorResourceName(id)); } - private async syncServer(server: ServerWithEnvVars): Promise { + private async syncServer( + server: ServerWithEnvVars & { + plugins?: Array<{ enabled: boolean; artifact: { download_token: string } }>; + } + ): Promise { const name = operatorResourceName(server.id); const secretName = `mc-${name}-api-key`; await this.upsertSecret(secretName, server.api_key, labels(server.id)); @@ -147,6 +155,7 @@ export class OperatorResourceSync { }, spec: { type: server.type, + running: server.running, description: server.description ?? undefined, listenPort: server.listen_port, serviceType: serviceType(server.service_type), @@ -163,7 +172,7 @@ export class OperatorResourceSync { opts: server.jvm_opts ?? undefined, useAikarFlags: server.use_aikar_flags, useMeowIceFlags: server.use_meowice_flags, - heapPercent: 80, + heapPercent: 60, }, properties: { difficulty: server.difficulty, @@ -175,13 +184,29 @@ export class OperatorResourceSync { levelSeed: server.level_seed ?? undefined, levelType: server.level_type ?? undefined, }, - env: server.env_variables.map((entry) => ({ name: entry.key, value: entry.value })), + env: this.serverEnvironment(server), apiKeySecretRef: secretName, }, }); await this.deleteSecret(`${name}-api-key`); } + private serverEnvironment( + server: ServerWithEnvVars & { + plugins?: Array<{ enabled: boolean; artifact: { download_token: string } }>; + } + ): Array<{ name: string; value: string }> { + const environment = new Map(server.env_variables.map((entry) => [entry.key, entry.value])); + const registryUrls = (server.plugins ?? []) + .filter((plugin) => plugin.enabled) + .map((plugin) => this.pluginRegistry.artifactUrl(plugin.artifact.download_token)); + if (registryUrls.length > 0) { + const existing = environment.get("PLUGINS"); + environment.set("PLUGINS", [existing, ...registryUrls].filter(Boolean).join(",")); + } + return [...environment].map(([name, value]) => ({ name, value })); + } + private async syncReverseProxy(proxy: ReverseProxyWithEnvVars): Promise { const name = operatorResourceName(proxy.id); const secretName = `rp-${name}-api-key`; diff --git a/apps/backend/src/services/plugin-registry.ts b/apps/backend/src/services/plugin-registry.ts new file mode 100644 index 0000000..5f35e48 --- /dev/null +++ b/apps/backend/src/services/plugin-registry.ts @@ -0,0 +1,541 @@ +import { createHash } from "node:crypto"; +import { + CreateBucketCommand, + DeleteObjectCommand, + GetObjectCommand, + HeadBucketCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { type PluginArtifact, type PluginProvider, prisma } from "@minikura/db"; +import { NotFoundError, ValidationError } from "../domain/errors/base.error"; + +const USER_AGENT = process.env.PLUGIN_REGISTRY_USER_AGENT || "Minikura/1.0 (plugin registry)"; +const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; + +export type RegistryProject = { + provider: "MODRINTH" | "HANGAR"; + projectId: string; + name: string; + description: string; + iconUrl: string | null; + downloads: number; + license: string | null; + author: string; + categories: string[]; + minecraftVersions: string[]; + updatedAt: string | null; + projectUrl: string; +}; + +export type RegistryVersion = { + provider: "MODRINTH" | "HANGAR"; + projectId: string; + projectName?: string; + versionId: string; + version: string; + platform: string; + minecraftVersions: string[]; + filename: string; + size: number; + sha256: string | null; + downloadUrl: string; + license: string | null; + description?: string; + author?: string; + iconUrl?: string | null; + projectUrl?: string; + categories?: string[]; + updatedAt?: string | null; +}; + +type ModrinthSearchResponse = { + hits: Array<{ + project_id: string; + title: string; + description: string; + icon_url?: string; + downloads: number; + license?: string; + author: string; + categories: string[]; + versions: string[]; + date_modified: string; + slug?: string; + }>; +}; + +type ModrinthVersion = { + id: string; + version_number: string; + version_type: string; + game_versions: string[]; + loaders: string[]; + files: Array<{ + filename: string; + size: number; + url: string; + primary: boolean; + hashes: { sha512?: string; sha1?: string }; + }>; +}; + +type HangarProject = { + namespace: { owner: string; slug: string }; + name: string; + description: string; + avatarUrl?: string; + stats: { downloads: number }; + settings?: { license?: { type?: string; name?: string } }; + category?: string; + lastUpdated?: string; + memberNames?: string[] | null; + supportedPlatforms?: Record; +}; + +type HangarVersion = { + id: number; + name: string; + channel: { name: string }; + downloads: Record< + string, + { + fileInfo: { name: string; sizeBytes: number; sha256Hash: string }; + downloadUrl: string | null; + externalUrl: string | null; + } + >; + platformDependencies: Record; +}; + +function s3Client(): S3Client { + return new S3Client({ + region: process.env.S3_REGION || "us-east-1", + endpoint: process.env.S3_ENDPOINT, + forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true", + credentials: + process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY + ? { + accessKeyId: process.env.S3_ACCESS_KEY_ID, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, + } + : undefined, + }); +} + +async function registryFetch(url: string): Promise { + const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } }); + if (!response.ok) { + throw new Error(`Plugin provider request failed (${response.status})`); + } + return (await response.json()) as T; +} + +function modrinthLoader(platform: string): string { + if (platform === "VELOCITY") return "velocity"; + if (platform === "FOLIA") return "folia"; + return "paper"; +} + +export class PluginRegistryService { + async search(query: string, platform: string, minecraftVersion?: string) { + const loader = modrinthLoader(platform); + const facets = [["project_type:plugin"], [`categories:${loader}`]]; + if (minecraftVersion && minecraftVersion !== "LATEST") { + facets.push([`versions:${minecraftVersion}`]); + } + + const modrinthUrl = new URL("https://api.modrinth.com/v2/search"); + modrinthUrl.searchParams.set("query", query); + modrinthUrl.searchParams.set("limit", "20"); + modrinthUrl.searchParams.set("index", "downloads"); + modrinthUrl.searchParams.set("facets", JSON.stringify(facets)); + + const hangarUrl = new URL("https://hangar.papermc.io/api/v1/projects"); + if (query.trim()) hangarUrl.searchParams.set("query", query); + hangarUrl.searchParams.set("limit", "20"); + hangarUrl.searchParams.set("sort", "-downloads"); + hangarUrl.searchParams.set("platform", platform === "FOLIA" ? "PAPER" : platform); + + const [modrinth, hangar] = await Promise.allSettled([ + registryFetch(modrinthUrl.toString()), + registryFetch<{ result: HangarProject[] }>(hangarUrl.toString()), + ]); + const projects: RegistryProject[] = []; + + if (modrinth.status === "fulfilled") { + projects.push( + ...modrinth.value.hits.map((project) => ({ + provider: "MODRINTH" as const, + projectId: project.project_id, + name: project.title, + description: project.description, + iconUrl: project.icon_url ?? null, + downloads: project.downloads, + license: project.license ?? null, + author: project.author, + categories: project.categories, + minecraftVersions: project.versions, + updatedAt: project.date_modified, + projectUrl: `https://modrinth.com/plugin/${project.slug ?? project.project_id}`, + })) + ); + } + if (hangar.status === "fulfilled") { + projects.push( + ...hangar.value.result.map((project) => ({ + provider: "HANGAR" as const, + projectId: `${project.namespace.owner}/${project.namespace.slug}`, + name: project.name, + description: project.description, + iconUrl: project.avatarUrl ?? null, + downloads: project.stats.downloads, + license: project.settings?.license?.type ?? project.settings?.license?.name ?? null, + author: project.memberNames?.[0] ?? project.namespace.owner, + categories: project.category ? [project.category] : [], + minecraftVersions: Object.values(project.supportedPlatforms ?? {}).flat(), + updatedAt: project.lastUpdated ?? null, + projectUrl: `https://hangar.papermc.io/${project.namespace.owner}/${project.namespace.slug}`, + })) + ); + } + return projects.sort((a, b) => b.downloads - a.downloads); + } + + async versions( + provider: "MODRINTH" | "HANGAR", + projectId: string, + platform: string, + minecraftVersion?: string + ): Promise { + if (provider === "MODRINTH") { + const url = new URL( + `https://api.modrinth.com/v2/project/${encodeURIComponent(projectId)}/version` + ); + url.searchParams.set("loaders", JSON.stringify([modrinthLoader(platform)])); + if (minecraftVersion && minecraftVersion !== "LATEST") { + url.searchParams.set("game_versions", JSON.stringify([minecraftVersion])); + } + url.searchParams.set("include_changelog", "false"); + const versions = await registryFetch(url.toString()); + return versions + .filter((version) => version.version_type === "release") + .flatMap((version): RegistryVersion[] => { + const file = version.files.find((candidate) => candidate.primary) ?? version.files[0]; + return file + ? [ + { + provider, + projectId, + versionId: version.id, + version: version.version_number, + platform, + minecraftVersions: version.game_versions, + filename: file.filename, + size: file.size, + sha256: null, + downloadUrl: file.url, + license: null, + }, + ] + : []; + }); + } + + const [owner, slug] = projectId.split("/", 2); + if (!owner || !slug) throw new ValidationError("Invalid Hangar project ID"); + const url = new URL( + `https://hangar.papermc.io/api/v1/projects/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}/versions` + ); + url.searchParams.set("limit", "100"); + const response = await registryFetch<{ result: HangarVersion[] }>(url.toString()); + const resolvedPlatform = platform === "FOLIA" ? "PAPER" : platform; + return response.result + .filter((version) => version.channel.name.toLowerCase() === "release") + .filter( + (version) => + !minecraftVersion || + minecraftVersion === "LATEST" || + version.platformDependencies[resolvedPlatform]?.includes(minecraftVersion) + ) + .flatMap((version) => { + const download = version.downloads[resolvedPlatform]; + const downloadUrl = download?.downloadUrl ?? download?.externalUrl; + if (!download || !downloadUrl) return []; + return [ + { + provider, + projectId, + versionId: String(version.id), + version: version.name, + platform, + minecraftVersions: version.platformDependencies[resolvedPlatform] ?? [], + filename: download.fileInfo.name, + size: download.fileInfo.sizeBytes, + sha256: download.fileInfo.sha256Hash, + downloadUrl, + license: null, + }, + ]; + }); + } + + async register(input: RegistryVersion): Promise { + this.validateProviderUrl(input.provider, input.downloadUrl); + const sha256 = input.sha256 ?? (await this.hashRemoteFile(input.downloadUrl, input.size)); + const artifact = await prisma.pluginArtifact.upsert({ + where: { + provider_provider_version_id_platform_filename: { + provider: input.provider, + provider_version_id: input.versionId, + platform: input.platform, + filename: input.filename, + }, + }, + update: { + source_url: input.downloadUrl, + sha256, + size: input.size, + name: input.projectName ?? input.projectId, + license: input.license, + description: input.description, + author: input.author, + icon_url: input.iconUrl, + project_url: input.projectUrl, + categories: input.categories ?? [], + provider_updated_at: input.updatedAt ? new Date(input.updatedAt) : null, + }, + create: { + provider: input.provider, + provider_project_id: input.projectId, + provider_version_id: input.versionId, + name: input.projectName ?? input.projectId, + version: input.version, + platform: input.platform, + minecraft_versions: input.minecraftVersions, + filename: input.filename, + size: input.size, + sha256, + source_url: input.downloadUrl, + license: input.license, + }, + }); + return artifact; + } + + async deploy(serverId: string, artifactId: string): Promise { + const [server, artifact] = await Promise.all([ + prisma.server.findUnique({ where: { id: serverId } }), + prisma.pluginArtifact.findUnique({ where: { id: artifactId } }), + ]); + if (!server) throw new NotFoundError("Server", serverId); + if (!artifact) throw new NotFoundError("Plugin artifact", artifactId); + await prisma.$transaction([ + prisma.serverPlugin.deleteMany({ + where: { + server_id: serverId, + artifact: { + provider: artifact.provider, + provider_project_id: artifact.provider_project_id, + id: { not: artifact.id }, + }, + }, + }), + prisma.serverPlugin.upsert({ + where: { server_id_artifact_id: { server_id: serverId, artifact_id: artifact.id } }, + update: { enabled: true }, + create: { server_id: serverId, artifact_id: artifact.id }, + }), + ]); + return artifact; + } + + async upload(file: File): Promise { + if (!file.name.toLowerCase().endsWith(".jar")) { + throw new ValidationError("Plugin upload must be a JAR file"); + } + if (file.size === 0 || file.size > MAX_UPLOAD_BYTES) { + throw new ValidationError("Plugin upload must be between 1 byte and 100 MiB"); + } + const bytes = new Uint8Array(await file.arrayBuffer()); + if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) { + throw new ValidationError("Plugin upload is not a valid JAR archive"); + } + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const bucket = this.bucket(); + const client = s3Client(); + await this.ensureBucket(client, bucket); + const objectKey = `artifacts/sha256/${sha256.slice(0, 2)}/${sha256}.jar`; + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: objectKey, + Body: bytes, + ContentType: "application/java-archive", + Metadata: { sha256, filename: file.name }, + }) + ); + const artifact = await prisma.pluginArtifact.upsert({ + where: { + provider_provider_version_id_platform_filename: { + provider: "UPLOAD", + provider_version_id: sha256, + platform: "SERVER", + filename: file.name, + }, + }, + update: { object_key: objectKey }, + create: { + provider: "UPLOAD", + provider_project_id: sha256, + provider_version_id: sha256, + name: file.name.replace(/\.jar$/i, ""), + version: "uploaded", + platform: "SERVER", + minecraft_versions: [], + filename: file.name, + size: file.size, + sha256, + storage_mode: "S3", + object_key: objectKey, + }, + }); + return artifact; + } + + async listArtifacts() { + return prisma.pluginArtifact.findMany({ + include: { server_plugins: { select: { server_id: true } } }, + orderBy: { created_at: "desc" }, + }); + } + + async deleteArtifact(artifactId: string): Promise { + const artifact = await prisma.pluginArtifact.findUnique({ + where: { id: artifactId }, + include: { server_plugins: { select: { server_id: true } } }, + }); + if (!artifact) throw new NotFoundError("Plugin artifact", artifactId); + + if (artifact.storage_mode === "S3" && artifact.object_key) { + await s3Client().send( + new DeleteObjectCommand({ Bucket: this.bucket(), Key: artifact.object_key }) + ); + } + + await prisma.$transaction([ + prisma.serverPlugin.deleteMany({ where: { artifact_id: artifactId } }), + prisma.pluginArtifact.delete({ where: { id: artifactId } }), + ]); + return [...new Set(artifact.server_plugins.map((plugin) => plugin.server_id))]; + } + + async list(serverId: string) { + return prisma.serverPlugin.findMany({ + where: { server_id: serverId }, + include: { artifact: true }, + orderBy: { created_at: "asc" }, + }); + } + + async reconcile(serverId: string, artifactIds: string[]): Promise { + const server = await prisma.server.findUnique({ where: { id: serverId } }); + if (!server) throw new NotFoundError("Server", serverId); + const uniqueIds = [...new Set(artifactIds)]; + const artifacts = await prisma.pluginArtifact.findMany({ where: { id: { in: uniqueIds } } }); + if (artifacts.length !== uniqueIds.length) { + throw new ValidationError("One or more plugin artifacts do not exist"); + } + await prisma.$transaction([ + prisma.serverPlugin.deleteMany({ + where: { server_id: serverId, artifact_id: { notIn: uniqueIds } }, + }), + ...uniqueIds.map((artifactId) => + prisma.serverPlugin.upsert({ + where: { server_id_artifact_id: { server_id: serverId, artifact_id: artifactId } }, + update: { enabled: true }, + create: { server_id: serverId, artifact_id: artifactId }, + }) + ), + ]); + } + + async remove(serverId: string, installationId: string): Promise { + const result = await prisma.serverPlugin.deleteMany({ + where: { id: installationId, server_id: serverId }, + }); + if (result.count === 0) throw new NotFoundError("Server plugin", installationId); + } + + async download(token: string): Promise<{ redirect?: string; response?: Response }> { + const artifact = await prisma.pluginArtifact.findUnique({ where: { download_token: token } }); + if (!artifact) throw new NotFoundError("Plugin artifact"); + if (artifact.storage_mode === "REMOTE" && artifact.source_url) { + return { redirect: artifact.source_url }; + } + if (!artifact.object_key) throw new NotFoundError("Plugin artifact object"); + const object = await s3Client().send( + new GetObjectCommand({ + Bucket: this.bucket(), + Key: artifact.object_key, + }) + ); + if (!object.Body) throw new NotFoundError("Plugin artifact object"); + return { + response: new Response(object.Body.transformToWebStream(), { + headers: { + "Content-Type": object.ContentType || "application/java-archive", + "Content-Length": String(object.ContentLength ?? artifact.size), + "Content-Disposition": `attachment; filename="${artifact.filename.replaceAll('"', "")}"`, + ETag: `"${artifact.sha256}"`, + "Cache-Control": "private, max-age=300, immutable", + }, + }), + }; + } + + artifactUrl(token: string): string { + const baseUrl = + process.env.MINIKURA_PLUGIN_DOWNLOAD_BASE_URL || + process.env.MINIKURA_OPERATOR_BACKEND_URL || + "http://minikura-backend:3000/api"; + return `${baseUrl.replace(/\/$/, "")}/registry/artifacts/${token}/download`; + } + + private async hashRemoteFile(url: string, expectedSize: number): Promise { + if (expectedSize > MAX_UPLOAD_BYTES) throw new ValidationError("Plugin artifact is too large"); + const response = await fetch(url, { + headers: { "User-Agent": USER_AGENT }, + redirect: "follow", + }); + if (!response.ok) throw new ValidationError("Unable to download plugin artifact"); + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength !== expectedSize) + throw new ValidationError("Plugin artifact size changed"); + return createHash("sha256").update(bytes).digest("hex"); + } + + private validateProviderUrl(provider: PluginProvider, value: string): void { + const url = new URL(value); + const allowed = + provider === "MODRINTH" + ? url.protocol === "https:" && url.hostname === "cdn.modrinth.com" + : provider === "HANGAR" + ? url.protocol === "https:" && url.hostname === "hangarcdn.papermc.io" + : false; + if (!allowed) + throw new ValidationError("Plugin artifact URL is not from the selected provider"); + } + + private bucket(): string { + return process.env.S3_BUCKET || "minikura-plugins"; + } + + private async ensureBucket(client: S3Client, bucket: string): Promise { + try { + await client.send(new HeadBucketCommand({ Bucket: bucket })); + } catch { + await client.send(new CreateBucketCommand({ Bucket: bucket })); + } + } +} diff --git a/apps/web/app/dashboard/plugins/page.tsx b/apps/web/app/dashboard/plugins/page.tsx new file mode 100644 index 0000000..5e96075 --- /dev/null +++ b/apps/web/app/dashboard/plugins/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { PluginRegistry } from "@/components/plugin-registry"; + +export default function PluginsPage() { + return ; +} diff --git a/apps/web/app/dashboard/servers/create/page.tsx b/apps/web/app/dashboard/servers/create/page.tsx index 15c93b4..282ef02 100644 --- a/apps/web/app/dashboard/servers/create/page.tsx +++ b/apps/web/app/dashboard/servers/create/page.tsx @@ -35,6 +35,11 @@ export default function CreateServerPage() { throw new Error(errorMsg); } + const pluginResponse = await api.api.registry + .servers({ serverId: payload.id }) + .plugins.put({ artifactIds: data.registryArtifactIds }); + if (pluginResponse.error) throw new Error("Server created, but plugin deployment failed"); + router.push("/dashboard/servers"); }; diff --git a/apps/web/app/dashboard/servers/edit/[id]/page.tsx b/apps/web/app/dashboard/servers/edit/[id]/page.tsx index 4f5eb2b..88ef3cc 100644 --- a/apps/web/app/dashboard/servers/edit/[id]/page.tsx +++ b/apps/web/app/dashboard/servers/edit/[id]/page.tsx @@ -95,6 +95,13 @@ export default function EditServerPage() { throw new Error(errorMsg); } + if (resourceKind === "server") { + const pluginResponse = await api.api.registry + .servers({ serverId }) + .plugins.put({ artifactIds: data.registryArtifactIds }); + if (pluginResponse.error) throw new Error("Server updated, but plugin deployment failed"); + } + router.push("/dashboard/servers"); }; diff --git a/apps/web/app/dashboard/servers/manage/[id]/page.tsx b/apps/web/app/dashboard/servers/manage/[id]/page.tsx new file mode 100644 index 0000000..d2bca57 --- /dev/null +++ b/apps/web/app/dashboard/servers/manage/[id]/page.tsx @@ -0,0 +1,181 @@ +"use client"; + +import type { NormalServer, PodInfo } from "@minikura/api"; +import { ArrowLeft, Play, RefreshCw, ServerIcon, Square } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { PageHeader, PageShell, StatePanel } from "@/components/page-layout"; +import { SectionCard } from "@/components/section-card"; +import { Terminal } from "@/components/terminal"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { api } from "@/lib/api-client"; + +export default function ManageServerPage() { + const router = useRouter(); + const serverId = useParams<{ id: string }>().id; + const [server, setServer] = useState(null); + const [pods, setPods] = useState([]); + const [loading, setLoading] = useState(true); + const [acting, setActing] = useState(false); + const [error, setError] = useState(null); + + const refresh = async () => { + const [serverResponse, podsResponse] = await Promise.all([ + api.api.servers({ id: serverId }).get(), + api.api.k8s.servers({ serverId }).pods.get(), + ]); + if (serverResponse.error) throw serverResponse.error; + if (podsResponse.error) throw podsResponse.error; + setServer(serverResponse.data as NormalServer); + setPods((podsResponse.data ?? []) as PodInfo[]); + }; + + useEffect(() => { + void Promise.all([ + api.api.servers({ id: serverId }).get(), + api.api.k8s.servers({ serverId }).pods.get(), + ]) + .then(([serverResponse, podsResponse]) => { + if (serverResponse.error) throw serverResponse.error; + if (podsResponse.error) throw podsResponse.error; + setServer(serverResponse.data as NormalServer); + setPods((podsResponse.data ?? []) as PodInfo[]); + }) + .catch(() => setError("Unable to load server operations")) + .finally(() => setLoading(false)); + }, [serverId]); + + const action = async (kind: "start" | "stop" | "restart") => { + setActing(true); + setError(null); + try { + const response = await api.api.servers({ id: serverId }).actions[kind].post(); + if (response.error) throw response.error; + await refresh(); + } catch { + setError(`Failed to ${kind} server`); + } finally { + setActing(false); + } + }; + + if (loading) + return ; + if (!server) return ; + + const pod = pods.find((candidate) => candidate.status === "Running") ?? pods[0]; + const running = server.running !== false; + + return ( + + router.push("/dashboard/servers")}> + + + } + actions={ +
+ + {running ? "Desired: running" : "Stopped"} + + {running ? ( + <> + + + + ) : ( + + )} +
+ } + className="flex-row items-center justify-start" + /> + + {error && ( +

{error}

+ )} + +
+
+

Pod

+

{pod?.name ?? "Not scheduled"}

+
+
+

Runtime

+

+ {server.jar_type} {server.minecraft_version} +

+
+
+

Status

+

+ {pod ? `${pod.status} · ${pod.ready} ready` : running ? "Starting" : "Stopped"} +

+
+
+ + } + headerAction={ + + } + contentClassName="p-0" + > + {!pod ? ( + + ) : ( + + + Live Console + Container Shell + + + + + + + + + )} + +
+ ); +} diff --git a/apps/web/app/dashboard/servers/page.tsx b/apps/web/app/dashboard/servers/page.tsx index 1b47329..f60a586 100644 --- a/apps/web/app/dashboard/servers/page.tsx +++ b/apps/web/app/dashboard/servers/page.tsx @@ -65,6 +65,9 @@ export default function ServersPage() { type="normal" servers={normalServers} onEdit={isAdmin ? (id) => router.push(`/dashboard/servers/edit/${id}`) : undefined} + onManage={ + isAdmin ? (id) => router.push(`/dashboard/servers/manage/${id}`) : undefined + } onDelete={isAdmin ? (id) => setDeleteTarget({ id, type: "normal" }) : undefined} /> diff --git a/apps/web/components/dashboard-layout.tsx b/apps/web/components/dashboard-layout.tsx index 67f7f96..995f8f6 100644 --- a/apps/web/components/dashboard-layout.tsx +++ b/apps/web/components/dashboard-layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { GitGraph, LogOut, type LucideIcon, Network, Server, Users } from "lucide-react"; +import { GitGraph, LogOut, type LucideIcon, Network, Package, Server, Users } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect } from "react"; @@ -55,6 +55,13 @@ const navigation: NavigationGroup[] = [ adminOnly: true, }, { href: "/dashboard/servers", icon: Server, label: "Servers", context: "Workloads" }, + { + href: "/dashboard/plugins", + icon: Package, + label: "Plugins", + context: "Registry", + adminOnly: true, + }, { href: "/dashboard/topology", icon: GitGraph, @@ -94,8 +101,10 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) { (pathname === "/dashboard/users" || pathname.startsWith("/dashboard/topology") || pathname.startsWith("/dashboard/k8s") || + pathname.startsWith("/dashboard/plugins") || pathname.startsWith("/dashboard/servers/create") || - pathname.startsWith("/dashboard/servers/edit")) + pathname.startsWith("/dashboard/servers/edit") || + pathname.startsWith("/dashboard/servers/manage")) ) { router.replace("/dashboard/servers"); } diff --git a/apps/web/components/plugin-registry.tsx b/apps/web/components/plugin-registry.tsx new file mode 100644 index 0000000..e1b47f8 --- /dev/null +++ b/apps/web/components/plugin-registry.tsx @@ -0,0 +1,557 @@ +"use client"; + +import { HardDriveUpload, LoaderCircle, Package, PackageSearch, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { ConfirmDialog } from "@/components/confirm-dialog"; +import { PageHeader, PageShell, StatePanel } from "@/components/page-layout"; +import { SectionCard } from "@/components/section-card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { api } from "@/lib/api-client"; + +type Project = { + provider: "MODRINTH" | "HANGAR"; + projectId: string; + name: string; + description: string; + iconUrl: string | null; + downloads: number; + license: string | null; + author: string; + categories: string[]; + minecraftVersions: string[]; + updatedAt: string | null; + projectUrl: string; +}; + +type Version = { + provider: "MODRINTH" | "HANGAR"; + projectId: string; + projectName?: string; + versionId: string; + version: string; + platform: "PAPER" | "FOLIA" | "VELOCITY"; + minecraftVersions: string[]; + filename: string; + size: number; + sha256: string | null; + downloadUrl: string; + license: string | null; + description?: string; + author?: string; + iconUrl?: string | null; + projectUrl?: string; + categories?: string[]; + updatedAt?: string | null; +}; + +type Artifact = { + id: string; + provider: string; + name: string; + version: string; + filename: string; + description: string | null; + author: string | null; + icon_url: string | null; + project_url: string | null; + categories: string[]; + server_plugins: Array<{ server_id: string }>; +}; + +export function PluginRegistry() { + const [query, setQuery] = useState(""); + const [projects, setProjects] = useState([]); + const [versions, setVersions] = useState([]); + const [selectedProject, setSelectedProject] = useState(null); + const [artifacts, setArtifacts] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const platform = "PAPER" as const; + + const loadArtifacts = async () => { + const response = await api.api.registry.artifacts.get(); + if (response.error) throw response.error; + setArtifacts((response.data ?? []) as Artifact[]); + }; + + useEffect(() => { + void api.api.registry.artifacts + .get() + .then(({ data, error: responseError }) => { + if (responseError) throw responseError; + setArtifacts((data ?? []) as Artifact[]); + }) + .catch((loadError) => + setError(loadError instanceof Error ? loadError.message : "Failed to load registry") + ); + }, []); + + useEffect(() => { + if (projects.length > 0) return; + void api.api.registry.search + .get({ + query: { + query: "", + platform, + }, + }) + .then(({ data, error: responseError }) => { + if (responseError) throw responseError; + setProjects((data ?? []) as Project[]); + }) + .catch((loadError) => + setError(loadError instanceof Error ? loadError.message : "Failed to load top plugins") + ); + }, [platform, projects.length]); + + const search = async () => { + if (!query.trim()) return; + setBusy(true); + setError(null); + setSelectedProject(null); + setVersions([]); + try { + const response = await api.api.registry.search.get({ + query: { + query, + platform, + }, + }); + if (response.error) throw response.error; + setProjects((response.data ?? []) as Project[]); + } catch (searchError) { + setError(searchError instanceof Error ? searchError.message : "Search failed"); + } finally { + setBusy(false); + } + }; + + const selectProject = async (project: Project) => { + setBusy(true); + setError(null); + setSelectedProject(project); + try { + const response = await api.api.registry.versions.get({ + query: { + provider: project.provider, + projectId: project.projectId, + platform, + }, + }); + if (response.error) throw response.error; + setVersions((response.data ?? []) as Version[]); + } catch (versionError) { + setError(versionError instanceof Error ? versionError.message : "Failed to load versions"); + } finally { + setBusy(false); + } + }; + + const store = async (version: Version) => { + setBusy(true); + setError(null); + try { + const response = await api.api.registry.artifacts.post({ + ...version, + projectName: selectedProject?.name, + description: selectedProject?.description, + author: selectedProject?.author, + iconUrl: selectedProject?.iconUrl, + projectUrl: selectedProject?.projectUrl, + categories: selectedProject?.categories, + updatedAt: selectedProject?.updatedAt, + }); + if (response.error) throw response.error; + await loadArtifacts(); + } catch (installError) { + setError(installError instanceof Error ? installError.message : "Registry import failed"); + } finally { + setBusy(false); + } + }; + + const upload = async (file: File | undefined) => { + if (!file) return; + setBusy(true); + setError(null); + try { + const response = await api.api.registry.artifacts.upload.post({ file }); + if (response.error) throw response.error; + await loadArtifacts(); + } catch (uploadError) { + setError(uploadError instanceof Error ? uploadError.message : "Upload failed"); + } finally { + setBusy(false); + } + }; + + const deleteArtifact = async () => { + if (!deleteTarget) return; + setBusy(true); + setError(null); + try { + const response = await api.api.registry.library({ artifactId: deleteTarget.id }).delete(); + if (response.error) throw response.error; + setDeleteTarget(null); + await loadArtifacts(); + } catch (deleteError) { + setError(deleteError instanceof Error ? deleteError.message : "Failed to delete artifact"); + } finally { + setBusy(false); + } + }; + + return ( + + + + + + + Discover + + + Library + + {artifacts.length} + + + + + + } + > +
+
+ setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void search(); + } + }} + placeholder="Search ViaVersion, LuckPerms, CoreProtect..." + /> + +
+ + {error && ( +

+ {error} +

+ )} + + {selectedProject ? ( +
+
+
+
+ {selectedProject.iconUrl ? ( + + ) : ( +
+ +
+ )} +
+

{selectedProject.name}

+

+ by {selectedProject.author} ·{" "} + {selectedProject.downloads.toLocaleString()} downloads +

+
+
+

+ {selectedProject.description} +

+
+ {selectedProject.provider} + {selectedProject.license && ( + {selectedProject.license} + )} + {selectedProject.categories.slice(0, 4).map((category) => ( + + {category} + + ))} +
+
+
+ + +
+
+ {!busy && versions.length === 0 && ( + + )} + {versions.slice(0, 15).map((version) => ( +
+
+

{version.version}

+

+ {version.filename} · {(version.size / 1024 / 1024).toFixed(1)} MiB +

+
+
+ +
+
+ ))} +
+ ) : projects.length > 0 ? ( +
+ {!query.trim() && ( +
+
+

Top plugins

+

+ Popular projects ranked by downloads +

+
+ {projects.length} projects +
+ )} +
+ {projects.map((project, index) => ( + + ))} +
+
+ ) : ( + } + className="min-h-56" + /> + )} +
+
+
+ + + } + headerAction={ + + } + > + {artifacts.length === 0 ? ( + } + className="min-h-52" + /> + ) : ( +
+ {artifacts.map((artifact) => ( +
+
+
+ {artifact.icon_url ? ( + + ) : ( +
+ +
+ )} + + {artifact.provider === "UPLOAD" ? "Private" : artifact.provider} + +
+
+

{artifact.name}

+ {artifact.author && ( +

+ by {artifact.author} +

+ )} +

+ {artifact.version} · {artifact.filename} +

+
+ {artifact.description && ( +

+ {artifact.description} +

+ )} + {artifact.categories.length > 0 && ( +
+ {artifact.categories.slice(0, 3).map((category) => ( + + {category} + + ))} +
+ )} +

+ Deployed to {artifact.server_plugins.length}{" "} + {artifact.server_plugins.length === 1 ? "server" : "servers"} +

+
+
+ + Assign from Create/Edit Server + + {artifact.project_url && ( + + )} + +
+
+ ))} +
+ )} +
+
+
+ + + Delete {deleteTarget?.name} from the global registry? This removes it + from every server and permanently deletes private S3 content. + + } + confirmLabel="Delete Artifact" + onConfirm={deleteArtifact} + onOpenChange={(open) => !open && setDeleteTarget(null)} + /> +
+ ); +} diff --git a/apps/web/components/server-form.tsx b/apps/web/components/server-form.tsx index 8c753e3..d192ab5 100644 --- a/apps/web/components/server-form.tsx +++ b/apps/web/components/server-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { AdvancedPanel } from "@/components/server-form/advanced-panel"; import { AutomationPanel } from "@/components/server-form/automation-panel"; import { BasicPanel } from "@/components/server-form/basic-panel"; @@ -14,6 +14,7 @@ import type { ServerFormData, UpdateServerField } from "@/components/server-form import { WorldPanel } from "@/components/server-form/world-panel"; import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { api } from "@/lib/api-client"; export type { Difficulty, @@ -87,6 +88,7 @@ export function ServerForm({ autostopTimeoutInit: initialData?.autostopTimeoutInit || "1800", autostopPeriod: initialData?.autostopPeriod || "10", removeOldPlugins: initialData?.removeOldPlugins ?? false, + registryArtifactIds: initialData?.registryArtifactIds ?? [], timezone: initialData?.timezone || "UTC", uid: initialData?.uid || "1000", gid: initialData?.gid || "1000", @@ -101,6 +103,22 @@ export function ServerForm({ }); const [error, setError] = useState(null); + useEffect(() => { + if (!initialData?.id) return; + void api.api.registry + .servers({ serverId: initialData.id }) + .plugins.get() + .then(({ data, error: responseError }) => { + if (responseError) throw responseError; + const plugins = (data ?? []) as Array<{ artifact_id: string }>; + setFormData((previous) => ({ + ...previous, + registryArtifactIds: plugins.map((plugin) => plugin.artifact_id), + })); + }) + .catch(() => setError("Failed to load deployed registry plugins")); + }, [initialData?.id]); + const updateField: UpdateServerField = (key, value) => { setFormData((previous) => ({ ...previous, [key]: value })); }; diff --git a/apps/web/components/server-form/artifact-selector.tsx b/apps/web/components/server-form/artifact-selector.tsx new file mode 100644 index 0000000..8e14b29 --- /dev/null +++ b/apps/web/components/server-form/artifact-selector.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Package } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { api } from "@/lib/api-client"; +import type { ServerFormPanelProps } from "./types"; + +type Artifact = { + id: string; + provider: string; + name: string; + version: string; + filename: string; +}; + +export function ArtifactSelector({ formData, updateField }: ServerFormPanelProps) { + const [artifacts, setArtifacts] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + void api.api.registry.artifacts + .get() + .then(({ data, error: responseError }) => { + if (responseError) throw responseError; + setArtifacts((data ?? []) as Artifact[]); + }) + .catch((loadError) => + setError(loadError instanceof Error ? loadError.message : "Failed to load plugins") + ); + }, []); + + const toggle = (artifactId: string, checked: boolean) => { + updateField( + "registryArtifactIds", + checked + ? [...new Set([...formData.registryArtifactIds, artifactId])] + : formData.registryArtifactIds.filter((id) => id !== artifactId) + ); + }; + + return ( +
+
+
+

Plugin Library

+

+ Select stored artifacts to deploy when this server is saved. +

+
+ {formData.registryArtifactIds.length} selected +
+ {error &&

{error}

} + {artifacts.length === 0 ? ( +
+ Store or upload plugins from the global Plugins page first. +
+ ) : ( +
+ {artifacts.map((artifact) => { + const checked = formData.registryArtifactIds.includes(artifact.id); + return ( +
+ toggle(artifact.id, value === true)} + /> + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/components/server-form/mods-panel.tsx b/apps/web/components/server-form/mods-panel.tsx index 8035d99..6360c5e 100644 --- a/apps/web/components/server-form/mods-panel.tsx +++ b/apps/web/components/server-form/mods-panel.tsx @@ -3,6 +3,7 @@ import { Input } from "@/components/ui/input"; import { TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { CheckboxField, Field } from "./fields"; +import { ArtifactSelector } from "./artifact-selector"; import type { ServerFormPanelProps } from "./types"; export function ModsPanel({ formData, updateField }: ServerFormPanelProps) { @@ -11,6 +12,9 @@ export function ModsPanel({ formData, updateField }: ServerFormPanelProps) { {formData.type === "CUSTOM" && ( Mods/plugins automation is intended for Vanilla/Paper workflows. )} + {["PAPER", "SPIGOT", "PURPUR"].includes(formData.type) && ( + + )} void; + onManage?: (id: string) => void; onDelete?: (id: string) => void; } | { @@ -26,15 +27,27 @@ function RowActions({ kind, onEdit, onDelete, + onManage, }: { id: string; kind: string; onEdit?: (id: string) => void; onDelete?: (id: string) => void; + onManage?: (id: string) => void; }) { - if (!onEdit && !onDelete) return null; + if (!onEdit && !onDelete && !onManage) return null; return ( + {onManage && ( + + )} {onEdit && (