diff --git a/.env.example b/.env.example index 02b09c3..f8425dc 100644 --- a/.env.example +++ b/.env.example @@ -95,6 +95,12 @@ WEBSITE_DEPLOY_TOKEN= SSL_SYNC_AGENT_URL=http://45.149.76.52:9051/ssl-sync SSL_SYNC_AGENT_TOKEN= +# ArvanCloud CDN DNS (Machine User key from Arvan panel) +# Header sent as: Authorization: Apikey +ARVAN_API_KEY= +# Optional override; default https://napi.arvancloud.ir/cdn/4.0 +# ARVAN_API_BASE_URL=https://napi.arvancloud.ir/cdn/4.0 + # Public domain for platform invoice links (https://{domain}/invoices/{id}) INVOICE_PUBLIC_DOMAIN=meshkee.com # Optional full origin for local (overrides domain + business host), e.g. https://meshkee.app:5174 diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index ade892e..cea1a3a 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -1,7 +1,7 @@ # Meshkee CMS API — Project Context > Living reference for developers and AI assistants working on this codebase. -> Last updated: August 11, 2026 +> Last updated: August 14, 2026 ## What This Project Is @@ -597,6 +597,7 @@ See `.env.example` for the full list. Key groups: | JWT | `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`, `JWT_*_EXPIRES_IN` | | SMS | `SMS_ENABLED`, `SMS_GAMA_BASE_URL`, `SMS_GAMA_USERNAME`, `SMS_GAMA_PASSWORD`, `SMS_GAMA_SOURCE_SERVICE`, `SMS_PARTNERS` | | S3 | `S3_ENDPOINT`, `S3_BUCKET`, `S3_PUBLIC_URL`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` | +| ArvanCloud DNS | `ARVAN_API_KEY`, optional `ARVAN_API_BASE_URL` | | Legacy MySQL (WillaEngine migrate) | `OLD_MYSQL_HOST`, `OLD_MYSQL_PORT`, `OLD_MYSQL_USER`, `OLD_MYSQL_PASSWORD`, `OLD_MYSQL_DATABASE` | | Legacy S3 source (media copy) | `OLD_S3_ENDPOINT`, `OLD_S3_BUCKET`, `OLD_S3_PUBLIC_URL`, `OLD_S3_ACCESS_KEY_ID`, `OLD_S3_SECRET_ACCESS_KEY` | | Media | `MEDIA_MAX_FILE_SIZE_MB` | @@ -609,6 +610,7 @@ See `.env.example` for the full list. Key groups: - Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile) - Super admin: users, businesses, domains, system business categories +- Super admin add/update domain upserts ArvanCloud DNS (`@` ANAME, `www`/`business`/`customer`/`api` CNAMEs) - Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images) - Business team management - Media upload (S3 + Sharp) diff --git a/src/arvan-dns/arvan-dns.module.ts b/src/arvan-dns/arvan-dns.module.ts new file mode 100644 index 0000000..9dc077b --- /dev/null +++ b/src/arvan-dns/arvan-dns.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ArvanDnsService } from './arvan-dns.service'; + +@Module({ + providers: [ArvanDnsService], + exports: [ArvanDnsService], +}) +export class ArvanDnsModule {} diff --git a/src/arvan-dns/arvan-dns.service.ts b/src/arvan-dns/arvan-dns.service.ts new file mode 100644 index 0000000..ec4ab50 --- /dev/null +++ b/src/arvan-dns/arvan-dns.service.ts @@ -0,0 +1,253 @@ +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +const DEFAULT_BASE_URL = 'https://napi.arvancloud.ir/cdn/4.0'; +const TTL = 120; + +type ArvanRecordType = 'aname' | 'cname'; + +type DesiredRecord = { + type: ArvanRecordType; + name: string; + value: Record; +}; + +type ArvanDnsRecord = { + id: string; + type: string; + name: string; + ttl?: number; + cloud?: boolean; + value?: Record | null; +}; + +@Injectable() +export class ArvanDnsService { + private readonly logger = new Logger(ArvanDnsService.name); + + constructor(private readonly config: ConfigService) {} + + /** + * Ensure Meshkee tenant records on an existing Arvan zone. + * Does not register the domain or delete unrelated records. + */ + async ensureTenantRecords(hostRaw: string): Promise<{ + created: string[]; + updated: string[]; + skipped: string[]; + }> { + const host = hostRaw.trim().toLowerCase(); + if (!host) { + throw new ServiceUnavailableException('Domain host is required for Arvan DNS'); + } + + const auth = this.authHeader(); + if (!auth) { + throw new ServiceUnavailableException('Arvan DNS is not configured'); + } + + const zone = await this.request('GET', `/domains/${encodeURIComponent(host)}`, auth); + if (zone.status === 404) { + throw new ServiceUnavailableException( + `Arvan zone "${host}" was not found. Add the domain in Arvan first.`, + ); + } + if (!zone.ok) { + throw new ServiceUnavailableException( + `Arvan could not load zone "${host}" (${zone.status})${zone.message ? `: ${zone.message}` : ''}`, + ); + } + + const existing = await this.listRecords(host, auth); + const created: string[] = []; + const updated: string[] = []; + const skipped: string[] = []; + + for (const desired of this.desiredRecords(host)) { + const match = existing.find( + (item) => item.name === desired.name && item.type === desired.type, + ); + const label = `${desired.type} ${desired.name}`; + + if (match && this.sameTarget(desired, match)) { + skipped.push(label); + continue; + } + + if (match) { + const put = await this.request('PUT', `/domains/${encodeURIComponent(host)}/dns-records/${match.id}`, auth, { + type: desired.type, + name: desired.name, + ttl: match.ttl || TTL, + cloud: false, + value: desired.value, + }); + if (!put.ok) { + throw new ServiceUnavailableException( + `Arvan failed to update ${label} (${put.status})${put.message ? `: ${put.message}` : ''}`, + ); + } + updated.push(label); + continue; + } + + const sameNameOtherType = existing.find( + (item) => item.name === desired.name && item.type !== desired.type && item.type !== 'ns', + ); + if (sameNameOtherType) { + const del = await this.request( + 'DELETE', + `/domains/${encodeURIComponent(host)}/dns-records/${sameNameOtherType.id}`, + auth, + ); + if (!del.ok) { + throw new ServiceUnavailableException( + `Arvan failed to replace ${sameNameOtherType.type} ${desired.name} (${del.status})${del.message ? `: ${del.message}` : ''}`, + ); + } + } + + const post = await this.request('POST', `/domains/${encodeURIComponent(host)}/dns-records`, auth, { + type: desired.type, + name: desired.name, + ttl: TTL, + cloud: false, + value: desired.value, + }); + if (!post.ok) { + throw new ServiceUnavailableException( + `Arvan failed to create ${label} (${post.status})${post.message ? `: ${post.message}` : ''}`, + ); + } + created.push(label); + } + + this.logger.log( + `Arvan DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'}`, + ); + + return { created, updated, skipped }; + } + + private desiredRecords(apex: string): DesiredRecord[] { + return [ + { + type: 'aname', + name: '@', + value: { location: 'ns.meshkee.com.', host_header: 'source' }, + }, + { + type: 'cname', + name: 'www', + value: { host: `${apex}.`, host_header: 'source' }, + }, + { + type: 'cname', + name: 'business', + value: { host: 'business.meshkee.com.', host_header: 'source' }, + }, + { + type: 'cname', + name: 'customer', + value: { host: 'customer.meshkee.com.', host_header: 'source' }, + }, + { + type: 'cname', + name: 'api', + value: { host: 'api.meshkee.com.', host_header: 'source' }, + }, + ]; + } + + private sameTarget(desired: DesiredRecord, existing: ArvanDnsRecord): boolean { + const current = existing.value ?? {}; + if (desired.type === 'aname') { + return this.fqdn(String(current.location ?? '')) === this.fqdn(String(desired.value.location ?? '')); + } + return this.fqdn(String(current.host ?? '')) === this.fqdn(String(desired.value.host ?? '')); + } + + private fqdn(value: string): string { + return value.trim().replace(/\.$/, '').toLowerCase(); + } + + private async listRecords(domain: string, auth: string): Promise { + const items: ArvanDnsRecord[] = []; + let page = 1; + for (;;) { + const res = await this.request( + 'GET', + `/domains/${encodeURIComponent(domain)}/dns-records?per_page=50&page=${page}`, + auth, + ); + if (!res.ok) { + throw new ServiceUnavailableException( + `Arvan could not list DNS records (${res.status})${res.message ? `: ${res.message}` : ''}`, + ); + } + const payload = (res.body ?? {}) as { + data?: ArvanDnsRecord[]; + meta?: { last_page?: number }; + }; + const data = Array.isArray(payload.data) ? payload.data : []; + items.push(...data); + const lastPage = payload.meta?.last_page ?? 1; + if (page >= lastPage) break; + page += 1; + } + return items; + } + + private authHeader(): string | null { + const raw = this.config.get('ARVAN_API_KEY')?.trim(); + if (!raw) return null; + return raw.toLowerCase().startsWith('apikey ') ? raw : `Apikey ${raw}`; + } + + private baseUrl(): string { + return ( + this.config.get('ARVAN_API_BASE_URL')?.trim().replace(/\/$/, '') || DEFAULT_BASE_URL + ); + } + + private async request( + method: string, + path: string, + auth: string, + body?: unknown, + ): Promise<{ ok: boolean; status: number; body: Record | null; message: string }> { + let response: Response; + try { + response = await fetch(`${this.baseUrl()}${path}`, { + method, + headers: { + Accept: 'application/json', + Authorization: auth, + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch { + throw new ServiceUnavailableException('Could not reach ArvanCloud DNS API'); + } + + const text = await response.text().catch(() => ''); + let parsed: Record = {}; + try { + parsed = text ? (JSON.parse(text) as Record) : {}; + } catch { + /* keep raw */ + } + + const message = + (typeof parsed.message === 'string' && parsed.message) || + (text && !response.ok ? text.slice(0, 300) : ''); + + return { + ok: response.ok, + status: response.status, + body: parsed, + message, + }; + } +} diff --git a/src/business-admin/business-admin.module.ts b/src/business-admin/business-admin.module.ts index 3b93aa4..007629c 100644 --- a/src/business-admin/business-admin.module.ts +++ b/src/business-admin/business-admin.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { ArvanDnsModule } from '../arvan-dns/arvan-dns.module'; import { WebsiteDeployModule } from '../website-deploy/website-deploy.module'; import { BusinessCategoriesController } from './business-categories.controller'; import { BusinessCategoriesService } from './business-categories.service'; @@ -9,7 +10,7 @@ import { LegacyMigrateService } from './legacy-migrate.service'; import { LegacyPurgeService } from './legacy-purge.service'; @Module({ - imports: [AuthModule, WebsiteDeployModule], + imports: [AuthModule, WebsiteDeployModule, ArvanDnsModule], controllers: [BusinessAdminController, BusinessCategoriesController], providers: [ BusinessAdminService, diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index 6a11286..79c98a7 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, ConflictException, ForbiddenException, + HttpException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -40,6 +41,7 @@ import { DEFAULT_ENABLED_BUSINESS_MODULES, DEFAULT_HOME_CHARTS, } from '../business-settings/business-settings.types'; +import { ArvanDnsService } from '../arvan-dns/arvan-dns.service'; import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service'; import { deploySlugFromHost, @@ -88,6 +90,7 @@ export class BusinessAdminService { private readonly legacyMigrate: LegacyMigrateService, private readonly legacyPurge: LegacyPurgeService, private readonly websiteDeployAgent: WebsiteDeployAgentService, + private readonly arvanDns: ArvanDnsService, ) {} private async assertSuperAdmin(actor: AuthUser) { @@ -545,9 +548,12 @@ export class BusinessAdminService { }, }); + const dnsError = await this.applyArvanDns(host); + return { ...domain, provisionError, + dnsError, }; } @@ -642,12 +648,35 @@ export class BusinessAdminService { }, }); + const dnsError = await this.applyArvanDns(host); + return { ...updated, provisionError, + dnsError, }; } + private async applyArvanDns(host: string): Promise { + try { + await this.arvanDns.ensureTenantRecords(host); + return null; + } catch (err) { + if (err instanceof HttpException) { + const res = err.getResponse(); + if (typeof res === 'string') return res; + if (res && typeof res === 'object' && 'message' in res) { + const message = (res as { message: unknown }).message; + return Array.isArray(message) ? message.map(String).join(', ') : String(message); + } + } + if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') { + return err.message; + } + return 'Arvan DNS update failed'; + } + } + async disable(businessIdRaw: string, dto: DisableBusinessDto, actor: AuthUser) { await this.assertSuperAdmin(actor);