track requesters for builds releases deployments and migrations
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -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 '작업을 요청한 관리자 이메일';
|
||||||
@@ -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?
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(' ');
|
||||||
|
|||||||
@@ -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,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
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,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],
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user