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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user