From be50c99f65512162e6a34391fadfd616a5ad7dbf Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sun, 9 Aug 2026 15:41:50 +0330 Subject: [PATCH] feat: public GET /manifest.json from tenant branding Serve PWA/APK web manifest per domain (name, theme colors, multi-size icons from avatar), exclude from api prefix, and proxy via nginx. Co-authored-by: Cursor --- deploy/nginx-adm.montzar.ir.conf | 9 +++ src/app.module.ts | 2 + src/get-manifest/get-manifest.controller.ts | 21 +++++++ src/get-manifest/get-manifest.module.ts | 9 +++ src/get-user/get-user.module.ts | 1 + src/get-user/get-user.service.ts | 67 +++++++++++++++++++++ src/main.ts | 1 + 7 files changed, 110 insertions(+) create mode 100644 src/get-manifest/get-manifest.controller.ts create mode 100644 src/get-manifest/get-manifest.module.ts diff --git a/deploy/nginx-adm.montzar.ir.conf b/deploy/nginx-adm.montzar.ir.conf index 90576aa..491a437 100644 --- a/deploy/nginx-adm.montzar.ir.conf +++ b/deploy/nginx-adm.montzar.ir.conf @@ -58,6 +58,15 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location = /manifest.json { + proxy_pass http://superadmin_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /uploads/ { proxy_pass http://superadmin_api; proxy_http_version 1.1; diff --git a/src/app.module.ts b/src/app.module.ts index 6834add..63f8cfe 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,7 @@ import { AuthModule } from './auth/auth.module'; import { TenantsModule } from './tenants/tenants.module'; import { GetUserModule } from './get-user/get-user.module'; import { GetVersionModule } from './get-version/get-version.module'; +import { GetManifestModule } from './get-manifest/get-manifest.module'; import { SettingsProxyModule } from './settings-proxy/settings-proxy.module'; import { AuditModule } from './audit/audit.module'; import { SmsModule } from './sms/sms.module'; @@ -24,6 +25,7 @@ import { HealthController } from './health.controller'; TenantsModule, GetUserModule, GetVersionModule, + GetManifestModule, SettingsProxyModule, AuditModule, SmsModule, diff --git a/src/get-manifest/get-manifest.controller.ts b/src/get-manifest/get-manifest.controller.ts new file mode 100644 index 0000000..8165317 --- /dev/null +++ b/src/get-manifest/get-manifest.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get, Header, Headers, Query } from '@nestjs/common'; +import { GetUserService } from '../get-user/get-user.service'; + +@Controller() +export class GetManifestController { + constructor(private readonly getUserService: GetUserService) {} + + @Get('manifest.json') + @Header('Content-Type', 'application/manifest+json; charset=utf-8') + @Header('Cache-Control', 'public, max-age=300') + getManifest( + @Headers('host') host?: string, + @Headers('x-forwarded-host') forwardedHost?: string, + @Query('domain') domain?: string, + ) { + return this.getUserService.resolveManifestByHost( + domain || forwardedHost || host, + domain, + ); + } +} diff --git a/src/get-manifest/get-manifest.module.ts b/src/get-manifest/get-manifest.module.ts new file mode 100644 index 0000000..777e373 --- /dev/null +++ b/src/get-manifest/get-manifest.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { GetUserModule } from '../get-user/get-user.module'; +import { GetManifestController } from './get-manifest.controller'; + +@Module({ + imports: [GetUserModule], + controllers: [GetManifestController], +}) +export class GetManifestModule {} diff --git a/src/get-user/get-user.module.ts b/src/get-user/get-user.module.ts index abd9f37..2aa722b 100644 --- a/src/get-user/get-user.module.ts +++ b/src/get-user/get-user.module.ts @@ -5,5 +5,6 @@ import { GetUserService } from './get-user.service'; @Module({ controllers: [GetUserController], providers: [GetUserService], + exports: [GetUserService], }) export class GetUserModule {} diff --git a/src/get-user/get-user.service.ts b/src/get-user/get-user.service.ts index f5247c5..1a7d51a 100644 --- a/src/get-user/get-user.service.ts +++ b/src/get-user/get-user.service.ts @@ -95,6 +95,73 @@ export class GetUserService { }; } + /** + * Web App Manifest برای PWA / ساخت APK — برندینگ همان get_user + * GET /manifest.json?domain= + */ + async resolveManifestByHost(hostHeader?: string, domainQuery?: string) { + const branding = await this.resolveByHost(hostHeader, domainQuery); + const name = + String(branding.name_fa || branding.name_en || branding.name || '').trim() || + 'App'; + const themeColor = String(branding.color || '').trim() || '#2563eb'; + const backgroundColor = + String(branding.background_color || '').trim() || '#ffffff'; + const avatar = this.toAbsoluteIconUrl(branding.avatar); + + if (!avatar) { + throw new NotFoundException('tenant avatar required for manifest'); + } + + const iconType = this.guessImageMime(avatar); + const iconSizes = ['1024x1024', '512x512', '192x192', '180x180'] as const; + + return { + name, + short_name: name, + lang: 'fa', + dir: 'rtl', + start_url: '/', + scope: '/', + display: 'standalone', + background_color: backgroundColor, + theme_color: themeColor, + description: name, + icons: iconSizes.map((sizes) => ({ + src: avatar, + sizes, + type: iconType, + purpose: 'any', + })), + }; + } + + private toAbsoluteIconUrl(raw: unknown): string { + const value = String(raw || '').trim(); + if (!value) return ''; + if (/^(https?:\/\/|data:)/i.test(value)) return value; + if (value.startsWith('//')) return `https:${value}`; + + const publicBase = String(process.env.PUBLIC_API_URL || process.env.APP_URL || '') + .trim() + .replace(/\/$/, ''); + if (publicBase && value.startsWith('/')) { + return `${publicBase}${value}`; + } + if (publicBase) { + return `${publicBase}/${value.replace(/^\/+/, '')}`; + } + return value.startsWith('/') ? value : `/${value}`; + } + + private guessImageMime(url: string) { + const path = url.split('?')[0].toLowerCase(); + if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg'; + if (path.endsWith('.webp')) return 'image/webp'; + if (path.endsWith('.svg')) return 'image/svg+xml'; + return 'image/png'; + } + private stripKhanegiSaMeta(ui: Record | null) { if (!ui || typeof ui !== 'object') return ui; const { defaultSnapshot: _snap, _backfill, ...rest } = ui; diff --git a/src/main.ts b/src/main.ts index 1d950da..d3a34a8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,7 @@ async function bootstrap() { exclude: [ { path: 'get_user', method: RequestMethod.GET }, { path: 'get_version', method: RequestMethod.GET }, + { path: 'manifest.json', method: RequestMethod.GET }, { path: 'uploads/(.*)', method: RequestMethod.GET }, ], });