From 106c2c176a26ab243e3fd67df19e29c64e05e6f6 Mon Sep 17 00:00:00 2001 From: hashbk Date: Sun, 1 Nov 2026 12:36:32 +0800 Subject: [PATCH 01/13] feat(custom-client): replace Nexus with GitHub Actions-based custom client builder Drop the Nexus module that proxied custom client builds through a third-party service and introduce a new custom-client module that triggers a workflow_dispatch on a user-configured generator repository. The generator workflow builds the client and POSTs artifacts back to a public callback endpoint authenticated by a per-build token. GitHub token and generator repo/ref are stored in system_settings and managed via API (no env vars). A migration drops the legacy nexus tables and creates custom_client_builds. update-check statistics now read from custom_client_builds. Only the console side is covered here; the matching generator workflow lives in rustdesk-generator. --- src/app.module.ts | 4 +- src/common/utils/data-dir.util.ts | 8 +- src/database/data-source-options.ts | 2 + src/database/entities.ts | 6 +- src/database/migration-manager.spec.ts | 10 +- .../mysql-migration.integration.spec.ts | 7 +- ...0000-ReplaceNexusWithCustomClientBuilds.ts | 49 ++ .../custom-client/custom-client.controller.ts | 119 ++++ .../custom-client/custom-client.module.ts | 19 + .../custom-client/custom-client.service.ts | 228 +++++++ .../dto/custom-client-build.dto.ts} | 38 +- .../dto/custom-client-settings.dto.ts | 21 + .../entities/custom-client-build.entity.ts | 52 ++ .../custom-client-settings.service.ts | 89 +++ src/modules/nexus/dto/nexus-auth.dto.ts | 30 - .../nexus/entities/nexus-build.entity.ts | 62 -- .../nexus/entities/nexus-token.entity.ts | 47 -- src/modules/nexus/nexus.controller.ts | 117 ---- src/modules/nexus/nexus.module.ts | 18 - src/modules/nexus/nexus.service.ts | 562 ------------------ .../update-check/dto/update-check.dto.ts | 4 +- .../update-check/update-check.module.ts | 6 +- .../update-check/update-check.service.ts | 33 +- 23 files changed, 626 insertions(+), 905 deletions(-) create mode 100644 src/migrations/1791212800000-ReplaceNexusWithCustomClientBuilds.ts create mode 100644 src/modules/custom-client/custom-client.controller.ts create mode 100644 src/modules/custom-client/custom-client.module.ts create mode 100644 src/modules/custom-client/custom-client.service.ts rename src/modules/{nexus/dto/nexus-client.dto.ts => custom-client/dto/custom-client-build.dto.ts} (65%) create mode 100644 src/modules/custom-client/dto/custom-client-settings.dto.ts create mode 100644 src/modules/custom-client/entities/custom-client-build.entity.ts create mode 100644 src/modules/custom-client/services/custom-client-settings.service.ts delete mode 100644 src/modules/nexus/dto/nexus-auth.dto.ts delete mode 100644 src/modules/nexus/entities/nexus-build.entity.ts delete mode 100644 src/modules/nexus/entities/nexus-token.entity.ts delete mode 100644 src/modules/nexus/nexus.controller.ts delete mode 100644 src/modules/nexus/nexus.module.ts delete mode 100644 src/modules/nexus/nexus.service.ts 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..60708112 100644 --- a/src/common/utils/data-dir.util.ts +++ b/src/common/utils/data-dir.util.ts @@ -2,7 +2,7 @@ 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 AVATAR_SUBDIR = 'avatars'; export type DbType = 'sqlite' | 'mysql'; @@ -38,10 +38,10 @@ export function getDbPath(): string { } /** - * Get the Nexus build artifact storage directory. + * Get the custom client 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); } /** 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..a934b52b 100644 --- a/src/database/mysql-migration.integration.spec.ts +++ b/src/database/mysql-migration.integration.spec.ts @@ -80,7 +80,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 +117,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 +149,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 +173,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..efd36875 --- /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" varchar, "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\` varchar(255) 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..ee93965b --- /dev/null +++ b/src/modules/custom-client/custom-client.controller.ts @@ -0,0 +1,119 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Res, + Req, + HttpCode, + HttpStatus, + NotFoundException, + UseGuards, + UseInterceptors, + UploadedFiles, +} from '@nestjs/common'; +import { FilesInterceptor } 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 { 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, + ) {} + + @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') + listBuildFiles(@Param('uuid') uuid: string) { + return this.customClientService.listBuildFiles(uuid); + } + + @Get('builds/:uuid/files/:filename') + downloadBuildFile( + @Param('uuid') uuid: string, + @Param('filename') filename: string, + @Res() res: Response, + ) { + const filePath = this.customClientService.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); + } + + @Post('builds/:uuid/callback') + @Public() + @UseInterceptors(FilesInterceptor('files')) + @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('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..1735c911 --- /dev/null +++ b/src/modules/custom-client/custom-client.module.ts @@ -0,0 +1,19 @@ +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 { 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], + 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..a638d50f --- /dev/null +++ b/src/modules/custom-client/custom-client.service.ts @@ -0,0 +1,228 @@ +import { businessWritesAllowed } from '../../updater/maintenance'; +import { + Injectable, + Logger, + BadRequestException, + UnauthorizedException, + NotFoundException, + InternalServerErrorException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { randomUUID } from 'crypto'; +import { + existsSync, + readdirSync, + mkdirSync, + writeFileSync, + createReadStream, +} 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 { GeneralSettingsService } from '../settings/services/general-settings.service'; +import { + SubmitBuildDto, + SubmitBuildResponse, +} from './dto/custom-client-build.dto'; + +const GITHUB_API = 'https://api.github.com'; +const WORKFLOW_FILE = 'custom-build.yml'; +const CALLBACK_TIMEOUT_MS = 10_000; + +interface GithubDispatchInputs { + 'build-id': string; + 'callback-url': string; + 'callback-token': string; + os: string; + arch: string; + 'custom-json': string; +} + +@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 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 callbackUrl = `${site.effectiveBackendUrl}/api/custom-client/builds/{uuid}/callback`; + + const uuid = randomUUID(); + const callbackToken = randomUUID(); + const callbackUrlResolved = callbackUrl.replace('{uuid}', uuid); + + const inputs: GithubDispatchInputs = { + 'build-id': uuid, + 'callback-url': callbackUrlResolved, + 'callback-token': callbackToken, + os: dto.os, + arch: dto.arch, + 'custom-json': dto.custom ? JSON.stringify(dto.custom) : '', + }; + + const dispatchUrl = `${GITHUB_API}/repos/${settings.generatorRepo}/actions/workflows/${WORKFLOW_FILE}/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': '2022-11-28', + }, + body: JSON.stringify({ ref: settings.generatorRef, inputs }), + signal: AbortSignal.timeout(CALLBACK_TIMEOUT_MS), + }); + + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedException( + 'GitHub token is invalid or lacks workflow permission', + ); + } + if (response.status === 404) { + throw new NotFoundException( + `Workflow or repository not found: ${settings.generatorRepo}`, + ); + } + if (response.status === 422) { + const body = await response.text(); + throw new BadRequestException( + `GitHub rejected the dispatch request: ${body}`, + ); + } + if (!response.ok) { + this.logger.error( + `GitHub dispatch failed: ${response.status} ${await response.text()}`, + ); + throw new InternalServerErrorException( + 'Failed to trigger build workflow', + ); + } + + const build = this.buildRepository.create({ + uuid, + userGuid, + os: dto.os, + arch: dto.arch, + custom: dto.custom ? JSON.stringify(dto.custom) : undefined, + status: 'pending', + callbackToken, + }); + await this.buildRepository.save(build); + + 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 }); + } + + listBuildFiles(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; + } + }); + } + + getLocalFilePath(uuid: string, filename: string): string { + return this.safeJoin(uuid, 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'); + } + if (build.callbackToken !== callbackToken) { + throw new UnauthorizedException('Invalid callback token'); + } + + 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); + } + } + + await this.buildRepository.update( + { uuid }, + { + status: status as CustomClientBuild['status'], + message: message ?? undefined, + files: savedNames.length > 0 ? JSON.stringify(savedNames) : undefined, + }, + ); + + this.logger.log(`Callback processed for build ${uuid}: ${status}`); + } + + 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 65% rename from src/modules/nexus/dto/nexus-client.dto.ts rename to src/modules/custom-client/dto/custom-client-build.dto.ts index abd283c9..9d3e741a 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,21 @@ export class NexusCustomDto { 'default-settings'?: Record; } -/** Submit build request DTO */ -export class NexusGenerateDto { - @IsIn(['windows']) +export class SubmitBuildDto { + @IsIn(['windows', 'linux', 'macos', 'android', 'ios']) os: string; - @IsIn(['x86_64', 'aarch64', 'x86']) + @IsIn(['x86_64', 'aarch64', 'x86', 'armv7']) 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; -} 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..b8125d1c --- /dev/null +++ b/src/modules/custom-client/dto/custom-client-settings.dto.ts @@ -0,0 +1,21 @@ +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; +} + +export interface CustomClientSettingsResponse { + githubToken: string; + generatorRepo: string; + generatorRef: 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..63ccb4a5 --- /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({ 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-settings.service.ts b/src/modules/custom-client/services/custom-client-settings.service.ts new file mode 100644 index 00000000..9a02d355 --- /dev/null +++ b/src/modules/custom-client/services/custom-client-settings.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +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 DEFAULT_GENERATOR_REPO = 'databk/rustdesk-generator'; +const DEFAULT_GENERATOR_REF = 'main'; +const TOKEN_MASK = '******'; + +@Injectable() +export class CustomClientSettingsService { + 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); + return { + githubToken: token ? TOKEN_MASK : '', + generatorRepo: repo ?? DEFAULT_GENERATOR_REPO, + generatorRef: ref ?? DEFAULT_GENERATOR_REF, + }; + } + + 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 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); + } + 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/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; From 14332af991d3d3e1fc6640025e268d6a12df7cbb Mon Sep 17 00:00:00 2001 From: hashbk Date: Sun, 1 Nov 2026 13:10:30 +0800 Subject: [PATCH 02/13] feat(custom-client): dispatch per-platform workflows instead of custom-build entry Map os/arch to the matching reusable workflow file (windows-x86_64.yml, linux-aarch64.yml, etc.) and drop the custom-build.yml entry point. Each platform workflow now carries its own workflow_dispatch trigger and notify-console callback job, so the console triggers the target workflow directly. --- .../custom-client/custom-client.service.ts | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/modules/custom-client/custom-client.service.ts b/src/modules/custom-client/custom-client.service.ts index a638d50f..b3823e39 100644 --- a/src/modules/custom-client/custom-client.service.ts +++ b/src/modules/custom-client/custom-client.service.ts @@ -28,18 +28,38 @@ import { } from './dto/custom-client-build.dto'; const GITHUB_API = 'https://api.github.com'; -const WORKFLOW_FILE = 'custom-build.yml'; const CALLBACK_TIMEOUT_MS = 10_000; interface GithubDispatchInputs { 'build-id': string; 'callback-url': string; 'callback-token': string; - os: string; - arch: string; 'custom-json': string; } +function resolveWorkflowFile(os: string, arch: string): string { + 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', + '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); @@ -78,16 +98,15 @@ export class CustomClientService { const callbackToken = randomUUID(); const callbackUrlResolved = callbackUrl.replace('{uuid}', uuid); + const workflowFile = resolveWorkflowFile(dto.os, dto.arch); const inputs: GithubDispatchInputs = { 'build-id': uuid, 'callback-url': callbackUrlResolved, 'callback-token': callbackToken, - os: dto.os, - arch: dto.arch, 'custom-json': dto.custom ? JSON.stringify(dto.custom) : '', }; - const dispatchUrl = `${GITHUB_API}/repos/${settings.generatorRepo}/actions/workflows/${WORKFLOW_FILE}/dispatches`; + const dispatchUrl = `${GITHUB_API}/repos/${settings.generatorRepo}/actions/workflows/${workflowFile}/dispatches`; const response = await fetch(dispatchUrl, { method: 'POST', headers: { From ce68db72ca6caa2388f4317a0db88516308a2d0b Mon Sep 17 00:00:00 2001 From: hashbk Date: Sun, 1 Nov 2026 13:33:25 +0800 Subject: [PATCH 03/13] feat(custom-client): support all-platform builds via flutter-build workflow Allow os='all' to dispatch the aggregated flutter-build workflow that builds every platform in parallel. arch becomes optional for all-platform builds and is stored as 'all' in that case. --- src/modules/custom-client/custom-client.service.ts | 12 ++++++++++-- .../custom-client/dto/custom-client-build.dto.ts | 5 +++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/modules/custom-client/custom-client.service.ts b/src/modules/custom-client/custom-client.service.ts index b3823e39..faf195e2 100644 --- a/src/modules/custom-client/custom-client.service.ts +++ b/src/modules/custom-client/custom-client.service.ts @@ -37,7 +37,15 @@ interface GithubDispatchInputs { 'custom-json': string; } -function resolveWorkflowFile(os: string, arch: string): 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', @@ -148,7 +156,7 @@ export class CustomClientService { uuid, userGuid, os: dto.os, - arch: dto.arch, + arch: dto.os === 'all' ? 'all' : (dto.arch ?? ''), custom: dto.custom ? JSON.stringify(dto.custom) : undefined, status: 'pending', callbackToken, diff --git a/src/modules/custom-client/dto/custom-client-build.dto.ts b/src/modules/custom-client/dto/custom-client-build.dto.ts index 9d3e741a..139e870f 100644 --- a/src/modules/custom-client/dto/custom-client-build.dto.ts +++ b/src/modules/custom-client/dto/custom-client-build.dto.ts @@ -54,11 +54,12 @@ export class CustomClientCustomDto { } export class SubmitBuildDto { - @IsIn(['windows', 'linux', 'macos', 'android', 'ios']) + @IsIn(['windows', 'linux', 'macos', 'android', 'ios', 'all']) os: string; + @IsOptional() @IsIn(['x86_64', 'aarch64', 'x86', 'armv7']) - arch: string; + arch?: string; @IsOptional() @ValidateNested() From 434ba716b9aead4d564c65ef68e26138514c47e9 Mon Sep 17 00:00:00 2001 From: hashbk Date: Sun, 1 Nov 2026 13:43:38 +0800 Subject: [PATCH 04/13] fix(custom-client): register migration in mysql integration spec The mysql-migration.integration.spec.ts maintains its own migrations array; the new migration was missing from it, causing CI to run only five migrations instead of six. --- src/database/mysql-migration.integration.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/database/mysql-migration.integration.spec.ts b/src/database/mysql-migration.integration.spec.ts index a934b52b..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 = [ From 5efa882a452e8827bad8846de1ce7252048f1787 Mon Sep 17 00:00:00 2001 From: hashbk Date: Sun, 1 Nov 2026 14:27:07 +0800 Subject: [PATCH 05/13] feat(custom-client): expose build progress via GitHub runs API Bump the GitHub API version to 2026-03-10 so workflow_dispatch returns the workflow_run_id directly; store it on the build record. Add a GET /builds/:uuid/progress endpoint that proxies GitHub's runs/{id} and runs/{id}/jobs APIs to return the current job/step status. --- .../custom-client/custom-client.controller.ts | 5 ++ .../custom-client/custom-client.service.ts | 90 ++++++++++++++++++- .../dto/custom-client-build.dto.ts | 20 +++++ 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/modules/custom-client/custom-client.controller.ts b/src/modules/custom-client/custom-client.controller.ts index ee93965b..9b03d29b 100644 --- a/src/modules/custom-client/custom-client.controller.ts +++ b/src/modules/custom-client/custom-client.controller.ts @@ -83,6 +83,11 @@ export class CustomClientController { stream.pipe(res); } + @Get('builds/:uuid/progress') + async getBuildProgress(@Param('uuid') uuid: string) { + return this.customClientService.getBuildProgress(uuid); + } + @Post('builds/:uuid/callback') @Public() @UseInterceptors(FilesInterceptor('files')) diff --git a/src/modules/custom-client/custom-client.service.ts b/src/modules/custom-client/custom-client.service.ts index faf195e2..10939615 100644 --- a/src/modules/custom-client/custom-client.service.ts +++ b/src/modules/custom-client/custom-client.service.ts @@ -25,6 +25,7 @@ import { GeneralSettingsService } from '../settings/services/general-settings.se import { SubmitBuildDto, SubmitBuildResponse, + BuildProgressResponse, } from './dto/custom-client-build.dto'; const GITHUB_API = 'https://api.github.com'; @@ -121,7 +122,7 @@ export class CustomClientService { Authorization: `Bearer ${settings.githubToken}`, Accept: 'application/vnd.github+json', 'Content-Type': 'application/json', - 'X-GitHub-Api-Version': '2022-11-28', + 'X-GitHub-Api-Version': '2026-03-10', }, body: JSON.stringify({ ref: settings.generatorRef, inputs }), signal: AbortSignal.timeout(CALLBACK_TIMEOUT_MS), @@ -152,6 +153,13 @@ export class CustomClientService { ); } + const dispatchBody = (await response.json()) as { + workflow_run_id?: number; + }; + const githubRunId = dispatchBody.workflow_run_id + ? String(dispatchBody.workflow_run_id) + : undefined; + const build = this.buildRepository.create({ uuid, userGuid, @@ -160,6 +168,7 @@ export class CustomClientService { custom: dto.custom ? JSON.stringify(dto.custom) : undefined, status: 'pending', callbackToken, + githubRunId, }); await this.buildRepository.save(build); @@ -244,6 +253,85 @@ export class CustomClientService { this.logger.log(`Callback processed for build ${uuid}: ${status}`); } + async getBuildProgress(uuid: string): Promise { + const build = await this.buildRepository.findOne({ where: { uuid } }); + if (!build) { + throw new NotFoundException('Build record not found'); + } + + 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 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); diff --git a/src/modules/custom-client/dto/custom-client-build.dto.ts b/src/modules/custom-client/dto/custom-client-build.dto.ts index 139e870f..2e85677b 100644 --- a/src/modules/custom-client/dto/custom-client-build.dto.ts +++ b/src/modules/custom-client/dto/custom-client-build.dto.ts @@ -72,3 +72,23 @@ export interface SubmitBuildResponse { status: 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[]; +} From f6916c637e44a7a82a0ae6c8b7451a6c9a7a0d1e Mon Sep 17 00:00:00 2001 From: hashbk Date: Sun, 1 Nov 2026 16:19:44 +0800 Subject: [PATCH 06/13] fix(custom-client): address CodeRabbit review comments - Check build ownership for listBuildFiles, downloadBuildFile, getBuildProgress - Add upload size limits (200MB/file, 10 files) on public callback route - Map GitHub 401/403 to BadGatewayException instead of UnauthorizedException - Delete artifact directory on disk when deleting a build record - Fix file descriptor leak in listBuildFiles (use statSync instead of createReadStream) - Validate callback status, reject callbacks for terminal builds, use timingSafeEqual for token, invalidate token after callback - Widen message column to text type in entity and migration --- ...0000-ReplaceNexusWithCustomClientBuilds.ts | 2 +- .../custom-client/custom-client.controller.ts | 29 ++++++-- .../custom-client/custom-client.service.ts | 66 +++++++++++++++---- .../entities/custom-client-build.entity.ts | 2 +- 4 files changed, 78 insertions(+), 21 deletions(-) diff --git a/src/migrations/1791212800000-ReplaceNexusWithCustomClientBuilds.ts b/src/migrations/1791212800000-ReplaceNexusWithCustomClientBuilds.ts index efd36875..8ef0eb74 100644 --- a/src/migrations/1791212800000-ReplaceNexusWithCustomClientBuilds.ts +++ b/src/migrations/1791212800000-ReplaceNexusWithCustomClientBuilds.ts @@ -20,7 +20,7 @@ export class ReplaceNexusWithCustomClientBuilds1791212800000 implements Migratio 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" varchar, "files" text, "callbackToken" varchar NOT NULL, "githubRunId" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`, + `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") `, diff --git a/src/modules/custom-client/custom-client.controller.ts b/src/modules/custom-client/custom-client.controller.ts index 9b03d29b..a9226a7b 100644 --- a/src/modules/custom-client/custom-client.controller.ts +++ b/src/modules/custom-client/custom-client.controller.ts @@ -58,17 +58,25 @@ export class CustomClientController { } @Get('builds/:uuid/files') - listBuildFiles(@Param('uuid') uuid: string) { - return this.customClientService.listBuildFiles(uuid); + async listBuildFiles( + @CurrentUser('id') userGuid: string, + @Param('uuid') uuid: string, + ) { + return this.customClientService.listBuildFiles(uuid, userGuid); } @Get('builds/:uuid/files/:filename') - downloadBuildFile( + async downloadBuildFile( + @CurrentUser('id') userGuid: string, @Param('uuid') uuid: string, @Param('filename') filename: string, @Res() res: Response, ) { - const filePath = this.customClientService.getLocalFilePath(uuid, filename); + const filePath = await this.customClientService.getLocalFilePath( + uuid, + filename, + userGuid, + ); if (!existsSync(filePath)) { throw new NotFoundException('File not found'); } @@ -84,13 +92,20 @@ export class CustomClientController { } @Get('builds/:uuid/progress') - async getBuildProgress(@Param('uuid') uuid: string) { - return this.customClientService.getBuildProgress(uuid); + async getBuildProgress( + @CurrentUser('id') userGuid: string, + @Param('uuid') uuid: string, + ) { + return this.customClientService.getBuildProgress(uuid, userGuid); } @Post('builds/:uuid/callback') @Public() - @UseInterceptors(FilesInterceptor('files')) + @UseInterceptors( + FilesInterceptor('files', 10, { + limits: { fileSize: 200 * 1024 * 1024, files: 10 }, + }), + ) @HttpCode(HttpStatus.OK) async handleCallback( @Param('uuid') uuid: string, diff --git a/src/modules/custom-client/custom-client.service.ts b/src/modules/custom-client/custom-client.service.ts index 10939615..6477af02 100644 --- a/src/modules/custom-client/custom-client.service.ts +++ b/src/modules/custom-client/custom-client.service.ts @@ -3,19 +3,21 @@ import { Injectable, Logger, BadRequestException, + BadGatewayException, UnauthorizedException, NotFoundException, InternalServerErrorException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { randomUUID } from 'crypto'; +import { randomUUID, timingSafeEqual } from 'crypto'; import { existsSync, readdirSync, mkdirSync, writeFileSync, - createReadStream, + rmSync, + statSync, } from 'fs'; import { resolve, relative } from 'path'; import { getCustomClientStoragePath } from '../../common/utils/data-dir.util'; @@ -30,6 +32,11 @@ import { 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; @@ -129,7 +136,7 @@ export class CustomClientService { }); if (response.status === 401 || response.status === 403) { - throw new UnauthorizedException( + throw new BadGatewayException( 'GitHub token is invalid or lacks workflow permission', ); } @@ -195,23 +202,30 @@ export class CustomClientService { ); } await this.buildRepository.delete({ uuid }); + rmSync(this.safeJoin(uuid), { recursive: true, force: true }); } - listBuildFiles(uuid: string): string[] { + 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 !createReadStream(this.safeJoin(uuid, f)).destroyed; + return statSync(this.safeJoin(uuid, f)).isFile(); } catch { return false; } }); } - getLocalFilePath(uuid: string, filename: string): string { + async getLocalFilePath( + uuid: string, + filename: string, + userGuid: string, + ): Promise { + await this.findOwnedBuild(uuid, userGuid); return this.safeJoin(uuid, filename); } @@ -226,9 +240,23 @@ export class CustomClientService { if (!build) { throw new NotFoundException('Build record not found'); } - if (build.callbackToken !== callbackToken) { + if ( + build.callbackToken.length !== callbackToken.length || + !timingSafeEqual( + Buffer.from(build.callbackToken), + Buffer.from(callbackToken), + ) + ) { throw new UnauthorizedException('Invalid callback token'); } + 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) { @@ -247,17 +275,18 @@ export class CustomClientService { status: status as CustomClientBuild['status'], message: message ?? undefined, files: savedNames.length > 0 ? JSON.stringify(savedNames) : undefined, + callbackToken: randomUUID(), }, ); this.logger.log(`Callback processed for build ${uuid}: ${status}`); } - async getBuildProgress(uuid: string): Promise { - const build = await this.buildRepository.findOne({ where: { uuid } }); - if (!build) { - throw new NotFoundException('Build record not found'); - } + async getBuildProgress( + uuid: string, + userGuid: string, + ): Promise { + const build = await this.findOwnedBuild(uuid, userGuid); if (!build.githubRunId) { return { @@ -315,6 +344,19 @@ export class CustomClientService { }; } + 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 async githubFetch(url: string, token: string): Promise { const response = await fetch(url, { headers: { diff --git a/src/modules/custom-client/entities/custom-client-build.entity.ts b/src/modules/custom-client/entities/custom-client-build.entity.ts index 63ccb4a5..d52cb2ef 100644 --- a/src/modules/custom-client/entities/custom-client-build.entity.ts +++ b/src/modules/custom-client/entities/custom-client-build.entity.ts @@ -32,7 +32,7 @@ export class CustomClientBuild { @Index('IDX_custom_client_builds_status', ['status']) status: CustomClientBuildStatus; - @Column({ nullable: true }) + @Column({ type: 'text', nullable: true }) message: string; @Column({ type: 'text', nullable: true }) From 2546cc8cf7049eac0a98a655606a9d1d72e07c35 Mon Sep 17 00:00:00 2001 From: hashbk Date: Fri, 6 Nov 2026 23:28:45 +0800 Subject: [PATCH 07/13] fix(migration): change MySQL message column from varchar(255) to text Fixes MySQL migration drift test failure where the entity defines message as text but the migration created it as varchar(255). --- .../1791212800000-ReplaceNexusWithCustomClientBuilds.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/migrations/1791212800000-ReplaceNexusWithCustomClientBuilds.ts b/src/migrations/1791212800000-ReplaceNexusWithCustomClientBuilds.ts index 8ef0eb74..df68f3d2 100644 --- a/src/migrations/1791212800000-ReplaceNexusWithCustomClientBuilds.ts +++ b/src/migrations/1791212800000-ReplaceNexusWithCustomClientBuilds.ts @@ -33,7 +33,7 @@ export class ReplaceNexusWithCustomClientBuilds1791212800000 implements Migratio 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\` varchar(255) 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`, + `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; } From e3bb05f6ec63267db98fb9378f2e1a113bd0ad33 Mon Sep 17 00:00:00 2001 From: hashbk Date: Sat, 7 Nov 2026 20:58:55 +0800 Subject: [PATCH 08/13] fix(custom-client): address CodeRabbit review findings - Reject directories in downloadBuildFile and handle stream errors - Persist build record before dispatching workflow to prevent lost callbacks - Compare callback-token byte lengths to prevent timingSafeEqual throw - Keep callback token stable for non-terminal callbacks (only rotate on completed/failed) --- .../custom-client/custom-client.controller.ts | 12 ++++++ .../custom-client/custom-client.service.ts | 42 +++++++++++-------- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/modules/custom-client/custom-client.controller.ts b/src/modules/custom-client/custom-client.controller.ts index a9226a7b..a20cf138 100644 --- a/src/modules/custom-client/custom-client.controller.ts +++ b/src/modules/custom-client/custom-client.controller.ts @@ -81,6 +81,9 @@ export class CustomClientController { 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', @@ -88,6 +91,15 @@ export class CustomClientController { ); 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); } diff --git a/src/modules/custom-client/custom-client.service.ts b/src/modules/custom-client/custom-client.service.ts index 6477af02..752c42e1 100644 --- a/src/modules/custom-client/custom-client.service.ts +++ b/src/modules/custom-client/custom-client.service.ts @@ -114,6 +114,17 @@ export class CustomClientService { const callbackToken = randomUUID(); const callbackUrlResolved = callbackUrl.replace('{uuid}', 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, @@ -136,17 +147,20 @@ export class CustomClientService { }); 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}`, ); @@ -155,6 +169,7 @@ export class CustomClientService { this.logger.error( `GitHub dispatch failed: ${response.status} ${await response.text()}`, ); + await this.buildRepository.delete({ uuid }); throw new InternalServerErrorException( 'Failed to trigger build workflow', ); @@ -167,17 +182,9 @@ export class CustomClientService { ? String(dispatchBody.workflow_run_id) : undefined; - 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, - githubRunId, - }); - await this.buildRepository.save(build); + if (githubRunId) { + await this.buildRepository.update({ uuid }, { githubRunId }); + } return { uuid, status: 'pending', message: 'Build submitted' }; } @@ -240,12 +247,11 @@ export class CustomClientService { if (!build) { throw new NotFoundException('Build record not found'); } + const tokenBuffer = Buffer.from(callbackToken); + const expectedBuffer = Buffer.from(build.callbackToken); if ( - build.callbackToken.length !== callbackToken.length || - !timingSafeEqual( - Buffer.from(build.callbackToken), - Buffer.from(callbackToken), - ) + tokenBuffer.length !== expectedBuffer.length || + !timingSafeEqual(tokenBuffer, expectedBuffer) ) { throw new UnauthorizedException('Invalid callback token'); } @@ -269,13 +275,15 @@ export class CustomClientService { } } + 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: randomUUID(), + callbackToken: isTerminalStatus ? randomUUID() : undefined, }, ); From 83f069622402e223b773ad03e24eca9ec132ba76 Mon Sep 17 00:00:00 2001 From: hashbk Date: Sat, 7 Nov 2026 22:09:29 +0800 Subject: [PATCH 09/13] feat(custom-client): add custom.txt signing, logo upload, and build config API - Add Ed25519 key pair generation for custom.txt signing (auto-generated on first use, stored in system_settings) - Add CustomClientSigningService to produce libsodium-compatible signed custom.txt content - Add CustomClientLogoService for logo/icon file storage (logo.png, logo_light.png, logo_dark.png, icon.png, icon.ico) - Add admin-only logo upload/delete endpoints under /api/custom-client/logos/* - Add public build config endpoint GET /api/custom-client/builds/:uuid/config (callbackToken auth) returning signed custom.txt, public key, and available logos - Add public build logo endpoint GET /api/custom-client/builds/:uuid/logo/:filename (callbackToken auth) - Pass config-url as workflow dispatch input so the generator can fetch custom.txt and logos during build - Extend settings DTO with signingPublicKey/signingPrivateKey fields --- src/common/utils/data-dir.util.ts | 25 ++---- .../custom-client/custom-client.controller.ts | 84 +++++++++++++++++- .../custom-client/custom-client.module.ts | 9 +- .../custom-client/custom-client.service.ts | 62 +++++++++++++ .../dto/custom-client-settings.dto.ts | 10 +++ .../services/custom-client-logo.service.ts | 87 +++++++++++++++++++ .../custom-client-settings.service.ts | 44 ++++++++++ .../services/custom-client-signing.service.ts | 67 ++++++++++++++ 8 files changed, 366 insertions(+), 22 deletions(-) create mode 100644 src/modules/custom-client/services/custom-client-logo.service.ts create mode 100644 src/modules/custom-client/services/custom-client-signing.service.ts diff --git a/src/common/utils/data-dir.util.ts b/src/common/utils/data-dir.util.ts index 60708112..cae28d85 100644 --- a/src/common/utils/data-dir.util.ts +++ b/src/common/utils/data-dir.util.ts @@ -3,24 +3,15 @@ import * as path from 'path'; const DEFAULT_DATA_DIR = './data'; const DB_FILENAME = 'rustdesk-console.db'; 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 custom client build artifact storage directory. - */ export function getCustomClientStoragePath(): string { return path.join(getDataDir(), CUSTOM_CLIENT_BUILD_SUBDIR); } -/** - * Get the avatar storage directory. - */ +export function getCustomClientLogoPath(): string { + return path.join(getDataDir(), CUSTOM_CLIENT_LOGO_SUBDIR); +} + export function getAvatarDir(): string { return path.join(getDataDir(), AVATAR_SUBDIR); } diff --git a/src/modules/custom-client/custom-client.controller.ts b/src/modules/custom-client/custom-client.controller.ts index a20cf138..44af4c17 100644 --- a/src/modules/custom-client/custom-client.controller.ts +++ b/src/modules/custom-client/custom-client.controller.ts @@ -14,13 +14,15 @@ import { UseGuards, UseInterceptors, UploadedFiles, + BadRequestException, } from '@nestjs/common'; -import { FilesInterceptor } from '@nestjs/platform-express'; +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'; @@ -32,6 +34,7 @@ export class CustomClientController { constructor( private readonly customClientService: CustomClientService, private readonly settingsService: CustomClientSettingsService, + private readonly logoService: CustomClientLogoService, ) {} @Post('builds') @@ -111,6 +114,53 @@ export class CustomClientController { 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( @@ -136,6 +186,38 @@ export class CustomClientController { 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, + @UploadedFiles() 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() { diff --git a/src/modules/custom-client/custom-client.module.ts b/src/modules/custom-client/custom-client.module.ts index 1735c911..13decd16 100644 --- a/src/modules/custom-client/custom-client.module.ts +++ b/src/modules/custom-client/custom-client.module.ts @@ -4,6 +4,8 @@ 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'; @@ -13,7 +15,12 @@ import { SettingsModule } from '../settings/settings.module'; SettingsModule, ], controllers: [CustomClientController], - providers: [CustomClientService, CustomClientSettingsService], + 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 index 752c42e1..61f8fc2d 100644 --- a/src/modules/custom-client/custom-client.service.ts +++ b/src/modules/custom-client/custom-client.service.ts @@ -23,11 +23,14 @@ 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'; @@ -42,6 +45,7 @@ interface GithubDispatchInputs { 'build-id': string; 'callback-url': string; 'callback-token': string; + 'config-url': string; 'custom-json': string; } @@ -85,6 +89,8 @@ export class CustomClientService { @InjectRepository(CustomClientBuild) private buildRepository: Repository, private readonly settingsService: CustomClientSettingsService, + private readonly signingService: CustomClientSigningService, + private readonly logoService: CustomClientLogoService, private readonly generalSettingsService: GeneralSettingsService, ) { this.storagePath = getCustomClientStoragePath(); @@ -109,10 +115,12 @@ export class CustomClientService { const site = await this.generalSettingsService.getSiteSettings(); const callbackUrl = `${site.effectiveBackendUrl}/api/custom-client/builds/{uuid}/callback`; + const configUrl = `${site.effectiveBackendUrl}/api/custom-client/builds/{uuid}/config`; const uuid = randomUUID(); const callbackToken = randomUUID(); const callbackUrlResolved = callbackUrl.replace('{uuid}', uuid); + const configUrlResolved = configUrl.replace('{uuid}', uuid); const build = this.buildRepository.create({ uuid, @@ -130,6 +138,7 @@ export class CustomClientService { 'build-id': uuid, 'callback-url': callbackUrlResolved, 'callback-token': callbackToken, + 'config-url': configUrlResolved, 'custom-json': dto.custom ? JSON.stringify(dto.custom) : '', }; @@ -236,6 +245,59 @@ export class CustomClientService { 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'); + } + 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'); + } + + 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'); + } + 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'); + } + + return this.logoService.getLogoPath(filename); + } + async handleCallback( uuid: string, callbackToken: string, diff --git a/src/modules/custom-client/dto/custom-client-settings.dto.ts b/src/modules/custom-client/dto/custom-client-settings.dto.ts index b8125d1c..a0625a65 100644 --- a/src/modules/custom-client/dto/custom-client-settings.dto.ts +++ b/src/modules/custom-client/dto/custom-client-settings.dto.ts @@ -12,10 +12,20 @@ export class UpdateCustomClientSettingsDto { @IsOptional() @IsString() generatorRef?: string; + + @IsOptional() + @IsString() + signingPublicKey?: string; + + @IsOptional() + @IsString() + signingPrivateKey?: string; } export interface CustomClientSettingsResponse { githubToken: string; generatorRepo: string; generatorRef: string; + signingPublicKey: string; + signingPrivateKey: string; } 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 index 9a02d355..b79d167c 100644 --- a/src/modules/custom-client/services/custom-client-settings.service.ts +++ b/src/modules/custom-client/services/custom-client-settings.service.ts @@ -1,6 +1,7 @@ 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, @@ -11,10 +12,24 @@ 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' }); + const rawPriv = privDer.subarray(-32); + return { + publicKey: rawPub.toString('base64'), + privateKey: rawPriv.toString('base64'), + }; +} + @Injectable() export class CustomClientSettingsService { constructor( @@ -26,10 +41,14 @@ export class CustomClientSettingsService { 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 : '', }; } @@ -48,6 +67,22 @@ export class CustomClientSettingsService { }; } + async getSigningKeys(): Promise<{ + publicKey: string; + privateKey: string; + }> { + 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 }; + } + async updateSettings( dto: UpdateCustomClientSettingsDto, ): Promise { @@ -60,6 +95,15 @@ export class CustomClientSettingsService { 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); + } + if ( + dto.signingPrivateKey !== undefined && + dto.signingPrivateKey !== TOKEN_MASK + ) { + await this.write(SIGNING_PRIVATE_KEY, dto.signingPrivateKey, true); + } return this.getSettings(); } 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; + } +} From 1d256c908802be0be0f8b46011c34ca51b0cc0fe Mon Sep 17 00:00:00 2001 From: hashbk Date: Sun, 8 Nov 2026 08:18:46 +0800 Subject: [PATCH 10/13] fix(custom-client): address CodeRabbit review findings - Fix uploadLogo to use @UploadedFile instead of @UploadedFiles (matches FileInterceptor) - Store full PKCS#8 DER for Ed25519 private key instead of raw 32-byte seed - Remove custom-json from GitHub dispatch inputs to prevent password/salt exposure - Serialize signing key initialization with in-flight promise to prevent concurrent generation - Add base64 validation for signingPublicKey/signingPrivateKey in settings DTO - Extract assertCallbackToken helper to eliminate duplicated token-check logic --- .../custom-client/custom-client.controller.ts | 3 +- .../custom-client/custom-client.service.ts | 43 ++++++++----------- .../dto/custom-client-settings.dto.ts | 6 ++- .../custom-client-settings.service.ts | 37 +++++++++++----- 4 files changed, 49 insertions(+), 40 deletions(-) diff --git a/src/modules/custom-client/custom-client.controller.ts b/src/modules/custom-client/custom-client.controller.ts index 44af4c17..67fdfce2 100644 --- a/src/modules/custom-client/custom-client.controller.ts +++ b/src/modules/custom-client/custom-client.controller.ts @@ -14,6 +14,7 @@ import { UseGuards, UseInterceptors, UploadedFiles, + UploadedFile, BadRequestException, } from '@nestjs/common'; import { FilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; @@ -202,7 +203,7 @@ export class CustomClientController { @HttpCode(HttpStatus.OK) uploadLogo( @Param('filename') filename: string, - @UploadedFiles() file: Express.Multer.File, + @UploadedFile() file: Express.Multer.File, ) { if (!file) { throw new BadRequestException('No file uploaded'); diff --git a/src/modules/custom-client/custom-client.service.ts b/src/modules/custom-client/custom-client.service.ts index 61f8fc2d..0aecabb1 100644 --- a/src/modules/custom-client/custom-client.service.ts +++ b/src/modules/custom-client/custom-client.service.ts @@ -46,7 +46,6 @@ interface GithubDispatchInputs { 'callback-url': string; 'callback-token': string; 'config-url': string; - 'custom-json': string; } function resolveWorkflowFile(os: string, arch: string | undefined): string { @@ -139,7 +138,6 @@ export class CustomClientService { 'callback-url': callbackUrlResolved, 'callback-token': callbackToken, 'config-url': configUrlResolved, - 'custom-json': dto.custom ? JSON.stringify(dto.custom) : '', }; const dispatchUrl = `${GITHUB_API}/repos/${settings.generatorRepo}/actions/workflows/${workflowFile}/dispatches`; @@ -257,14 +255,7 @@ export class CustomClientService { if (!build) { throw new NotFoundException('Build record not found'); } - 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'); - } + this.assertCallbackToken(build, callbackToken); const customObj: CustomClientCustomDto | undefined = build.custom ? (JSON.parse(build.custom) as CustomClientCustomDto) @@ -286,14 +277,7 @@ export class CustomClientService { if (!build) { throw new NotFoundException('Build record not found'); } - 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'); - } + this.assertCallbackToken(build, callbackToken); return this.logoService.getLogoPath(filename); } @@ -309,14 +293,7 @@ export class CustomClientService { if (!build) { throw new NotFoundException('Build record not found'); } - 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'); - } + this.assertCallbackToken(build, callbackToken); if ( !ALLOWED_CALLBACK_STATUSES.includes(status as CustomClientBuild['status']) ) { @@ -427,6 +404,20 @@ export class CustomClientService { 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: { diff --git a/src/modules/custom-client/dto/custom-client-settings.dto.ts b/src/modules/custom-client/dto/custom-client-settings.dto.ts index a0625a65..0bf27684 100644 --- a/src/modules/custom-client/dto/custom-client-settings.dto.ts +++ b/src/modules/custom-client/dto/custom-client-settings.dto.ts @@ -14,11 +14,13 @@ export class UpdateCustomClientSettingsDto { generatorRef?: string; @IsOptional() - @IsString() + @Matches(/^[A-Za-z0-9+/]{43}=$/) signingPublicKey?: string; @IsOptional() - @IsString() + @Matches( + /^(?:\*{6}|(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?)$/, + ) signingPrivateKey?: string; } diff --git a/src/modules/custom-client/services/custom-client-settings.service.ts b/src/modules/custom-client/services/custom-client-settings.service.ts index b79d167c..11d39092 100644 --- a/src/modules/custom-client/services/custom-client-settings.service.ts +++ b/src/modules/custom-client/services/custom-client-settings.service.ts @@ -23,15 +23,19 @@ function generateEd25519KeyPair(): { publicKey: string; privateKey: string } { const pubDer = publicKey.export({ type: 'spki', format: 'der' }); const rawPub = pubDer.subarray(-32); const privDer = privateKey.export({ type: 'pkcs8', format: 'der' }); - const rawPriv = privDer.subarray(-32); return { publicKey: rawPub.toString('base64'), - privateKey: rawPriv.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, @@ -71,16 +75,27 @@ export class CustomClientSettingsService { publicKey: string; privateKey: string; }> { - 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); + 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; } - return { publicKey: pubKey, privateKey: privKey }; } async updateSettings( From 0c557785f64f59f50a46faf517b749a12a302e96 Mon Sep 17 00:00:00 2001 From: hashbk Date: Sun, 8 Nov 2026 08:33:04 +0800 Subject: [PATCH 11/13] fix(custom-client): invalidate signing key cache on settings update When updateSettings() writes new signing keys, clear the cached signingKeysPromise so subsequent getSigningKeys() calls read the updated values instead of returning the stale cached pair. --- .../custom-client/services/custom-client-settings.service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/modules/custom-client/services/custom-client-settings.service.ts b/src/modules/custom-client/services/custom-client-settings.service.ts index 11d39092..fd7bb44d 100644 --- a/src/modules/custom-client/services/custom-client-settings.service.ts +++ b/src/modules/custom-client/services/custom-client-settings.service.ts @@ -112,12 +112,14 @@ export class CustomClientSettingsService { } 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(); } From e1f7e10f878959ba33dd5745c590fd49065d8a74 Mon Sep 17 00:00:00 2001 From: hashbk Date: Sun, 8 Nov 2026 09:37:32 +0800 Subject: [PATCH 12/13] refactor(custom-client): use backend-url instead of separate callback-url and config-url Replace separate callback-url and config-url dispatch inputs with a single backend-url input. The generator workflows derive config-url and callback-url internally from backend-url, reducing the number of parameters passed through the workflow chain. --- src/modules/custom-client/custom-client.service.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/modules/custom-client/custom-client.service.ts b/src/modules/custom-client/custom-client.service.ts index 0aecabb1..734c43e4 100644 --- a/src/modules/custom-client/custom-client.service.ts +++ b/src/modules/custom-client/custom-client.service.ts @@ -43,9 +43,8 @@ const ALLOWED_CALLBACK_STATUSES: CustomClientBuild['status'][] = [ interface GithubDispatchInputs { 'build-id': string; - 'callback-url': string; + 'backend-url': string; 'callback-token': string; - 'config-url': string; } function resolveWorkflowFile(os: string, arch: string | undefined): string { @@ -113,13 +112,9 @@ export class CustomClientService { } const site = await this.generalSettingsService.getSiteSettings(); - const callbackUrl = `${site.effectiveBackendUrl}/api/custom-client/builds/{uuid}/callback`; - const configUrl = `${site.effectiveBackendUrl}/api/custom-client/builds/{uuid}/config`; - const uuid = randomUUID(); const callbackToken = randomUUID(); - const callbackUrlResolved = callbackUrl.replace('{uuid}', uuid); - const configUrlResolved = configUrl.replace('{uuid}', uuid); + const backendUrl = `${site.effectiveBackendUrl}/api/custom-client/builds/${uuid}`; const build = this.buildRepository.create({ uuid, @@ -135,9 +130,8 @@ export class CustomClientService { const workflowFile = resolveWorkflowFile(dto.os, dto.arch); const inputs: GithubDispatchInputs = { 'build-id': uuid, - 'callback-url': callbackUrlResolved, + 'backend-url': backendUrl, 'callback-token': callbackToken, - 'config-url': configUrlResolved, }; const dispatchUrl = `${GITHUB_API}/repos/${settings.generatorRepo}/actions/workflows/${workflowFile}/dispatches`; From a4ee50c43386e602452a5e72ee77344aa9fdd170 Mon Sep 17 00:00:00 2001 From: hashbk Date: Sun, 8 Nov 2026 11:41:59 +0800 Subject: [PATCH 13/13] feat: add Android universal build target Add 'android/universal' to the workflow file mapping and 'universal' to the arch validation in SubmitBuildDto. --- src/modules/custom-client/custom-client.service.ts | 1 + src/modules/custom-client/dto/custom-client-build.dto.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/custom-client/custom-client.service.ts b/src/modules/custom-client/custom-client.service.ts index 734c43e4..2c54d7c6 100644 --- a/src/modules/custom-client/custom-client.service.ts +++ b/src/modules/custom-client/custom-client.service.ts @@ -69,6 +69,7 @@ function resolveWorkflowFile(os: string, arch: string | undefined): string { '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]; diff --git a/src/modules/custom-client/dto/custom-client-build.dto.ts b/src/modules/custom-client/dto/custom-client-build.dto.ts index 2e85677b..6f524ab9 100644 --- a/src/modules/custom-client/dto/custom-client-build.dto.ts +++ b/src/modules/custom-client/dto/custom-client-build.dto.ts @@ -58,7 +58,7 @@ export class SubmitBuildDto { os: string; @IsOptional() - @IsIn(['x86_64', 'aarch64', 'x86', 'armv7']) + @IsIn(['x86_64', 'aarch64', 'x86', 'armv7', 'universal']) arch?: string; @IsOptional()