diff --git a/src/app.module.ts b/src/app.module.ts index ca20b071..69c72907 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -24,7 +24,7 @@ import { LdapModule } from './modules/ldap/ldap.module'; import { StrategyModule } from './modules/strategy/strategy.module'; import { SystemUpdateModule } from './modules/system-update/system-update.module'; import { UpdateCheckModule } from './modules/update-check/update-check.module'; -import { NexusModule } from './modules/nexus/nexus.module'; +import { CustomClientModule } from './modules/custom-client/custom-client.module'; import { UserGroupModule } from './modules/user-group/user-group.module'; import { RbacModule } from './modules/rbac/rbac.module'; import { RbacGuard } from './modules/rbac/guards/rbac.guard'; @@ -98,7 +98,7 @@ import { WebClientModule } from './modules/web-client/web-client.module'; StrategyModule, UpdateCheckModule, SystemUpdateModule, - NexusModule, + CustomClientModule, UserGroupModule, RbacModule, ServerManagementModule, diff --git a/src/common/utils/data-dir.util.ts b/src/common/utils/data-dir.util.ts index 79d196e5..cae28d85 100644 --- a/src/common/utils/data-dir.util.ts +++ b/src/common/utils/data-dir.util.ts @@ -2,25 +2,16 @@ import * as path from 'path'; const DEFAULT_DATA_DIR = './data'; const DB_FILENAME = 'rustdesk-console.db'; -const NEXUS_BUILD_SUBDIR = 'nexus-builds'; +const CUSTOM_CLIENT_BUILD_SUBDIR = 'custom-client-builds'; +const CUSTOM_CLIENT_LOGO_SUBDIR = 'custom-client-logos'; const AVATAR_SUBDIR = 'avatars'; export type DbType = 'sqlite' | 'mysql'; -/** - * Get the unified data directory. - * Controlled by the DATA_DIR env var (default: ./data). - */ export function getDataDir(): string { return process.env.DATA_DIR || DEFAULT_DATA_DIR; } -/** - * Get the configured database type. - * Controlled by the DB_TYPE env var (default: sqlite). - * When set to 'mysql', the app connects to a MySQL server instead of using - * the embedded SQLite file. - */ export function getDbType(): DbType { const raw = process.env.DB_TYPE?.trim().toLowerCase() || 'sqlite'; if (raw === 'sqlite' || raw === 'mysql') { @@ -29,24 +20,18 @@ export function getDbType(): DbType { throw new Error(`Unsupported DB_TYPE: ${raw}`); } -/** - * Get the SQLite database file path. - * Only meaningful when getDbType() === 'sqlite'. - */ export function getDbPath(): string { return path.join(getDataDir(), DB_FILENAME); } -/** - * Get the Nexus build artifact storage directory. - */ -export function getNexusStoragePath(): string { - return path.join(getDataDir(), NEXUS_BUILD_SUBDIR); +export function getCustomClientStoragePath(): string { + return path.join(getDataDir(), CUSTOM_CLIENT_BUILD_SUBDIR); +} + +export function getCustomClientLogoPath(): string { + return path.join(getDataDir(), CUSTOM_CLIENT_LOGO_SUBDIR); } -/** - * Get the avatar storage directory. - */ export function getAvatarDir(): string { return path.join(getDataDir(), AVATAR_SUBDIR); } diff --git a/src/database/data-source-options.ts b/src/database/data-source-options.ts index c8144a1a..220e0619 100644 --- a/src/database/data-source-options.ts +++ b/src/database/data-source-options.ts @@ -6,6 +6,7 @@ import { FixMySqlDefaultUserGroupIndex1790985600001 } from '../migrations/179098 import { AddPasswordResetTokens1790985600002 } from '../migrations/1790985600002-AddPasswordResetTokens'; import { AddDeviceTombstones1791040000000 } from '../migrations/1791040000000-AddDeviceTombstones'; import { AddDeviceTombstoneReleaseTracking1791126400000 } from '../migrations/1791126400000-AddDeviceTombstoneReleaseTracking'; +import { ReplaceNexusWithCustomClientBuilds1791212800000 } from '../migrations/1791212800000-ReplaceNexusWithCustomClientBuilds'; export function createDataSourceOptions(): DataSourceOptions { const common = { @@ -16,6 +17,7 @@ export function createDataSourceOptions(): DataSourceOptions { AddPasswordResetTokens1790985600002, AddDeviceTombstones1791040000000, AddDeviceTombstoneReleaseTracking1791126400000, + ReplaceNexusWithCustomClientBuilds1791212800000, ], migrationsRun: false, synchronize: false, diff --git a/src/database/entities.ts b/src/database/entities.ts index 7aa9b89e..bdf2d20c 100644 --- a/src/database/entities.ts +++ b/src/database/entities.ts @@ -22,8 +22,7 @@ import { PasswordResetToken } from '../modules/auth/entities/password-reset-toke import { SystemSetting } from '../modules/settings/entities/system-setting.entity'; import { ActiveConnection } from '../modules/heartbeat/entities/active-connection.entity'; import { Strategy } from '../modules/strategy/entities/strategy.entity'; -import { NexusToken } from '../modules/nexus/entities/nexus-token.entity'; -import { NexusBuild } from '../modules/nexus/entities/nexus-build.entity'; +import { CustomClientBuild } from '../modules/custom-client/entities/custom-client-build.entity'; import { UserGroup } from '../modules/user-group/entities/user-group.entity'; import { Role } from '../modules/rbac/entities/role.entity'; import { RolePermission } from '../modules/rbac/entities/role-permission.entity'; @@ -58,8 +57,7 @@ export const DATABASE_ENTITIES = [ SystemSetting, ActiveConnection, Strategy, - NexusToken, - NexusBuild, + CustomClientBuild, UserGroup, Role, RolePermission, diff --git a/src/database/migration-manager.spec.ts b/src/database/migration-manager.spec.ts index 76357dbb..32b8b97a 100644 --- a/src/database/migration-manager.spec.ts +++ b/src/database/migration-manager.spec.ts @@ -9,6 +9,7 @@ import { FixMySqlDefaultUserGroupIndex1790985600001 } from '../migrations/179098 import { AddPasswordResetTokens1790985600002 } from '../migrations/1790985600002-AddPasswordResetTokens'; import { AddDeviceTombstones1791040000000 } from '../migrations/1791040000000-AddDeviceTombstones'; import { AddDeviceTombstoneReleaseTracking1791126400000 } from '../migrations/1791126400000-AddDeviceTombstoneReleaseTracking'; +import { ReplaceNexusWithCustomClientBuilds1791212800000 } from '../migrations/1791212800000-ReplaceNexusWithCustomClientBuilds'; import { AddressBookPeer } from '../modules/address-book/entities/address-book-peer.entity'; import { migrateDatabase } from './migration-manager'; @@ -32,6 +33,7 @@ describe('database migrations', () => { AddPasswordResetTokens1790985600002, AddDeviceTombstones1791040000000, AddDeviceTombstoneReleaseTracking1791126400000, + ReplaceNexusWithCustomClientBuilds1791212800000, ], synchronize, }); @@ -55,7 +57,7 @@ describe('database migrations', () => { ).toHaveLength(0); expect( await dataSource.query('SELECT name FROM migrations'), - ).toHaveLength(5); + ).toHaveLength(6); await dataSource.query( "INSERT INTO address_books (guid, owner) VALUES ('book', 'owner')", ); @@ -98,7 +100,7 @@ describe('database migrations', () => { ).toEqual([{ name: 'retained' }]); expect( await dataSource.query('SELECT name FROM migrations'), - ).toHaveLength(5); + ).toHaveLength(6); } finally { await dataSource.destroy(); } @@ -117,7 +119,7 @@ describe('database migrations', () => { ).toHaveLength(0); expect( await dataSource.query('SELECT name FROM migrations'), - ).toHaveLength(5); + ).toHaveLength(6); } finally { await dataSource.destroy(); } @@ -154,7 +156,7 @@ describe('database migrations', () => { ).toHaveLength(0); expect( await dataSource.query('SELECT name FROM migrations'), - ).toHaveLength(5); + ).toHaveLength(6); const indexes = await dataSource.query( "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'strategies' AND sql IS NOT NULL", ); diff --git a/src/database/mysql-migration.integration.spec.ts b/src/database/mysql-migration.integration.spec.ts index c68efc1c..4160a992 100644 --- a/src/database/mysql-migration.integration.spec.ts +++ b/src/database/mysql-migration.integration.spec.ts @@ -6,6 +6,7 @@ import { FixMySqlDefaultUserGroupIndex1790985600001 } from '../migrations/179098 import { AddPasswordResetTokens1790985600002 } from '../migrations/1790985600002-AddPasswordResetTokens'; import { AddDeviceTombstones1791040000000 } from '../migrations/1791040000000-AddDeviceTombstones'; import { AddDeviceTombstoneReleaseTracking1791126400000 } from '../migrations/1791126400000-AddDeviceTombstoneReleaseTracking'; +import { ReplaceNexusWithCustomClientBuilds1791212800000 } from '../migrations/1791212800000-ReplaceNexusWithCustomClientBuilds'; import { migrateDatabase } from './migration-manager'; const describeMysql = process.env.RUN_MYSQL_MIGRATION_TESTS @@ -26,6 +27,7 @@ describeMysql('MySQL database migrations', () => { AddPasswordResetTokens1790985600002, AddDeviceTombstones1791040000000, AddDeviceTombstoneReleaseTracking1791126400000, + ReplaceNexusWithCustomClientBuilds1791212800000, ], }; const databases = [ @@ -80,7 +82,7 @@ describeMysql('MySQL database migrations', () => { ).toHaveLength(0); expect( await dataSource.query('SELECT name FROM migrations'), - ).toHaveLength(5); + ).toHaveLength(6); await dataSource.query( "INSERT INTO user_groups (guid, name, normalizedName, isDefault) VALUES ('a', 'A', 'a', 0), ('b', 'B', 'b', 0), ('d', 'D', 'd', 1)", ); @@ -117,7 +119,7 @@ describeMysql('MySQL database migrations', () => { ).toEqual([{ name: 'retained' }]); expect( await dataSource.query('SELECT name FROM migrations'), - ).toHaveLength(5); + ).toHaveLength(6); await dataSource.query( "INSERT INTO user_groups (guid, name, normalizedName, isDefault) VALUES ('a', 'A', 'a', 0), ('b', 'B', 'b', 0)", ); @@ -149,6 +151,7 @@ describeMysql('MySQL database migrations', () => { 'user_user_permissions', 'nexus_tokens', 'nexus_builds', + 'custom_client_builds', ]) { await legacy.query(`DROP TABLE IF EXISTS \`${table}\``); } @@ -172,7 +175,7 @@ describeMysql('MySQL database migrations', () => { ).toHaveLength(0); expect( await dataSource.query('SELECT name FROM migrations'), - ).toHaveLength(5); + ).toHaveLength(6); } finally { await dataSource.destroy(); } diff --git a/src/migrations/1791212800000-ReplaceNexusWithCustomClientBuilds.ts b/src/migrations/1791212800000-ReplaceNexusWithCustomClientBuilds.ts new file mode 100644 index 00000000..df68f3d2 --- /dev/null +++ b/src/migrations/1791212800000-ReplaceNexusWithCustomClientBuilds.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ReplaceNexusWithCustomClientBuilds1791212800000 implements MigrationInterface { + name = 'ReplaceNexusWithCustomClientBuilds1791212800000'; + + async up(queryRunner: QueryRunner): Promise { + const type = queryRunner.connection.options.type; + + // Drop legacy Nexus tables (replaced by the custom-client module). + if (await queryRunner.hasTable('nexus_tokens')) { + await queryRunner.dropTable('nexus_tokens'); + } + if (await queryRunner.hasTable('nexus_builds')) { + await queryRunner.dropTable('nexus_builds'); + } + + if (await queryRunner.hasTable('custom_client_builds')) { + return; + } + + if (type === 'sqlite') { + await queryRunner.query( + `CREATE TABLE "custom_client_builds" ("uuid" varchar PRIMARY KEY NOT NULL, "userGuid" varchar NOT NULL, "os" varchar NOT NULL, "arch" varchar NOT NULL, "custom" text, "status" varchar NOT NULL DEFAULT ('pending'), "message" text, "files" text, "callbackToken" varchar NOT NULL, "githubRunId" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_custom_client_builds_userGuid" ON "custom_client_builds" ("userGuid") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_custom_client_builds_status" ON "custom_client_builds" ("status") `, + ); + return; + } + + if (type === 'mysql') { + await queryRunner.query( + `CREATE TABLE \`custom_client_builds\` (\`uuid\` varchar(255) NOT NULL, \`userGuid\` varchar(255) NOT NULL, \`os\` varchar(255) NOT NULL, \`arch\` varchar(255) NOT NULL, \`custom\` text NULL, \`status\` varchar(255) NOT NULL DEFAULT 'pending', \`message\` text NULL, \`files\` text NULL, \`callbackToken\` varchar(255) NOT NULL, \`githubRunId\` varchar(255) NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), INDEX \`IDX_custom_client_builds_userGuid\` (\`userGuid\`), INDEX \`IDX_custom_client_builds_status\` (\`status\`), PRIMARY KEY (\`uuid\`)) ENGINE=InnoDB`, + ); + return; + } + + throw new Error(`Unsupported database type: ${type}`); + } + + async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('custom_client_builds')) { + await queryRunner.dropTable('custom_client_builds'); + } + } +} diff --git a/src/modules/custom-client/custom-client.controller.ts b/src/modules/custom-client/custom-client.controller.ts new file mode 100644 index 00000000..67fdfce2 --- /dev/null +++ b/src/modules/custom-client/custom-client.controller.ts @@ -0,0 +1,234 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Res, + Req, + HttpCode, + HttpStatus, + NotFoundException, + UseGuards, + UseInterceptors, + UploadedFiles, + UploadedFile, + BadRequestException, +} from '@nestjs/common'; +import { FilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; +import type { Request, Response } from 'express'; +import { createReadStream, existsSync, statSync } from 'fs'; +import { basename } from 'path'; +import { CustomClientService } from './custom-client.service'; +import { CustomClientSettingsService } from './services/custom-client-settings.service'; +import { CustomClientLogoService } from './services/custom-client-logo.service'; +import { SubmitBuildDto } from './dto/custom-client-build.dto'; +import { UpdateCustomClientSettingsDto } from './dto/custom-client-settings.dto'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Public } from '../auth/decorators/public.decorator'; +import { AdminGuard } from '../../common/guards/admin.guard'; + +@Controller('custom-client') +export class CustomClientController { + constructor( + private readonly customClientService: CustomClientService, + private readonly settingsService: CustomClientSettingsService, + private readonly logoService: CustomClientLogoService, + ) {} + + @Post('builds') + @HttpCode(HttpStatus.CREATED) + async createBuild( + @CurrentUser('id') userGuid: string, + @Body() dto: SubmitBuildDto, + ) { + return this.customClientService.submitBuild(userGuid, dto); + } + + @Get('builds') + async listBuilds(@CurrentUser('id') userGuid: string) { + return this.customClientService.listBuilds(userGuid); + } + + @Delete('builds/:uuid') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteBuild( + @CurrentUser('id') userGuid: string, + @Param('uuid') uuid: string, + ) { + await this.customClientService.deleteBuild(userGuid, uuid); + } + + @Get('builds/:uuid/files') + async listBuildFiles( + @CurrentUser('id') userGuid: string, + @Param('uuid') uuid: string, + ) { + return this.customClientService.listBuildFiles(uuid, userGuid); + } + + @Get('builds/:uuid/files/:filename') + async downloadBuildFile( + @CurrentUser('id') userGuid: string, + @Param('uuid') uuid: string, + @Param('filename') filename: string, + @Res() res: Response, + ) { + const filePath = await this.customClientService.getLocalFilePath( + uuid, + filename, + userGuid, + ); + if (!existsSync(filePath)) { + throw new NotFoundException('File not found'); + } + const stat = statSync(filePath); + if (!stat.isFile()) { + throw new NotFoundException('File not found'); + } + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${basename(filePath)}"`, + ); + res.setHeader('Content-Length', stat.size); + const stream = createReadStream(filePath); + stream.on('error', () => { + if (res.headersSent) { + res.destroy(); + } else { + res.removeHeader('Content-Length'); + res.removeHeader('Content-Disposition'); + res.status(500).end(); + } + }); + stream.pipe(res); + } + + @Get('builds/:uuid/progress') + async getBuildProgress( + @CurrentUser('id') userGuid: string, + @Param('uuid') uuid: string, + ) { + return this.customClientService.getBuildProgress(uuid, userGuid); + } + + @Get('builds/:uuid/config') + @Public() + async getBuildConfig(@Param('uuid') uuid: string, @Req() req: Request) { + const callbackToken = String(req.headers['x-callback-token'] ?? ''); + return this.customClientService.getBuildConfig(uuid, callbackToken); + } + + @Get('builds/:uuid/logo/:filename') + @Public() + async downloadBuildLogo( + @Param('uuid') uuid: string, + @Param('filename') filename: string, + @Req() req: Request, + @Res() res: Response, + ) { + const callbackToken = String(req.headers['x-callback-token'] ?? ''); + const filePath = await this.customClientService.getBuildLogoFile( + uuid, + filename, + callbackToken, + ); + if (!existsSync(filePath)) { + throw new NotFoundException('Logo file not found'); + } + const stat = statSync(filePath); + if (!stat.isFile()) { + throw new NotFoundException('Logo file not found'); + } + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${basename(filePath)}"`, + ); + res.setHeader('Content-Length', stat.size); + const stream = createReadStream(filePath); + stream.on('error', () => { + if (res.headersSent) { + res.destroy(); + } else { + res.removeHeader('Content-Length'); + res.removeHeader('Content-Disposition'); + res.status(500).end(); + } + }); + stream.pipe(res); + } + + @Post('builds/:uuid/callback') + @Public() + @UseInterceptors( + FilesInterceptor('files', 10, { + limits: { fileSize: 200 * 1024 * 1024, files: 10 }, + }), + ) + @HttpCode(HttpStatus.OK) + async handleCallback( + @Param('uuid') uuid: string, + @Req() req: Request, + @Body() body: { status?: string; message?: string }, + @UploadedFiles() files: Express.Multer.File[], + ) { + const callbackToken = String(req.headers['x-callback-token'] ?? ''); + await this.customClientService.handleCallback( + uuid, + callbackToken, + body?.status ?? 'completed', + body?.message, + files ?? [], + ); + return { message: 'Callback processed' }; + } + + @Get('logos') + @UseGuards(AdminGuard) + listLogos() { + return this.logoService.listLogos(); + } + + @Post('logos/:filename') + @UseGuards(AdminGuard) + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: 10 * 1024 * 1024 }, + }), + ) + @HttpCode(HttpStatus.OK) + uploadLogo( + @Param('filename') filename: string, + @UploadedFile() file: Express.Multer.File, + ) { + if (!file) { + throw new BadRequestException('No file uploaded'); + } + this.logoService.saveLogo(filename, file.buffer); + return { message: 'Logo uploaded', filename }; + } + + @Delete('logos/:filename') + @UseGuards(AdminGuard) + @HttpCode(HttpStatus.NO_CONTENT) + deleteLogo(@Param('filename') filename: string) { + this.logoService.deleteLogo(filename); + } + + @Get('settings') + @UseGuards(AdminGuard) + async getSettings() { + return this.settingsService.getSettings(); + } + + @Put('settings') + @UseGuards(AdminGuard) + @HttpCode(HttpStatus.OK) + async updateSettings(@Body() dto: UpdateCustomClientSettingsDto) { + return this.settingsService.updateSettings(dto); + } +} diff --git a/src/modules/custom-client/custom-client.module.ts b/src/modules/custom-client/custom-client.module.ts new file mode 100644 index 00000000..13decd16 --- /dev/null +++ b/src/modules/custom-client/custom-client.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CustomClientController } from './custom-client.controller'; +import { CustomClientService } from './custom-client.service'; +import { CustomClientBuild } from './entities/custom-client-build.entity'; +import { CustomClientSettingsService } from './services/custom-client-settings.service'; +import { CustomClientSigningService } from './services/custom-client-signing.service'; +import { CustomClientLogoService } from './services/custom-client-logo.service'; +import { SystemSetting } from '../settings/entities/system-setting.entity'; +import { SettingsModule } from '../settings/settings.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([CustomClientBuild, SystemSetting]), + SettingsModule, + ], + controllers: [CustomClientController], + providers: [ + CustomClientService, + CustomClientSettingsService, + CustomClientSigningService, + CustomClientLogoService, + ], + exports: [CustomClientService, CustomClientSettingsService], +}) +export class CustomClientModule {} diff --git a/src/modules/custom-client/custom-client.service.ts b/src/modules/custom-client/custom-client.service.ts new file mode 100644 index 00000000..2c54d7c6 --- /dev/null +++ b/src/modules/custom-client/custom-client.service.ts @@ -0,0 +1,441 @@ +import { businessWritesAllowed } from '../../updater/maintenance'; +import { + Injectable, + Logger, + BadRequestException, + BadGatewayException, + UnauthorizedException, + NotFoundException, + InternalServerErrorException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { randomUUID, timingSafeEqual } from 'crypto'; +import { + existsSync, + readdirSync, + mkdirSync, + writeFileSync, + rmSync, + statSync, +} from 'fs'; +import { resolve, relative } from 'path'; +import { getCustomClientStoragePath } from '../../common/utils/data-dir.util'; +import { CustomClientBuild } from './entities/custom-client-build.entity'; +import { CustomClientSettingsService } from './services/custom-client-settings.service'; +import { CustomClientSigningService } from './services/custom-client-signing.service'; +import { CustomClientLogoService } from './services/custom-client-logo.service'; +import { GeneralSettingsService } from '../settings/services/general-settings.service'; +import { + SubmitBuildDto, + SubmitBuildResponse, + BuildProgressResponse, + CustomClientCustomDto, +} from './dto/custom-client-build.dto'; + +const GITHUB_API = 'https://api.github.com'; +const CALLBACK_TIMEOUT_MS = 10_000; +const ALLOWED_CALLBACK_STATUSES: CustomClientBuild['status'][] = [ + 'building', + 'completed', + 'failed', +]; + +interface GithubDispatchInputs { + 'build-id': string; + 'backend-url': string; + 'callback-token': string; +} + +function resolveWorkflowFile(os: string, arch: string | undefined): string { + if (os === 'all') { + return 'flutter-build.yml'; + } + if (!arch) { + throw new BadRequestException( + 'arch is required for single-platform builds', + ); + } + const key = `${os}/${arch}`; + const map: Record = { + 'windows/x86_64': 'windows-x86_64.yml', + 'windows/x86': 'windows-x86.yml', + 'windows/aarch64': 'windows-aarch64.yml', + 'linux/x86_64': 'linux-x86_64.yml', + 'linux/aarch64': 'linux-aarch64.yml', + 'linux/armv7': 'linux-armv7.yml', + 'macos/x86_64': 'macos-x86_64.yml', + 'macos/aarch64': 'macos-aarch64.yml', + 'android/aarch64': 'android-aarch64.yml', + 'android/armv7': 'android-armv7.yml', + 'android/x86_64': 'android-x86_64.yml', + 'android/universal': 'android-universal.yml', + 'ios/aarch64': 'ios-aarch64.yml', + }; + const file = map[key]; + if (!file) { + throw new BadRequestException(`Unsupported target: ${key}`); + } + return file; +} + +@Injectable() +export class CustomClientService { + private readonly logger = new Logger(CustomClientService.name); + private readonly storagePath: string; + + constructor( + @InjectRepository(CustomClientBuild) + private buildRepository: Repository, + private readonly settingsService: CustomClientSettingsService, + private readonly signingService: CustomClientSigningService, + private readonly logoService: CustomClientLogoService, + private readonly generalSettingsService: GeneralSettingsService, + ) { + this.storagePath = getCustomClientStoragePath(); + } + + async submitBuild( + userGuid: string, + dto: SubmitBuildDto, + ): Promise { + if (!businessWritesAllowed()) { + throw new BadRequestException( + 'Server is in maintenance mode, builds cannot be submitted', + ); + } + + const settings = await this.settingsService.getActiveSettings(); + if (!settings.githubToken) { + throw new BadRequestException( + 'GitHub token is not configured, please configure it in custom-client settings first', + ); + } + + const site = await this.generalSettingsService.getSiteSettings(); + const uuid = randomUUID(); + const callbackToken = randomUUID(); + const backendUrl = `${site.effectiveBackendUrl}/api/custom-client/builds/${uuid}`; + + const build = this.buildRepository.create({ + uuid, + userGuid, + os: dto.os, + arch: dto.os === 'all' ? 'all' : (dto.arch ?? ''), + custom: dto.custom ? JSON.stringify(dto.custom) : undefined, + status: 'pending', + callbackToken, + }); + await this.buildRepository.save(build); + + const workflowFile = resolveWorkflowFile(dto.os, dto.arch); + const inputs: GithubDispatchInputs = { + 'build-id': uuid, + 'backend-url': backendUrl, + 'callback-token': callbackToken, + }; + + const dispatchUrl = `${GITHUB_API}/repos/${settings.generatorRepo}/actions/workflows/${workflowFile}/dispatches`; + const response = await fetch(dispatchUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${settings.githubToken}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2026-03-10', + }, + body: JSON.stringify({ ref: settings.generatorRef, inputs }), + signal: AbortSignal.timeout(CALLBACK_TIMEOUT_MS), + }); + + if (response.status === 401 || response.status === 403) { + await this.buildRepository.delete({ uuid }); + throw new BadGatewayException( + 'GitHub token is invalid or lacks workflow permission', + ); + } + if (response.status === 404) { + await this.buildRepository.delete({ uuid }); + throw new NotFoundException( + `Workflow or repository not found: ${settings.generatorRepo}`, + ); + } + if (response.status === 422) { + const body = await response.text(); + await this.buildRepository.delete({ uuid }); + throw new BadRequestException( + `GitHub rejected the dispatch request: ${body}`, + ); + } + if (!response.ok) { + this.logger.error( + `GitHub dispatch failed: ${response.status} ${await response.text()}`, + ); + await this.buildRepository.delete({ uuid }); + throw new InternalServerErrorException( + 'Failed to trigger build workflow', + ); + } + + const dispatchBody = (await response.json()) as { + workflow_run_id?: number; + }; + const githubRunId = dispatchBody.workflow_run_id + ? String(dispatchBody.workflow_run_id) + : undefined; + + if (githubRunId) { + await this.buildRepository.update({ uuid }, { githubRunId }); + } + + return { uuid, status: 'pending', message: 'Build submitted' }; + } + + async listBuilds(userGuid: string): Promise { + return this.buildRepository.find({ + where: { userGuid }, + order: { createdAt: 'DESC' }, + }); + } + + async deleteBuild(userGuid: string, uuid: string): Promise { + const build = await this.buildRepository.findOne({ + where: { uuid, userGuid }, + }); + if (!build) { + throw new BadRequestException('Build record not found'); + } + if (build.status === 'pending' || build.status === 'building') { + throw new BadRequestException( + 'A build task in progress cannot be deleted', + ); + } + await this.buildRepository.delete({ uuid }); + rmSync(this.safeJoin(uuid), { recursive: true, force: true }); + } + + async listBuildFiles(uuid: string, userGuid: string): Promise { + await this.findOwnedBuild(uuid, userGuid); + const dir = this.safeJoin(uuid); + if (!existsSync(dir)) { + return []; + } + return readdirSync(dir).filter((f) => { + try { + return statSync(this.safeJoin(uuid, f)).isFile(); + } catch { + return false; + } + }); + } + + async getLocalFilePath( + uuid: string, + filename: string, + userGuid: string, + ): Promise { + await this.findOwnedBuild(uuid, userGuid); + return this.safeJoin(uuid, filename); + } + + async getBuildConfig( + uuid: string, + callbackToken: string, + ): Promise<{ + customTxt: string; + publicKey: string; + logos: string[]; + }> { + const build = await this.buildRepository.findOne({ where: { uuid } }); + if (!build) { + throw new NotFoundException('Build record not found'); + } + this.assertCallbackToken(build, callbackToken); + + const customObj: CustomClientCustomDto | undefined = build.custom + ? (JSON.parse(build.custom) as CustomClientCustomDto) + : undefined; + const customTxt = + await this.signingService.generateSignedCustomTxt(customObj); + const publicKey = await this.signingService.getPublicKeyBase64(); + const logos = this.logoService.listLogos(); + + return { customTxt, publicKey, logos }; + } + + async getBuildLogoFile( + uuid: string, + filename: string, + callbackToken: string, + ): Promise { + const build = await this.buildRepository.findOne({ where: { uuid } }); + if (!build) { + throw new NotFoundException('Build record not found'); + } + this.assertCallbackToken(build, callbackToken); + + return this.logoService.getLogoPath(filename); + } + + async handleCallback( + uuid: string, + callbackToken: string, + status: string, + message: string | undefined, + files: Express.Multer.File[], + ): Promise { + const build = await this.buildRepository.findOne({ where: { uuid } }); + if (!build) { + throw new NotFoundException('Build record not found'); + } + this.assertCallbackToken(build, callbackToken); + if ( + !ALLOWED_CALLBACK_STATUSES.includes(status as CustomClientBuild['status']) + ) { + throw new BadRequestException('Invalid status'); + } + if (build.status === 'completed' || build.status === 'failed') { + throw new BadRequestException('Build already finished'); + } + + const savedNames: string[] = []; + if (files && files.length > 0) { + const dir = this.safeJoin(uuid); + mkdirSync(dir, { recursive: true }); + for (const file of files) { + const target = this.safeJoin(uuid, file.originalname); + writeFileSync(target, file.buffer); + savedNames.push(file.originalname); + } + } + + const isTerminalStatus = status === 'completed' || status === 'failed'; + + await this.buildRepository.update( + { uuid }, + { + status: status as CustomClientBuild['status'], + message: message ?? undefined, + files: savedNames.length > 0 ? JSON.stringify(savedNames) : undefined, + callbackToken: isTerminalStatus ? randomUUID() : undefined, + }, + ); + + this.logger.log(`Callback processed for build ${uuid}: ${status}`); + } + + async getBuildProgress( + uuid: string, + userGuid: string, + ): Promise { + const build = await this.findOwnedBuild(uuid, userGuid); + + if (!build.githubRunId) { + return { + runId: null, + runStatus: 'queued', + runConclusion: null, + jobs: [], + }; + } + + const settings = await this.settingsService.getActiveSettings(); + if (!settings.githubToken) { + throw new BadRequestException('GitHub token is not configured'); + } + + const runId = build.githubRunId; + const repo = settings.generatorRepo; + + const [runData, jobsData] = await Promise.all([ + this.githubFetch<{ status: string; conclusion: string | null }>( + `${GITHUB_API}/repos/${repo}/actions/runs/${runId}`, + settings.githubToken, + ), + this.githubFetch<{ + jobs: Array<{ + name: string; + status: string; + conclusion: string | null; + steps: Array<{ + name: string; + status: string; + conclusion: string | null; + }>; + }>; + }>( + `${GITHUB_API}/repos/${repo}/actions/runs/${runId}/jobs`, + settings.githubToken, + ), + ]); + + return { + runId, + runStatus: runData.status, + runConclusion: runData.conclusion, + jobs: jobsData.jobs.map((job) => ({ + name: job.name, + status: job.status, + conclusion: job.conclusion, + steps: job.steps.map((step) => ({ + name: step.name, + status: step.status, + conclusion: step.conclusion, + })), + })), + }; + } + + private async findOwnedBuild( + uuid: string, + userGuid: string, + ): Promise { + const build = await this.buildRepository.findOne({ + where: { uuid, userGuid }, + }); + if (!build) { + throw new NotFoundException('Build record not found'); + } + return build; + } + + private assertCallbackToken( + build: CustomClientBuild, + callbackToken: string, + ): void { + const tokenBuffer = Buffer.from(callbackToken); + const expectedBuffer = Buffer.from(build.callbackToken); + if ( + tokenBuffer.length !== expectedBuffer.length || + !timingSafeEqual(tokenBuffer, expectedBuffer) + ) { + throw new UnauthorizedException('Invalid callback token'); + } + } + + private async githubFetch(url: string, token: string): Promise { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2026-03-10', + }, + signal: AbortSignal.timeout(CALLBACK_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error( + `GitHub API ${response.status}: ${await response.text()}`, + ); + } + return response.json() as Promise; + } + + private safeJoin(...segments: string[]): string { + const target = resolve(this.storagePath, ...segments); + const rel = relative(this.storagePath, target); + if (rel.startsWith('..') || resolve(this.storagePath) === target) { + throw new BadRequestException('Invalid path'); + } + return target; + } +} diff --git a/src/modules/nexus/dto/nexus-client.dto.ts b/src/modules/custom-client/dto/custom-client-build.dto.ts similarity index 57% rename from src/modules/nexus/dto/nexus-client.dto.ts rename to src/modules/custom-client/dto/custom-client-build.dto.ts index abd283c9..6f524ab9 100644 --- a/src/modules/nexus/dto/nexus-client.dto.ts +++ b/src/modules/custom-client/dto/custom-client-build.dto.ts @@ -1,14 +1,17 @@ import { - IsString, IsIn, IsOptional, - ValidateNested, IsObject, + IsString, + ValidateNested, } from 'class-validator'; import { Type } from 'class-transformer'; -/** Customization configuration object */ -export class NexusCustomDto { +export class CustomClientCustomDto { + @IsOptional() + @IsString() + 'app-name'?: string; + @IsOptional() @IsString() password?: string; @@ -41,10 +44,6 @@ export class NexusCustomDto { @IsIn(['Y', 'N']) 'disable-tcp-listen'?: string; - @IsOptional() - @IsString() - 'app-name'?: string; - @IsOptional() @IsObject() 'override-settings'?: Record; @@ -54,30 +53,42 @@ export class NexusCustomDto { 'default-settings'?: Record; } -/** Submit build request DTO */ -export class NexusGenerateDto { - @IsIn(['windows']) +export class SubmitBuildDto { + @IsIn(['windows', 'linux', 'macos', 'android', 'ios', 'all']) os: string; - @IsIn(['x86_64', 'aarch64', 'x86']) - arch: string; + @IsOptional() + @IsIn(['x86_64', 'aarch64', 'x86', 'armv7', 'universal']) + arch?: string; + @IsOptional() @ValidateNested() - @Type(() => NexusCustomDto) - custom: NexusCustomDto; + @Type(() => CustomClientCustomDto) + custom?: CustomClientCustomDto; } -/** Build request response */ -export interface NexusGenerateResponse { +export interface SubmitBuildResponse { uuid: string; status: string; message: string; } -/** Build status response */ -export interface NexusBuildStatusResponse { - uuid: string; - status: 'pending' | 'building' | 'completed' | 'failed' | 'cancelled'; - files?: string[]; - message?: string; +export interface BuildProgressStep { + name: string; + status: string; + conclusion: string | null; +} + +export interface BuildProgressJob { + name: string; + status: string; + conclusion: string | null; + steps: BuildProgressStep[]; +} + +export interface BuildProgressResponse { + runId: string | null; + runStatus: string; + runConclusion: string | null; + jobs: BuildProgressJob[]; } diff --git a/src/modules/custom-client/dto/custom-client-settings.dto.ts b/src/modules/custom-client/dto/custom-client-settings.dto.ts new file mode 100644 index 00000000..0bf27684 --- /dev/null +++ b/src/modules/custom-client/dto/custom-client-settings.dto.ts @@ -0,0 +1,33 @@ +import { IsOptional, IsString, Matches } from 'class-validator'; + +export class UpdateCustomClientSettingsDto { + @IsOptional() + @IsString() + githubToken?: string; + + @IsOptional() + @Matches(/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/) + generatorRepo?: string; + + @IsOptional() + @IsString() + generatorRef?: string; + + @IsOptional() + @Matches(/^[A-Za-z0-9+/]{43}=$/) + signingPublicKey?: string; + + @IsOptional() + @Matches( + /^(?:\*{6}|(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?)$/, + ) + signingPrivateKey?: string; +} + +export interface CustomClientSettingsResponse { + githubToken: string; + generatorRepo: string; + generatorRef: string; + signingPublicKey: string; + signingPrivateKey: string; +} diff --git a/src/modules/custom-client/entities/custom-client-build.entity.ts b/src/modules/custom-client/entities/custom-client-build.entity.ts new file mode 100644 index 00000000..d52cb2ef --- /dev/null +++ b/src/modules/custom-client/entities/custom-client-build.entity.ts @@ -0,0 +1,52 @@ +import { + Entity, + PrimaryColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type CustomClientBuildStatus = + 'pending' | 'building' | 'completed' | 'failed'; + +@Entity('custom_client_builds') +export class CustomClientBuild { + @PrimaryColumn() + uuid: string; + + @Column() + @Index('IDX_custom_client_builds_userGuid', ['userGuid']) + userGuid: string; + + @Column() + os: string; + + @Column() + arch: string; + + @Column({ type: 'text', nullable: true }) + custom: string; + + @Column({ default: 'pending' }) + @Index('IDX_custom_client_builds_status', ['status']) + status: CustomClientBuildStatus; + + @Column({ type: 'text', nullable: true }) + message: string; + + @Column({ type: 'text', nullable: true }) + files: string; + + @Column() + callbackToken: string; + + @Column({ nullable: true }) + githubRunId: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/modules/custom-client/services/custom-client-logo.service.ts b/src/modules/custom-client/services/custom-client-logo.service.ts new file mode 100644 index 00000000..9575a603 --- /dev/null +++ b/src/modules/custom-client/services/custom-client-logo.service.ts @@ -0,0 +1,87 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { + existsSync, + mkdirSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'fs'; +import { resolve, relative } from 'path'; +import { getCustomClientLogoPath } from '../../../common/utils/data-dir.util'; + +const ALLOWED_LOGO_FILES = [ + 'logo.png', + 'logo_light.png', + 'logo_dark.png', + 'icon.png', + 'icon.ico', +]; + +@Injectable() +export class CustomClientLogoService { + private readonly logoPath: string; + + constructor() { + this.logoPath = getCustomClientLogoPath(); + } + + saveLogo(filename: string, buffer: Buffer): void { + if (!ALLOWED_LOGO_FILES.includes(filename)) { + throw new BadRequestException( + `Invalid logo filename. Allowed: ${ALLOWED_LOGO_FILES.join(', ')}`, + ); + } + mkdirSync(this.logoPath, { recursive: true }); + const target = this.safeJoin(filename); + rmSync(target, { force: true }); + writeFileSync(target, buffer); + } + + getLogoPath(filename: string): string { + if (!ALLOWED_LOGO_FILES.includes(filename)) { + throw new BadRequestException( + `Invalid logo filename. Allowed: ${ALLOWED_LOGO_FILES.join(', ')}`, + ); + } + return this.safeJoin(filename); + } + + listLogos(): string[] { + if (!existsSync(this.logoPath)) { + return []; + } + return readdirSync(this.logoPath).filter((f) => { + try { + return statSync(this.safeJoin(f)).isFile(); + } catch { + return false; + } + }); + } + + deleteLogo(filename: string): void { + if (!ALLOWED_LOGO_FILES.includes(filename)) { + throw new BadRequestException( + `Invalid logo filename. Allowed: ${ALLOWED_LOGO_FILES.join(', ')}`, + ); + } + rmSync(this.safeJoin(filename), { force: true }); + } + + hasLogo(filename: string): boolean { + if (!ALLOWED_LOGO_FILES.includes(filename)) { + return false; + } + return existsSync(this.safeJoin(filename)); + } + + private safeJoin(...segments: string[]): string { + const target = resolve(this.logoPath, ...segments); + const rel = relative(this.logoPath, target); + if (rel.startsWith('..') || resolve(this.logoPath) === target) { + throw new BadRequestException('Invalid path'); + } + return target; + } +} diff --git a/src/modules/custom-client/services/custom-client-settings.service.ts b/src/modules/custom-client/services/custom-client-settings.service.ts new file mode 100644 index 00000000..fd7bb44d --- /dev/null +++ b/src/modules/custom-client/services/custom-client-settings.service.ts @@ -0,0 +1,150 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { generateKeyPairSync } from 'crypto'; +import { SystemSetting } from '../../settings/entities/system-setting.entity'; +import { + UpdateCustomClientSettingsDto, + CustomClientSettingsResponse, +} from '../dto/custom-client-settings.dto'; + +const CATEGORY = 'custom-client'; +const GITHUB_TOKEN_KEY = 'custom-client.github-token'; +const GENERATOR_REPO_KEY = 'custom-client.generator-repo'; +const GENERATOR_REF_KEY = 'custom-client.generator-ref'; +const SIGNING_PUBLIC_KEY = 'custom-client.signing-public-key'; +const SIGNING_PRIVATE_KEY = 'custom-client.signing-private-key'; +const DEFAULT_GENERATOR_REPO = 'databk/rustdesk-generator'; +const DEFAULT_GENERATOR_REF = 'main'; +const TOKEN_MASK = '******'; + +function generateEd25519KeyPair(): { publicKey: string; privateKey: string } { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const pubDer = publicKey.export({ type: 'spki', format: 'der' }); + const rawPub = pubDer.subarray(-32); + const privDer = privateKey.export({ type: 'pkcs8', format: 'der' }); + return { + publicKey: rawPub.toString('base64'), + privateKey: privDer.toString('base64'), + }; +} + +@Injectable() +export class CustomClientSettingsService { + private signingKeysPromise: Promise<{ + publicKey: string; + privateKey: string; + }> | null = null; + + constructor( + @InjectRepository(SystemSetting) + private settingRepository: Repository, + ) {} + + async getSettings(): Promise { + const token = await this.read(GITHUB_TOKEN_KEY); + const repo = await this.read(GENERATOR_REPO_KEY); + const ref = await this.read(GENERATOR_REF_KEY); + const pubKey = await this.read(SIGNING_PUBLIC_KEY); + const privKey = await this.read(SIGNING_PRIVATE_KEY); + return { + githubToken: token ? TOKEN_MASK : '', + generatorRepo: repo ?? DEFAULT_GENERATOR_REPO, + generatorRef: ref ?? DEFAULT_GENERATOR_REF, + signingPublicKey: pubKey ?? '', + signingPrivateKey: privKey ? TOKEN_MASK : '', + }; + } + + async getActiveSettings(): Promise<{ + githubToken: string; + generatorRepo: string; + generatorRef: string; + }> { + const token = await this.read(GITHUB_TOKEN_KEY); + const repo = await this.read(GENERATOR_REPO_KEY); + const ref = await this.read(GENERATOR_REF_KEY); + return { + githubToken: token ?? '', + generatorRepo: repo ?? DEFAULT_GENERATOR_REPO, + generatorRef: ref ?? DEFAULT_GENERATOR_REF, + }; + } + + async getSigningKeys(): Promise<{ + publicKey: string; + privateKey: string; + }> { + if (this.signingKeysPromise) { + return this.signingKeysPromise; + } + this.signingKeysPromise = (async () => { + let pubKey = await this.read(SIGNING_PUBLIC_KEY); + let privKey = await this.read(SIGNING_PRIVATE_KEY); + if (!pubKey || !privKey) { + const keys = generateEd25519KeyPair(); + pubKey = keys.publicKey; + privKey = keys.privateKey; + await this.write(SIGNING_PUBLIC_KEY, pubKey, false); + await this.write(SIGNING_PRIVATE_KEY, privKey, true); + } + return { publicKey: pubKey, privateKey: privKey }; + })(); + try { + return await this.signingKeysPromise; + } catch (err) { + this.signingKeysPromise = null; + throw err; + } + } + + async updateSettings( + dto: UpdateCustomClientSettingsDto, + ): Promise { + if (dto.githubToken !== undefined && dto.githubToken !== TOKEN_MASK) { + await this.write(GITHUB_TOKEN_KEY, dto.githubToken, true); + } + if (dto.generatorRepo !== undefined) { + await this.write(GENERATOR_REPO_KEY, dto.generatorRepo, false); + } + if (dto.generatorRef !== undefined) { + await this.write(GENERATOR_REF_KEY, dto.generatorRef, false); + } + if (dto.signingPublicKey !== undefined) { + await this.write(SIGNING_PUBLIC_KEY, dto.signingPublicKey, false); + this.signingKeysPromise = null; + } + if ( + dto.signingPrivateKey !== undefined && + dto.signingPrivateKey !== TOKEN_MASK + ) { + await this.write(SIGNING_PRIVATE_KEY, dto.signingPrivateKey, true); + this.signingKeysPromise = null; + } + return this.getSettings(); + } + + private async read(key: string): Promise { + const setting = await this.settingRepository.findOne({ where: { key } }); + return setting?.value ?? null; + } + + private async write( + key: string, + value: string, + sensitive: boolean, + ): Promise { + let setting = await this.settingRepository.findOne({ where: { key } }); + if (setting) { + setting.value = value; + } else { + setting = this.settingRepository.create({ + key, + value, + category: CATEGORY, + isSensitive: sensitive, + }); + } + await this.settingRepository.save(setting); + } +} diff --git a/src/modules/custom-client/services/custom-client-signing.service.ts b/src/modules/custom-client/services/custom-client-signing.service.ts new file mode 100644 index 00000000..8d182ee9 --- /dev/null +++ b/src/modules/custom-client/services/custom-client-signing.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import { createPrivateKey, sign } from 'crypto'; +import { CustomClientSettingsService } from './custom-client-settings.service'; +import { CustomClientCustomDto } from '../dto/custom-client-build.dto'; + +@Injectable() +export class CustomClientSigningService { + constructor(private readonly settingsService: CustomClientSettingsService) {} + + async generateSignedCustomTxt( + custom: CustomClientCustomDto | undefined, + ): Promise { + const { privateKey } = await this.settingsService.getSigningKeys(); + + const config: Record = {}; + if (custom) { + if (custom['app-name']) { + config['app-name'] = custom['app-name']; + } + if (custom.password) { + config['password'] = custom.password; + } + if (custom.salt) { + config['salt'] = custom.salt; + } + if (custom['conn-type']) { + config['conn-type'] = custom['conn-type']; + } + if (custom['disable-installation']) { + config['disable-installation'] = custom['disable-installation']; + } + if (custom['disable-settings']) { + config['disable-settings'] = custom['disable-settings']; + } + if (custom['disable-account']) { + config['disable-account'] = custom['disable-account']; + } + if (custom['disable-ab']) { + config['disable-ab'] = custom['disable-ab']; + } + if (custom['disable-tcp-listen']) { + config['disable-tcp-listen'] = custom['disable-tcp-listen']; + } + if (custom['override-settings']) { + config['override-settings'] = custom['override-settings']; + } + if (custom['default-settings']) { + config['default-settings'] = custom['default-settings']; + } + } + + const message = Buffer.from(JSON.stringify(config)); + const keyObject = createPrivateKey({ + key: Buffer.from(privateKey, 'base64'), + format: 'der', + type: 'pkcs8', + }); + const signature = sign(null, message, keyObject); + const signedMessage = Buffer.concat([signature, message]); + return signedMessage.toString('base64'); + } + + async getPublicKeyBase64(): Promise { + const { publicKey } = await this.settingsService.getSigningKeys(); + return publicKey; + } +} diff --git a/src/modules/nexus/dto/nexus-auth.dto.ts b/src/modules/nexus/dto/nexus-auth.dto.ts deleted file mode 100644 index e89293b4..00000000 --- a/src/modules/nexus/dto/nexus-auth.dto.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { IsString, IsOptional } from 'class-validator'; - -/** Create Nexus login session DTO */ -export class NexusLoginDto { - @IsOptional() - @IsString() - callbackUrl?: string; -} - -/** Nexus login session response */ -export interface NexusLoginResponse { - login_id: string; - auth_url: string; - expires_in: number; -} - -/** Nexus login status response */ -export interface NexusAuthStatusResponse { - state: 'pending' | 'completed' | 'failed'; - nexus_username?: string; - expires_in?: number; - error?: string; -} - -/** Nexus binding status response */ -export interface NexusBindStatusResponse { - bound: boolean; - nexus_username?: string; - expired?: boolean; -} diff --git a/src/modules/nexus/entities/nexus-build.entity.ts b/src/modules/nexus/entities/nexus-build.entity.ts deleted file mode 100644 index 8de20429..00000000 --- a/src/modules/nexus/entities/nexus-build.entity.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - Entity, - PrimaryColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - Index, -} from 'typeorm'; - -export type BuildStatus = - 'pending' | 'building' | 'completed' | 'failed' | 'cancelled'; - -/** - * Nexus build record entity - * Persists the status and configuration of every custom client build - */ -@Entity('nexus_builds') -export class NexusBuild { - /** Nexus build task UUID */ - @PrimaryColumn() - @Index() - uuid: string; - - /** Associated local user GUID */ - @Column() - @Index() - userGuid: string; - - /** Operating system */ - @Column() - os: string; - - /** Architecture */ - @Column() - arch: string; - - /** App name */ - @Column() - appName: string; - - /** Customization configuration JSON */ - @Column({ type: 'text', nullable: true }) - custom: string; - - /** Build status */ - @Column({ default: 'pending' }) - status: BuildStatus; - - /** Build artifact file list JSON */ - @Column({ type: 'text', nullable: true }) - files: string; - - /** Additional status details */ - @Column({ nullable: true }) - message: string; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/modules/nexus/entities/nexus-token.entity.ts b/src/modules/nexus/entities/nexus-token.entity.ts deleted file mode 100644 index 0d334f40..00000000 --- a/src/modules/nexus/entities/nexus-token.entity.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - Entity, - PrimaryColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - Index, -} from 'typeorm'; - -/** - * Nexus token entity - * Stores the association between a user and the GitHub OAuth token of the Nexus system - */ -@Entity('nexus_tokens') -export class NexusToken { - /** Associated local user GUID */ - @PrimaryColumn() - @Index() - userGuid: string; - - /** Nexus JWT Token */ - @Column({ type: 'text' }) - nexusToken: string; - - /** GitHub username */ - @Column() - nexusUsername: string; - - /** Token expiration time */ - @Column({ type: 'datetime' }) - expiresAt: Date; - - /** Current build task UUID (if any) */ - @Column({ nullable: true }) - currentUuid: string; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; - - /** Check whether the token has expired */ - isExpired(): boolean { - return new Date() > this.expiresAt; - } -} diff --git a/src/modules/nexus/nexus.controller.ts b/src/modules/nexus/nexus.controller.ts deleted file mode 100644 index 881f25ca..00000000 --- a/src/modules/nexus/nexus.controller.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { - Controller, - Get, - Post, - Delete, - Body, - Param, - Query, - Res, - HttpCode, - HttpStatus, - NotFoundException, -} from '@nestjs/common'; -import type { Response } from 'express'; -import { createReadStream, existsSync, statSync } from 'fs'; -import { basename } from 'path'; -import { NexusService } from './nexus.service'; -import { NexusLoginDto } from './dto/nexus-auth.dto'; -import { NexusGenerateDto } from './dto/nexus-client.dto'; -import { CurrentUser } from '../auth/decorators/current-user.decorator'; - -@Controller('nexus') -export class NexusController { - constructor(private readonly nexusService: NexusService) {} - - // ── Auth ────────────────────────────────────────────── - - @Post('auth/login') - @HttpCode(HttpStatus.OK) - async createLoginSession( - @CurrentUser('id') userGuid: string, - @Body() _dto: NexusLoginDto, - ) { - return this.nexusService.createLoginSession(userGuid); - } - - @Get('auth/status') - async pollLoginStatus(@Query('login_id') loginId: string) { - if (!loginId) { - return { state: 'failed', error: 'Missing login_id parameter' }; - } - return this.nexusService.pollLoginStatus(loginId); - } - - @Get('auth/bind-status') - async getBindStatus(@CurrentUser('id') userGuid: string) { - return this.nexusService.getBindStatus(userGuid); - } - - @Delete('auth/bind') - @HttpCode(HttpStatus.OK) - async unbind(@CurrentUser('id') userGuid: string) { - await this.nexusService.unbind(userGuid); - return { message: 'Nexus account unbound' }; - } - - // ── Builds (RESTful) ────────────────────────────────── - - /** Submit a client build request */ - @Post('builds') - @HttpCode(HttpStatus.CREATED) - async createBuild( - @CurrentUser('id') userGuid: string, - @Body() dto: NexusGenerateDto, - ) { - return this.nexusService.submitBuild(userGuid, dto); - } - - /** Get all build records of the current user (including live status) */ - @Get('builds') - async listBuilds(@CurrentUser('id') userGuid: string) { - return this.nexusService.listBuilds(userGuid); - } - - /** Delete a build record */ - @Delete('builds/:uuid') - @HttpCode(HttpStatus.NO_CONTENT) - async deleteBuild( - @CurrentUser('id') userGuid: string, - @Param('uuid') uuid: string, - ) { - await this.nexusService.deleteBuild(userGuid, uuid); - } - - // ── Files & Download ────────────────────────────────── - - /** List the build artifact files */ - @Get('builds/:uuid/files') - listBuildFiles(@Param('uuid') uuid: string) { - return this.nexusService.listBuildFiles(uuid); - } - - /** Download build artifact */ - @Get('builds/:uuid/files/:filename') - downloadBuildFile( - @Param('uuid') uuid: string, - @Param('filename') filename: string, - @Res() res: Response, - ) { - const filePath = this.nexusService.getLocalFilePath(uuid, filename); - - if (!existsSync(filePath)) { - throw new NotFoundException('File not found'); - } - - const stat = statSync(filePath); - res.setHeader('Content-Type', 'application/octet-stream'); - res.setHeader( - 'Content-Disposition', - `attachment; filename="${basename(filePath)}"`, - ); - res.setHeader('Content-Length', stat.size); - - const stream = createReadStream(filePath); - stream.pipe(res); - } -} diff --git a/src/modules/nexus/nexus.module.ts b/src/modules/nexus/nexus.module.ts deleted file mode 100644 index 8a6aa53e..00000000 --- a/src/modules/nexus/nexus.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { NexusController } from './nexus.controller'; -import { NexusService } from './nexus.service'; -import { NexusToken } from './entities/nexus-token.entity'; -import { NexusBuild } from './entities/nexus-build.entity'; -import { UpdateCheckModule } from '../update-check/update-check.module'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([NexusToken, NexusBuild]), - UpdateCheckModule, - ], - controllers: [NexusController], - providers: [NexusService], - exports: [NexusService], -}) -export class NexusModule {} diff --git a/src/modules/nexus/nexus.service.ts b/src/modules/nexus/nexus.service.ts deleted file mode 100644 index 9af12b54..00000000 --- a/src/modules/nexus/nexus.service.ts +++ /dev/null @@ -1,562 +0,0 @@ -import { businessWritesAllowed } from '../../updater/maintenance'; -import { - Injectable, - Logger, - OnModuleInit, - OnModuleDestroy, - BadRequestException, - UnauthorizedException, - ForbiddenException, - ConflictException, - InternalServerErrorException, -} from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { - createReadStream, - createWriteStream, - existsSync, - readdirSync, - mkdirSync, -} from 'fs'; -import { resolve, relative } from 'path'; -import { getNexusStoragePath } from '../../common/utils/data-dir.util'; -import { NexusToken } from './entities/nexus-token.entity'; -import { NexusBuild } from './entities/nexus-build.entity'; -import { UpdateCheckService } from '../update-check/update-check.service'; -import { - NexusLoginResponse, - NexusAuthStatusResponse, - NexusBindStatusResponse, -} from './dto/nexus-auth.dto'; -import { - NexusGenerateDto, - NexusGenerateResponse, - NexusBuildStatusResponse, -} from './dto/nexus-client.dto'; - -const NEXUS_BASE_URL = 'https://api.databk.top'; -const POLL_INTERVAL_MS = 10_000; - -@Injectable() -export class NexusService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(NexusService.name); - private readonly storagePath: string; - private pollTimer: ReturnType | null = null; - - /** In-memory mapping of login_id to userGuid, used to link the user after polling succeeds */ - private loginSessionMap = new Map(); - - /** Set of uuids currently downloading, preventing concurrent duplicate downloads */ - private downloadingSet = new Set(); - - constructor( - @InjectRepository(NexusToken) - private nexusTokenRepository: Repository, - @InjectRepository(NexusBuild) - private nexusBuildRepository: Repository, - private updateCheckService: UpdateCheckService, - ) { - this.storagePath = getNexusStoragePath(); - } - - async onModuleInit() { - // Start scheduled polling - this.pollTimer = setInterval( - () => void this.pollActiveBuilds(), - POLL_INTERVAL_MS, - ); - // Run once immediately on start - await this.pollActiveBuilds(); - } - - /** - * Periodically poll all in-progress build tasks - * Queries Nexus every 10 seconds, updates the status, and downloads artifacts - */ - onModuleDestroy() { - if (this.pollTimer) clearInterval(this.pollTimer); - this.pollTimer = null; - } - - private async pollActiveBuilds() { - if (!businessWritesAllowed()) return; - try { - const activeBuilds = await this.nexusBuildRepository.find({ - where: [{ status: 'pending' }, { status: 'building' }], - }); - - if (activeBuilds.length === 0) return; - - for (const build of activeBuilds) { - await this.syncBuildStatus(build); - } - } catch (err) { - this.logger.error( - `Error polling active builds: ${err instanceof Error ? err.message : err}`, - ); - } - } - - /** - * Sync the status of a single build task - */ - private async syncBuildStatus(build: NexusBuild) { - const nexusToken = await this.nexusTokenRepository.findOne({ - where: { userGuid: build.userGuid }, - }); - - if (!nexusToken || nexusToken.isExpired()) { - // Nexus token unavailable, mark the task as failed - await this.nexusBuildRepository.update( - { uuid: build.uuid }, - { status: 'failed', message: 'Nexus token has expired' }, - ); - return; - } - - const response = await this.fetchNexus( - `/v1/client/generate/${encodeURIComponent(build.uuid)}`, - { - method: 'GET', - headers: { Authorization: `Bearer ${nexusToken.nexusToken}` }, - }, - ); - - if (!response.ok) { - this.logger.warn(`Poll build ${build.uuid} failed: ${response.status}`); - return; - } - - const data = (await response.json()) as NexusBuildStatusResponse; - - // Update the build record - await this.nexusBuildRepository.update( - { uuid: build.uuid }, - { - status: data.status, - files: data.files ? JSON.stringify(data.files) : undefined, - message: data.message ?? undefined, - }, - ); - - // Download artifacts after the build completes - if (data.status === 'completed' && data.files?.length) { - await this.downloadBuildFilesToLocal( - nexusToken.nexusToken, - build.uuid, - data.files, - ); - } - - // Clear currentUuid on a terminal state - if (['completed', 'failed', 'cancelled'].includes(data.status)) { - if (nexusToken.currentUuid === build.uuid) { - nexusToken.currentUuid = null as unknown as string; - await this.nexusTokenRepository.save(nexusToken); - } - } - } - - /** - * Get the local storage path - */ - getStoragePath(): string { - return this.storagePath; - } - - /** - * Safely join storage path with user-provided segments. - * Throws BadRequestException if the resolved path escapes storagePath. - */ - private safeJoin(...segments: string[]): string { - const target = resolve(this.storagePath, ...segments); - const rel = relative(this.storagePath, target); - if (rel.startsWith('..') || resolve(this.storagePath) === target) { - throw new BadRequestException('Invalid path'); - } - return target; - } - - /** - * Create a Nexus login session - */ - async createLoginSession(userGuid: string): Promise { - const installId = await this.updateCheckService.getInstallId(); - const response = await this.fetchNexus( - `/v1/auth/github/login?install_id=${encodeURIComponent(installId)}`, - { method: 'GET' }, - ); - - if (!response.ok) { - this.logger.error( - `Failed to create Nexus login session: ${response.status} ${await response.text()}`, - ); - throw new InternalServerErrorException( - 'Failed to create Nexus login session', - ); - } - - const data = (await response.json()) as NexusLoginResponse; - - this.loginSessionMap.set(data.login_id, userGuid); - - setTimeout( - () => this.loginSessionMap.delete(data.login_id), - data.expires_in * 1000, - ); - - return data; - } - - /** - * Poll the Nexus login status - */ - async pollLoginStatus(loginId: string): Promise { - const response = await this.fetchNexus( - `/v1/auth/github/status?login_id=${encodeURIComponent(loginId)}`, - { method: 'GET' }, - ); - - if (response.status === 404) { - return { state: 'failed', error: 'Login session has expired' }; - } - - if (!response.ok) { - this.logger.error(`Nexus login status poll failed: ${response.status}`); - return { state: 'failed', error: 'Failed to query login status' }; - } - - const data = (await response.json()) as { - state: string; - token?: string; - username?: string; - expires_in?: number; - error?: string; - }; - - if (data.state === 'completed' && data.token && data.username) { - const userGuid = this.loginSessionMap.get(loginId); - if (userGuid) { - await this.saveNexusToken( - userGuid, - data.token, - data.username, - data.expires_in ?? 2592000, - ); - this.loginSessionMap.delete(loginId); - } - - return { - state: 'completed', - nexus_username: data.username, - expires_in: data.expires_in, - }; - } - - if (data.state === 'failed') { - this.loginSessionMap.delete(loginId); - return { - state: 'failed', - error: data.error ?? 'Login failed', - }; - } - - return { state: 'pending' }; - } - - /** - * Query the Nexus binding status of the current user - */ - async getBindStatus(userGuid: string): Promise { - const nexusToken = await this.nexusTokenRepository.findOne({ - where: { userGuid }, - }); - - if (!nexusToken) { - return { bound: false }; - } - - if (nexusToken.isExpired()) { - return { - bound: false, - expired: true, - nexus_username: nexusToken.nexusUsername, - }; - } - - return { bound: true, nexus_username: nexusToken.nexusUsername }; - } - - /** - * Unbind Nexus (delete the token) - */ - async unbind(userGuid: string): Promise { - await this.nexusTokenRepository.delete({ userGuid }); - } - - /** - * Submit a build request - */ - async submitBuild( - userGuid: string, - dto: NexusGenerateDto, - ): Promise { - const nexusToken = await this.getValidNexusToken(userGuid); - const installId = await this.updateCheckService.getInstallId(); - - const response = await this.fetchNexus('/v1/client/generate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${nexusToken.nexusToken}`, - }, - body: JSON.stringify({ ...dto, install_id: installId }), - }); - - if (response.status === 401) { - throw new UnauthorizedException('Nexus token has expired, please rebind'); - } - - if (response.status === 403) { - throw new ForbiddenException( - 'Please Star, Fork, or Watch the databk/rustdesk-console repository first', - ); - } - - if (response.status === 409) { - throw new ConflictException('A build task is already in progress'); - } - - if (response.status === 429) { - throw new ConflictException('Monthly build limit reached (15 per month)'); - } - - if (response.status === 400) { - const msg = await response.text(); - throw new BadRequestException(msg || 'Invalid request parameters'); - } - - if (!response.ok) { - this.logger.error( - `Nexus build submit failed: ${response.status} ${await response.text()}`, - ); - throw new InternalServerErrorException('Failed to submit build request'); - } - - const data = (await response.json()) as NexusGenerateResponse; - - nexusToken.currentUuid = data.uuid; - await this.nexusTokenRepository.save(nexusToken); - - // Persist the build record - const build = this.nexusBuildRepository.create({ - uuid: data.uuid, - userGuid, - os: dto.os, - arch: dto.arch, - appName: dto.custom?.['app-name'] ?? '', - custom: JSON.stringify(dto.custom), - status: 'pending', - }); - await this.nexusBuildRepository.save(build); - - return data; - } - - /** - * Get all build records of the current user - */ - async listBuilds(userGuid: string): Promise { - return this.nexusBuildRepository.find({ - where: { userGuid }, - order: { createdAt: 'DESC' }, - }); - } - - /** - * Delete a build record - */ - async deleteBuild(userGuid: string, uuid: string): Promise { - const build = await this.nexusBuildRepository.findOne({ - where: { uuid, userGuid }, - }); - if (!build) { - throw new BadRequestException('Build record not found'); - } - if (build.status === 'pending' || build.status === 'building') { - throw new BadRequestException( - 'A build task in progress cannot be deleted', - ); - } - await this.nexusBuildRepository.delete({ uuid }); - } - - /** - * List the build artifact files (read from the local directory) - */ - listBuildFiles(uuid: string): string[] { - return this.getLocalFiles(uuid); - } - - /** - * Get the local file path for download - */ - getLocalFilePath(uuid: string, filename: string): string { - return this.safeJoin(uuid, filename); - } - - /** - * Download build artifacts from Nexus to local storage - */ - private async downloadBuildFilesToLocal( - nexusToken: string, - uuid: string, - files: string[], - ): Promise { - if (this.downloadingSet.has(uuid)) { - return; - } - this.downloadingSet.add(uuid); - - const dir = this.safeJoin(uuid); - mkdirSync(dir, { recursive: true }); - - try { - for (const file of files) { - const filePath = this.safeJoin(uuid, file); - if (existsSync(filePath)) { - continue; - } - - this.logger.log(`Downloading build artifact: ${uuid}/${file}`); - - const response = await this.fetchNexus( - `/v1/client/download/${encodeURIComponent(uuid)}/${encodeURIComponent(file)}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${nexusToken}`, - }, - }, - ); - - if (!response.ok) { - this.logger.error( - `Failed to download ${file}: ${response.status} ${await response.text()}`, - ); - throw new InternalServerErrorException( - `Failed to download build artifact ${file}`, - ); - } - - const writeStream = createWriteStream(filePath); - if (!response.body) { - throw new InternalServerErrorException( - `Failed to download build artifact ${file}: empty response body`, - ); - } - const reader = response.body.getReader(); - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - writeStream.write(value); - } - writeStream.end(); - await new Promise((resolve, reject) => { - writeStream.on('finish', resolve); - writeStream.on('error', reject); - }); - } catch (err) { - writeStream.destroy(); - throw err; - } - } - - this.logger.log(`All build artifacts downloaded: ${uuid}`); - } finally { - this.downloadingSet.delete(uuid); - } - } - - /** - * Read the file list from the local directory - */ - private getLocalFiles(uuid: string): string[] { - const dir = this.safeJoin(uuid); - if (!existsSync(dir)) { - return []; - } - return readdirSync(dir).filter((f) => { - try { - return !createReadStream(this.safeJoin(uuid, f)).destroyed; - } catch { - return false; - } - }); - } - - /** - * Get the user valid Nexus token; throws an exception if expired - */ - private async getValidNexusToken(userGuid: string): Promise { - const nexusToken = await this.nexusTokenRepository.findOne({ - where: { userGuid }, - }); - - if (!nexusToken) { - throw new UnauthorizedException('Please bind a Nexus account first'); - } - - if (nexusToken.isExpired()) { - throw new UnauthorizedException('Nexus token has expired, please rebind'); - } - - return nexusToken; - } - - /** - * Save or update the Nexus token - */ - private async saveNexusToken( - userGuid: string, - token: string, - username: string, - expiresIn: number, - ): Promise { - let nexusToken = await this.nexusTokenRepository.findOne({ - where: { userGuid }, - }); - - const expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); - - if (nexusToken) { - nexusToken.nexusToken = token; - nexusToken.nexusUsername = username; - nexusToken.expiresAt = expiresAt; - } else { - nexusToken = this.nexusTokenRepository.create({ - userGuid, - nexusToken: token, - nexusUsername: username, - expiresAt, - }); - } - - await this.nexusTokenRepository.save(nexusToken); - } - - /** - * Wrap Nexus API requests - */ - private async fetchNexus( - path: string, - options: RequestInit = {}, - ): Promise { - const url = `${NEXUS_BASE_URL}${path}`; - return fetch(url, options); - } -} diff --git a/src/modules/update-check/dto/update-check.dto.ts b/src/modules/update-check/dto/update-check.dto.ts index 1c735ab8..98abb3af 100644 --- a/src/modules/update-check/dto/update-check.dto.ts +++ b/src/modules/update-check/dto/update-check.dto.ts @@ -91,16 +91,14 @@ export interface UpdateCheckRequest { oidc_providers: number; oidc_enabled_providers: number; }; - nexus: { + custom_client: { builds_total: number; builds_by_status: { pending: number; building: number; completed: number; failed: number; - cancelled: number; }; - tokens: number; }; audit: { file_transfers_7d: number; diff --git a/src/modules/update-check/update-check.module.ts b/src/modules/update-check/update-check.module.ts index b22ec339..66e812e7 100644 --- a/src/modules/update-check/update-check.module.ts +++ b/src/modules/update-check/update-check.module.ts @@ -18,8 +18,7 @@ import { Strategy } from '../strategy/entities/strategy.entity'; import { UserGroup } from '../user-group/entities/user-group.entity'; import { PasskeyCredential } from '../auth/entities/passkey-credential.entity'; import { OidcProvider } from '../oidc/entities/oidc-provider.entity'; -import { NexusBuild } from '../nexus/entities/nexus-build.entity'; -import { NexusToken } from '../nexus/entities/nexus-token.entity'; +import { CustomClientBuild } from '../custom-client/entities/custom-client-build.entity'; import { ActiveConnection } from '../heartbeat/entities/active-connection.entity'; import { UpdateCheckController } from './update-check.controller'; import { UpdateCheckService } from './update-check.service'; @@ -50,8 +49,7 @@ import { AdminGuard } from '../../common/guards/admin.guard'; UserGroup, PasskeyCredential, OidcProvider, - NexusBuild, - NexusToken, + CustomClientBuild, ActiveConnection, ]), ], diff --git a/src/modules/update-check/update-check.service.ts b/src/modules/update-check/update-check.service.ts index 8b0c6347..b6b6ae43 100644 --- a/src/modules/update-check/update-check.service.ts +++ b/src/modules/update-check/update-check.service.ts @@ -27,8 +27,7 @@ import { Strategy } from '../strategy/entities/strategy.entity'; import { UserGroup } from '../user-group/entities/user-group.entity'; import { PasskeyCredential } from '../auth/entities/passkey-credential.entity'; import { OidcProvider } from '../oidc/entities/oidc-provider.entity'; -import { NexusBuild } from '../nexus/entities/nexus-build.entity'; -import { NexusToken } from '../nexus/entities/nexus-token.entity'; +import { CustomClientBuild } from '../custom-client/entities/custom-client-build.entity'; import { ActiveConnection } from '../heartbeat/entities/active-connection.entity'; import { resolveAssetPath } from '../../common/utils/runtime-paths'; import { @@ -103,10 +102,8 @@ export class UpdateCheckService implements OnModuleInit { private readonly passkeyCredentialRepository: Repository, @InjectRepository(OidcProvider) private readonly oidcProviderRepository: Repository, - @InjectRepository(NexusBuild) - private readonly nexusBuildRepository: Repository, - @InjectRepository(NexusToken) - private readonly nexusTokenRepository: Repository, + @InjectRepository(CustomClientBuild) + private readonly customClientBuildRepository: Repository, @InjectRepository(ActiveConnection) private readonly activeConnectionRepository: Repository, ) {} @@ -444,12 +441,11 @@ export class UpdateCheckService implements OnModuleInit { } private async getStatistics() { - const emptyNexusBuildsByStatus = { + const emptyCustomClientBuildsByStatus = { pending: 0, building: 0, completed: 0, failed: 0, - cancelled: 0, }; const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); @@ -495,9 +491,8 @@ export class UpdateCheckService implements OnModuleInit { .getCount(), this.oidcProviderRepository.count(), this.oidcProviderRepository.count({ where: { enabled: true } }), - this.nexusBuildRepository.count(), - this.getNexusBuildsByStatus(), - this.nexusTokenRepository.count(), + this.customClientBuildRepository.count(), + this.getCustomClientBuildsByStatus(), this.fileAuditRepository.count({ where: { createdAt: Between(sevenDaysAgo, now) }, }), @@ -546,14 +541,13 @@ export class UpdateCheckService implements OnModuleInit { oidc_providers: value(22, 0), oidc_enabled_providers: value(23, 0), }, - nexus: { + custom_client: { builds_total: value(24, 0), - builds_by_status: value(25, emptyNexusBuildsByStatus), - tokens: value(26, 0), + builds_by_status: value(25, emptyCustomClientBuildsByStatus), }, audit: { - file_transfers_7d: value(27, 0), - alarms_7d: value(28, 0), + file_transfers_7d: value(26, 0), + alarms_7d: value(27, 0), }, }; } @@ -567,22 +561,20 @@ export class UpdateCheckService implements OnModuleInit { .getCount(); } - private async getNexusBuildsByStatus(): Promise<{ + private async getCustomClientBuildsByStatus(): Promise<{ pending: number; building: number; completed: number; failed: number; - cancelled: number; }> { const empty = { pending: 0, building: 0, completed: 0, failed: 0, - cancelled: 0, }; try { - const rows = await this.nexusBuildRepository + const rows = await this.customClientBuildRepository .createQueryBuilder('build') .select('build.status', 'status') .addSelect('COUNT(*)', 'count') @@ -599,7 +591,6 @@ export class UpdateCheckService implements OnModuleInit { building: map.get('building') ?? 0, completed: map.get('completed') ?? 0, failed: map.get('failed') ?? 0, - cancelled: map.get('cancelled') ?? 0, }; } catch { return empty;