diff --git a/src/apps/apps.service.ts b/src/apps/apps.service.ts index 46e2241..0a422dc 100644 --- a/src/apps/apps.service.ts +++ b/src/apps/apps.service.ts @@ -2456,6 +2456,7 @@ type ComponentPort = { name: string; port: number; targetPort: number; + nodePort?: number; protocol: string; }; @@ -2464,6 +2465,7 @@ function normalizeComponentPorts( name?: string; port?: number; targetPort?: number; + nodePort?: number; protocol?: string; }> | null, fallbackPort?: number | null, @@ -2496,6 +2498,7 @@ function normalizeComponentPort( name?: string; port?: number; targetPort?: number; + nodePort?: number; protocol?: string; }, index: number, @@ -2512,11 +2515,16 @@ function normalizeComponentPort( const protocol = port.protocol?.toUpperCase() === 'UDP' ? 'UDP' : 'TCP'; const name = sanitizePortName(port.name || (index === 0 ? 'http' : `port-${servicePort}`)); + const nodePort = port.nodePort ? Number(port.nodePort) : undefined; + if (nodePort && (!Number.isInteger(nodePort) || nodePort < 30000 || nodePort > 32767)) { + return null; + } return { name, port: servicePort, targetPort, + ...(nodePort ? { nodePort } : {}), protocol, }; } @@ -2537,6 +2545,7 @@ function getComponentPorts(component?: { name: typeof item.name === 'string' ? item.name : undefined, port: typeof item.port === 'number' ? item.port : Number(item.port), targetPort: typeof item.targetPort === 'number' ? item.targetPort : Number(item.targetPort), + nodePort: typeof item.nodePort === 'number' ? item.nodePort : Number(item.nodePort), protocol: typeof item.protocol === 'string' ? item.protocol : undefined, }, index); }) @@ -2950,6 +2959,7 @@ function createServiceManifest(input: { ports: ComponentPort[]; serviceType?: string | null; }) { + const serviceType = normalizeServiceType(input.serviceType); return { apiVersion: 'v1', kind: 'Service', @@ -2959,7 +2969,7 @@ function createServiceManifest(input: { labels: getManagedLabels(input.name, 'service'), }, spec: { - type: normalizeServiceType(input.serviceType), + type: serviceType, selector: { 'app.kubernetes.io/name': input.name, }, @@ -2968,6 +2978,7 @@ function createServiceManifest(input: { port: port.port, targetPort: port.targetPort, protocol: port.protocol, + ...(serviceType === 'NodePort' && port.nodePort ? { nodePort: port.nodePort } : {}), })), }, }; diff --git a/src/apps/dto.ts b/src/apps/dto.ts index bcd17a1..cb5822b 100644 --- a/src/apps/dto.ts +++ b/src/apps/dto.ts @@ -115,6 +115,7 @@ export class CreateAppComponentDto { name?: string; port?: number; targetPort?: number; + nodePort?: number; protocol?: string; }>; } diff --git a/src/users/dto.ts b/src/users/dto.ts index d471f40..6e102bd 100644 --- a/src/users/dto.ts +++ b/src/users/dto.ts @@ -1,4 +1,4 @@ -import { IsOptional, IsString } from 'class-validator'; +import { IsEmail, IsIn, IsOptional, IsString, IsUUID, MinLength } from 'class-validator'; export class ListUserSessionsQuery { @IsOptional() @@ -9,3 +9,31 @@ export class ListUserSessionsQuery { @IsString() state?: string; } + +export class CreateManagedUserDto { + @IsEmail() + email!: string; + + @IsString() + @MinLength(8) + password!: string; + + @IsString() + name!: string; + + @IsOptional() + @IsUUID() + organizationId?: string | null; + + @IsOptional() + @IsString() + department?: string | null; + + @IsOptional() + @IsString() + position?: string | null; + + @IsOptional() + @IsIn(['FREE', 'EDU', 'PRO']) + licenseCode?: 'FREE' | 'EDU' | 'PRO'; +} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index f677826..e66c2ca 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,6 +1,6 @@ -import { Controller, Delete, Get, Param, Patch, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; -import { ListUserSessionsQuery } from './dto'; +import { CreateManagedUserDto, ListUserSessionsQuery } from './dto'; import { UsersService } from './users.service'; @Controller('users') @@ -12,6 +12,11 @@ export class UsersController { return this.usersService.listSessions(query); } + @Post() + createManagedUser(@Body() body: CreateManagedUserDto) { + return this.usersService.createManagedUser(body); + } + @Patch(':userId/lock') lockUser(@Param('userId') userId: string) { return this.usersService.lockUser(userId); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 450e9e3..9a7fa2c 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,8 +1,13 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { randomBytes, scrypt as scryptCallback } from 'node:crypto'; +import { promisify } from 'node:util'; import { AuthPrismaService } from '../auth-prisma/auth-prisma.service'; -import { ListUserSessionsQuery } from './dto'; +import { CreateManagedUserDto, ListUserSessionsQuery } from './dto'; + +const scrypt = promisify(scryptCallback); +const passwordKeyLength = 64; type UserSessionRow = { id: string; @@ -144,6 +149,66 @@ export class UsersService { }; } + async createManagedUser(body: CreateManagedUserDto) { + const email = body.email.trim().toLowerCase(); + const existing = await this.authPrisma.user.findUnique({ + where: { email }, + select: { id: true }, + }); + + if (existing) { + throw new ConflictException('이미 등록된 이메일입니다.'); + } + + const license = await this.authPrisma.license.findUnique({ + where: { code: body.licenseCode ?? 'EDU' }, + select: { id: true, defaultDurationDays: true }, + }); + + if (!license) { + throw new NotFoundException('라이센스를 찾을 수 없습니다.'); + } + + const startsAt = new Date(); + const expiresAt = calculateLicenseExpiresAt( + startsAt, + license.defaultDurationDays, + ); + + const user = await this.authPrisma.user.create({ + data: { + email, + passwordHash: await hashPassword(body.password), + name: body.name.trim(), + role: 'USER', + affiliations: body.organizationId + ? { + create: { + organizationId: body.organizationId, + department: body.department?.trim() || null, + position: body.position?.trim() || null, + isPrimary: true, + }, + } + : undefined, + userLicenses: { + create: { + licenseId: license.id, + startsAt, + expiresAt, + }, + }, + }, + select: { + id: true, + email: true, + name: true, + }, + }); + + return { user }; + } + async lockUser(userId: string) { const user = await this.authPrisma.user.findUnique({ where: { id: userId }, @@ -209,6 +274,23 @@ export class UsersService { } } +async function hashPassword(password: string) { + const salt = randomBytes(16).toString('hex'); + const derivedKey = (await scrypt(password, salt, passwordKeyLength)) as Buffer; + return `scrypt:${salt}:${derivedKey.toString('hex')}`; +} + +function calculateLicenseExpiresAt( + startsAt: Date, + durationDays: number | null | undefined, +) { + if (!durationDays) return null; + + const expiresAt = new Date(startsAt); + expiresAt.setDate(expiresAt.getDate() + durationDays); + return expiresAt; +} + function getUserAvatarUrl( userId: string, mimeType?: string | null,