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 <cursoragent@cursor.com>
This commit is contained in:
2026-08-09 15:41:50 +03:30
co-authored by Cursor
parent 1adaef5d18
commit be50c99f65
7 changed files with 110 additions and 0 deletions
+9
View File
@@ -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;
+2
View File
@@ -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,
@@ -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,
);
}
}
+9
View File
@@ -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 {}
+1
View File
@@ -5,5 +5,6 @@ import { GetUserService } from './get-user.service';
@Module({
controllers: [GetUserController],
providers: [GetUserService],
exports: [GetUserService],
})
export class GetUserModule {}
+67
View File
@@ -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<string, any> | null) {
if (!ui || typeof ui !== 'object') return ui;
const { defaultSnapshot: _snap, _backfill, ...rest } = ui;
+1
View File
@@ -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 },
],
});