improve app registration and component service ports
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -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 } : {}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -115,6 +115,7 @@ export class CreateAppComponentDto {
|
||||
name?: string;
|
||||
port?: number;
|
||||
targetPort?: number;
|
||||
nodePort?: number;
|
||||
protocol?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
+29
-1
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user