Files
cloud-host/backend/src/applications/applications.service.ts
T
keyhan 22359be40e fix(platform): apply production hardening from audit plan
Close billing, tenancy, migration, build, and CI/CD gaps identified in the
audit: wallet/gateway guards, full-UUID namespaces, idempotent migrations with
base schema, stateful service stability, safer Dockerfiles/git builds, and
platform chart hardening (Redis auth, RollingUpdate, backups, Swagger off).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:35:07 +03:30

337 lines
12 KiB
TypeScript

import { Injectable, NotFoundException, ForbiddenException, Logger, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { Application } from './entities/application.entity';
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
import { ClustersService } from '../clusters/clusters.service';
import {
DatabaseType,
CustomDomainStatus,
AppRuntime,
ProductType,
isManagedProductType,
} from '../common/enums';
import { ensureAppUrlEnv } from './app-url.util';
import { normalizeCreateApplicationDto } from './managed-service.util';
import {
assertRuntimeMatch,
detectRuntimeFromArchive,
} from '../build/runtime-detector';
import { SourceStorageService } from '../storage/source-storage.service';
import { userIdSlug } from '../kubernetes/k8s-workload.util';
import * as os from 'os';
@Injectable()
export class ApplicationsService {
private readonly logger = new Logger(ApplicationsService.name);
constructor(
@InjectRepository(Application)
private appsRepository: Repository<Application>,
private clustersService: ClustersService,
private configService: ConfigService,
private sourceStorage: SourceStorageService,
) {}
private toDnsLabel(value: string): string {
return (value || '')
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 63);
}
private async generateRandomSubdomain(baseLabel: string): Promise<string> {
const base = this.toDnsLabel(baseLabel) || 'app';
const suffixLenBytes = 5; // 10 hex chars
const suffixLen = suffixLenBytes * 2;
for (let attempt = 0; attempt < 10; attempt++) {
const suffix = crypto.randomBytes(suffixLenBytes).toString('hex'); // [0-9a-f]
const maxBaseLen = 63 - 1 - suffixLen; // base + '-' + suffix
const truncatedBase = base.slice(0, Math.max(1, maxBaseLen)).replace(/-+$/g, '');
const candidate = `${truncatedBase}-${suffix}`;
const exists = await this.appsRepository.findOne({ where: { subdomain: candidate } });
if (!exists) return candidate;
}
throw new BadRequestException('Failed to generate a unique subdomain. Please try again.');
}
async create(userId: string, dto: CreateApplicationDto): Promise<Application> {
dto = normalizeCreateApplicationDto(dto);
const productType = dto.productType ?? ProductType.APPLICATION;
// WordPress only runs on MySQL/MariaDB — reject PostgreSQL/Mongo/none up
// front instead of failing at runtime inside the WordPress container.
if (dto.runtime === AppRuntime.WORDPRESS) {
if (!dto.databaseType || dto.databaseType === DatabaseType.NONE) {
dto.databaseType = DatabaseType.MYSQL;
} else if (![DatabaseType.MYSQL, DatabaseType.MARIADB].includes(dto.databaseType)) {
throw new BadRequestException(
`WordPress requires a MySQL or MariaDB database — "${dto.databaseType}" is not supported.`,
);
}
}
// Placement is always decided automatically by the allocator.
const allocation = await this.clustersService.selectClusterForApplication(dto, userId);
const clusterId = allocation.cluster.id;
const poolId = allocation.pool?.id;
const allocationLogId = allocation.allocationLogId;
if (!clusterId) {
throw new BadRequestException('No eligible cluster available for this application');
}
// Generate database credentials if a database is requested
let dbUsername: string | undefined;
let dbPassword: string | undefined;
if (dto.databaseType && dto.databaseType !== DatabaseType.NONE) {
dbUsername = dto.dbUsername?.trim() || 'appuser';
dbPassword = dto.dbPassword?.trim() || crypto.randomBytes(16).toString('hex');
this.logger.log(`Generated DB credentials for app "${dto.name}" — user: ${dbUsername}`);
}
const customDomain = isManagedProductType(productType)
? undefined
: dto.customDomain?.toLowerCase().trim() || undefined;
const runtime = dto.runtime ?? AppRuntime.NODEJS;
const defaultPort = [AppRuntime.WORDPRESS, AppRuntime.PHP, AppRuntime.LARAVEL].includes(runtime)
? 80
: 3000;
const baseLabel = dto.name;
const subdomain = customDomain
? `${this.toDnsLabel(baseLabel)}-${this.toDnsLabel(userIdSlug(userId).slice(0, 12))}`
: await this.generateRandomSubdomain(baseLabel);
const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir';
const app = this.appsRepository.create({
...dto,
productType,
runtime,
userId,
clusterId,
poolId,
dbUsername,
dbPassword,
replicas: isManagedProductType(productType) ? 0 : (dto.replicas ?? 1),
port: dto.port ?? defaultPort,
subdomain,
customDomain: customDomain || undefined,
customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE,
envVars: isManagedProductType(productType)
? (dto.envVars ?? {})
: ensureAppUrlEnv(
{
name: dto.name,
runtime,
subdomain,
customDomain: customDomain || undefined,
customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE,
envVars: dto.envVars ?? {},
},
platformDomain,
),
});
const saved = await this.appsRepository.save(app);
if (allocationLogId) {
await this.clustersService.attachAllocationToApplication(allocationLogId, saved.id);
}
return saved;
}
async findAllByUser(
userId: string,
options?: { productType?: 'application' | 'managed' },
): Promise<Application[]> {
const qb = this.appsRepository
.createQueryBuilder('app')
.leftJoinAndSelect('app.deployments', 'deployments')
.where('app.userId = :userId', { userId })
.orderBy('app.createdAt', 'DESC')
// Ensure deployments[0] is the most recent so the UI shows the latest status.
.addOrderBy('deployments.createdAt', 'DESC');
if (options?.productType === 'application') {
qb.andWhere(
'(app.productType = :applicationType OR app.productType IS NULL)',
{ applicationType: ProductType.APPLICATION },
);
} else if (options?.productType === 'managed') {
qb.andWhere('app.productType IN (:...managedTypes)', {
managedTypes: [
ProductType.MANAGED_DATABASE,
ProductType.MANAGED_REDIS,
ProductType.MANAGED_RABBITMQ,
],
});
}
return qb.getMany();
}
async findAll(search?: string): Promise<Application[]> {
const qb = this.appsRepository
.createQueryBuilder('app')
.leftJoinAndSelect('app.user', 'user')
.leftJoinAndSelect('app.deployments', 'deployments')
.orderBy('app.createdAt', 'DESC')
// Ensure deployments[0] is the most recent so the UI shows the latest status.
.addOrderBy('deployments.createdAt', 'DESC');
if (search && search.trim()) {
const s = `%${search.trim()}%`;
qb.where(
'(user.firstName ILIKE :s OR user.lastName ILIKE :s OR user.email ILIKE :s OR CAST(app.userId AS TEXT) ILIKE :s OR app.name ILIKE :s)',
{ s },
);
}
return qb.getMany();
}
async findOne(id: string, userId?: string): Promise<Application> {
const where: any = { id };
if (userId) {
where.userId = userId;
}
const app = await this.appsRepository.findOne({
where,
relations: { deployments: true },
});
if (!app) {
throw new NotFoundException('Application not found');
}
return app;
}
async update(id: string, userId: string, dto: UpdateApplicationDto): Promise<Application> {
const app = await this.findOne(id, userId);
Object.assign(app, dto);
return this.appsRepository.save(app);
}
async delete(id: string, userId: string): Promise<Application> {
const app = await this.findOne(id, userId);
// Delete uploaded source files
if (app.codePath) {
try {
await this.sourceStorage.deleteSource(app.userId, app.id, app.codePath);
} catch (e: any) {
this.logger.warn(`Failed to delete source for ${app.name}: ${e.message}`);
}
}
await this.appsRepository.remove(app);
this.logger.log(`Deleted application ${app.name} (${id})`);
return app;
}
async updateImageTag(id: string, imageTag: string): Promise<Application> {
const app = await this.findOne(id);
app.latestImageTag = imageTag;
return this.appsRepository.save(app);
}
async updateClusterAssignment(id: string, clusterId: string, poolId?: string): Promise<Application> {
const app = await this.findOne(id);
app.clusterId = clusterId;
app.poolId = poolId || app.poolId;
return this.appsRepository.save(app);
}
async saveSuspendedReplicas(
id: string,
snapshot: Record<string, number>,
): Promise<Application> {
const app = await this.findOne(id);
app.suspendedReplicas = snapshot;
app.suspendedAt = new Date();
if (snapshot[app.name] !== undefined) {
app.replicas = snapshot[app.name];
}
return this.appsRepository.save(app);
}
async clearSuspendedReplicas(id: string): Promise<Application> {
const app = await this.findOne(id);
app.suspendedReplicas = undefined;
return this.appsRepository.save(app);
}
async uploadCode(id: string, userId: string, file: Express.Multer.File): Promise<Application> {
if (!file) {
throw new BadRequestException('No file uploaded');
}
const app = await this.findOne(id, userId);
const tempPath = path.join(os.tmpdir(), `upload-${app.id}-${Date.now()}.zip`);
fs.writeFileSync(tempPath, file.buffer);
try {
const detected = await detectRuntimeFromArchive(tempPath);
assertRuntimeMatch(app.runtime, detected);
const storedPath = await this.sourceStorage.putSource(app.userId, app.id, file.buffer);
app.codePath = storedPath;
const saved = await this.appsRepository.save(app);
if (detected.confidence === 'low') {
Object.assign(saved, {
runtimeWarning:
'Could not determine the project type from the archive with high confidence. Build may fail if the selected runtime is wrong.',
});
}
this.logger.log(`Uploaded code for ${app.name}${storedPath} (${(file.size / 1024).toFixed(1)} KB)`);
return saved;
} catch (err) {
try {
await this.sourceStorage.deleteSource(app.userId, app.id);
} catch {
// ignore rollback errors
}
throw err;
} finally {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
async uploadDbDump(id: string, userId: string, file: Express.Multer.File): Promise<Application> {
if (!file) {
throw new BadRequestException('No file uploaded');
}
const app = await this.findOne(id, userId);
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
const appDir = path.join(uploadDir, app.userId, app.id);
// Ensure directory exists
fs.mkdirSync(appDir, { recursive: true });
// Save the SQL dump file
const dumpPath = path.join(appDir, 'dump.sql');
fs.writeFileSync(dumpPath, file.buffer);
// Update app with dump path
app.dbDumpPath = dumpPath;
const saved = await this.appsRepository.save(app);
this.logger.log(`Uploaded DB dump for ${app.name}${dumpPath} (${(file.size / 1024).toFixed(1)} KB)`);
return saved;
}
}