track requesters for builds releases deployments and migrations
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-10 10:43:02 +09:00
parent dd09471267
commit 3873ce3169
17 changed files with 690 additions and 61 deletions
@@ -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 '빌드 작업을 요청한 관리자 이메일';
@@ -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 '작업을 요청한 관리자 이메일';
+15
View File
@@ -217,6 +217,9 @@ model BuildJob {
artifact String? artifact String?
image String? image String?
status JobStatus @default(PENDING) 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? logs String?
startedAt DateTime? @map("started_at") @db.Timestamptz(3) startedAt DateTime? @map("started_at") @db.Timestamptz(3)
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3) finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
@@ -238,6 +241,9 @@ model AppRelease {
artifact String? artifact String?
imageTags Json? @map("image_tags") imageTags Json? @map("image_tags")
status JobStatus @default(PENDING) 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? logs String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@ -256,6 +262,9 @@ model DeploymentJob {
namespace String namespace String
status JobStatus @default(PENDING) status JobStatus @default(PENDING)
requestedBy String? @map("requested_by") 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? logs String?
startedAt DateTime? @map("started_at") @db.Timestamptz(3) startedAt DateTime? @map("started_at") @db.Timestamptz(3)
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3) finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
@@ -278,6 +287,9 @@ model LauncherBuildJob {
uploadPath String @map("upload_path") uploadPath String @map("upload_path")
downloadUrl String @map("download_url") downloadUrl String @map("download_url")
status JobStatus @default(PENDING) 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? logs String?
startedAt DateTime? @map("started_at") @db.Timestamptz(3) startedAt DateTime? @map("started_at") @db.Timestamptz(3)
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3) finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
@@ -299,6 +311,9 @@ model RuntimeControlJob {
action RuntimeAction action RuntimeAction
status JobStatus @default(PENDING) status JobStatus @default(PENDING)
requestedBy String? @map("requested_by") 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? request Json?
result Json? result Json?
logs String? logs String?
+40 -2
View File
@@ -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 { AdminAuthService } from './admin-auth.service';
import { Public } from './decorators/admin-auth.decorators'; import { Public } from './decorators/admin-auth.decorators';
import { AdminLoginDto } from './dto'; import { AdminLoginDto, ChangeAdminPasswordDto } from './dto';
@Controller('auth/admin') @Controller('auth/admin')
export class AdminAuthController { export class AdminAuthController {
constructor(private readonly adminAuthService: AdminAuthService) {} 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() @Public()
@Post('login') @Post('login')
login( login(
@@ -22,3 +48,15 @@ export class AdminAuthController {
}); });
} }
} }
function readRequestBody(request: Request) {
return new Promise<Buffer>((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);
});
}
+3 -1
View File
@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common'; 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 { PrismaModule } from '../prisma/prisma.module';
import { AdminAuthController } from './admin-auth.controller'; import { AdminAuthController } from './admin-auth.controller';
import { AdminAuthService } from './admin-auth.service'; import { AdminAuthService } from './admin-auth.service';
@Module({ @Module({
imports: [PrismaModule], imports: [AuthPrismaModule, GiteaModule, PrismaModule],
controllers: [AdminAuthController], controllers: [AdminAuthController],
providers: [AdminAuthService], providers: [AdminAuthService],
}) })
+307 -3
View File
@@ -1,13 +1,25 @@
import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'node:crypto'; 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 { PrismaService } from '../prisma/prisma.service';
import { AdminLoginDto } from './dto'; import { getUserAvatarUrl } from '../users/users.service';
import { AdminLoginDto, ChangeAdminPasswordDto } from './dto';
@Injectable() @Injectable()
export class AdminAuthService { 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( async login(
body: AdminLoginDto, body: AdminLoginDto,
@@ -25,6 +37,16 @@ export class AdminAuthService {
appPermissions: { appPermissions: {
select: { appId: true }, 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 { return {
token, token,
expiresAt, expiresAt,
@@ -60,10 +84,272 @@ export class AdminAuthService {
email: admin.email, email: admin.email,
name: admin.name, name: admin.name,
role: admin.role, role: admin.role,
avatarUrl,
appPermissions: admin.appPermissions.map((permission) => permission.appId), 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<string>('AUTH_PUBLIC_URL')
?? this.configService.get<string>('ATNMY_AUTH_URL')
?? this.configService.get<string>('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<string>('R2_BUCKET');
const accessKeyId = this.configService.get<string>('R2_ACCESS_KEY_ID');
const secretAccessKey = this.configService.get<string>('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<string>('R2_BUCKET');
const accessKeyId = this.configService.get<string>('R2_ACCESS_KEY_ID');
const secretAccessKey = this.configService.get<string>('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<string>('R2_ENDPOINT')
|| `https://${this.configService.get<string>('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`;
this.r2Client = new S3Client({
region: this.configService.get<string>('R2_REGION', 'auto'),
endpoint,
credentials: {
accessKeyId,
secretAccessKey,
},
forcePathStyle: this.configService.get<string>('R2_FORCE_PATH_STYLE', 'true') !== 'false',
});
return this.r2Client;
}
private isR2Enabled() {
return Boolean(
this.configService.get<string>('R2_BUCKET')
&& this.configService.get<string>('R2_ACCESS_KEY_ID')
&& this.configService.get<string>('R2_SECRET_ACCESS_KEY'),
);
}
private createAvatarObjectKey(userId: string) {
const prefix = this.configService.get<string>('R2_PUBLIC_FILE_PREFIX', 'studio-files/public');
return `${prefix.replace(/^\/+|\/+$/g, '')}/users/${userId}/avatar`;
}
} }
function verifyPassword(password: string, passwordHash: string) { function verifyPassword(password: string, passwordHash: string) {
@@ -85,3 +371,21 @@ function verifyPassword(password: string, passwordHash: string) {
function hashToken(token: string) { function hashToken(token: string) {
return createHash('sha256').update(token).digest('hex'); 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)}`;
}
+10 -1
View File
@@ -1,4 +1,4 @@
import { IsEmail, IsString } from 'class-validator'; import { IsEmail, IsString, MinLength } from 'class-validator';
export class AdminLoginDto { export class AdminLoginDto {
@IsEmail() @IsEmail()
@@ -7,3 +7,12 @@ export class AdminLoginDto {
@IsString() @IsString()
password!: string; password!: string;
} }
export class ChangeAdminPasswordDto {
@IsString()
currentPassword!: string;
@IsString()
@MinLength(8)
newPassword!: string;
}
+2 -1
View File
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module';
import { GiteaModule } from '../gitea/gitea.module'; import { GiteaModule } from '../gitea/gitea.module';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { AdminsController } from './admins.controller'; import { AdminsController } from './admins.controller';
import { AdminsService } from './admins.service'; import { AdminsService } from './admins.service';
@Module({ @Module({
imports: [GiteaModule, PrismaModule], imports: [AuthPrismaModule, GiteaModule, PrismaModule],
controllers: [AdminsController], controllers: [AdminsController],
providers: [AdminsService], providers: [AdminsService],
}) })
+57 -9
View File
@@ -1,9 +1,12 @@
import { createHash, randomBytes, scryptSync } from 'node:crypto'; import { createHash, randomBytes, scryptSync } from 'node:crypto';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; 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 { GiteaService } from '../gitea/gitea.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { getUserAvatarUrl } from '../users/users.service';
import { AdminRole } from './admin-role'; import { AdminRole } from './admin-role';
import { import {
CreateAdminSshKeyDto, CreateAdminSshKeyDto,
@@ -17,6 +20,8 @@ import {
@Injectable() @Injectable()
export class AdminsService { export class AdminsService {
constructor( constructor(
private readonly authPrisma: AuthPrismaService,
private readonly configService: ConfigService,
private readonly giteaService: GiteaService, private readonly giteaService: GiteaService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
) {} ) {}
@@ -38,8 +43,8 @@ export class AdminsService {
}); });
} }
list() { async list() {
return this.prisma.admin.findMany({ const admins = await this.prisma.admin.findMany({
orderBy: [{ role: 'asc' }, { createdAt: 'desc' }], orderBy: [{ role: 'asc' }, { createdAt: 'desc' }],
select: { select: {
id: true, 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<string>('AUTH_PUBLIC_URL')
?? this.configService.get<string>('ATNMY_AUTH_URL')
?? this.configService.get<string>('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) { async create(body: CreateAdminDto) {
await this.ensureRoleEnabled(body.role); 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({ const admin = await this.prisma.admin.create({
data: { data: {
email: body.email.trim().toLowerCase(), email,
name: body.name.trim(), name,
role: body.role, role: body.role,
passwordHash: hashPassword(body.temporaryPassword), passwordHash: hashPassword(body.temporaryPassword),
}, },
@@ -186,20 +222,18 @@ export class AdminsService {
} }
async createSshKey(adminId: string, body: CreateAdminSshKeyDto) { async createSshKey(adminId: string, body: CreateAdminSshKeyDto) {
await this.ensureExists(adminId); const admin = await this.ensureExists(adminId);
const parsedKey = parseSshPublicKey(body.publicKey); const parsedKey = parseSshPublicKey(body.publicKey);
const label = body.label?.trim() || `${parsedKey.keyType} ${parsedKey.fingerprint}`; 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; let giteaKeyId: number | null = null;
if (giteaUsername) {
const giteaKey = await this.giteaService.createUserSshKey(giteaUsername, { const giteaKey = await this.giteaService.createUserSshKey(giteaUsername, {
title: label, title: label,
key: parsedKey.normalizedKey, key: parsedKey.normalizedKey,
}); });
giteaKeyId = giteaKey.id; giteaKeyId = giteaKey.id;
}
const sshKey = await this.prisma.adminSshKey.create({ const sshKey = await this.prisma.adminSshKey.create({
data: { data: {
@@ -273,12 +307,14 @@ export class AdminsService {
private async ensureExists(id: string) { private async ensureExists(id: string) {
const admin = await this.prisma.admin.findUnique({ const admin = await this.prisma.admin.findUnique({
where: { id }, where: { id },
select: { id: true }, select: { id: true, email: true },
}); });
if (!admin) { if (!admin) {
throw new NotFoundException('관리자를 찾을 수 없습니다.'); throw new NotFoundException('관리자를 찾을 수 없습니다.');
} }
return admin;
} }
} }
@@ -309,6 +345,18 @@ function hashPassword(password: string) {
return `scrypt:${salt}:${hash}`; 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) { function parseSshPublicKey(publicKey: string) {
const normalizedKey = publicKey.trim().replace(/\s+/g, ' '); const normalizedKey = publicKey.trim().replace(/\s+/g, ' ');
const [keyType, keyBody] = normalizedKey.split(' '); const [keyType, keyBody] = normalizedKey.split(' ');
+21 -9
View File
@@ -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 { LinkComponentSourceDto } from '../gitea/dto';
import { AppsService } from './apps.service'; import { AppsService } from './apps.service';
@@ -83,8 +83,11 @@ export class AppsController {
} }
@Post('builds') @Post('builds')
createBuildJob(@Body() body: CreateBuildJobDto) { createBuildJob(
return this.appsService.createBuildJob(body); @Body() body: CreateBuildJobDto,
@Req() request: { admin?: { id: string; name: string; email: string } },
) {
return this.appsService.createBuildJob(body, request.admin);
} }
@Patch('builds/:buildJobId/cancel') @Patch('builds/:buildJobId/cancel')
@@ -134,8 +137,11 @@ export class AppsController {
} }
@Post('releases') @Post('releases')
createRelease(@Body() body: CreateAppReleaseDto) { createRelease(
return this.appsService.createRelease(body); @Body() body: CreateAppReleaseDto,
@Req() request: { admin?: { id: string; name: string; email: string } },
) {
return this.appsService.createRelease(body, request.admin);
} }
@Delete('releases/:releaseId') @Delete('releases/:releaseId')
@@ -154,8 +160,11 @@ export class AppsController {
} }
@Post('deployments') @Post('deployments')
createDeploymentJob(@Body() body: CreateDeploymentJobDto) { createDeploymentJob(
return this.appsService.createDeploymentJob(body); @Body() body: CreateDeploymentJobDto,
@Req() request: { admin?: { id: string; name: string; email: string } },
) {
return this.appsService.createDeploymentJob(body, request.admin);
} }
@Get('migrations') @Get('migrations')
@@ -169,8 +178,11 @@ export class AppsController {
} }
@Post('migrations') @Post('migrations')
createMigrationJob(@Body() body: CreateMigrationJobDto) { createMigrationJob(
return this.appsService.createMigrationJob(body); @Body() body: CreateMigrationJobDto,
@Req() request: { admin?: { id: string; name: string; email: string } },
) {
return this.appsService.createMigrationJob(body, request.admin);
} }
@Get('runtime-namespaces/:namespace') @Get('runtime-namespaces/:namespace')
+2 -1
View File
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { LauncherModule } from '../launcher/launcher.module'; import { LauncherModule } from '../launcher/launcher.module';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module';
import { AppsController } from './apps.controller'; import { AppsController } from './apps.controller';
import { AppsService } from './apps.service'; import { AppsService } from './apps.service';
import { BuildRunnerService } from './build-runner.service'; import { BuildRunnerService } from './build-runner.service';
@Module({ @Module({
imports: [PrismaModule, LauncherModule], imports: [PrismaModule, LauncherModule, AuthPrismaModule],
controllers: [AppsController], controllers: [AppsController],
providers: [AppsService, BuildRunnerService], providers: [AppsService, BuildRunnerService],
}) })
+79 -19
View File
@@ -10,6 +10,8 @@ import { promisify } from 'node:util';
import { LinkComponentSourceDto } from '../gitea/dto'; import { LinkComponentSourceDto } from '../gitea/dto';
import { LauncherService } from '../launcher/launcher.service'; import { LauncherService } from '../launcher/launcher.service';
import { PrismaService } from '../prisma/prisma.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 { BuildRunnerService } from './build-runner.service';
import { import {
CreateAppComponentDto, CreateAppComponentDto,
@@ -29,6 +31,12 @@ import {
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const serviceAccountDir = '/var/run/secrets/kubernetes.io/serviceaccount'; const serviceAccountDir = '/var/run/secrets/kubernetes.io/serviceaccount';
type AdminRequester = {
id: string;
name: string;
email: string;
};
@Injectable() @Injectable()
export class AppsService { export class AppsService {
private giteaUsername?: string; private giteaUsername?: string;
@@ -38,8 +46,46 @@ export class AppsService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly buildRunnerService: BuildRunnerService, private readonly buildRunnerService: BuildRunnerService,
private readonly launcherService: LauncherService, 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<T extends { requestedByEmail?: string | null }>(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<string>('AUTH_PUBLIC_URL')
?? this.configService.get<string>('ATNMY_AUTH_URL')
?? this.configService.get<string>('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() { async listApps() {
const [apps, launcherAppKeys] = await Promise.all([ const [apps, launcherAppKeys] = await Promise.all([
this.prisma.app.findMany({ this.prisma.app.findMany({
@@ -312,11 +358,12 @@ export class AppsService {
}; };
if (!pagination && !sinceDate) { if (!pagination && !sinceDate) {
return this.prisma.appRelease.findMany({ const releases = await this.prisma.appRelease.findMany({
where: appKey ? { app: { key: appKey } } : undefined, where: appKey ? { app: { key: appKey } } : undefined,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { app: true }, include: { app: true },
}); });
return this.withRequesterAvatars(releases);
} }
const pageNumber = pagination?.page ?? 1; const pageNumber = pagination?.page ?? 1;
@@ -340,7 +387,7 @@ export class AppsService {
}); });
return { return {
releases: items, releases: await this.withRequesterAvatars(items),
total, total,
page: pageNumber, page: pageNumber,
limit: pageLimit, 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({ const app = await this.prisma.app.findUnique({
where: { id: body.appId }, where: { id: body.appId },
include: { include: {
@@ -439,6 +486,7 @@ export class AppsService {
}, },
status: 'PENDING' as const, status: 'PENDING' as const,
logs: releaseLogs.join('\n'), logs: releaseLogs.join('\n'),
...this.requesterData(requester),
}; };
const release = existingRelease const release = existingRelease
? await this.prisma.appRelease.update({ ? await this.prisma.appRelease.update({
@@ -457,7 +505,7 @@ export class AppsService {
void this.finalizeReleaseFromBuilds(release.id, buildJobs.map((job) => job.id)); 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[]) { private async finalizeReleaseFromBuilds(releaseId: string, sourceBuildJobIds: string[]) {
@@ -550,6 +598,9 @@ export class AppsService {
sourceBuildJobs: Array<{ sourceBuildJobs: Array<{
componentId: string; componentId: string;
commitSha: string | null; commitSha: string | null;
requestedById?: string | null;
requestedByName?: string | null;
requestedByEmail?: string | null;
component: { component: {
id: string; id: string;
name: string; name: string;
@@ -579,6 +630,9 @@ export class AppsService {
artifact, artifact,
image, image,
status: 'PENDING', status: 'PENDING',
requestedById: sourceJob.requestedById,
requestedByName: sourceJob.requestedByName,
requestedByEmail: sourceJob.requestedByEmail,
logs: getBuildPlanLogs({ logs: getBuildPlanLogs({
appName: component.app.name, appName: component.app.name,
componentName: component.name, componentName: component.name,
@@ -879,11 +933,12 @@ export class AppsService {
}; };
if (!pagination && !sinceDate) { if (!pagination && !sinceDate) {
return this.prisma.deploymentJob.findMany({ const deployments = await this.prisma.deploymentJob.findMany({
where: appKey ? { app: { key: appKey } } : undefined, where: appKey ? { app: { key: appKey } } : undefined,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include, include,
}); });
return this.withRequesterAvatars(deployments);
} }
const pageNumber = pagination?.page ?? 1; const pageNumber = pagination?.page ?? 1;
@@ -908,7 +963,7 @@ export class AppsService {
}); });
return { return {
deployments: items, deployments: await this.withRequesterAvatars(items),
total, total,
page: pageNumber, page: pageNumber,
limit: pageLimit, 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 namespace = normalizeNamespace(body.namespace);
const environment = body.environment.trim(); const environment = body.environment.trim();
const release = await this.prisma.appRelease.findUnique({ const release = await this.prisma.appRelease.findUnique({
@@ -973,7 +1028,8 @@ export class AppsService {
environment, environment,
namespace, namespace,
status: 'RUNNING', status: 'RUNNING',
requestedBy: 'admin', requestedBy: requester?.name ?? 'admin',
...this.requesterData(requester),
startedAt: new Date(), startedAt: new Date(),
logs: [ logs: [
`[running] deployment started: ${release.app.name} ${release.version}`, `[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) { } catch (error) {
const message = error instanceof Error ? error.message : 'Deployment failed'; const message = error instanceof Error ? error.message : 'Deployment failed';
const failed = await this.prisma.deploymentJob.update({ 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) { if (!pagination && !sinceDate && !appId) {
return this.prisma.runtimeControlJob.findMany({ const jobs = await this.prisma.runtimeControlJob.findMany({
where: { action: 'MIGRATION' }, where: { action: 'MIGRATION' },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
return this.withRequesterAvatars(jobs);
} }
const pageNumber = pagination?.page ?? 1; const pageNumber = pagination?.page ?? 1;
@@ -1207,14 +1264,14 @@ export class AppsService {
]); ]);
return { return {
jobs: items, jobs: await this.withRequesterAvatars(items),
total, total,
page: pageNumber, page: pageNumber,
limit: pageLimit, limit: pageLimit,
}; };
} }
async createMigrationJob(body: CreateMigrationJobDto) { async createMigrationJob(body: CreateMigrationJobDto, requester?: AdminRequester) {
const namespace = normalizeNamespace(body.namespace); const namespace = normalizeNamespace(body.namespace);
const command = body.command.trim(); const command = body.command.trim();
const envSecretName = sanitizeK8sName(body.envSecretName); const envSecretName = sanitizeK8sName(body.envSecretName);
@@ -1279,7 +1336,8 @@ export class AppsService {
name: jobName, name: jobName,
action: 'MIGRATION', action: 'MIGRATION',
status: 'RUNNING', status: 'RUNNING',
requestedBy: 'admin', requestedBy: requester?.name ?? 'admin',
...this.requesterData(requester),
request, request,
startedAt, startedAt,
logs: [ logs: [
@@ -1299,7 +1357,7 @@ export class AppsService {
envSecretName, envSecretName,
}); });
return { migrationJob }; return { migrationJob: (await this.withRequesterAvatars([migrationJob]))[0] };
} }
private async finalizeMigrationJob( private async finalizeMigrationJob(
@@ -1384,7 +1442,7 @@ export class AppsService {
}; };
if (!pagination && !sinceDate) { if (!pagination && !sinceDate) {
return this.prisma.buildJob.findMany({ const jobs = await this.prisma.buildJob.findMany({
where: { where: {
...(appKey ? { component: { app: { key: appKey } } } : {}), ...(appKey ? { component: { app: { key: appKey } } } : {}),
...(status ? { status: normalizeJobStatus(status) } : {}), ...(status ? { status: normalizeJobStatus(status) } : {}),
@@ -1393,6 +1451,7 @@ export class AppsService {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include, include,
}); });
return this.withRequesterAvatars(jobs);
} }
const pageNumber = pagination?.page ?? 1; const pageNumber = pagination?.page ?? 1;
@@ -1409,7 +1468,7 @@ export class AppsService {
]); ]);
return { return {
jobs: items, jobs: await this.withRequesterAvatars(items),
total, total,
page: pageNumber, page: pageNumber,
limit: pageLimit, limit: pageLimit,
@@ -1872,7 +1931,7 @@ export class AppsService {
return { applied: true, logs: logs.filter(Boolean).join('\n') }; 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({ const component = await this.prisma.appComponent.findUnique({
where: { id: body.componentId }, where: { id: body.componentId },
include: { app: true }, include: { app: true },
@@ -1904,6 +1963,7 @@ export class AppsService {
artifact, artifact,
image, image,
status: 'PENDING', status: 'PENDING',
...this.requesterData(requester),
logs: getBuildPlanLogs({ logs: getBuildPlanLogs({
appName: component.app.name, appName: component.app.name,
componentName: component.name, componentName: component.name,
@@ -1929,7 +1989,7 @@ export class AppsService {
}, },
}); });
return { buildJob }; return { buildJob: (await this.withRequesterAvatars([buildJob]))[0] };
} }
async cancelBuildJob(buildJobId: string) { async cancelBuildJob(buildJobId: string) {
+46
View File
@@ -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) { async deleteUserSshKey(username: string, keyId: number) {
this.assertToken(); this.assertToken();
+6 -3
View File
@@ -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 { CreateLauncherAppLinkDto, CreateLauncherBuildJobDto, CreateLauncherVersionDto, UpdateLauncherSourceDto, LinkLauncherVersionSourceDto, MoveLauncherAppLinkDto, UpdateLauncherAppLinkDto, UpdateLauncherBuildEnvDto, UpdateLauncherUserAppPermissionsDto, DeployLauncherBackendDto, ListLauncherBuildJobsQuery } from './dto';
import { LauncherService } from './launcher.service'; import { LauncherService } from './launcher.service';
@@ -54,8 +54,11 @@ export class LauncherController {
} }
@Post('builds') @Post('builds')
createBuildJob(@Body() body: CreateLauncherBuildJobDto) { createBuildJob(
return this.launcherService.createBuildJob(body); @Body() body: CreateLauncherBuildJobDto,
@Req() request: { admin?: { id: string; name: string; email: string } },
) {
return this.launcherService.createBuildJob(body, request.admin);
} }
@Get('app-links') @Get('app-links')
+2 -1
View File
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { LauncherPrismaModule } from '../launcher-prisma/launcher-prisma.module'; import { LauncherPrismaModule } from '../launcher-prisma/launcher-prisma.module';
import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module';
import { LauncherController } from './launcher.controller'; import { LauncherController } from './launcher.controller';
import { LauncherService } from './launcher.service'; import { LauncherService } from './launcher.service';
@Module({ @Module({
imports: [PrismaModule, LauncherPrismaModule], imports: [PrismaModule, LauncherPrismaModule, AuthPrismaModule],
controllers: [LauncherController], controllers: [LauncherController],
providers: [LauncherService], providers: [LauncherService],
exports: [LauncherService], exports: [LauncherService],
+42 -5
View File
@@ -10,6 +10,8 @@ import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service'; import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuthPrismaService } from '../auth-prisma/auth-prisma.service';
import { getUserAvatarUrl } from '../users/users.service';
import { import {
CreateLauncherAppLinkDto, CreateLauncherAppLinkDto,
CreateLauncherBuildJobDto, CreateLauncherBuildJobDto,
@@ -61,6 +63,7 @@ export class LauncherService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly launcherPrisma: LauncherPrismaService, private readonly launcherPrisma: LauncherPrismaService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly authPrisma: AuthPrismaService,
) {} ) {}
async listVersions() { 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 paged = Boolean(query.page || query.limit || query.target || query.since);
const page = toPositiveInt(query.page, 1); const page = toPositiveInt(query.page, 1);
const limit = Math.min(toPositiveInt(query.limit, 30), 100); const limit = Math.min(toPositiveInt(query.limit, 30), 100);
@@ -391,10 +394,11 @@ export class LauncherService {
}; };
if (!paged) { if (!paged) {
return this.prisma.launcherBuildJob.findMany({ const jobs = await this.prisma.launcherBuildJob.findMany({
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 30, take: 30,
}); });
return this.withLauncherBuildRequesters(jobs);
} }
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
@@ -408,7 +412,34 @@ export class LauncherService {
tx.launcherBuildJob.count({ where }), tx.launcherBuildJob.count({ where }),
]); ]);
return { jobs, total, page, limit }; return { jobs: await this.withLauncherBuildRequesters(jobs), total, page, limit };
});
}
private async withLauncherBuildRequesters<T extends { requestedByEmail?: string | null }>(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<string>('AUTH_PUBLIC_URL')
?? this.configService.get<string>('ATNMY_AUTH_URL')
?? this.configService.get<string>('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]) }; 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 source = await this.getSourceSettings();
const target = body.target?.trim() || 'launcher-client'; const target = body.target?.trim() || 'launcher-client';
const isBackend = target === 'launcher-backend'; const isBackend = target === 'launcher-backend';
@@ -957,12 +991,15 @@ export class LauncherService {
artifact: requestedArtifact, artifact: requestedArtifact,
uploadPath: plan.uploadPath, uploadPath: plan.uploadPath,
downloadUrl: plan.downloadUrl, downloadUrl: plan.downloadUrl,
requestedById: requester?.id,
requestedByName: requester?.name,
requestedByEmail: requester?.email,
logs, logs,
}, },
}); });
void this.runBuildJob(job.id); void this.runBuildJob(job.id);
return { job }; return { job: (await this.withLauncherBuildRequesters([job]))[0] };
} }
async linkVersionSource(id: string, body: LinkLauncherVersionSourceDto) { async linkVersionSource(id: string, body: LinkLauncherVersionSourceDto) {
+1 -1
View File
@@ -291,7 +291,7 @@ function calculateLicenseExpiresAt(
return expiresAt; return expiresAt;
} }
function getUserAvatarUrl( export function getUserAvatarUrl(
userId: string, userId: string,
mimeType?: string | null, mimeType?: string | null,
authBaseUrl?: string | null, authBaseUrl?: string | null,