diff --git a/backend/src/applications/app-url.util.spec.ts b/backend/src/applications/app-url.util.spec.ts new file mode 100644 index 0000000..6591ef8 --- /dev/null +++ b/backend/src/applications/app-url.util.spec.ts @@ -0,0 +1,83 @@ +import { AppRuntime, CustomDomainStatus } from '../common/enums'; +import { + ensureAppUrlEnv, + getApplicationPublicUrl, + hasAppUrlEnv, + usesAppUrl, +} from './app-url.util'; + +describe('app-url.util', () => { + const platformDomain = 'apps.cloudhost.ir'; + + it('detects Laravel and PHP as APP_URL runtimes', () => { + expect(usesAppUrl(AppRuntime.LARAVEL)).toBe(true); + expect(usesAppUrl(AppRuntime.PHP)).toBe(true); + expect(usesAppUrl(AppRuntime.NODEJS)).toBe(false); + }); + + it('builds platform URL from subdomain', () => { + expect( + getApplicationPublicUrl( + { + name: 'my-app', + subdomain: 'my-app-abc', + customDomain: undefined, + customDomainStatus: CustomDomainStatus.NONE, + }, + platformDomain, + ), + ).toBe('https://my-app-abc.apps.cloudhost.ir'); + }); + + it('prefers verified custom domain for public URL', () => { + expect( + getApplicationPublicUrl( + { + name: 'my-app', + subdomain: 'my-app-abc', + customDomain: 'www.example.com', + customDomainStatus: CustomDomainStatus.VERIFIED, + }, + platformDomain, + ), + ).toBe('https://www.example.com'); + }); + + it('sets APP_URL when missing for Laravel apps', () => { + const envVars = ensureAppUrlEnv( + { + name: 'laravel2', + runtime: AppRuntime.LARAVEL, + subdomain: 'laravel2-c02087e3', + customDomain: undefined, + customDomainStatus: CustomDomainStatus.NONE, + envVars: { APP_ENV: 'production' }, + }, + platformDomain, + ); + + expect(envVars.APP_URL).toBe('https://laravel2-c02087e3.apps.cloudhost.ir'); + expect(envVars.APP_ENV).toBe('production'); + }); + + it('does not overwrite user-provided APP_URL', () => { + const envVars = ensureAppUrlEnv( + { + name: 'laravel2', + runtime: AppRuntime.LARAVEL, + subdomain: 'laravel2-c02087e3', + customDomain: 'www.example.com', + customDomainStatus: CustomDomainStatus.VERIFIED, + envVars: { APP_URL: 'https://custom.local' }, + }, + platformDomain, + ); + + expect(envVars.APP_URL).toBe('https://custom.local'); + }); + + it('treats empty APP_URL as missing', () => { + expect(hasAppUrlEnv({ APP_URL: ' ' })).toBe(false); + expect(hasAppUrlEnv({ APP_URL: 'null' })).toBe(false); + }); +}); diff --git a/backend/src/applications/app-url.util.ts b/backend/src/applications/app-url.util.ts new file mode 100644 index 0000000..9378b31 --- /dev/null +++ b/backend/src/applications/app-url.util.ts @@ -0,0 +1,46 @@ +import { AppRuntime, CustomDomainStatus } from '../common/enums'; +import { Application } from './entities/application.entity'; + +type AppUrlContext = Pick< + Application, + 'subdomain' | 'customDomain' | 'customDomainStatus' | 'name' | 'envVars' | 'runtime' +>; + +const APP_URL_RUNTIMES = new Set([AppRuntime.LARAVEL, AppRuntime.PHP]); + +export function usesAppUrl(runtime: AppRuntime): boolean { + return APP_URL_RUNTIMES.has(runtime); +} + +export function hasAppUrlEnv(envVars?: Record | null): boolean { + const value = envVars?.APP_URL?.trim(); + return !!value && value !== 'null'; +} + +export function getApplicationPublicHost( + app: Pick, + platformDomain: string, +): string { + if (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED) { + return app.customDomain; + } + const subdomain = app.subdomain || app.name; + return `${subdomain}.${platformDomain}`; +} + +export function getApplicationPublicUrl( + app: Pick, + platformDomain: string, +): string { + return `https://${getApplicationPublicHost(app, platformDomain)}`; +} + +/** Set APP_URL from the active public domain when the user has not provided one. */ +export function ensureAppUrlEnv(app: AppUrlContext, platformDomain: string): Record { + const envVars = { ...(app.envVars || {}) }; + if (!usesAppUrl(app.runtime) || hasAppUrlEnv(envVars)) { + return envVars; + } + envVars.APP_URL = getApplicationPublicUrl(app, platformDomain); + return envVars; +} diff --git a/backend/src/applications/applications.service.ts b/backend/src/applications/applications.service.ts index 48b873d..fddab06 100644 --- a/backend/src/applications/applications.service.ts +++ b/backend/src/applications/applications.service.ts @@ -9,6 +9,7 @@ import { Application } from './entities/application.entity'; import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto'; import { ClustersService } from '../clusters/clusters.service'; import { UserRole, DatabaseType, CustomDomainStatus, AppRuntime } from '../common/enums'; +import { ensureAppUrlEnv } from './app-url.util'; @Injectable() export class ApplicationsService { @@ -82,6 +83,9 @@ export class ApplicationsService { ? 80 : 3000; + const subdomain = `${dto.name}-${userId.split('-')[0]}`; + const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir'; + const app = this.appsRepository.create({ ...dto, userId, @@ -90,9 +94,20 @@ export class ApplicationsService { dbUsername, dbPassword, port: dto.port ?? defaultPort, - subdomain: `${dto.name}-${userId.split('-')[0]}`, + subdomain, customDomain: customDomain || undefined, customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE, + envVars: ensureAppUrlEnv( + { + name: dto.name, + runtime: dto.runtime, + subdomain, + customDomain: customDomain || undefined, + customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE, + envVars: dto.envVars, + }, + platformDomain, + ), }); return this.appsRepository.save(app); } diff --git a/backend/src/applications/domain.service.ts b/backend/src/applications/domain.service.ts index fdbcc13..8291141 100644 --- a/backend/src/applications/domain.service.ts +++ b/backend/src/applications/domain.service.ts @@ -12,6 +12,7 @@ import * as dns from 'dns'; import { Application } from './entities/application.entity'; import { PlatformSetting } from '../billing/entities/platform-setting.entity'; import { CustomDomainStatus } from '../common/enums'; +import { ensureAppUrlEnv } from './app-url.util'; @Injectable() export class DomainService { @@ -93,6 +94,8 @@ export class DomainService { if (isValid) { app.customDomainStatus = CustomDomainStatus.VERIFIED; app.customDomainVerifiedAt = new Date(); + const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir'; + app.envVars = ensureAppUrlEnv(app, platformDomain); const saved = await this.appRepo.save(app); this.logger.log(`DNS verified for ${app.name}: ${app.customDomain}`); return { diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index 9bdfb2f..f775e58 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -8,6 +8,7 @@ import { promisify } from 'util'; import { PassThrough } from 'stream'; import { ClustersService } from '../clusters/clusters.service'; import { Application } from '../applications/entities/application.entity'; +import { ensureAppUrlEnv } from '../applications/app-url.util'; import { AppRuntime, DatabaseType, CustomDomainStatus } from '../common/enums'; import { HelmService } from './helm.service'; @@ -101,6 +102,11 @@ export class KubernetesService implements OnModuleInit { /** * Build Helm values object from an Application entity and image URI. */ + private resolveEnvVars(app: Application): Record { + const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir'; + return ensureAppUrlEnv(app, platformDomain); + } + private buildHelmValues(app: Application, imageUri: string): Record { const domain = this.configService.get('platform.domain'); const pullRegistryUrl = this.configService.get('registry.pullUrl') || 'localhost:30500'; @@ -124,7 +130,7 @@ export class KubernetesService implements OnModuleInit { memoryRequest: app.memoryRequest, memoryLimit: app.memoryLimit, }, - envVars: app.envVars || {}, + envVars: this.resolveEnvVars(app), ingress: { enabled: true, subdomain: app.subdomain || app.name, @@ -224,7 +230,7 @@ export class KubernetesService implements OnModuleInit { cpuLimit: app.cpuLimit, memoryRequest: app.memoryRequest, memoryLimit: app.memoryLimit, - envVars: app.envVars || {}, + envVars: this.resolveEnvVars(app), runtime: app.runtime, databaseType: app.databaseType, domain, @@ -282,7 +288,7 @@ export class KubernetesService implements OnModuleInit { cpuLimit: app.cpuLimit, memoryRequest: app.memoryRequest, memoryLimit: app.memoryLimit, - envVars: app.envVars || {}, + envVars: this.resolveEnvVars(app), runtime: app.runtime, databaseType: app.databaseType, domain: domain,