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:
@@ -58,6 +58,15 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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/ {
|
location /uploads/ {
|
||||||
proxy_pass http://superadmin_api;
|
proxy_pass http://superadmin_api;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
import { TenantsModule } from './tenants/tenants.module';
|
import { TenantsModule } from './tenants/tenants.module';
|
||||||
import { GetUserModule } from './get-user/get-user.module';
|
import { GetUserModule } from './get-user/get-user.module';
|
||||||
import { GetVersionModule } from './get-version/get-version.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 { SettingsProxyModule } from './settings-proxy/settings-proxy.module';
|
||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
import { SmsModule } from './sms/sms.module';
|
import { SmsModule } from './sms/sms.module';
|
||||||
@@ -24,6 +25,7 @@ import { HealthController } from './health.controller';
|
|||||||
TenantsModule,
|
TenantsModule,
|
||||||
GetUserModule,
|
GetUserModule,
|
||||||
GetVersionModule,
|
GetVersionModule,
|
||||||
|
GetManifestModule,
|
||||||
SettingsProxyModule,
|
SettingsProxyModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
SmsModule,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -5,5 +5,6 @@ import { GetUserService } from './get-user.service';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [GetUserController],
|
controllers: [GetUserController],
|
||||||
providers: [GetUserService],
|
providers: [GetUserService],
|
||||||
|
exports: [GetUserService],
|
||||||
})
|
})
|
||||||
export class GetUserModule {}
|
export class GetUserModule {}
|
||||||
|
|||||||
@@ -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) {
|
private stripKhanegiSaMeta(ui: Record<string, any> | null) {
|
||||||
if (!ui || typeof ui !== 'object') return ui;
|
if (!ui || typeof ui !== 'object') return ui;
|
||||||
const { defaultSnapshot: _snap, _backfill, ...rest } = ui;
|
const { defaultSnapshot: _snap, _backfill, ...rest } = ui;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ async function bootstrap() {
|
|||||||
exclude: [
|
exclude: [
|
||||||
{ path: 'get_user', method: RequestMethod.GET },
|
{ path: 'get_user', method: RequestMethod.GET },
|
||||||
{ path: 'get_version', method: RequestMethod.GET },
|
{ path: 'get_version', method: RequestMethod.GET },
|
||||||
|
{ path: 'manifest.json', method: RequestMethod.GET },
|
||||||
{ path: 'uploads/(.*)', method: RequestMethod.GET },
|
{ path: 'uploads/(.*)', method: RequestMethod.GET },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user