feat: initial prototype

This commit is contained in:
2026-02-13 15:52:13 +07:00
parent 134351b326
commit e8dbefde43
140 changed files with 12390 additions and 1369 deletions

View File

@@ -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}`;
}
}

View File

@@ -0,0 +1,45 @@
import type { DomainEvent } from "../domain/events/domain-event";
type EventHandler<T extends DomainEvent = DomainEvent> = (event: T) => void | Promise<void>;
export class EventBus {
private handlers = new Map<string, Set<EventHandler>>();
private eventHistory: DomainEvent[] = [];
subscribe<T extends DomainEvent>(
eventClass: { new (...args: any[]): T },
handler: EventHandler<T>
): () => 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<T extends DomainEvent>(event: T): Promise<void> {
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();

View File

@@ -0,0 +1,4 @@
import "./server-event.handler";
import "./user-event.handler";
console.log("[EventBus] All event handlers registered");

View File

@@ -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);
});

View File

@@ -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}`);
});

View File

@@ -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<ReverseProxyWithEnvVars | null> {
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<ReverseProxyWithEnvVars[]> {
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<boolean> {
const proxy = await prisma.reverseProxyServer.findUnique({
where: { id },
select: { id: true },
});
return proxy !== null;
}
async create(input: ReverseProxyCreateInput): Promise<ReverseProxyWithEnvVars> {
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<ReverseProxyWithEnvVars> {
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<void> {
await prisma.reverseProxyServer.delete({
where: { id },
});
}
async setEnvVariable(proxyId: string, key: string, value: string): Promise<void> {
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<EnvVariable[]> {
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<void> {
await prisma.customEnvironmentVariable.deleteMany({
where: {
key,
reverse_proxy_id: proxyId,
},
});
}
async replaceEnvVariables(proxyId: string, envVars: EnvVariable[]): Promise<void> {
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,
})),
});
}
}
}

View File

@@ -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<ServerWithEnvVars | null> {
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<ServerWithEnvVars[]> {
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<boolean> {
const server = await prisma.server.findUnique({
where: { id },
select: { id: true },
});
return server !== null;
}
async create(input: ServerCreateInput): Promise<ServerWithEnvVars> {
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<ServerWithEnvVars> {
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<void> {
await prisma.server.delete({
where: { id },
});
}
async setEnvVariable(serverId: string, key: string, value: string): Promise<void> {
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<EnvVariable[]> {
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<void> {
await prisma.customEnvironmentVariable.deleteMany({
where: {
key,
server_id: serverId,
},
});
}
async replaceEnvVariables(serverId: string, envVars: EnvVariable[]): Promise<void> {
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,
})),
});
}
}
}

View File

@@ -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<User | null> {
return await prisma.user.findUnique({
where: { id },
});
}
async findByEmail(email: string): Promise<User | null> {
return await prisma.user.findUnique({
where: { email },
});
}
async findAll(): Promise<User[]> {
return await prisma.user.findMany({
orderBy: { createdAt: "desc" },
});
}
async update(id: string, input: UpdateUserInput): Promise<User> {
return await prisma.user.update({
where: { id },
data: input,
});
}
async updateSuspension(id: string, input: UpdateSuspensionInput): Promise<User> {
return await prisma.user.update({
where: { id },
data: input,
});
}
async delete(id: string): Promise<void> {
await prisma.user.delete({
where: { id },
});
}
async count(): Promise<number> {
return await prisma.user.count();
}
}