diff --git a/src/auth/tenant-host.util.spec.ts b/src/auth/tenant-host.util.spec.ts index 1d656b8..dffca35 100644 --- a/src/auth/tenant-host.util.spec.ts +++ b/src/auth/tenant-host.util.spec.ts @@ -56,6 +56,17 @@ describe('findTenantForHost', () => { expect(scoreTenantHost(mine, api)).toBe(75); }); + it('full match uses extra IP aliases like get_user', () => { + const tenant = fromDomain('k.montzar.ir', { + domains: ['192.168.70.191', '192.168.70.184', '192.168.70.158'], + }); + + expect(findTenantForHost([tenant], '192.168.70.191')?.domain).toBe( + 'k.montzar.ir', + ); + expect(scoreTenantHost(tenant, '192.168.70.191')).toBe(90); + }); + it('does not pick a tenant when only a shared nodeServer matches', () => { const api = 'box.example.ir'; const left = fromDomain('a.ir', { nodeServer: `https://${api}/server` }); diff --git a/src/plugins/plugin-store.service.ts b/src/plugins/plugin-store.service.ts index eee63dd..d0e5f5b 100644 --- a/src/plugins/plugin-store.service.ts +++ b/src/plugins/plugin-store.service.ts @@ -7,7 +7,6 @@ import { NotFoundException, OnModuleDestroy, OnModuleInit, - ServiceUnavailableException, } from '@nestjs/common'; import { PluginCatalogItem, @@ -33,6 +32,11 @@ import { iranDaysUntil, } from '../common/iran-time.util'; import { rewriteLegacyUploadUrl } from '../uploads/uploads.util'; +import { + findTenantForHost, + normalizeTenantHost, + scoreTenantHost, +} from '../auth/tenant-host.util'; import { daysToPlanMonths, inferSettingKeyFromName, @@ -493,17 +497,90 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy { return { ok: true }; } + async resolvePublicTenant(domainRaw?: string, originRaw?: string) { + const host = normalizeTenantHost(domainRaw || ''); + if (!host) { + throw new BadRequestException('دامنه لازم است'); + } + + const tenants = await this.prisma.tenant.findMany({ + where: { isActive: true }, + select: { + id: true, + nameFa: true, + domain: true, + domains: true, + adminUrl: true, + appUrl: true, + nodeServer: true, + product: true, + isActive: true, + }, + }); + + const tenant = findTenantForHost(tenants, host); + if (!tenant) { + throw new NotFoundException('نسخه‌ای با این دامنه یافت نشد'); + } + if (tenant.product !== ProductType.khanegi) { + throw new ForbiddenException('فروشگاه افزونه فقط برای خانگی است'); + } + if (!(await this.isStoreEnabled(tenant.id))) { + throw new ForbiddenException('فروشگاه افزونه برای این نسخه خاموش است'); + } + + const originHost = normalizeTenantHost(originRaw || ''); + if (originHost && scoreTenantHost(tenant, originHost) <= 0) { + throw new ForbiddenException('دامنه مبدأ با این نسخه هم‌خوان نیست'); + } + + return tenant; + } + + async publicStore(domain?: string, origin?: string) { + const tenant = await this.resolvePublicTenant(domain, origin); + return this.buildRemoteStore(tenant.id); + } + + async publicListRequests(domain?: string, origin?: string) { + const tenant = await this.resolvePublicTenant(domain, origin); + return { + requests: await this.buildRemoteRequestRows(tenant.id), + deposit: this.runtime.pluginStoreDeposit(), + }; + } + + async publicCreateRequest( + domain: string | undefined, + origin: string | undefined, + dto: CreatePurchaseRequestDto, + ) { + const tenant = await this.resolvePublicTenant(domain, origin); + return this.remoteCreateRequest(tenant.id, tenant.product, dto); + } + + async publicCancelRequest( + domain: string | undefined, + origin: string | undefined, + requestId: string, + ) { + const tenant = await this.resolvePublicTenant(domain, origin); + return this.remoteCancelRequest(tenant.id, tenant.product, requestId); + } + + async publicAttachReceipt( + domain: string | undefined, + origin: string | undefined, + requestId: string, + receiptUrl: string, + ) { + const tenant = await this.resolvePublicTenant(domain, origin); + return this.remoteAttachReceipt(tenant.id, tenant.product, requestId, receiptUrl); + } + async remoteStore(tenantId: string, product: string) { await this.assertRemoteKhanegi(tenantId, product); - try { - return await this.buildRemoteStore(tenantId); - } catch (error: any) { - if (error instanceof ForbiddenException) throw error; - this.logger.warn(`remoteStore failed: ${error?.message || error}`); - throw new ServiceUnavailableException( - error?.message || 'فروشگاه افزونه در سوپرادمین در دسترس نیست', - ); - } + return this.buildRemoteStore(tenantId); } private isCatalogPriceEnabled(price: Pick | { enabled?: unknown }) { diff --git a/src/plugins/plugins.module.ts b/src/plugins/plugins.module.ts index 9191a26..4b282d5 100644 --- a/src/plugins/plugins.module.ts +++ b/src/plugins/plugins.module.ts @@ -4,13 +4,19 @@ import { NotificationsModule } from '../notifications/notifications.module'; import { ProgramSettingsModule } from '../program-settings/program-settings.module'; import { PluginsController } from './plugins.controller'; import { PluginStoreController } from './plugin-store.controller'; +import { PublicPluginStoreController } from './public-plugin-store.controller'; import { RemotePluginsController } from './remote-plugins.controller'; import { PluginsService } from './plugins.service'; import { PluginStoreService } from './plugin-store.service'; @Module({ imports: [NotificationsModule, AuthModule, ProgramSettingsModule], - controllers: [PluginsController, PluginStoreController, RemotePluginsController], + controllers: [ + PluginsController, + PluginStoreController, + PublicPluginStoreController, + RemotePluginsController, + ], providers: [PluginsService, PluginStoreService], exports: [PluginsService, PluginStoreService], }) diff --git a/src/plugins/public-plugin-store.controller.ts b/src/plugins/public-plugin-store.controller.ts new file mode 100644 index 0000000..a867913 --- /dev/null +++ b/src/plugins/public-plugin-store.controller.ts @@ -0,0 +1,109 @@ +import { + BadRequestException, + Body, + Controller, + Get, + Header, + Headers, + Param, + Post, + Query, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +import { extname } from 'path'; +import { + IMAGE_MIME, + PLUGIN_RECEIPTS_DIR, + ensureUploadsDirs, + publicApiBase, +} from '../uploads/uploads.util'; +import { CreatePurchaseRequestDto } from './dto/plugin-store.dto'; +import { PluginStoreService } from './plugin-store.service'; + +@Controller('public/plugins') +export class PublicPluginStoreController { + constructor( + private readonly store: PluginStoreService, + private readonly config: ConfigService, + ) {} + + @Get('store') + @Header('Cache-Control', 'no-store') + storeFront( + @Query('domain') domain?: string, + @Headers('origin') origin?: string, + ) { + return this.store.publicStore(domain, origin); + } + + @Get('requests') + @Header('Cache-Control', 'no-store') + list( + @Query('domain') domain?: string, + @Headers('origin') origin?: string, + ) { + return this.store.publicListRequests(domain, origin); + } + + @Post('requests') + create( + @Query('domain') domain?: string, + @Headers('origin') origin?: string, + @Body() dto: CreatePurchaseRequestDto, + ) { + return this.store.publicCreateRequest(domain, origin, dto); + } + + @Post('requests/:id/cancel') + cancel( + @Param('id') id: string, + @Query('domain') domain?: string, + @Headers('origin') origin?: string, + ) { + return this.store.publicCancelRequest(domain, origin, id); + } + + @Post('requests/:id/receipt') + @UseInterceptors( + FileInterceptor('file', { + storage: diskStorage({ + destination: (_req, _file, cb) => { + ensureUploadsDirs(); + cb(null, PLUGIN_RECEIPTS_DIR); + }, + filename: (_req, file, cb) => { + const safeExt = extname(file.originalname).toLowerCase() || '.png'; + const name = `receipt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${safeExt}`; + cb(null, name); + }, + }), + limits: { fileSize: 2 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + if (!IMAGE_MIME.test(file.mimetype)) { + cb(new BadRequestException('فقط تصویر (jpg/png/gif/webp/svg) مجاز است') as any, false); + return; + } + cb(null, true); + }, + }), + ) + receipt( + @Param('id') id: string, + @Query('domain') domain?: string, + @Headers('origin') origin?: string, + @UploadedFile() file: Express.Multer.File, + ) { + if (!file) throw new BadRequestException('فایل ارسال نشده است'); + const base = publicApiBase(this.config); + return this.store.publicAttachReceipt( + domain, + origin, + id, + `${base}/uploads/plugin-receipts/${file.filename}`, + ); + } +}