import { createHash, randomBytes, scryptSync } from 'node:crypto'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { GiteaService } from '../gitea/gitea.service'; import { PrismaService } from '../prisma/prisma.service'; import { getAdminAvatarUrl } from './admin-avatar'; import { AdminRole } from './admin-role'; import { CreateAdminSshKeyDto, CreateAdminDto, ListAdminsQuery, ResetAdminPasswordDto, UpdateAdminAppPermissionsDto, UpdateAdminRoleDto, UpdateAdminStatusDto, } from './dto'; @Injectable() export class AdminsService { constructor( private readonly configService: ConfigService, private readonly giteaService: GiteaService, private readonly prisma: PrismaService, ) {} async listRoles() { await this.ensureDefaultRoles(); return this.prisma.adminRoleOption.findMany({ where: { isActive: true }, orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }], select: { id: true, code: true, name: true, description: true, sortOrder: true, isActive: true, }, }); } async list(query: ListAdminsQuery = {}) { const page = toPositiveInt(query.page, 1); const limit = Math.min(toPositiveInt(query.limit, 20), 100); const keyword = query.keyword?.trim(); const where = { ...(query.role ? { role: query.role as AdminRole } : {}), ...(query.state === 'active' ? { isActive: true } : query.state === 'inactive' ? { isActive: false } : {}), ...(keyword ? { OR: [ { email: { contains: keyword, mode: 'insensitive' as const } }, { name: { contains: keyword, mode: 'insensitive' as const } }, ], } : {}), }; const [admins, total] = await this.prisma.$transaction([ this.prisma.admin.findMany({ where, orderBy: [{ role: 'asc' }, { createdAt: 'desc' }], skip: (page - 1) * limit, take: limit, select: { id: true, email: true, name: true, role: true, isActive: true, lastLoginAt: true, createdAt: true, updatedAt: true, sessions: { orderBy: { createdAt: 'desc' }, take: 1, select: { id: true, expiresAt: true, revokedAt: true, createdAt: true, }, }, sshKeys: { orderBy: { createdAt: 'desc' }, select: adminSshKeySelect, }, appPermissions: { select: { appId: true }, }, }, }), this.prisma.admin.count({ where }), ]); return { admins: admins.map((admin, index) => { return { ...admin, no: total - ((page - 1) * limit + index), avatarUrl: getAdminAvatarUrl(admin.id, this.configService), }; }), total, page, limit, }; } 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, name, role: body.role, passwordHash: hashPassword(body.temporaryPassword), }, select: adminSelect, }); return { admin }; } async updateStatus(id: string, body: UpdateAdminStatusDto) { const existing = await this.ensureExists(id); await this.giteaService.updateUserLoginDisabled( createGiteaUsername(existing.email), !body.isActive, ); const admin = await this.prisma.admin.update({ where: { id }, data: { isActive: body.isActive }, select: adminSelect, }); if (!body.isActive) { await this.prisma.adminSession.updateMany({ where: { adminId: id, revokedAt: null }, data: { revokedAt: new Date() }, }); } return { admin }; } async updateRole(id: string, body: UpdateAdminRoleDto) { await this.ensureExists(id); await this.ensureRoleEnabled(body.role); const admin = await this.prisma.admin.update({ where: { id }, data: { role: body.role }, select: adminSelect, }); return { admin }; } async resetPassword(id: string, body: ResetAdminPasswordDto) { await this.ensureExists(id); const admin = await this.prisma.admin.update({ where: { id }, data: { passwordHash: hashPassword(body.temporaryPassword) }, select: adminSelect, }); await this.prisma.adminSession.updateMany({ where: { adminId: id, revokedAt: null }, data: { revokedAt: new Date() }, }); return { admin }; } async listAppPermissions(id: string) { await this.ensureExists(id); const permissions = await this.prisma.adminAppPermission.findMany({ where: { adminId: id }, orderBy: { createdAt: 'asc' }, select: { appId: true }, }); return { appIds: permissions.map((permission) => permission.appId) }; } async updateAppPermissions(id: string, body: UpdateAdminAppPermissionsDto) { const admin = await this.prisma.admin.findUnique({ where: { id }, select: { id: true, role: true }, }); if (!admin) { throw new NotFoundException('관리자를 찾을 수 없습니다.'); } if (admin.role === AdminRole.OWNER) { throw new BadRequestException('오너 권한은 앱 권한을 개별 설정하지 않습니다.'); } const appIds = [...new Set(body.appIds.filter(Boolean))]; const existingApps = await this.prisma.app.findMany({ where: { id: { in: appIds } }, select: { id: true }, }); const existingAppIds = new Set(existingApps.map((app) => app.id)); const validAppIds = appIds.filter((appId) => existingAppIds.has(appId)); await this.prisma.$transaction([ this.prisma.adminAppPermission.deleteMany({ where: { adminId: id } }), ...(validAppIds.length ? [ this.prisma.adminAppPermission.createMany({ data: validAppIds.map((appId) => ({ adminId: id, appId })), skipDuplicates: true, }), ] : []), ]); return { appIds: validAppIds }; } async createSshKey(adminId: string, body: CreateAdminSshKeyDto) { const admin = await this.ensureExists(adminId); const parsedKey = parseSshPublicKey(body.publicKey); const label = body.label?.trim() || `${parsedKey.keyType} ${parsedKey.fingerprint}`; const giteaUsername = createGiteaUsername(admin.email); let giteaKeyId: number | null = null; const giteaKey = await this.giteaService.createUserSshKey(giteaUsername, { title: label, key: parsedKey.normalizedKey, }); giteaKeyId = giteaKey.id; const sshKey = await this.prisma.adminSshKey.create({ data: { adminId, label, keyType: parsedKey.keyType, publicKey: parsedKey.normalizedKey, publicKeyHash: parsedKey.publicKeyHash, fingerprint: parsedKey.fingerprint, giteaUsername, giteaKeyId, }, select: adminSshKeySelect, }); return { sshKey }; } async deleteSshKey(adminId: string, keyId: string) { await this.ensureExists(adminId); const sshKey = await this.prisma.adminSshKey.findFirst({ where: { id: keyId, adminId }, select: { id: true, giteaUsername: true, giteaKeyId: true, }, }); if (!sshKey) { throw new NotFoundException('SSH 키를 찾을 수 없습니다.'); } await this.prisma.adminSshKey.delete({ where: { id: keyId } }); if (sshKey.giteaUsername && sshKey.giteaKeyId) { await this.giteaService.deleteUserSshKey( sshKey.giteaUsername, sshKey.giteaKeyId, ); } return { deleted: true }; } private async ensureRoleEnabled(role: AdminRole) { await this.ensureDefaultRoles(); const roleOption = await this.prisma.adminRoleOption.findUnique({ where: { code: role }, select: { isActive: true }, }); if (!roleOption?.isActive) { throw new BadRequestException('사용할 수 없는 관리자 권한입니다.'); } } private async ensureDefaultRoles() { await this.prisma.adminRoleOption.createMany({ data: [ { code: AdminRole.OWNER, name: 'OWNER', description: '전체 관리자 권한', sortOrder: 1 }, { code: AdminRole.ADMIN, name: 'ADMIN', description: '관리자 권한', sortOrder: 2 }, { code: AdminRole.VIEWER, name: 'VIEWER', description: '조회 권한', sortOrder: 3 }, ], skipDuplicates: true, }); } private async ensureExists(id: string) { const admin = await this.prisma.admin.findUnique({ where: { id }, select: { id: true, email: true }, }); if (!admin) { throw new NotFoundException('관리자를 찾을 수 없습니다.'); } return admin; } } const adminSelect = { id: true, email: true, name: true, role: true, isActive: true, lastLoginAt: true, createdAt: true, updatedAt: true, } as const; const adminSshKeySelect = { id: true, label: true, keyType: true, fingerprint: true, giteaUsername: true, giteaKeyId: true, createdAt: true, } as const; function hashPassword(password: string) { const salt = randomBytes(16).toString('hex'); const hash = scryptSync(password, salt, 64).toString('hex'); return `scrypt:${salt}:${hash}`; } function toPositiveInt(value: string | number | undefined, fallback: number) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed < 1) return fallback; return Math.floor(parsed); } 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(' '); if (!keyType || !keyBody || !isSupportedSshKeyType(keyType)) { throw new BadRequestException('지원하지 않는 SSH 공개키 형식입니다.'); } let keyBuffer: Buffer; try { keyBuffer = Buffer.from(keyBody, 'base64'); } catch { throw new BadRequestException('SSH 공개키 base64 값을 확인하세요.'); } if (!keyBuffer.length) { throw new BadRequestException('SSH 공개키 값이 비어 있습니다.'); } return { keyType, normalizedKey, publicKeyHash: createHash('sha256').update(normalizedKey).digest('hex'), fingerprint: `SHA256:${createHash('sha256') .update(keyBuffer) .digest('base64') .replace(/=+$/, '')}`, }; } function isSupportedSshKeyType(keyType: string) { return ( keyType === 'ssh-ed25519' || keyType === 'ssh-rsa' || keyType.startsWith('ecdsa-sha2-') ); }