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
+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;