diff --git a/prisma/migrations/20260710093000_add_launcher_build_requested_by/migration.sql b/prisma/migrations/20260710093000_add_launcher_build_requested_by/migration.sql new file mode 100644 index 0000000..6f9dda4 --- /dev/null +++ b/prisma/migrations/20260710093000_add_launcher_build_requested_by/migration.sql @@ -0,0 +1,11 @@ +ALTER TABLE "launcher_build_jobs" + ADD COLUMN "requested_by_id" UUID, + ADD COLUMN "requested_by_name" TEXT, + ADD COLUMN "requested_by_email" TEXT; + +CREATE INDEX IF NOT EXISTS "launcher_build_jobs_requested_by_id_idx" + ON "launcher_build_jobs"("requested_by_id"); + +COMMENT ON COLUMN "launcher_build_jobs"."requested_by_id" IS '빌드 작업을 요청한 관리자 ID'; +COMMENT ON COLUMN "launcher_build_jobs"."requested_by_name" IS '빌드 작업을 요청한 관리자 이름'; +COMMENT ON COLUMN "launcher_build_jobs"."requested_by_email" IS '빌드 작업을 요청한 관리자 이메일'; diff --git a/prisma/migrations/20260710094500_add_app_operation_requesters/migration.sql b/prisma/migrations/20260710094500_add_app_operation_requesters/migration.sql new file mode 100644 index 0000000..cdefc70 --- /dev/null +++ b/prisma/migrations/20260710094500_add_app_operation_requesters/migration.sql @@ -0,0 +1,41 @@ +ALTER TABLE "build_jobs" + ADD COLUMN "requested_by_id" UUID, + ADD COLUMN "requested_by_name" TEXT, + ADD COLUMN "requested_by_email" TEXT; + +ALTER TABLE "app_releases" + ADD COLUMN "requested_by_id" UUID, + ADD COLUMN "requested_by_name" TEXT, + ADD COLUMN "requested_by_email" TEXT; + +ALTER TABLE "deployment_jobs" + ADD COLUMN "requested_by_id" UUID, + ADD COLUMN "requested_by_name" TEXT, + ADD COLUMN "requested_by_email" TEXT; + +ALTER TABLE "runtime_control_jobs" + ADD COLUMN "requested_by_id" UUID, + ADD COLUMN "requested_by_name" TEXT, + ADD COLUMN "requested_by_email" TEXT; + +CREATE INDEX IF NOT EXISTS "build_jobs_requested_by_id_idx" + ON "build_jobs"("requested_by_id"); +CREATE INDEX IF NOT EXISTS "app_releases_requested_by_id_idx" + ON "app_releases"("requested_by_id"); +CREATE INDEX IF NOT EXISTS "deployment_jobs_requested_by_id_idx" + ON "deployment_jobs"("requested_by_id"); +CREATE INDEX IF NOT EXISTS "runtime_control_jobs_requested_by_id_idx" + ON "runtime_control_jobs"("requested_by_id"); + +COMMENT ON COLUMN "build_jobs"."requested_by_id" IS '작업을 요청한 관리자 ID'; +COMMENT ON COLUMN "build_jobs"."requested_by_name" IS '작업을 요청한 관리자 이름'; +COMMENT ON COLUMN "build_jobs"."requested_by_email" IS '작업을 요청한 관리자 이메일'; +COMMENT ON COLUMN "app_releases"."requested_by_id" IS '작업을 요청한 관리자 ID'; +COMMENT ON COLUMN "app_releases"."requested_by_name" IS '작업을 요청한 관리자 이름'; +COMMENT ON COLUMN "app_releases"."requested_by_email" IS '작업을 요청한 관리자 이메일'; +COMMENT ON COLUMN "deployment_jobs"."requested_by_id" IS '작업을 요청한 관리자 ID'; +COMMENT ON COLUMN "deployment_jobs"."requested_by_name" IS '작업을 요청한 관리자 이름'; +COMMENT ON COLUMN "deployment_jobs"."requested_by_email" IS '작업을 요청한 관리자 이메일'; +COMMENT ON COLUMN "runtime_control_jobs"."requested_by_id" IS '작업을 요청한 관리자 ID'; +COMMENT ON COLUMN "runtime_control_jobs"."requested_by_name" IS '작업을 요청한 관리자 이름'; +COMMENT ON COLUMN "runtime_control_jobs"."requested_by_email" IS '작업을 요청한 관리자 이메일'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6c98cb3..8035032 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -217,6 +217,9 @@ model BuildJob { artifact String? image String? status JobStatus @default(PENDING) + requestedById String? @map("requested_by_id") @db.Uuid + requestedByName String? @map("requested_by_name") + requestedByEmail String? @map("requested_by_email") logs String? startedAt DateTime? @map("started_at") @db.Timestamptz(3) finishedAt DateTime? @map("finished_at") @db.Timestamptz(3) @@ -238,6 +241,9 @@ model AppRelease { artifact String? imageTags Json? @map("image_tags") status JobStatus @default(PENDING) + requestedById String? @map("requested_by_id") @db.Uuid + requestedByName String? @map("requested_by_name") + requestedByEmail String? @map("requested_by_email") logs String? createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) @@ -256,6 +262,9 @@ model DeploymentJob { namespace String status JobStatus @default(PENDING) requestedBy String? @map("requested_by") + requestedById String? @map("requested_by_id") @db.Uuid + requestedByName String? @map("requested_by_name") + requestedByEmail String? @map("requested_by_email") logs String? startedAt DateTime? @map("started_at") @db.Timestamptz(3) finishedAt DateTime? @map("finished_at") @db.Timestamptz(3) @@ -278,6 +287,9 @@ model LauncherBuildJob { uploadPath String @map("upload_path") downloadUrl String @map("download_url") status JobStatus @default(PENDING) + requestedById String? @map("requested_by_id") @db.Uuid + requestedByName String? @map("requested_by_name") + requestedByEmail String? @map("requested_by_email") logs String? startedAt DateTime? @map("started_at") @db.Timestamptz(3) finishedAt DateTime? @map("finished_at") @db.Timestamptz(3) @@ -299,6 +311,9 @@ model RuntimeControlJob { action RuntimeAction status JobStatus @default(PENDING) requestedBy String? @map("requested_by") + requestedById String? @map("requested_by_id") @db.Uuid + requestedByName String? @map("requested_by_name") + requestedByEmail String? @map("requested_by_email") request Json? result Json? logs String? diff --git a/src/admin-auth/admin-auth.controller.ts b/src/admin-auth/admin-auth.controller.ts index 482a92c..4ec3cea 100644 --- a/src/admin-auth/admin-auth.controller.ts +++ b/src/admin-auth/admin-auth.controller.ts @@ -1,13 +1,39 @@ -import { Body, Controller, Post, Req } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Patch, Post, Put, Req } from '@nestjs/common'; +import type { Request } from 'express'; import { AdminAuthService } from './admin-auth.service'; import { Public } from './decorators/admin-auth.decorators'; -import { AdminLoginDto } from './dto'; +import { AdminLoginDto, ChangeAdminPasswordDto } from './dto'; @Controller('auth/admin') export class AdminAuthController { constructor(private readonly adminAuthService: AdminAuthService) {} + @Get('me') + me(@Req() request: { admin?: { id: string } }) { + return this.adminAuthService.me(request.admin!.id); + } + + @Put('avatar') + async updateAvatar(@Req() request: Request & { admin?: { id: string } }) { + const mimeType = String(request.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase(); + const imageData = await readRequestBody(request); + return this.adminAuthService.updateAvatar(request.admin!.id, imageData, mimeType); + } + + @Delete('avatar') + deleteAvatar(@Req() request: { admin?: { id: string } }) { + return this.adminAuthService.deleteAvatar(request.admin!.id); + } + + @Patch('password') + changePassword( + @Req() request: { admin?: { id: string } }, + @Body() body: ChangeAdminPasswordDto, + ) { + return this.adminAuthService.changePassword(request.admin!.id, body); + } + @Public() @Post('login') login( @@ -22,3 +48,15 @@ export class AdminAuthController { }); } } + +function readRequestBody(request: Request) { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + + request.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + request.on('end', () => resolve(Buffer.concat(chunks))); + request.on('error', reject); + }); +} diff --git a/src/admin-auth/admin-auth.module.ts b/src/admin-auth/admin-auth.module.ts index a5e9b43..f1477bd 100644 --- a/src/admin-auth/admin-auth.module.ts +++ b/src/admin-auth/admin-auth.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; +import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module'; +import { GiteaModule } from '../gitea/gitea.module'; import { PrismaModule } from '../prisma/prisma.module'; import { AdminAuthController } from './admin-auth.controller'; import { AdminAuthService } from './admin-auth.service'; @Module({ - imports: [PrismaModule], + imports: [AuthPrismaModule, GiteaModule, PrismaModule], controllers: [AdminAuthController], providers: [AdminAuthService], }) diff --git a/src/admin-auth/admin-auth.service.ts b/src/admin-auth/admin-auth.service.ts index 4ed6de4..012efb2 100644 --- a/src/admin-auth/admin-auth.service.ts +++ b/src/admin-auth/admin-auth.service.ts @@ -1,13 +1,25 @@ import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'node:crypto'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AuthPrismaService } from '../auth-prisma/auth-prisma.service'; +import { GiteaService } from '../gitea/gitea.service'; import { PrismaService } from '../prisma/prisma.service'; -import { AdminLoginDto } from './dto'; +import { getUserAvatarUrl } from '../users/users.service'; +import { AdminLoginDto, ChangeAdminPasswordDto } from './dto'; @Injectable() export class AdminAuthService { - constructor(private readonly prisma: PrismaService) {} + private r2Client?: S3Client; + + constructor( + private readonly authPrisma: AuthPrismaService, + private readonly configService: ConfigService, + private readonly giteaService: GiteaService, + private readonly prisma: PrismaService, + ) {} async login( body: AdminLoginDto, @@ -25,6 +37,16 @@ export class AdminAuthService { appPermissions: { select: { appId: true }, }, + sshKeys: { + orderBy: { createdAt: 'desc' }, + select: { + id: true, + label: true, + fingerprint: true, + giteaUsername: true, + createdAt: true, + }, + }, }, }); @@ -52,6 +74,8 @@ export class AdminAuthService { }), ]); + const avatarUrl = await this.getAdminAvatarUrl(admin.email); + return { token, expiresAt, @@ -60,10 +84,272 @@ export class AdminAuthService { email: admin.email, name: admin.name, role: admin.role, + avatarUrl, appPermissions: admin.appPermissions.map((permission) => permission.appId), + sshKeys: admin.sshKeys, }, }; } + + async me(adminId: string) { + const admin = await this.prisma.admin.findUnique({ + where: { id: adminId }, + select: { + id: true, + email: true, + name: true, + role: true, + appPermissions: { + select: { appId: true }, + }, + sshKeys: { + orderBy: { createdAt: 'desc' }, + select: { + id: true, + label: true, + fingerprint: true, + giteaUsername: true, + createdAt: true, + }, + }, + }, + }); + + if (!admin) { + throw new UnauthorizedException('관리자 세션이 유효하지 않습니다.'); + } + + return { + admin: { + id: admin.id, + email: admin.email, + name: admin.name, + role: admin.role, + avatarUrl: await this.getAdminAvatarUrl(admin.email), + appPermissions: admin.appPermissions.map((permission) => permission.appId), + sshKeys: admin.sshKeys, + }, + }; + } + + async updateAvatar(adminId: string, imageData: Buffer, mimeType: string) { + if (!['image/jpeg', 'image/png', 'image/webp'].includes(mimeType)) { + throw new BadRequestException('지원하지 않는 이미지 형식입니다.'); + } + + if (!imageData.length) { + throw new BadRequestException('이미지 파일이 비어 있습니다.'); + } + + if (imageData.length > 80 * 1024) { + throw new BadRequestException('프로필 이미지는 80KB 이하로 업로드해 주세요.'); + } + + const { admin, user } = await this.getLinkedAuthUser(adminId); + const giteaUsername = await this.ensureGiteaUser(admin); + + await this.uploadAvatarToR2(user.id, imageData, mimeType); + await this.giteaService.updateUserAvatar(giteaUsername, imageData, mimeType); + await this.authPrisma.user.update({ + where: { id: user.id }, + data: { + profileImageData: null, + profileImageMime: mimeType, + profileImageUpdatedAt: new Date(), + }, + }); + + return { + avatarUrl: await this.getAdminAvatarUrl(admin.email), + }; + } + + async deleteAvatar(adminId: string) { + const { admin, user } = await this.getLinkedAuthUser(adminId); + const giteaUsername = this.resolveGiteaUsername(admin); + + if (this.isR2Enabled()) { + await this.deleteAvatarFromR2(user.id); + } + await this.giteaService.deleteUserAvatar(giteaUsername); + + await this.authPrisma.user.update({ + where: { id: user.id }, + data: { + profileImageData: null, + profileImageMime: null, + profileImageUpdatedAt: new Date(), + }, + }); + + return { + avatarUrl: await this.getAdminAvatarUrl(admin.email), + }; + } + + async changePassword(adminId: string, body: ChangeAdminPasswordDto) { + if (body.currentPassword === body.newPassword) { + throw new BadRequestException('새 비밀번호는 현재 비밀번호와 달라야 합니다.'); + } + + const admin = await this.prisma.admin.findUnique({ + where: { id: adminId }, + select: { + id: true, + passwordHash: true, + isActive: true, + }, + }); + + if (!admin || !admin.isActive) { + throw new UnauthorizedException('관리자 세션이 유효하지 않습니다.'); + } + + if (!verifyPassword(body.currentPassword, admin.passwordHash)) { + throw new BadRequestException('현재 비밀번호가 올바르지 않습니다.'); + } + + await this.prisma.admin.update({ + where: { id: admin.id }, + data: { passwordHash: hashPassword(body.newPassword) }, + }); + + return { ok: true }; + } + + private async getAdminAvatarUrl(email: string) { + const user = await this.authPrisma.user.findUnique({ + where: { email }, + select: { + id: true, + profileImageMime: true, + }, + }); + + const authBaseUrl = this.configService.get('AUTH_PUBLIC_URL') + ?? this.configService.get('ATNMY_AUTH_URL') + ?? this.configService.get('AUTH_EXTERNAL_URL') + ?? 'https://auth.autonomystudio.net'; + + return getUserAvatarUrl(user?.id ?? '', user?.profileImageMime, authBaseUrl); + } + + private async getLinkedAuthUser(adminId: string) { + const admin = await this.prisma.admin.findUnique({ + where: { id: adminId }, + select: { + id: true, + email: true, + name: true, + sshKeys: { + where: { giteaUsername: { not: null } }, + orderBy: { createdAt: 'desc' }, + take: 1, + select: { giteaUsername: true }, + }, + }, + }); + + if (!admin) { + throw new UnauthorizedException('관리자 세션이 유효하지 않습니다.'); + } + + const user = await this.authPrisma.user.findUnique({ + where: { email: admin.email }, + select: { id: true }, + }); + + if (!user) { + throw new NotFoundException('프로필 이미지와 연결할 사용자 계정을 찾을 수 없습니다.'); + } + + return { admin, user }; + } + + private async ensureGiteaUser(admin: { email: string; name: string; sshKeys?: Array<{ giteaUsername: string | null }> }) { + const storedUsername = this.resolveStoredGiteaUsername(admin); + if (storedUsername) return storedUsername; + + const username = createGiteaUsername(admin.email); + await this.giteaService.createUser({ + username, + email: admin.email, + password: randomBytes(24).toString('hex'), + fullName: admin.name, + }); + return username; + } + + private resolveGiteaUsername(admin: { email: string; sshKeys?: Array<{ giteaUsername: string | null }> }) { + return this.resolveStoredGiteaUsername(admin) ?? createGiteaUsername(admin.email); + } + + private resolveStoredGiteaUsername(admin: { sshKeys?: Array<{ giteaUsername: string | null }> }) { + return admin.sshKeys?.find((key) => key.giteaUsername?.trim())?.giteaUsername?.trim() || null; + } + + private async uploadAvatarToR2(userId: string, imageData: Buffer, mimeType: string) { + const bucket = this.configService.get('R2_BUCKET'); + const accessKeyId = this.configService.get('R2_ACCESS_KEY_ID'); + const secretAccessKey = this.configService.get('R2_SECRET_ACCESS_KEY'); + + if (!bucket || !accessKeyId || !secretAccessKey) { + throw new BadRequestException('R2 설정이 필요합니다.'); + } + + await this.getR2Client(accessKeyId, secretAccessKey).send(new PutObjectCommand({ + Bucket: bucket, + Key: this.createAvatarObjectKey(userId), + Body: imageData, + ContentType: mimeType, + CacheControl: 'public, max-age=60', + })); + } + + private async deleteAvatarFromR2(userId: string) { + const bucket = this.configService.get('R2_BUCKET'); + const accessKeyId = this.configService.get('R2_ACCESS_KEY_ID'); + const secretAccessKey = this.configService.get('R2_SECRET_ACCESS_KEY'); + + if (!bucket || !accessKeyId || !secretAccessKey) return; + + await this.getR2Client(accessKeyId, secretAccessKey).send(new DeleteObjectCommand({ + Bucket: bucket, + Key: this.createAvatarObjectKey(userId), + })); + } + + private getR2Client(accessKeyId: string, secretAccessKey: string) { + if (this.r2Client) return this.r2Client; + + const endpoint = this.configService.get('R2_ENDPOINT') + || `https://${this.configService.get('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`; + + this.r2Client = new S3Client({ + region: this.configService.get('R2_REGION', 'auto'), + endpoint, + credentials: { + accessKeyId, + secretAccessKey, + }, + forcePathStyle: this.configService.get('R2_FORCE_PATH_STYLE', 'true') !== 'false', + }); + + return this.r2Client; + } + + private isR2Enabled() { + return Boolean( + this.configService.get('R2_BUCKET') + && this.configService.get('R2_ACCESS_KEY_ID') + && this.configService.get('R2_SECRET_ACCESS_KEY'), + ); + } + + private createAvatarObjectKey(userId: string) { + const prefix = this.configService.get('R2_PUBLIC_FILE_PREFIX', 'studio-files/public'); + return `${prefix.replace(/^\/+|\/+$/g, '')}/users/${userId}/avatar`; + } } function verifyPassword(password: string, passwordHash: string) { @@ -85,3 +371,21 @@ function verifyPassword(password: string, passwordHash: string) { function hashToken(token: string) { return createHash('sha256').update(token).digest('hex'); } + +function hashPassword(password: string) { + const salt = randomBytes(16).toString('hex'); + const hash = scryptSync(password, salt, 64).toString('hex'); + return `scrypt:${salt}:${hash}`; +} + +function createGiteaUsername(email: string) { + const account = email.split('@')[0] || email; + const username = account + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^[._-]+|[._-]+$/g, '') + .slice(0, 40); + + return username || `admin-${createHash('sha1').update(email).digest('hex').slice(0, 8)}`; +} diff --git a/src/admin-auth/dto.ts b/src/admin-auth/dto.ts index 612017a..3abf149 100644 --- a/src/admin-auth/dto.ts +++ b/src/admin-auth/dto.ts @@ -1,4 +1,4 @@ -import { IsEmail, IsString } from 'class-validator'; +import { IsEmail, IsString, MinLength } from 'class-validator'; export class AdminLoginDto { @IsEmail() @@ -7,3 +7,12 @@ export class AdminLoginDto { @IsString() password!: string; } + +export class ChangeAdminPasswordDto { + @IsString() + currentPassword!: string; + + @IsString() + @MinLength(8) + newPassword!: string; +} diff --git a/src/admins/admins.module.ts b/src/admins/admins.module.ts index 2464787..4b797f4 100644 --- a/src/admins/admins.module.ts +++ b/src/admins/admins.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; +import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module'; import { GiteaModule } from '../gitea/gitea.module'; import { PrismaModule } from '../prisma/prisma.module'; import { AdminsController } from './admins.controller'; import { AdminsService } from './admins.service'; @Module({ - imports: [GiteaModule, PrismaModule], + imports: [AuthPrismaModule, GiteaModule, PrismaModule], controllers: [AdminsController], providers: [AdminsService], }) diff --git a/src/admins/admins.service.ts b/src/admins/admins.service.ts index 2fb6287..06039b5 100644 --- a/src/admins/admins.service.ts +++ b/src/admins/admins.service.ts @@ -1,9 +1,12 @@ import { createHash, randomBytes, scryptSync } from 'node:crypto'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AuthPrismaService } from '../auth-prisma/auth-prisma.service'; import { GiteaService } from '../gitea/gitea.service'; import { PrismaService } from '../prisma/prisma.service'; +import { getUserAvatarUrl } from '../users/users.service'; import { AdminRole } from './admin-role'; import { CreateAdminSshKeyDto, @@ -17,6 +20,8 @@ import { @Injectable() export class AdminsService { constructor( + private readonly authPrisma: AuthPrismaService, + private readonly configService: ConfigService, private readonly giteaService: GiteaService, private readonly prisma: PrismaService, ) {} @@ -38,8 +43,8 @@ export class AdminsService { }); } - list() { - return this.prisma.admin.findMany({ + async list() { + const admins = await this.prisma.admin.findMany({ orderBy: [{ role: 'asc' }, { createdAt: 'desc' }], select: { id: true, @@ -69,15 +74,46 @@ export class AdminsService { }, }, }); + + const users = await this.authPrisma.user.findMany({ + where: { email: { in: admins.map((admin) => admin.email) } }, + select: { + id: true, + email: true, + profileImageMime: true, + }, + }); + const userByEmail = new Map(users.map((user) => [user.email, user])); + const authBaseUrl = this.configService.get('AUTH_PUBLIC_URL') + ?? this.configService.get('ATNMY_AUTH_URL') + ?? this.configService.get('AUTH_EXTERNAL_URL') + ?? 'https://auth.autonomystudio.net'; + + return admins.map((admin) => { + const user = userByEmail.get(admin.email); + return { + ...admin, + avatarUrl: getUserAvatarUrl(user?.id ?? '', user?.profileImageMime, authBaseUrl), + }; + }); } async create(body: CreateAdminDto) { await this.ensureRoleEnabled(body.role); + const email = body.email.trim().toLowerCase(); + const name = body.name.trim(); + + await this.giteaService.createUser({ + username: createGiteaUsername(email), + email, + password: body.temporaryPassword, + fullName: name, + }); const admin = await this.prisma.admin.create({ data: { - email: body.email.trim().toLowerCase(), - name: body.name.trim(), + email, + name, role: body.role, passwordHash: hashPassword(body.temporaryPassword), }, @@ -186,20 +222,18 @@ export class AdminsService { } async createSshKey(adminId: string, body: CreateAdminSshKeyDto) { - await this.ensureExists(adminId); + const admin = await this.ensureExists(adminId); const parsedKey = parseSshPublicKey(body.publicKey); const label = body.label?.trim() || `${parsedKey.keyType} ${parsedKey.fingerprint}`; - const giteaUsername = body.giteaUsername?.trim() || null; + const giteaUsername = createGiteaUsername(admin.email); let giteaKeyId: number | null = null; - if (giteaUsername) { - const giteaKey = await this.giteaService.createUserSshKey(giteaUsername, { - title: label, - key: parsedKey.normalizedKey, - }); - giteaKeyId = giteaKey.id; - } + const giteaKey = await this.giteaService.createUserSshKey(giteaUsername, { + title: label, + key: parsedKey.normalizedKey, + }); + giteaKeyId = giteaKey.id; const sshKey = await this.prisma.adminSshKey.create({ data: { @@ -273,12 +307,14 @@ export class AdminsService { private async ensureExists(id: string) { const admin = await this.prisma.admin.findUnique({ where: { id }, - select: { id: true }, + select: { id: true, email: true }, }); if (!admin) { throw new NotFoundException('관리자를 찾을 수 없습니다.'); } + + return admin; } } @@ -309,6 +345,18 @@ function hashPassword(password: string) { return `scrypt:${salt}:${hash}`; } +function createGiteaUsername(email: string) { + const account = email.split('@')[0] || email; + const username = account + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^[._-]+|[._-]+$/g, '') + .slice(0, 40); + + return username || `admin-${createHash('sha1').update(email).digest('hex').slice(0, 8)}`; +} + function parseSshPublicKey(publicKey: string) { const normalizedKey = publicKey.trim().replace(/\s+/g, ' '); const [keyType, keyBody] = normalizedKey.split(' '); diff --git a/src/apps/apps.controller.ts b/src/apps/apps.controller.ts index 21a9905..40d647b 100644 --- a/src/apps/apps.controller.ts +++ b/src/apps/apps.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req } from '@nestjs/common'; import { LinkComponentSourceDto } from '../gitea/dto'; import { AppsService } from './apps.service'; @@ -83,8 +83,11 @@ export class AppsController { } @Post('builds') - createBuildJob(@Body() body: CreateBuildJobDto) { - return this.appsService.createBuildJob(body); + createBuildJob( + @Body() body: CreateBuildJobDto, + @Req() request: { admin?: { id: string; name: string; email: string } }, + ) { + return this.appsService.createBuildJob(body, request.admin); } @Patch('builds/:buildJobId/cancel') @@ -134,8 +137,11 @@ export class AppsController { } @Post('releases') - createRelease(@Body() body: CreateAppReleaseDto) { - return this.appsService.createRelease(body); + createRelease( + @Body() body: CreateAppReleaseDto, + @Req() request: { admin?: { id: string; name: string; email: string } }, + ) { + return this.appsService.createRelease(body, request.admin); } @Delete('releases/:releaseId') @@ -154,8 +160,11 @@ export class AppsController { } @Post('deployments') - createDeploymentJob(@Body() body: CreateDeploymentJobDto) { - return this.appsService.createDeploymentJob(body); + createDeploymentJob( + @Body() body: CreateDeploymentJobDto, + @Req() request: { admin?: { id: string; name: string; email: string } }, + ) { + return this.appsService.createDeploymentJob(body, request.admin); } @Get('migrations') @@ -169,8 +178,11 @@ export class AppsController { } @Post('migrations') - createMigrationJob(@Body() body: CreateMigrationJobDto) { - return this.appsService.createMigrationJob(body); + createMigrationJob( + @Body() body: CreateMigrationJobDto, + @Req() request: { admin?: { id: string; name: string; email: string } }, + ) { + return this.appsService.createMigrationJob(body, request.admin); } @Get('runtime-namespaces/:namespace') diff --git a/src/apps/apps.module.ts b/src/apps/apps.module.ts index 50bf93f..85b835c 100644 --- a/src/apps/apps.module.ts +++ b/src/apps/apps.module.ts @@ -2,12 +2,13 @@ import { Module } from '@nestjs/common'; import { LauncherModule } from '../launcher/launcher.module'; import { PrismaModule } from '../prisma/prisma.module'; +import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module'; import { AppsController } from './apps.controller'; import { AppsService } from './apps.service'; import { BuildRunnerService } from './build-runner.service'; @Module({ - imports: [PrismaModule, LauncherModule], + imports: [PrismaModule, LauncherModule, AuthPrismaModule], controllers: [AppsController], providers: [AppsService, BuildRunnerService], }) diff --git a/src/apps/apps.service.ts b/src/apps/apps.service.ts index 9453ead..5f3d75e 100644 --- a/src/apps/apps.service.ts +++ b/src/apps/apps.service.ts @@ -10,6 +10,8 @@ import { promisify } from 'node:util'; import { LinkComponentSourceDto } from '../gitea/dto'; import { LauncherService } from '../launcher/launcher.service'; import { PrismaService } from '../prisma/prisma.service'; +import { AuthPrismaService } from '../auth-prisma/auth-prisma.service'; +import { getUserAvatarUrl } from '../users/users.service'; import { BuildRunnerService } from './build-runner.service'; import { CreateAppComponentDto, @@ -29,6 +31,12 @@ import { const execFileAsync = promisify(execFile); const serviceAccountDir = '/var/run/secrets/kubernetes.io/serviceaccount'; +type AdminRequester = { + id: string; + name: string; + email: string; +}; + @Injectable() export class AppsService { private giteaUsername?: string; @@ -38,8 +46,46 @@ export class AppsService { private readonly configService: ConfigService, private readonly buildRunnerService: BuildRunnerService, private readonly launcherService: LauncherService, + private readonly authPrisma: AuthPrismaService, ) {} + private requesterData(requester?: AdminRequester) { + return requester + ? { + requestedById: requester.id, + requestedByName: requester.name, + requestedByEmail: requester.email, + } + : {}; + } + + private async withRequesterAvatars(items: T[]) { + const emails = [...new Set(items.map((item) => item.requestedByEmail).filter((email): email is string => Boolean(email)))]; + if (emails.length === 0) return items.map((item) => ({ ...item, requestedByAvatarUrl: null })); + + const users = await this.authPrisma.user.findMany({ + where: { email: { in: emails } }, + select: { + id: true, + email: true, + profileImageMime: true, + }, + }); + const userByEmail = new Map(users.map((user) => [user.email, user])); + const authBaseUrl = this.configService.get('AUTH_PUBLIC_URL') + ?? this.configService.get('ATNMY_AUTH_URL') + ?? this.configService.get('AUTH_EXTERNAL_URL') + ?? 'https://auth.autonomystudio.net'; + + return items.map((item) => { + const user = item.requestedByEmail ? userByEmail.get(item.requestedByEmail) : null; + return { + ...item, + requestedByAvatarUrl: getUserAvatarUrl(user?.id ?? '', user?.profileImageMime, authBaseUrl), + }; + }); + } + async listApps() { const [apps, launcherAppKeys] = await Promise.all([ this.prisma.app.findMany({ @@ -312,11 +358,12 @@ export class AppsService { }; if (!pagination && !sinceDate) { - return this.prisma.appRelease.findMany({ + const releases = await this.prisma.appRelease.findMany({ where: appKey ? { app: { key: appKey } } : undefined, orderBy: { createdAt: 'desc' }, include: { app: true }, }); + return this.withRequesterAvatars(releases); } const pageNumber = pagination?.page ?? 1; @@ -340,7 +387,7 @@ export class AppsService { }); return { - releases: items, + releases: await this.withRequesterAvatars(items), total, page: pageNumber, limit: pageLimit, @@ -349,7 +396,7 @@ export class AppsService { }; } - async createRelease(body: CreateAppReleaseDto) { + async createRelease(body: CreateAppReleaseDto, requester?: AdminRequester) { const app = await this.prisma.app.findUnique({ where: { id: body.appId }, include: { @@ -439,6 +486,7 @@ export class AppsService { }, status: 'PENDING' as const, logs: releaseLogs.join('\n'), + ...this.requesterData(requester), }; const release = existingRelease ? await this.prisma.appRelease.update({ @@ -457,7 +505,7 @@ export class AppsService { void this.finalizeReleaseFromBuilds(release.id, buildJobs.map((job) => job.id)); - return { release }; + return { release: (await this.withRequesterAvatars([release]))[0] }; } private async finalizeReleaseFromBuilds(releaseId: string, sourceBuildJobIds: string[]) { @@ -550,6 +598,9 @@ export class AppsService { sourceBuildJobs: Array<{ componentId: string; commitSha: string | null; + requestedById?: string | null; + requestedByName?: string | null; + requestedByEmail?: string | null; component: { id: string; name: string; @@ -579,6 +630,9 @@ export class AppsService { artifact, image, status: 'PENDING', + requestedById: sourceJob.requestedById, + requestedByName: sourceJob.requestedByName, + requestedByEmail: sourceJob.requestedByEmail, logs: getBuildPlanLogs({ appName: component.app.name, componentName: component.name, @@ -879,11 +933,12 @@ export class AppsService { }; if (!pagination && !sinceDate) { - return this.prisma.deploymentJob.findMany({ + const deployments = await this.prisma.deploymentJob.findMany({ where: appKey ? { app: { key: appKey } } : undefined, orderBy: { createdAt: 'desc' }, include, }); + return this.withRequesterAvatars(deployments); } const pageNumber = pagination?.page ?? 1; @@ -908,7 +963,7 @@ export class AppsService { }); return { - deployments: items, + deployments: await this.withRequesterAvatars(items), total, page: pageNumber, limit: pageLimit, @@ -918,7 +973,7 @@ export class AppsService { }; } - async createDeploymentJob(body: CreateDeploymentJobDto) { + async createDeploymentJob(body: CreateDeploymentJobDto, requester?: AdminRequester) { const namespace = normalizeNamespace(body.namespace); const environment = body.environment.trim(); const release = await this.prisma.appRelease.findUnique({ @@ -973,7 +1028,8 @@ export class AppsService { environment, namespace, status: 'RUNNING', - requestedBy: 'admin', + requestedBy: requester?.name ?? 'admin', + ...this.requesterData(requester), startedAt: new Date(), logs: [ `[running] deployment started: ${release.app.name} ${release.version}`, @@ -1128,7 +1184,7 @@ export class AppsService { }, }); - return { deploymentJob: completed }; + return { deploymentJob: (await this.withRequesterAvatars([completed]))[0] }; } catch (error) { const message = error instanceof Error ? error.message : 'Deployment failed'; const failed = await this.prisma.deploymentJob.update({ @@ -1144,7 +1200,7 @@ export class AppsService { }, }); - return { deploymentJob: failed }; + return { deploymentJob: (await this.withRequesterAvatars([failed]))[0] }; } } @@ -1188,10 +1244,11 @@ export class AppsService { }; if (!pagination && !sinceDate && !appId) { - return this.prisma.runtimeControlJob.findMany({ + const jobs = await this.prisma.runtimeControlJob.findMany({ where: { action: 'MIGRATION' }, orderBy: { createdAt: 'desc' }, }); + return this.withRequesterAvatars(jobs); } const pageNumber = pagination?.page ?? 1; @@ -1207,14 +1264,14 @@ export class AppsService { ]); return { - jobs: items, + jobs: await this.withRequesterAvatars(items), total, page: pageNumber, limit: pageLimit, }; } - async createMigrationJob(body: CreateMigrationJobDto) { + async createMigrationJob(body: CreateMigrationJobDto, requester?: AdminRequester) { const namespace = normalizeNamespace(body.namespace); const command = body.command.trim(); const envSecretName = sanitizeK8sName(body.envSecretName); @@ -1279,7 +1336,8 @@ export class AppsService { name: jobName, action: 'MIGRATION', status: 'RUNNING', - requestedBy: 'admin', + requestedBy: requester?.name ?? 'admin', + ...this.requesterData(requester), request, startedAt, logs: [ @@ -1299,7 +1357,7 @@ export class AppsService { envSecretName, }); - return { migrationJob }; + return { migrationJob: (await this.withRequesterAvatars([migrationJob]))[0] }; } private async finalizeMigrationJob( @@ -1384,7 +1442,7 @@ export class AppsService { }; if (!pagination && !sinceDate) { - return this.prisma.buildJob.findMany({ + const jobs = await this.prisma.buildJob.findMany({ where: { ...(appKey ? { component: { app: { key: appKey } } } : {}), ...(status ? { status: normalizeJobStatus(status) } : {}), @@ -1393,6 +1451,7 @@ export class AppsService { orderBy: { createdAt: 'desc' }, include, }); + return this.withRequesterAvatars(jobs); } const pageNumber = pagination?.page ?? 1; @@ -1409,7 +1468,7 @@ export class AppsService { ]); return { - jobs: items, + jobs: await this.withRequesterAvatars(items), total, page: pageNumber, limit: pageLimit, @@ -1872,7 +1931,7 @@ export class AppsService { return { applied: true, logs: logs.filter(Boolean).join('\n') }; } - async createBuildJob(body: CreateBuildJobDto) { + async createBuildJob(body: CreateBuildJobDto, requester?: AdminRequester) { const component = await this.prisma.appComponent.findUnique({ where: { id: body.componentId }, include: { app: true }, @@ -1904,6 +1963,7 @@ export class AppsService { artifact, image, status: 'PENDING', + ...this.requesterData(requester), logs: getBuildPlanLogs({ appName: component.app.name, componentName: component.name, @@ -1929,7 +1989,7 @@ export class AppsService { }, }); - return { buildJob }; + return { buildJob: (await this.withRequesterAvatars([buildJob]))[0] }; } async cancelBuildJob(buildJobId: string) { diff --git a/src/gitea/gitea.service.ts b/src/gitea/gitea.service.ts index 39d4f3d..5bc0178 100644 --- a/src/gitea/gitea.service.ts +++ b/src/gitea/gitea.service.ts @@ -237,6 +237,52 @@ export class GiteaService { ); } + async createUser(body: { username: string; email: string; password: string; fullName?: string }) { + this.assertToken(); + + await this.request( + '/api/v1/admin/users', + { + method: 'POST', + body: JSON.stringify({ + username: body.username, + email: body.email, + password: body.password, + full_name: body.fullName, + must_change_password: false, + send_notify: false, + }), + allowedStatuses: [409, 422], + }, + ); + } + + async updateUserAvatar(username: string, imageData: Buffer, mimeType: string) { + this.assertToken(); + + await this.request( + `/api/v1/user/avatar?sudo=${encodeURIComponent(username)}`, + { + method: 'POST', + body: JSON.stringify({ + image: imageData.toString('base64'), + }), + }, + ); + } + + async deleteUserAvatar(username: string) { + this.assertToken(); + + await this.request( + `/api/v1/user/avatar?sudo=${encodeURIComponent(username)}`, + { + method: 'DELETE', + allowedStatuses: [404], + }, + ); + } + async deleteUserSshKey(username: string, keyId: number) { this.assertToken(); diff --git a/src/launcher/launcher.controller.ts b/src/launcher/launcher.controller.ts index 9a496ee..3ac4032 100644 --- a/src/launcher/launcher.controller.ts +++ b/src/launcher/launcher.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req } from '@nestjs/common'; import { CreateLauncherAppLinkDto, CreateLauncherBuildJobDto, CreateLauncherVersionDto, UpdateLauncherSourceDto, LinkLauncherVersionSourceDto, MoveLauncherAppLinkDto, UpdateLauncherAppLinkDto, UpdateLauncherBuildEnvDto, UpdateLauncherUserAppPermissionsDto, DeployLauncherBackendDto, ListLauncherBuildJobsQuery } from './dto'; import { LauncherService } from './launcher.service'; @@ -54,8 +54,11 @@ export class LauncherController { } @Post('builds') - createBuildJob(@Body() body: CreateLauncherBuildJobDto) { - return this.launcherService.createBuildJob(body); + createBuildJob( + @Body() body: CreateLauncherBuildJobDto, + @Req() request: { admin?: { id: string; name: string; email: string } }, + ) { + return this.launcherService.createBuildJob(body, request.admin); } @Get('app-links') diff --git a/src/launcher/launcher.module.ts b/src/launcher/launcher.module.ts index 5f35493..968d50e 100644 --- a/src/launcher/launcher.module.ts +++ b/src/launcher/launcher.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../prisma/prisma.module'; import { LauncherPrismaModule } from '../launcher-prisma/launcher-prisma.module'; +import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module'; import { LauncherController } from './launcher.controller'; import { LauncherService } from './launcher.service'; @Module({ - imports: [PrismaModule, LauncherPrismaModule], + imports: [PrismaModule, LauncherPrismaModule, AuthPrismaModule], controllers: [LauncherController], providers: [LauncherService], exports: [LauncherService], diff --git a/src/launcher/launcher.service.ts b/src/launcher/launcher.service.ts index 3e5e6ff..5e5a065 100644 --- a/src/launcher/launcher.service.ts +++ b/src/launcher/launcher.service.ts @@ -10,6 +10,8 @@ import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service'; import { PrismaService } from '../prisma/prisma.service'; +import { AuthPrismaService } from '../auth-prisma/auth-prisma.service'; +import { getUserAvatarUrl } from '../users/users.service'; import { CreateLauncherAppLinkDto, CreateLauncherBuildJobDto, @@ -61,6 +63,7 @@ export class LauncherService { private readonly prisma: PrismaService, private readonly launcherPrisma: LauncherPrismaService, private readonly configService: ConfigService, + private readonly authPrisma: AuthPrismaService, ) {} async listVersions() { @@ -380,7 +383,7 @@ export class LauncherService { }; } - listBuildJobs(query: ListLauncherBuildJobsQuery = {}) { + async listBuildJobs(query: ListLauncherBuildJobsQuery = {}) { const paged = Boolean(query.page || query.limit || query.target || query.since); const page = toPositiveInt(query.page, 1); const limit = Math.min(toPositiveInt(query.limit, 30), 100); @@ -391,10 +394,11 @@ export class LauncherService { }; if (!paged) { - return this.prisma.launcherBuildJob.findMany({ + const jobs = await this.prisma.launcherBuildJob.findMany({ orderBy: { createdAt: 'desc' }, take: 30, }); + return this.withLauncherBuildRequesters(jobs); } return this.prisma.$transaction(async (tx) => { @@ -408,7 +412,34 @@ export class LauncherService { tx.launcherBuildJob.count({ where }), ]); - return { jobs, total, page, limit }; + return { jobs: await this.withLauncherBuildRequesters(jobs), total, page, limit }; + }); + } + + private async withLauncherBuildRequesters(jobs: T[]) { + const emails = [...new Set(jobs.map((job) => job.requestedByEmail).filter((email): email is string => Boolean(email)))]; + if (emails.length === 0) return jobs.map((job) => ({ ...job, requestedByAvatarUrl: null })); + + const users = await this.authPrisma.user.findMany({ + where: { email: { in: emails } }, + select: { + id: true, + email: true, + profileImageMime: true, + }, + }); + const userByEmail = new Map(users.map((user) => [user.email, user])); + const authBaseUrl = this.configService.get('AUTH_PUBLIC_URL') + ?? this.configService.get('ATNMY_AUTH_URL') + ?? this.configService.get('AUTH_EXTERNAL_URL') + ?? 'https://auth.autonomystudio.net'; + + return jobs.map((job) => { + const user = job.requestedByEmail ? userByEmail.get(job.requestedByEmail) : null; + return { + ...job, + requestedByAvatarUrl: getUserAvatarUrl(user?.id ?? '', user?.profileImageMime, authBaseUrl), + }; }); } @@ -913,7 +944,10 @@ export class LauncherService { return { version: mapLauncherVersionRow(rows[0]) }; } - async createBuildJob(body: CreateLauncherBuildJobDto) { + async createBuildJob( + body: CreateLauncherBuildJobDto, + requester?: { id: string; name: string; email: string }, + ) { const source = await this.getSourceSettings(); const target = body.target?.trim() || 'launcher-client'; const isBackend = target === 'launcher-backend'; @@ -957,12 +991,15 @@ export class LauncherService { artifact: requestedArtifact, uploadPath: plan.uploadPath, downloadUrl: plan.downloadUrl, + requestedById: requester?.id, + requestedByName: requester?.name, + requestedByEmail: requester?.email, logs, }, }); void this.runBuildJob(job.id); - return { job }; + return { job: (await this.withLauncherBuildRequesters([job]))[0] }; } async linkVersionSource(id: string, body: LinkLauncherVersionSourceDto) { diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 9a7fa2c..08af140 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -291,7 +291,7 @@ function calculateLicenseExpiresAt( return expiresAt; } -function getUserAvatarUrl( +export function getUserAvatarUrl( userId: string, mimeType?: string | null, authBaseUrl?: string | null,