diff --git a/prisma/migrations/20260811120000_server_access_blocked/migration.sql b/prisma/migrations/20260811120000_server_access_blocked/migration.sql new file mode 100644 index 0000000..53ee7d7 --- /dev/null +++ b/prisma/migrations/20260811120000_server_access_blocked/migration.sql @@ -0,0 +1,7 @@ +-- Per-tenant app/admin access kill switch (shared physical servers stay up) + +ALTER TABLE "tenants" + ADD COLUMN IF NOT EXISTS "server_access_blocked" BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE "tenants" + ADD COLUMN IF NOT EXISTS "server_access_blocked_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4240ee7..37eb09a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -76,6 +76,9 @@ model Tenant { /// Client cache-bust / force-reload signal for this sold version (GET /get_version). appVersion String @default("1") @map("app_version") isActive Boolean @default(true) @map("is_active") + /// Kill switch: block this sold version's app+admin (server stays up). Not the same as ServerAccount billing. + serverAccessBlocked Boolean @default(false) @map("server_access_blocked") + serverAccessBlockedAt DateTime? @map("server_access_blocked_at") notes String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/get-user/get-user.service.ts b/src/get-user/get-user.service.ts index 51fe587..79e718c 100644 --- a/src/get-user/get-user.service.ts +++ b/src/get-user/get-user.service.ts @@ -79,6 +79,8 @@ export class GetUserService { product: tenant.product, theme: themeMode, mode: themeMode, + /** Stealth cold-boot gate — false looks like product server unreachable */ + server_reachable: !Boolean(tenant.serverAccessBlocked), /** Khanegi front-end family for consumers */ ui_variant: isKhanegi ? uiVariant : undefined, app_ui_config: diff --git a/src/servers/dto/server-account.dto.ts b/src/servers/dto/server-account.dto.ts index d71171b..99c0687 100644 --- a/src/servers/dto/server-account.dto.ts +++ b/src/servers/dto/server-account.dto.ts @@ -78,4 +78,9 @@ export class RenewServerDto { note?: string; } +export class SetServerAccessDto { + @IsBoolean() + blocked!: boolean; +} + export { PLAN_MONTHS }; diff --git a/src/servers/servers.controller.ts b/src/servers/servers.controller.ts index 3658585..f543450 100644 --- a/src/servers/servers.controller.ts +++ b/src/servers/servers.controller.ts @@ -13,7 +13,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { PermissionsGuard } from '../auth/permissions.guard'; import { RequirePermissions } from '../auth/permissions.decorator'; import { CurrentUser } from '../auth/current-user.decorator'; -import { RenewServerDto, UpdateServerAccountDto } from './dto/server-account.dto'; +import { RenewServerDto, SetServerAccessDto, UpdateServerAccountDto } from './dto/server-account.dto'; import { ServersService } from './servers.service'; @Controller('servers') @@ -45,6 +45,20 @@ export class ServersController { return this.serversService.getTenantServers(id); } + @Post('tenants/:id/server-access') + @RequirePermissions('servers:write') + setServerAccess( + @Param('id') id: string, + @Body() dto: SetServerAccessDto, + @CurrentUser() user: { id: string }, + ) { + return this.serversService.setServerAccessBlocked( + id, + dto.blocked, + user.id, + ); + } + @Post('tenants/:id/servers') @RequirePermissions('servers:write') createServer( diff --git a/src/servers/servers.module.ts b/src/servers/servers.module.ts index 83b88c3..7ff1445 100644 --- a/src/servers/servers.module.ts +++ b/src/servers/servers.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { SettingsProxyModule } from '../settings-proxy/settings-proxy.module'; import { ServersController } from './servers.controller'; import { ServersService } from './servers.service'; @Module({ - imports: [NotificationsModule, AuthModule], + imports: [NotificationsModule, AuthModule, SettingsProxyModule], controllers: [ServersController], providers: [ServersService], exports: [ServersService], diff --git a/src/servers/servers.service.ts b/src/servers/servers.service.ts index 8b1b8af..7cb78c1 100644 --- a/src/servers/servers.service.ts +++ b/src/servers/servers.service.ts @@ -10,6 +10,7 @@ import { Prisma, ServerAccount } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { AdminNotifyService } from '../notifications/admin-notify.service'; +import { SettingsProxyService } from '../settings-proxy/settings-proxy.service'; import { PLAN_MONTHS, RenewServerDto, @@ -45,6 +46,7 @@ export class ServersService implements OnModuleInit, OnModuleDestroy { private readonly prisma: PrismaService, private readonly audit: AuditService, private readonly notify: AdminNotifyService, + private readonly settingsProxy: SettingsProxyService, ) {} onModuleInit() { @@ -141,6 +143,8 @@ export class ServersService implements OnModuleInit, OnModuleDestroy { nameEn: tenant.nameEn, domain: tenant.domain, avatarUrl: tenant.avatarUrl, + serverAccessBlocked: Boolean(tenant.serverAccessBlocked), + serverAccessBlockedAt: tenant.serverAccessBlockedAt, }, servers: accounts.map((account) => ({ account: this.publicAccount(account), @@ -669,4 +673,110 @@ export class ServersService implements OnModuleInit, OnModuleDestroy { if (!tenant) throw new NotFoundException('نسخه یافت نشد'); return tenant; } + + /** + * Kill switch for this sold version's app+admin (physical server stays up). + * Bumps appVersion and pushes flag to product /super/settings for immediate effect. + */ + async setServerAccessBlocked( + tenantId: string, + blocked: boolean, + userId?: string, + ) { + const before = await this.requireTenant(tenantId); + if (Boolean(before.serverAccessBlocked) === blocked) { + return { + tenant: { + id: before.id, + serverAccessBlocked: Boolean(before.serverAccessBlocked), + serverAccessBlockedAt: before.serverAccessBlockedAt, + appVersion: before.appVersion, + }, + push: { skipped: true, reason: 'unchanged' as const }, + }; + } + + const nextVersion = this.bumpAppVersion(before.appVersion); + const updated = await this.prisma.tenant.update({ + where: { id: tenantId }, + data: { + serverAccessBlocked: blocked, + serverAccessBlockedAt: blocked ? new Date() : null, + appVersion: nextVersion, + }, + }); + + await this.audit.log({ + action: blocked + ? 'tenant.server_access.block' + : 'tenant.server_access.unblock', + entityType: 'tenant', + entityId: tenantId, + tenantId, + userId, + before: { + serverAccessBlocked: before.serverAccessBlocked, + appVersion: before.appVersion, + } as Prisma.InputJsonValue, + after: { + serverAccessBlocked: updated.serverAccessBlocked, + appVersion: updated.appVersion, + } as Prisma.InputJsonValue, + }); + + this.notify.notifyAdmins( + blocked + ? `سوپرادمین: دسترسی اپ/ادمین «${updated.nameFa}» قطع شد (ظاهر قطعی سرور).` + : `سوپرادمین: دسترسی اپ/ادمین «${updated.nameFa}» وصل شد.`, + ); + + let push: { + ok: boolean; + stub?: boolean; + message?: string; + result?: unknown; + }; + try { + const result = await this.settingsProxy.updateSettings( + tenantId, + { server_access_blocked: blocked }, + userId, + ); + push = { + ok: true, + stub: Boolean((result as { stub?: boolean })?.stub), + message: (result as { message?: string })?.message, + result, + }; + } catch (error: any) { + push = { + ok: false, + message: + error?.message || + 'قفل در رجیستری ثبت شد ولی push فوری به سرور نسخه ناموفق بود', + }; + } + + return { + tenant: { + id: updated.id, + serverAccessBlocked: updated.serverAccessBlocked, + serverAccessBlockedAt: updated.serverAccessBlockedAt, + appVersion: updated.appVersion, + }, + push, + }; + } + + private bumpAppVersion(current?: string | null): string { + const v = String(current || '1').trim() || '1'; + if (/^\d+$/.test(v)) { + return String(Number(v) + 1); + } + const semver = v.match(/^(\d+)\.(\d+)\.(\d+)$/); + if (semver) { + return `${semver[1]}.${semver[2]}.${Number(semver[3]) + 1}`; + } + return `${Date.now()}`; + } } diff --git a/src/settings-proxy/settings-proxy.module.ts b/src/settings-proxy/settings-proxy.module.ts index d04968c..832e544 100644 --- a/src/settings-proxy/settings-proxy.module.ts +++ b/src/settings-proxy/settings-proxy.module.ts @@ -7,5 +7,6 @@ import { SettingsProxyService } from './settings-proxy.service'; imports: [AuthModule], controllers: [SettingsProxyController], providers: [SettingsProxyService], + exports: [SettingsProxyService], }) export class SettingsProxyModule {} diff --git a/src/settings-proxy/settings-proxy.service.ts b/src/settings-proxy/settings-proxy.service.ts index 89c67f9..1e8c91a 100644 --- a/src/settings-proxy/settings-proxy.service.ts +++ b/src/settings-proxy/settings-proxy.service.ts @@ -33,16 +33,13 @@ export class SettingsProxyService { } try { - const { data } = await axios.get( - this.joinUrl(tenant.nodeServer, '/super/settings'), - { - headers: { - Authorization: `Bearer ${tenant.remoteApiToken}`, - Accept: 'application/json', - }, - timeout: 15000, + const { data } = await axios.get(this.resolveSettingsUrl(tenant.nodeServer), { + headers: { + Authorization: `Bearer ${tenant.remoteApiToken}`, + Accept: 'application/json', }, - ); + timeout: 15000, + }); return { stub: false, settings: data }; } catch (error) { throw new BadGatewayException( @@ -79,7 +76,7 @@ export class SettingsProxyService { try { const { data } = await axios.post( - this.joinUrl(tenant.nodeServer, '/super/settings'), + this.resolveSettingsUrl(tenant.nodeServer), payload, { headers: { @@ -122,6 +119,18 @@ export class SettingsProxyService { return `${base.replace(/\/$/, '')}${path}`; } + /** + * nodeServer may be `https://host` or `https://host/api`. + * Laravel exposes both `/super/settings` (web) and `/api/super/settings`. + */ + private resolveSettingsUrl(nodeServer: string): string { + const base = String(nodeServer || '').replace(/\/$/, ''); + if (/\/api$/i.test(base)) { + return this.joinUrl(base, '/super/settings'); + } + return this.joinUrl(base, '/api/super/settings'); + } + private axiosMessage(error: unknown, fallback: string) { if (axios.isAxiosError(error)) { const status = error.response?.status;