improve app registration and component service ports
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-09 14:26:49 +09:00
parent c46c176ca8
commit 3a79364905
5 changed files with 133 additions and 6 deletions
+12 -1
View File
@@ -2456,6 +2456,7 @@ type ComponentPort = {
name: string; name: string;
port: number; port: number;
targetPort: number; targetPort: number;
nodePort?: number;
protocol: string; protocol: string;
}; };
@@ -2464,6 +2465,7 @@ function normalizeComponentPorts(
name?: string; name?: string;
port?: number; port?: number;
targetPort?: number; targetPort?: number;
nodePort?: number;
protocol?: string; protocol?: string;
}> | null, }> | null,
fallbackPort?: number | null, fallbackPort?: number | null,
@@ -2496,6 +2498,7 @@ function normalizeComponentPort(
name?: string; name?: string;
port?: number; port?: number;
targetPort?: number; targetPort?: number;
nodePort?: number;
protocol?: string; protocol?: string;
}, },
index: number, index: number,
@@ -2512,11 +2515,16 @@ function normalizeComponentPort(
const protocol = port.protocol?.toUpperCase() === 'UDP' ? 'UDP' : 'TCP'; const protocol = port.protocol?.toUpperCase() === 'UDP' ? 'UDP' : 'TCP';
const name = sanitizePortName(port.name || (index === 0 ? 'http' : `port-${servicePort}`)); 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 { return {
name, name,
port: servicePort, port: servicePort,
targetPort, targetPort,
...(nodePort ? { nodePort } : {}),
protocol, protocol,
}; };
} }
@@ -2537,6 +2545,7 @@ function getComponentPorts(component?: {
name: typeof item.name === 'string' ? item.name : undefined, name: typeof item.name === 'string' ? item.name : undefined,
port: typeof item.port === 'number' ? item.port : Number(item.port), port: typeof item.port === 'number' ? item.port : Number(item.port),
targetPort: typeof item.targetPort === 'number' ? item.targetPort : Number(item.targetPort), 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, protocol: typeof item.protocol === 'string' ? item.protocol : undefined,
}, index); }, index);
}) })
@@ -2950,6 +2959,7 @@ function createServiceManifest(input: {
ports: ComponentPort[]; ports: ComponentPort[];
serviceType?: string | null; serviceType?: string | null;
}) { }) {
const serviceType = normalizeServiceType(input.serviceType);
return { return {
apiVersion: 'v1', apiVersion: 'v1',
kind: 'Service', kind: 'Service',
@@ -2959,7 +2969,7 @@ function createServiceManifest(input: {
labels: getManagedLabels(input.name, 'service'), labels: getManagedLabels(input.name, 'service'),
}, },
spec: { spec: {
type: normalizeServiceType(input.serviceType), type: serviceType,
selector: { selector: {
'app.kubernetes.io/name': input.name, 'app.kubernetes.io/name': input.name,
}, },
@@ -2968,6 +2978,7 @@ function createServiceManifest(input: {
port: port.port, port: port.port,
targetPort: port.targetPort, targetPort: port.targetPort,
protocol: port.protocol, protocol: port.protocol,
...(serviceType === 'NodePort' && port.nodePort ? { nodePort: port.nodePort } : {}),
})), })),
}, },
}; };
+1
View File
@@ -115,6 +115,7 @@ export class CreateAppComponentDto {
name?: string; name?: string;
port?: number; port?: number;
targetPort?: number; targetPort?: number;
nodePort?: number;
protocol?: string; protocol?: string;
}>; }>;
} }
+29 -1
View File
@@ -1,4 +1,4 @@
import { IsOptional, IsString } from 'class-validator'; import { IsEmail, IsIn, IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
export class ListUserSessionsQuery { export class ListUserSessionsQuery {
@IsOptional() @IsOptional()
@@ -9,3 +9,31 @@ export class ListUserSessionsQuery {
@IsString() @IsString()
state?: string; 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';
}
+7 -2
View File
@@ -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'; import { UsersService } from './users.service';
@Controller('users') @Controller('users')
@@ -12,6 +12,11 @@ export class UsersController {
return this.usersService.listSessions(query); return this.usersService.listSessions(query);
} }
@Post()
createManagedUser(@Body() body: CreateManagedUserDto) {
return this.usersService.createManagedUser(body);
}
@Patch(':userId/lock') @Patch(':userId/lock')
lockUser(@Param('userId') userId: string) { lockUser(@Param('userId') userId: string) {
return this.usersService.lockUser(userId); return this.usersService.lockUser(userId);
+84 -2
View File
@@ -1,8 +1,13 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; 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 { 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 = { type UserSessionRow = {
id: string; 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) { async lockUser(userId: string) {
const user = await this.authPrisma.user.findUnique({ const user = await this.authPrisma.user.findUnique({
where: { id: userId }, 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( function getUserAvatarUrl(
userId: string, userId: string,
mimeType?: string | null, mimeType?: string | null,