diff --git a/src/main.ts b/src/main.ts index 54f3024..a63481e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,10 +12,14 @@ async function bootstrap() { ensureUploadsDirs(); app.useStaticAssets(UPLOADS_ROOT, { prefix: '/uploads/' }); + app.useBodyParser('json', { limit: '2mb' }); + app.useBodyParser('urlencoded', { limit: '2mb', extended: true }); + app.setGlobalPrefix('api', { exclude: [ { path: 'get_user', method: RequestMethod.GET }, { path: 'get_version', method: RequestMethod.GET }, + { path: 'get_settings', method: RequestMethod.GET }, { path: 'manifest.json', method: RequestMethod.GET }, { path: 'uploads/(.*)', method: RequestMethod.GET }, ], diff --git a/src/program-settings/api-public-program-settings.controller.ts b/src/program-settings/api-public-program-settings.controller.ts new file mode 100644 index 0000000..620a76c --- /dev/null +++ b/src/program-settings/api-public-program-settings.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Headers, Header, Query } from '@nestjs/common'; +import { ProgramSettingsService } from './program-settings.service'; + +@Controller('public/program-settings') +export class ApiPublicProgramSettingsController { + constructor(private readonly programSettings: ProgramSettingsService) {} + + @Get() + @Header('Cache-Control', 'public, max-age=30') + getSettings( + @Headers('host') host?: string, + @Headers('x-forwarded-host') forwardedHost?: string, + @Query('domain') domain?: string, + ) { + return this.programSettings.getPublicByHost(domain || forwardedHost || host, domain); + } +} diff --git a/src/program-settings/dto/update-program-settings.dto.ts b/src/program-settings/dto/update-program-settings.dto.ts new file mode 100644 index 0000000..9952601 --- /dev/null +++ b/src/program-settings/dto/update-program-settings.dto.ts @@ -0,0 +1,7 @@ +import { Allow, IsArray } from 'class-validator'; + +export class UpdateProgramSettingsDto { + @IsArray() + @Allow() + items!: Array>; +} diff --git a/src/program-settings/program-settings.controller.ts b/src/program-settings/program-settings.controller.ts index 37bd1d8..138a5d6 100644 --- a/src/program-settings/program-settings.controller.ts +++ b/src/program-settings/program-settings.controller.ts @@ -1,9 +1,10 @@ -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; 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 { ImportProgramSettingsDto } from './dto/import-program-settings.dto'; +import { UpdateProgramSettingsDto } from './dto/update-program-settings.dto'; import { ProgramSettingsService } from './program-settings.service'; @Controller('tenants/:tenantId/program-settings') @@ -17,6 +18,16 @@ export class ProgramSettingsController { return this.programSettings.get(tenantId); } + @Patch() + @RequirePermissions('khanegi:write') + update( + @Param('tenantId') tenantId: string, + @Body() body: UpdateProgramSettingsDto, + @CurrentUser() user: { id: string }, + ) { + return this.programSettings.updateItems(tenantId, body.items || [], user.id); + } + @Post('import') @RequirePermissions('khanegi:write') import( diff --git a/src/program-settings/program-settings.module.ts b/src/program-settings/program-settings.module.ts index 52b1eeb..3db1861 100644 --- a/src/program-settings/program-settings.module.ts +++ b/src/program-settings/program-settings.module.ts @@ -1,11 +1,20 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { SmsModule } from '../sms/sms.module'; import { ProgramSettingsController } from './program-settings.controller'; +import { PublicProgramSettingsController } from './public-program-settings.controller'; +import { ApiPublicProgramSettingsController } from './api-public-program-settings.controller'; +import { RemoteProgramSettingsController } from './remote-program-settings.controller'; import { ProgramSettingsService } from './program-settings.service'; @Module({ - imports: [AuthModule], - controllers: [ProgramSettingsController], + imports: [AuthModule, SmsModule], + controllers: [ + ProgramSettingsController, + PublicProgramSettingsController, + ApiPublicProgramSettingsController, + RemoteProgramSettingsController, + ], providers: [ProgramSettingsService], exports: [ProgramSettingsService], }) diff --git a/src/program-settings/program-settings.service.ts b/src/program-settings/program-settings.service.ts index 183d50f..c5ab35c 100644 --- a/src/program-settings/program-settings.service.ts +++ b/src/program-settings/program-settings.service.ts @@ -10,6 +10,8 @@ import axios from 'axios'; import { Prisma, ProductType } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; +import { isSecretOrServerOnlySettingKey } from './secret-setting-keys'; +import { isRuntimeProgramSettingKey } from './runtime-setting-keys'; export type ProgramSettingRow = { id?: number; @@ -24,7 +26,7 @@ export type ProgramSettingRow = { export type ProgramSettingsPayload = { importedAt: string; - source: 'khanegi_admin'; + source: 'khanegi_admin' | 'superadmin'; apiBase: string; items: ProgramSettingRow[]; }; @@ -44,8 +46,103 @@ export class ProgramSettingsService { importedAt: stored?.importedAt ?? null, source: stored?.source ?? null, apiBase: stored?.apiBase ?? null, - items: stored?.items ?? [], - count: stored?.items?.length ?? 0, + items: this.omitRuntime(stored?.items ?? []), + count: this.omitRuntime(stored?.items ?? []).length, + }; + } + + async getPublicByHost(hostHeader?: string, domainQuery?: string) { + const tenant = await this.findKhanegiTenantByHost(domainQuery || hostHeader || ''); + const stored = this.readStored(tenant.setting); + const items = this.omitRuntime(stored?.items ?? []).filter( + (row) => !isSecretOrServerOnlySettingKey(String(row.key || '')), + ); + return { + settings: items, + updatedAt: stored?.importedAt || null, + count: items.length, + }; + } + + async getRemoteFull(tenantId: string) { + const tenant = await this.requireKhanegiTenant(tenantId); + const stored = this.readStored(tenant.setting); + return { + settings: this.omitRuntime(stored?.items ?? []), + updatedAt: stored?.importedAt || null, + count: this.omitRuntime(stored?.items ?? []).length, + }; + } + + async updateItems( + tenantId: string, + itemsRaw: Array>, + userId?: string, + ) { + const tenant = await this.requireKhanegiTenant(tenantId); + const incoming = this.asSettingsArray(itemsRaw) + .map((row) => ({ + ...row, + key: String(row.key || '').trim(), + value: row.value == null ? '' : String(row.value), + can_edit: 0 as const, + is_active: + row.is_active === 0 || row.is_active === false || row.is_active === '0' ? 0 : 1, + })) + .filter((row) => row.key && !isRuntimeProgramSettingKey(row.key)); + + if (!incoming.length) { + throw new BadRequestException('هیچ تنظیم معتبری برای ذخیره ارسال نشده'); + } + + const previous = this.readStored(tenant.setting); + const byKey = new Map(); + for (const row of previous?.items ?? []) { + const key = String(row.key || '').trim(); + if (!key || isRuntimeProgramSettingKey(key)) continue; + byKey.set(key, row); + } + for (const row of incoming) { + const prev = byKey.get(row.key) || {}; + byKey.set(row.key, { ...prev, ...row }); + } + const items = [...byKey.values()]; + + const payload: ProgramSettingsPayload = { + importedAt: new Date().toISOString(), + source: 'superadmin', + apiBase: previous?.apiBase || '', + items, + }; + + await this.prisma.tenant.update({ + where: { id: tenantId }, + data: { + setting: payload as unknown as Prisma.InputJsonValue, + appVersion: this.bumpAppVersion(tenant.appVersion), + }, + }); + + await this.audit.log({ + action: 'tenant.program_settings.update', + entityType: 'tenant_program_settings', + entityId: tenantId, + tenantId, + userId, + after: { + importedAt: payload.importedAt, + count: incoming.length, + keys: incoming.map((row) => row.key), + } as Prisma.InputJsonValue, + }); + + return { + tenant: this.tenantMeta(tenant), + importedAt: payload.importedAt, + source: payload.source, + apiBase: payload.apiBase, + items: this.omitRuntime(items), + count: this.omitRuntime(items).length, }; } @@ -75,7 +172,10 @@ export class ProgramSettingsService { await this.prisma.tenant.update({ where: { id: tenantId }, - data: { setting: payload as unknown as Prisma.InputJsonValue }, + data: { + setting: payload as unknown as Prisma.InputJsonValue, + appVersion: this.bumpAppVersion(tenant.appVersion), + }, }); await this.audit.log({ @@ -155,12 +255,72 @@ export class ProgramSettingsService { const items = this.asSettingsArray(obj.items ?? obj); return { importedAt: String(obj.importedAt || ''), - source: 'khanegi_admin', + source: obj.source === 'superadmin' ? 'superadmin' : 'khanegi_admin', apiBase: String(obj.apiBase || ''), items, }; } + private async findKhanegiTenantByHost(hostRaw: string) { + const host = this.normalizeHost(hostRaw); + if (!host) { + throw new NotFoundException('domain required'); + } + + const tenants = await this.prisma.tenant.findMany({ + where: { isActive: true, product: ProductType.khanegi }, + }); + + const hostNoWww = host.replace(/^www\./, ''); + const primary = tenants.find((item) => { + const d = this.normalizeHost(item.domain); + return d === host || d.replace(/^www\./, '') === hostNoWww; + }); + const tenant = + primary || + tenants.find((item) => { + for (const value of this.readDomains(item.domains)) { + const normalized = this.normalizeHost(value); + if (!normalized) continue; + if (normalized === host || normalized.replace(/^www\./, '') === hostNoWww) { + return true; + } + } + return false; + }); + + if (!tenant) { + throw new NotFoundException('tenant not found for domain'); + } + return tenant; + } + + private readDomains(raw: unknown): string[] { + if (!raw) return []; + if (Array.isArray(raw)) return raw.map(String); + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.map(String) : []; + } catch { + return []; + } + } + return []; + } + + 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()}`; + } + private async loginKhanegiAdmin( tenant: { nodeServer: string; @@ -251,7 +411,9 @@ export class ProgramSettingsService { headers, timeout: 25000, }); - const items = this.asSettingsArray(data).filter((row) => this.isSystemSetting(row)); + const items = this.asSettingsArray(data) + .filter((row) => this.isSystemSetting(row)) + .filter((row) => !isRuntimeProgramSettingKey(String(row.key || ''))); if (items.length) return items; } catch (error) { const status = axios.isAxiosError(error) ? error.response?.status : undefined; @@ -264,7 +426,9 @@ export class ProgramSettingsService { try { const { data } = await axios.get(url, { headers, timeout: 25000 }); - return this.asSettingsArray(data).filter((row) => this.isSystemSetting(row)); + return this.asSettingsArray(data) + .filter((row) => this.isSystemSetting(row)) + .filter((row) => !isRuntimeProgramSettingKey(String(row.key || ''))); } catch (error) { throw new BadGatewayException( this.axiosMessage(error, 'خواندن get_settings از اپ خانگی ناموفق بود'), @@ -313,6 +477,10 @@ export class ProgramSettingsService { return String(raw || '').trim(); } + private omitRuntime(items: ProgramSettingRow[]): ProgramSettingRow[] { + return items.filter((row) => !isRuntimeProgramSettingKey(String(row.key || ''))); + } + private asSettingsArray(data: unknown): ProgramSettingRow[] { if (Array.isArray(data)) return data as ProgramSettingRow[]; if (data && typeof data === 'object') { diff --git a/src/program-settings/public-program-settings.controller.ts b/src/program-settings/public-program-settings.controller.ts new file mode 100644 index 0000000..46c019e --- /dev/null +++ b/src/program-settings/public-program-settings.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Headers, Header, Query } from '@nestjs/common'; +import { ProgramSettingsService } from './program-settings.service'; + +@Controller() +export class PublicProgramSettingsController { + constructor(private readonly programSettings: ProgramSettingsService) {} + + @Get('get_settings') + @Header('Cache-Control', 'public, max-age=30') + getSettings( + @Headers('host') host?: string, + @Headers('x-forwarded-host') forwardedHost?: string, + @Query('domain') domain?: string, + ) { + return this.programSettings.getPublicByHost(domain || forwardedHost || host, domain); + } +} diff --git a/src/program-settings/remote-program-settings.controller.ts b/src/program-settings/remote-program-settings.controller.ts new file mode 100644 index 0000000..1c5ca68 --- /dev/null +++ b/src/program-settings/remote-program-settings.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Req, UseGuards } from '@nestjs/common'; +import { RemoteSmsAuthGuard } from '../auth/remote-sms-auth.guard'; +import { ProgramSettingsService } from './program-settings.service'; + +@Controller('remote/program-settings') +@UseGuards(RemoteSmsAuthGuard) +export class RemoteProgramSettingsController { + constructor(private readonly programSettings: ProgramSettingsService) {} + + @Get() + getFull(@Req() req: { remoteTenant?: { id: string } }) { + const tenantId = String(req.remoteTenant?.id || ''); + return this.programSettings.getRemoteFull(tenantId); + } +} diff --git a/src/program-settings/runtime-setting-keys.ts b/src/program-settings/runtime-setting-keys.ts new file mode 100644 index 0000000..b7f20cd --- /dev/null +++ b/src/program-settings/runtime-setting-keys.ts @@ -0,0 +1,20 @@ +/** کلیدهای runtime / لاگ — مال سوپرادمین نیستند؛ در اپ خانگی توسط cron پر می‌شوند. */ +export const RUNTIME_PROGRAM_SETTING_KEYS = new Set([ + 'montzar_last_product_log', + 'montzar_order_response', + 'montzar_live_price', + 'montzar_mazaneh', + 'montzar_diff', + 'montzar_truly_buy_mazaneh', + 'montzar_truly_sell_mazaneh', + 'montzar_token', + 'montzar_closed', + 'up_price', + 'down_price', + 'price_extreme_date', + 'do_send_liman', +]); + +export function isRuntimeProgramSettingKey(keyRaw: string): boolean { + return RUNTIME_PROGRAM_SETTING_KEYS.has(String(keyRaw || '').trim()); +} diff --git a/src/program-settings/secret-setting-keys.ts b/src/program-settings/secret-setting-keys.ts new file mode 100644 index 0000000..31571e5 --- /dev/null +++ b/src/program-settings/secret-setting-keys.ts @@ -0,0 +1,41 @@ +/** هم‌تراز `Setting::isSecretOrServerOnlyKey` در سرور خانگی — برای پاسخ عمومی `/get_settings`. */ +export function isSecretOrServerOnlySettingKey(keyRaw: string): boolean { + const key = String(keyRaw || '') + .trim() + .toLowerCase(); + if (!key) return false; + + if ( + key.startsWith('external_payment_') || + key === 'wallet_payment_provider' || + key.startsWith('payping_') + ) { + return true; + } + + const exact = new Set([ + 'node_client_id', + 'node_shared_secret', + 'montzar_password', + 'montzar_token', + 'montzar_mobile', + 'bale_bot_token', + 'telegram_bot_token', + 'rubika_bot_token', + 'tahesab_token', + 'tahesab_host', + 'tahesab_port', + 'tahesab_db', + 'pargar_password', + 'pargar_api_token', + 'pargar_username', + 'zarin_api', + 'zarin_token', + 'chat_webhook_secret', + 'chat_webhook_base_url', + 'chat_api_key', + ]); + if (exact.has(key)) return true; + + return /(^|_)(secret|password|token|api_key|apikey|private)(_|$)/.test(key); +} diff --git a/src/sms/sms.module.ts b/src/sms/sms.module.ts index 9d0e805..b0a0164 100644 --- a/src/sms/sms.module.ts +++ b/src/sms/sms.module.ts @@ -10,6 +10,6 @@ import { SmsService } from './sms.service'; imports: [NotificationsModule, AuthModule], controllers: [SmsController, RemoteSmsController], providers: [SmsService, RemoteSmsAuthGuard], - exports: [SmsService], + exports: [SmsService, RemoteSmsAuthGuard], }) export class SmsModule {}