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?
|
||||
image String?
|
||||
status JobStatus @default(PENDING)
|
||||
requestedById String? @map("requested_by_id") @db.Uuid
|
||||
requestedByName String? @map("requested_by_name")
|
||||
requestedByEmail String? @map("requested_by_email")
|
||||
logs String?
|
||||
startedAt DateTime? @map("started_at") @db.Timestamptz(3)
|
||||
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
|
||||
@@ -238,6 +241,9 @@ model AppRelease {
|
||||
artifact String?
|
||||
imageTags Json? @map("image_tags")
|
||||
status JobStatus @default(PENDING)
|
||||
requestedById String? @map("requested_by_id") @db.Uuid
|
||||
requestedByName String? @map("requested_by_name")
|
||||
requestedByEmail String? @map("requested_by_email")
|
||||
logs String?
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
@@ -256,6 +262,9 @@ model DeploymentJob {
|
||||
namespace String
|
||||
status JobStatus @default(PENDING)
|
||||
requestedBy String? @map("requested_by")
|
||||
requestedById String? @map("requested_by_id") @db.Uuid
|
||||
requestedByName String? @map("requested_by_name")
|
||||
requestedByEmail String? @map("requested_by_email")
|
||||
logs String?
|
||||
startedAt DateTime? @map("started_at") @db.Timestamptz(3)
|
||||
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
|
||||
@@ -278,6 +287,9 @@ model LauncherBuildJob {
|
||||
uploadPath String @map("upload_path")
|
||||
downloadUrl String @map("download_url")
|
||||
status JobStatus @default(PENDING)
|
||||
requestedById String? @map("requested_by_id") @db.Uuid
|
||||
requestedByName String? @map("requested_by_name")
|
||||
requestedByEmail String? @map("requested_by_email")
|
||||
logs String?
|
||||
startedAt DateTime? @map("started_at") @db.Timestamptz(3)
|
||||
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
|
||||
@@ -299,6 +311,9 @@ model RuntimeControlJob {
|
||||
action RuntimeAction
|
||||
status JobStatus @default(PENDING)
|
||||
requestedBy String? @map("requested_by")
|
||||
requestedById String? @map("requested_by_id") @db.Uuid
|
||||
requestedByName String? @map("requested_by_name")
|
||||
requestedByEmail String? @map("requested_by_email")
|
||||
request Json?
|
||||
result Json?
|
||||
logs String?
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
import { Body, Controller, Post, Req } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Patch, Post, Put, Req } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import { Public } from './decorators/admin-auth.decorators';
|
||||
import { AdminLoginDto } from './dto';
|
||||
import { AdminLoginDto, ChangeAdminPasswordDto } from './dto';
|
||||
|
||||
@Controller('auth/admin')
|
||||
export class AdminAuthController {
|
||||
constructor(private readonly adminAuthService: AdminAuthService) {}
|
||||
|
||||
@Get('me')
|
||||
me(@Req() request: { admin?: { id: string } }) {
|
||||
return this.adminAuthService.me(request.admin!.id);
|
||||
}
|
||||
|
||||
@Put('avatar')
|
||||
async updateAvatar(@Req() request: Request & { admin?: { id: string } }) {
|
||||
const mimeType = String(request.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
|
||||
const imageData = await readRequestBody(request);
|
||||
return this.adminAuthService.updateAvatar(request.admin!.id, imageData, mimeType);
|
||||
}
|
||||
|
||||
@Delete('avatar')
|
||||
deleteAvatar(@Req() request: { admin?: { id: string } }) {
|
||||
return this.adminAuthService.deleteAvatar(request.admin!.id);
|
||||
}
|
||||
|
||||
@Patch('password')
|
||||
changePassword(
|
||||
@Req() request: { admin?: { id: string } },
|
||||
@Body() body: ChangeAdminPasswordDto,
|
||||
) {
|
||||
return this.adminAuthService.changePassword(request.admin!.id, body);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
login(
|
||||
@@ -22,3 +48,15 @@ export class AdminAuthController {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function readRequestBody(request: Request) {
|
||||
return new Promise<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 { AuthPrismaModule } from '../auth-prisma/auth-prisma.module';
|
||||
import { GiteaModule } from '../gitea/gitea.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AdminAuthController } from './admin-auth.controller';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [AuthPrismaModule, GiteaModule, PrismaModule],
|
||||
controllers: [AdminAuthController],
|
||||
providers: [AdminAuthService],
|
||||
})
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'node:crypto';
|
||||
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { AuthPrismaService } from '../auth-prisma/auth-prisma.service';
|
||||
import { GiteaService } from '../gitea/gitea.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AdminLoginDto } from './dto';
|
||||
import { getUserAvatarUrl } from '../users/users.service';
|
||||
import { AdminLoginDto, ChangeAdminPasswordDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuthService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
private r2Client?: S3Client;
|
||||
|
||||
constructor(
|
||||
private readonly authPrisma: AuthPrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly giteaService: GiteaService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async login(
|
||||
body: AdminLoginDto,
|
||||
@@ -25,6 +37,16 @@ export class AdminAuthService {
|
||||
appPermissions: {
|
||||
select: { appId: true },
|
||||
},
|
||||
sshKeys: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
label: true,
|
||||
fingerprint: true,
|
||||
giteaUsername: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,6 +74,8 @@ export class AdminAuthService {
|
||||
}),
|
||||
]);
|
||||
|
||||
const avatarUrl = await this.getAdminAvatarUrl(admin.email);
|
||||
|
||||
return {
|
||||
token,
|
||||
expiresAt,
|
||||
@@ -60,10 +84,272 @@ export class AdminAuthService {
|
||||
email: admin.email,
|
||||
name: admin.name,
|
||||
role: admin.role,
|
||||
avatarUrl,
|
||||
appPermissions: admin.appPermissions.map((permission) => permission.appId),
|
||||
sshKeys: admin.sshKeys,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async me(adminId: string) {
|
||||
const admin = await this.prisma.admin.findUnique({
|
||||
where: { id: adminId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
appPermissions: {
|
||||
select: { appId: true },
|
||||
},
|
||||
sshKeys: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
label: true,
|
||||
fingerprint: true,
|
||||
giteaUsername: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
throw new UnauthorizedException('관리자 세션이 유효하지 않습니다.');
|
||||
}
|
||||
|
||||
return {
|
||||
admin: {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
name: admin.name,
|
||||
role: admin.role,
|
||||
avatarUrl: await this.getAdminAvatarUrl(admin.email),
|
||||
appPermissions: admin.appPermissions.map((permission) => permission.appId),
|
||||
sshKeys: admin.sshKeys,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async updateAvatar(adminId: string, imageData: Buffer, mimeType: string) {
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(mimeType)) {
|
||||
throw new BadRequestException('지원하지 않는 이미지 형식입니다.');
|
||||
}
|
||||
|
||||
if (!imageData.length) {
|
||||
throw new BadRequestException('이미지 파일이 비어 있습니다.');
|
||||
}
|
||||
|
||||
if (imageData.length > 80 * 1024) {
|
||||
throw new BadRequestException('프로필 이미지는 80KB 이하로 업로드해 주세요.');
|
||||
}
|
||||
|
||||
const { admin, user } = await this.getLinkedAuthUser(adminId);
|
||||
const giteaUsername = await this.ensureGiteaUser(admin);
|
||||
|
||||
await this.uploadAvatarToR2(user.id, imageData, mimeType);
|
||||
await this.giteaService.updateUserAvatar(giteaUsername, imageData, mimeType);
|
||||
await this.authPrisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
profileImageData: null,
|
||||
profileImageMime: mimeType,
|
||||
profileImageUpdatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
avatarUrl: await this.getAdminAvatarUrl(admin.email),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteAvatar(adminId: string) {
|
||||
const { admin, user } = await this.getLinkedAuthUser(adminId);
|
||||
const giteaUsername = this.resolveGiteaUsername(admin);
|
||||
|
||||
if (this.isR2Enabled()) {
|
||||
await this.deleteAvatarFromR2(user.id);
|
||||
}
|
||||
await this.giteaService.deleteUserAvatar(giteaUsername);
|
||||
|
||||
await this.authPrisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
profileImageData: null,
|
||||
profileImageMime: null,
|
||||
profileImageUpdatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
avatarUrl: await this.getAdminAvatarUrl(admin.email),
|
||||
};
|
||||
}
|
||||
|
||||
async changePassword(adminId: string, body: ChangeAdminPasswordDto) {
|
||||
if (body.currentPassword === body.newPassword) {
|
||||
throw new BadRequestException('새 비밀번호는 현재 비밀번호와 달라야 합니다.');
|
||||
}
|
||||
|
||||
const admin = await this.prisma.admin.findUnique({
|
||||
where: { id: adminId },
|
||||
select: {
|
||||
id: true,
|
||||
passwordHash: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!admin || !admin.isActive) {
|
||||
throw new UnauthorizedException('관리자 세션이 유효하지 않습니다.');
|
||||
}
|
||||
|
||||
if (!verifyPassword(body.currentPassword, admin.passwordHash)) {
|
||||
throw new BadRequestException('현재 비밀번호가 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
await this.prisma.admin.update({
|
||||
where: { id: admin.id },
|
||||
data: { passwordHash: hashPassword(body.newPassword) },
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async getAdminAvatarUrl(email: string) {
|
||||
const user = await this.authPrisma.user.findUnique({
|
||||
where: { email },
|
||||
select: {
|
||||
id: true,
|
||||
profileImageMime: true,
|
||||
},
|
||||
});
|
||||
|
||||
const authBaseUrl = this.configService.get<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) {
|
||||
@@ -85,3 +371,21 @@ function verifyPassword(password: string, passwordHash: string) {
|
||||
function hashToken(token: string) {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
function hashPassword(password: string) {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const hash = scryptSync(password, salt, 64).toString('hex');
|
||||
return `scrypt:${salt}:${hash}`;
|
||||
}
|
||||
|
||||
function createGiteaUsername(email: string) {
|
||||
const account = email.split('@')[0] || email;
|
||||
const username = account
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^[._-]+|[._-]+$/g, '')
|
||||
.slice(0, 40);
|
||||
|
||||
return username || `admin-${createHash('sha1').update(email).digest('hex').slice(0, 8)}`;
|
||||
}
|
||||
|
||||
+10
-1
@@ -1,4 +1,4 @@
|
||||
import { IsEmail, IsString } from 'class-validator';
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class AdminLoginDto {
|
||||
@IsEmail()
|
||||
@@ -7,3 +7,12 @@ export class AdminLoginDto {
|
||||
@IsString()
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class ChangeAdminPasswordDto {
|
||||
@IsString()
|
||||
currentPassword!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword!: string;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module';
|
||||
import { GiteaModule } from '../gitea/gitea.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AdminsController } from './admins.controller';
|
||||
import { AdminsService } from './admins.service';
|
||||
|
||||
@Module({
|
||||
imports: [GiteaModule, PrismaModule],
|
||||
imports: [AuthPrismaModule, GiteaModule, PrismaModule],
|
||||
controllers: [AdminsController],
|
||||
providers: [AdminsService],
|
||||
})
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { createHash, randomBytes, scryptSync } from 'node:crypto';
|
||||
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { AuthPrismaService } from '../auth-prisma/auth-prisma.service';
|
||||
import { GiteaService } from '../gitea/gitea.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { getUserAvatarUrl } from '../users/users.service';
|
||||
import { AdminRole } from './admin-role';
|
||||
import {
|
||||
CreateAdminSshKeyDto,
|
||||
@@ -17,6 +20,8 @@ import {
|
||||
@Injectable()
|
||||
export class AdminsService {
|
||||
constructor(
|
||||
private readonly authPrisma: AuthPrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly giteaService: GiteaService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
@@ -38,8 +43,8 @@ export class AdminsService {
|
||||
});
|
||||
}
|
||||
|
||||
list() {
|
||||
return this.prisma.admin.findMany({
|
||||
async list() {
|
||||
const admins = await this.prisma.admin.findMany({
|
||||
orderBy: [{ role: 'asc' }, { createdAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
@@ -69,15 +74,46 @@ export class AdminsService {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const users = await this.authPrisma.user.findMany({
|
||||
where: { email: { in: admins.map((admin) => admin.email) } },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
profileImageMime: true,
|
||||
},
|
||||
});
|
||||
const userByEmail = new Map(users.map((user) => [user.email, user]));
|
||||
const authBaseUrl = this.configService.get<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) {
|
||||
await this.ensureRoleEnabled(body.role);
|
||||
const email = body.email.trim().toLowerCase();
|
||||
const name = body.name.trim();
|
||||
|
||||
await this.giteaService.createUser({
|
||||
username: createGiteaUsername(email),
|
||||
email,
|
||||
password: body.temporaryPassword,
|
||||
fullName: name,
|
||||
});
|
||||
|
||||
const admin = await this.prisma.admin.create({
|
||||
data: {
|
||||
email: body.email.trim().toLowerCase(),
|
||||
name: body.name.trim(),
|
||||
email,
|
||||
name,
|
||||
role: body.role,
|
||||
passwordHash: hashPassword(body.temporaryPassword),
|
||||
},
|
||||
@@ -186,20 +222,18 @@ export class AdminsService {
|
||||
}
|
||||
|
||||
async createSshKey(adminId: string, body: CreateAdminSshKeyDto) {
|
||||
await this.ensureExists(adminId);
|
||||
const admin = await this.ensureExists(adminId);
|
||||
|
||||
const parsedKey = parseSshPublicKey(body.publicKey);
|
||||
const label = body.label?.trim() || `${parsedKey.keyType} ${parsedKey.fingerprint}`;
|
||||
const giteaUsername = body.giteaUsername?.trim() || null;
|
||||
const giteaUsername = createGiteaUsername(admin.email);
|
||||
let giteaKeyId: number | null = null;
|
||||
|
||||
if (giteaUsername) {
|
||||
const giteaKey = await this.giteaService.createUserSshKey(giteaUsername, {
|
||||
title: label,
|
||||
key: parsedKey.normalizedKey,
|
||||
});
|
||||
giteaKeyId = giteaKey.id;
|
||||
}
|
||||
const giteaKey = await this.giteaService.createUserSshKey(giteaUsername, {
|
||||
title: label,
|
||||
key: parsedKey.normalizedKey,
|
||||
});
|
||||
giteaKeyId = giteaKey.id;
|
||||
|
||||
const sshKey = await this.prisma.adminSshKey.create({
|
||||
data: {
|
||||
@@ -273,12 +307,14 @@ export class AdminsService {
|
||||
private async ensureExists(id: string) {
|
||||
const admin = await this.prisma.admin.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
throw new NotFoundException('관리자를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return admin;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +345,18 @@ function hashPassword(password: string) {
|
||||
return `scrypt:${salt}:${hash}`;
|
||||
}
|
||||
|
||||
function createGiteaUsername(email: string) {
|
||||
const account = email.split('@')[0] || email;
|
||||
const username = account
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^[._-]+|[._-]+$/g, '')
|
||||
.slice(0, 40);
|
||||
|
||||
return username || `admin-${createHash('sha1').update(email).digest('hex').slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function parseSshPublicKey(publicKey: string) {
|
||||
const normalizedKey = publicKey.trim().replace(/\s+/g, ' ');
|
||||
const [keyType, keyBody] = normalizedKey.split(' ');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req } from '@nestjs/common';
|
||||
|
||||
import { LinkComponentSourceDto } from '../gitea/dto';
|
||||
import { AppsService } from './apps.service';
|
||||
@@ -83,8 +83,11 @@ export class AppsController {
|
||||
}
|
||||
|
||||
@Post('builds')
|
||||
createBuildJob(@Body() body: CreateBuildJobDto) {
|
||||
return this.appsService.createBuildJob(body);
|
||||
createBuildJob(
|
||||
@Body() body: CreateBuildJobDto,
|
||||
@Req() request: { admin?: { id: string; name: string; email: string } },
|
||||
) {
|
||||
return this.appsService.createBuildJob(body, request.admin);
|
||||
}
|
||||
|
||||
@Patch('builds/:buildJobId/cancel')
|
||||
@@ -134,8 +137,11 @@ export class AppsController {
|
||||
}
|
||||
|
||||
@Post('releases')
|
||||
createRelease(@Body() body: CreateAppReleaseDto) {
|
||||
return this.appsService.createRelease(body);
|
||||
createRelease(
|
||||
@Body() body: CreateAppReleaseDto,
|
||||
@Req() request: { admin?: { id: string; name: string; email: string } },
|
||||
) {
|
||||
return this.appsService.createRelease(body, request.admin);
|
||||
}
|
||||
|
||||
@Delete('releases/:releaseId')
|
||||
@@ -154,8 +160,11 @@ export class AppsController {
|
||||
}
|
||||
|
||||
@Post('deployments')
|
||||
createDeploymentJob(@Body() body: CreateDeploymentJobDto) {
|
||||
return this.appsService.createDeploymentJob(body);
|
||||
createDeploymentJob(
|
||||
@Body() body: CreateDeploymentJobDto,
|
||||
@Req() request: { admin?: { id: string; name: string; email: string } },
|
||||
) {
|
||||
return this.appsService.createDeploymentJob(body, request.admin);
|
||||
}
|
||||
|
||||
@Get('migrations')
|
||||
@@ -169,8 +178,11 @@ export class AppsController {
|
||||
}
|
||||
|
||||
@Post('migrations')
|
||||
createMigrationJob(@Body() body: CreateMigrationJobDto) {
|
||||
return this.appsService.createMigrationJob(body);
|
||||
createMigrationJob(
|
||||
@Body() body: CreateMigrationJobDto,
|
||||
@Req() request: { admin?: { id: string; name: string; email: string } },
|
||||
) {
|
||||
return this.appsService.createMigrationJob(body, request.admin);
|
||||
}
|
||||
|
||||
@Get('runtime-namespaces/:namespace')
|
||||
|
||||
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { LauncherModule } from '../launcher/launcher.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module';
|
||||
import { AppsController } from './apps.controller';
|
||||
import { AppsService } from './apps.service';
|
||||
import { BuildRunnerService } from './build-runner.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, LauncherModule],
|
||||
imports: [PrismaModule, LauncherModule, AuthPrismaModule],
|
||||
controllers: [AppsController],
|
||||
providers: [AppsService, BuildRunnerService],
|
||||
})
|
||||
|
||||
+79
-19
@@ -10,6 +10,8 @@ import { promisify } from 'node:util';
|
||||
import { LinkComponentSourceDto } from '../gitea/dto';
|
||||
import { LauncherService } from '../launcher/launcher.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuthPrismaService } from '../auth-prisma/auth-prisma.service';
|
||||
import { getUserAvatarUrl } from '../users/users.service';
|
||||
import { BuildRunnerService } from './build-runner.service';
|
||||
import {
|
||||
CreateAppComponentDto,
|
||||
@@ -29,6 +31,12 @@ import {
|
||||
const execFileAsync = promisify(execFile);
|
||||
const serviceAccountDir = '/var/run/secrets/kubernetes.io/serviceaccount';
|
||||
|
||||
type AdminRequester = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AppsService {
|
||||
private giteaUsername?: string;
|
||||
@@ -38,8 +46,46 @@ export class AppsService {
|
||||
private readonly configService: ConfigService,
|
||||
private readonly buildRunnerService: BuildRunnerService,
|
||||
private readonly launcherService: LauncherService,
|
||||
private readonly authPrisma: AuthPrismaService,
|
||||
) {}
|
||||
|
||||
private requesterData(requester?: AdminRequester) {
|
||||
return requester
|
||||
? {
|
||||
requestedById: requester.id,
|
||||
requestedByName: requester.name,
|
||||
requestedByEmail: requester.email,
|
||||
}
|
||||
: {};
|
||||
}
|
||||
|
||||
private async withRequesterAvatars<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() {
|
||||
const [apps, launcherAppKeys] = await Promise.all([
|
||||
this.prisma.app.findMany({
|
||||
@@ -312,11 +358,12 @@ export class AppsService {
|
||||
};
|
||||
|
||||
if (!pagination && !sinceDate) {
|
||||
return this.prisma.appRelease.findMany({
|
||||
const releases = await this.prisma.appRelease.findMany({
|
||||
where: appKey ? { app: { key: appKey } } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { app: true },
|
||||
});
|
||||
return this.withRequesterAvatars(releases);
|
||||
}
|
||||
|
||||
const pageNumber = pagination?.page ?? 1;
|
||||
@@ -340,7 +387,7 @@ export class AppsService {
|
||||
});
|
||||
|
||||
return {
|
||||
releases: items,
|
||||
releases: await this.withRequesterAvatars(items),
|
||||
total,
|
||||
page: pageNumber,
|
||||
limit: pageLimit,
|
||||
@@ -349,7 +396,7 @@ export class AppsService {
|
||||
};
|
||||
}
|
||||
|
||||
async createRelease(body: CreateAppReleaseDto) {
|
||||
async createRelease(body: CreateAppReleaseDto, requester?: AdminRequester) {
|
||||
const app = await this.prisma.app.findUnique({
|
||||
where: { id: body.appId },
|
||||
include: {
|
||||
@@ -439,6 +486,7 @@ export class AppsService {
|
||||
},
|
||||
status: 'PENDING' as const,
|
||||
logs: releaseLogs.join('\n'),
|
||||
...this.requesterData(requester),
|
||||
};
|
||||
const release = existingRelease
|
||||
? await this.prisma.appRelease.update({
|
||||
@@ -457,7 +505,7 @@ export class AppsService {
|
||||
|
||||
void this.finalizeReleaseFromBuilds(release.id, buildJobs.map((job) => job.id));
|
||||
|
||||
return { release };
|
||||
return { release: (await this.withRequesterAvatars([release]))[0] };
|
||||
}
|
||||
|
||||
private async finalizeReleaseFromBuilds(releaseId: string, sourceBuildJobIds: string[]) {
|
||||
@@ -550,6 +598,9 @@ export class AppsService {
|
||||
sourceBuildJobs: Array<{
|
||||
componentId: string;
|
||||
commitSha: string | null;
|
||||
requestedById?: string | null;
|
||||
requestedByName?: string | null;
|
||||
requestedByEmail?: string | null;
|
||||
component: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -579,6 +630,9 @@ export class AppsService {
|
||||
artifact,
|
||||
image,
|
||||
status: 'PENDING',
|
||||
requestedById: sourceJob.requestedById,
|
||||
requestedByName: sourceJob.requestedByName,
|
||||
requestedByEmail: sourceJob.requestedByEmail,
|
||||
logs: getBuildPlanLogs({
|
||||
appName: component.app.name,
|
||||
componentName: component.name,
|
||||
@@ -879,11 +933,12 @@ export class AppsService {
|
||||
};
|
||||
|
||||
if (!pagination && !sinceDate) {
|
||||
return this.prisma.deploymentJob.findMany({
|
||||
const deployments = await this.prisma.deploymentJob.findMany({
|
||||
where: appKey ? { app: { key: appKey } } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include,
|
||||
});
|
||||
return this.withRequesterAvatars(deployments);
|
||||
}
|
||||
|
||||
const pageNumber = pagination?.page ?? 1;
|
||||
@@ -908,7 +963,7 @@ export class AppsService {
|
||||
});
|
||||
|
||||
return {
|
||||
deployments: items,
|
||||
deployments: await this.withRequesterAvatars(items),
|
||||
total,
|
||||
page: pageNumber,
|
||||
limit: pageLimit,
|
||||
@@ -918,7 +973,7 @@ export class AppsService {
|
||||
};
|
||||
}
|
||||
|
||||
async createDeploymentJob(body: CreateDeploymentJobDto) {
|
||||
async createDeploymentJob(body: CreateDeploymentJobDto, requester?: AdminRequester) {
|
||||
const namespace = normalizeNamespace(body.namespace);
|
||||
const environment = body.environment.trim();
|
||||
const release = await this.prisma.appRelease.findUnique({
|
||||
@@ -973,7 +1028,8 @@ export class AppsService {
|
||||
environment,
|
||||
namespace,
|
||||
status: 'RUNNING',
|
||||
requestedBy: 'admin',
|
||||
requestedBy: requester?.name ?? 'admin',
|
||||
...this.requesterData(requester),
|
||||
startedAt: new Date(),
|
||||
logs: [
|
||||
`[running] deployment started: ${release.app.name} ${release.version}`,
|
||||
@@ -1128,7 +1184,7 @@ export class AppsService {
|
||||
},
|
||||
});
|
||||
|
||||
return { deploymentJob: completed };
|
||||
return { deploymentJob: (await this.withRequesterAvatars([completed]))[0] };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Deployment failed';
|
||||
const failed = await this.prisma.deploymentJob.update({
|
||||
@@ -1144,7 +1200,7 @@ export class AppsService {
|
||||
},
|
||||
});
|
||||
|
||||
return { deploymentJob: failed };
|
||||
return { deploymentJob: (await this.withRequesterAvatars([failed]))[0] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1188,10 +1244,11 @@ export class AppsService {
|
||||
};
|
||||
|
||||
if (!pagination && !sinceDate && !appId) {
|
||||
return this.prisma.runtimeControlJob.findMany({
|
||||
const jobs = await this.prisma.runtimeControlJob.findMany({
|
||||
where: { action: 'MIGRATION' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return this.withRequesterAvatars(jobs);
|
||||
}
|
||||
|
||||
const pageNumber = pagination?.page ?? 1;
|
||||
@@ -1207,14 +1264,14 @@ export class AppsService {
|
||||
]);
|
||||
|
||||
return {
|
||||
jobs: items,
|
||||
jobs: await this.withRequesterAvatars(items),
|
||||
total,
|
||||
page: pageNumber,
|
||||
limit: pageLimit,
|
||||
};
|
||||
}
|
||||
|
||||
async createMigrationJob(body: CreateMigrationJobDto) {
|
||||
async createMigrationJob(body: CreateMigrationJobDto, requester?: AdminRequester) {
|
||||
const namespace = normalizeNamespace(body.namespace);
|
||||
const command = body.command.trim();
|
||||
const envSecretName = sanitizeK8sName(body.envSecretName);
|
||||
@@ -1279,7 +1336,8 @@ export class AppsService {
|
||||
name: jobName,
|
||||
action: 'MIGRATION',
|
||||
status: 'RUNNING',
|
||||
requestedBy: 'admin',
|
||||
requestedBy: requester?.name ?? 'admin',
|
||||
...this.requesterData(requester),
|
||||
request,
|
||||
startedAt,
|
||||
logs: [
|
||||
@@ -1299,7 +1357,7 @@ export class AppsService {
|
||||
envSecretName,
|
||||
});
|
||||
|
||||
return { migrationJob };
|
||||
return { migrationJob: (await this.withRequesterAvatars([migrationJob]))[0] };
|
||||
}
|
||||
|
||||
private async finalizeMigrationJob(
|
||||
@@ -1384,7 +1442,7 @@ export class AppsService {
|
||||
};
|
||||
|
||||
if (!pagination && !sinceDate) {
|
||||
return this.prisma.buildJob.findMany({
|
||||
const jobs = await this.prisma.buildJob.findMany({
|
||||
where: {
|
||||
...(appKey ? { component: { app: { key: appKey } } } : {}),
|
||||
...(status ? { status: normalizeJobStatus(status) } : {}),
|
||||
@@ -1393,6 +1451,7 @@ export class AppsService {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include,
|
||||
});
|
||||
return this.withRequesterAvatars(jobs);
|
||||
}
|
||||
|
||||
const pageNumber = pagination?.page ?? 1;
|
||||
@@ -1409,7 +1468,7 @@ export class AppsService {
|
||||
]);
|
||||
|
||||
return {
|
||||
jobs: items,
|
||||
jobs: await this.withRequesterAvatars(items),
|
||||
total,
|
||||
page: pageNumber,
|
||||
limit: pageLimit,
|
||||
@@ -1872,7 +1931,7 @@ export class AppsService {
|
||||
return { applied: true, logs: logs.filter(Boolean).join('\n') };
|
||||
}
|
||||
|
||||
async createBuildJob(body: CreateBuildJobDto) {
|
||||
async createBuildJob(body: CreateBuildJobDto, requester?: AdminRequester) {
|
||||
const component = await this.prisma.appComponent.findUnique({
|
||||
where: { id: body.componentId },
|
||||
include: { app: true },
|
||||
@@ -1904,6 +1963,7 @@ export class AppsService {
|
||||
artifact,
|
||||
image,
|
||||
status: 'PENDING',
|
||||
...this.requesterData(requester),
|
||||
logs: getBuildPlanLogs({
|
||||
appName: component.app.name,
|
||||
componentName: component.name,
|
||||
@@ -1929,7 +1989,7 @@ export class AppsService {
|
||||
},
|
||||
});
|
||||
|
||||
return { buildJob };
|
||||
return { buildJob: (await this.withRequesterAvatars([buildJob]))[0] };
|
||||
}
|
||||
|
||||
async cancelBuildJob(buildJobId: string) {
|
||||
|
||||
@@ -237,6 +237,52 @@ export class GiteaService {
|
||||
);
|
||||
}
|
||||
|
||||
async createUser(body: { username: string; email: string; password: string; fullName?: string }) {
|
||||
this.assertToken();
|
||||
|
||||
await this.request(
|
||||
'/api/v1/admin/users',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
full_name: body.fullName,
|
||||
must_change_password: false,
|
||||
send_notify: false,
|
||||
}),
|
||||
allowedStatuses: [409, 422],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async updateUserAvatar(username: string, imageData: Buffer, mimeType: string) {
|
||||
this.assertToken();
|
||||
|
||||
await this.request(
|
||||
`/api/v1/user/avatar?sudo=${encodeURIComponent(username)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
image: imageData.toString('base64'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async deleteUserAvatar(username: string) {
|
||||
this.assertToken();
|
||||
|
||||
await this.request(
|
||||
`/api/v1/user/avatar?sudo=${encodeURIComponent(username)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
allowedStatuses: [404],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async deleteUserSshKey(username: string, keyId: number) {
|
||||
this.assertToken();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req } from '@nestjs/common';
|
||||
|
||||
import { CreateLauncherAppLinkDto, CreateLauncherBuildJobDto, CreateLauncherVersionDto, UpdateLauncherSourceDto, LinkLauncherVersionSourceDto, MoveLauncherAppLinkDto, UpdateLauncherAppLinkDto, UpdateLauncherBuildEnvDto, UpdateLauncherUserAppPermissionsDto, DeployLauncherBackendDto, ListLauncherBuildJobsQuery } from './dto';
|
||||
import { LauncherService } from './launcher.service';
|
||||
@@ -54,8 +54,11 @@ export class LauncherController {
|
||||
}
|
||||
|
||||
@Post('builds')
|
||||
createBuildJob(@Body() body: CreateLauncherBuildJobDto) {
|
||||
return this.launcherService.createBuildJob(body);
|
||||
createBuildJob(
|
||||
@Body() body: CreateLauncherBuildJobDto,
|
||||
@Req() request: { admin?: { id: string; name: string; email: string } },
|
||||
) {
|
||||
return this.launcherService.createBuildJob(body, request.admin);
|
||||
}
|
||||
|
||||
@Get('app-links')
|
||||
|
||||
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { LauncherPrismaModule } from '../launcher-prisma/launcher-prisma.module';
|
||||
import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module';
|
||||
import { LauncherController } from './launcher.controller';
|
||||
import { LauncherService } from './launcher.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, LauncherPrismaModule],
|
||||
imports: [PrismaModule, LauncherPrismaModule, AuthPrismaModule],
|
||||
controllers: [LauncherController],
|
||||
providers: [LauncherService],
|
||||
exports: [LauncherService],
|
||||
|
||||
@@ -10,6 +10,8 @@ import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
|
||||
import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuthPrismaService } from '../auth-prisma/auth-prisma.service';
|
||||
import { getUserAvatarUrl } from '../users/users.service';
|
||||
import {
|
||||
CreateLauncherAppLinkDto,
|
||||
CreateLauncherBuildJobDto,
|
||||
@@ -61,6 +63,7 @@ export class LauncherService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly launcherPrisma: LauncherPrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly authPrisma: AuthPrismaService,
|
||||
) {}
|
||||
|
||||
async listVersions() {
|
||||
@@ -380,7 +383,7 @@ export class LauncherService {
|
||||
};
|
||||
}
|
||||
|
||||
listBuildJobs(query: ListLauncherBuildJobsQuery = {}) {
|
||||
async listBuildJobs(query: ListLauncherBuildJobsQuery = {}) {
|
||||
const paged = Boolean(query.page || query.limit || query.target || query.since);
|
||||
const page = toPositiveInt(query.page, 1);
|
||||
const limit = Math.min(toPositiveInt(query.limit, 30), 100);
|
||||
@@ -391,10 +394,11 @@ export class LauncherService {
|
||||
};
|
||||
|
||||
if (!paged) {
|
||||
return this.prisma.launcherBuildJob.findMany({
|
||||
const jobs = await this.prisma.launcherBuildJob.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 30,
|
||||
});
|
||||
return this.withLauncherBuildRequesters(jobs);
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
@@ -408,7 +412,34 @@ export class LauncherService {
|
||||
tx.launcherBuildJob.count({ where }),
|
||||
]);
|
||||
|
||||
return { jobs, total, page, limit };
|
||||
return { jobs: await this.withLauncherBuildRequesters(jobs), total, page, limit };
|
||||
});
|
||||
}
|
||||
|
||||
private async withLauncherBuildRequesters<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]) };
|
||||
}
|
||||
|
||||
async createBuildJob(body: CreateLauncherBuildJobDto) {
|
||||
async createBuildJob(
|
||||
body: CreateLauncherBuildJobDto,
|
||||
requester?: { id: string; name: string; email: string },
|
||||
) {
|
||||
const source = await this.getSourceSettings();
|
||||
const target = body.target?.trim() || 'launcher-client';
|
||||
const isBackend = target === 'launcher-backend';
|
||||
@@ -957,12 +991,15 @@ export class LauncherService {
|
||||
artifact: requestedArtifact,
|
||||
uploadPath: plan.uploadPath,
|
||||
downloadUrl: plan.downloadUrl,
|
||||
requestedById: requester?.id,
|
||||
requestedByName: requester?.name,
|
||||
requestedByEmail: requester?.email,
|
||||
logs,
|
||||
},
|
||||
});
|
||||
|
||||
void this.runBuildJob(job.id);
|
||||
return { job };
|
||||
return { job: (await this.withLauncherBuildRequesters([job]))[0] };
|
||||
}
|
||||
|
||||
async linkVersionSource(id: string, body: LinkLauncherVersionSourceDto) {
|
||||
|
||||
@@ -291,7 +291,7 @@ function calculateLicenseExpiresAt(
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
function getUserAvatarUrl(
|
||||
export function getUserAvatarUrl(
|
||||
userId: string,
|
||||
mimeType?: string | null,
|
||||
authBaseUrl?: string | null,
|
||||
|
||||
Reference in New Issue
Block a user