From a4815b37b3728d4308edcad5fd9cc715e50d62eb Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Tue, 15 Sep 2026 12:56:13 +0330 Subject: [PATCH] widget improvement + install for customer sites guideline --- src/widget/widget.controller.ts | 67 +++--- src/widget/widget.service.ts | 408 +++++++++++++++++++++++++------- 2 files changed, 358 insertions(+), 117 deletions(-) diff --git a/src/widget/widget.controller.ts b/src/widget/widget.controller.ts index 622db2b..58891d4 100644 --- a/src/widget/widget.controller.ts +++ b/src/widget/widget.controller.ts @@ -1,59 +1,54 @@ -import { Controller, Get, Query, Header, BadRequestException, Req, Res } from '@nestjs/common'; +import { Controller, Get, Query, BadRequestException, Req, Res } from '@nestjs/common'; +import { Request, Response } from 'express'; import { Public } from 'src/auth/auth.decorator'; import { WidgetService } from './widget.service'; @Controller('widget') @Public() export class WidgetController { - constructor(private readonly widgetService: WidgetService) { } + constructor(private readonly widgetService: WidgetService) {} @Get('script') - @Header('Content-Type', 'application/javascript') - @Header('Access-Control-Allow-Origin', 'https://staging.hdmplus.ir/, https://si24.ir') - @Header('X-Frame-Options', 'ALLOW-FROM https://staging.hdmplus.ir/ https://si24.ir') - @Header( - 'Content-Security-Policy', - "frame-ancestors 'self' https://staging.hdmplus.ir/ https://si24.ir", - ) - @Header('X-Content-Type-Options', 'nosniff') getWidgetScript( @Query('apiKey') apiKey: string, @Req() req: Request, - ): string { - if (!apiKey) { - throw new BadRequestException('API key is required'); + @Res() res: Response, + ) { + if (!this.widgetService.isValidApiKey(apiKey)) { + throw new BadRequestException( + apiKey ? 'Invalid API key' : 'API key is required', + ); } - const isValidApiKey = - Buffer.from(apiKey).length === Buffer.from('si24samanwebsite').length && - Buffer.from(apiKey).compare(Buffer.from('si24samanwebsite')) === 0; - - if (!isValidApiKey) { - throw new BadRequestException('Invalid API key'); - } - - return this.widgetService.generateWidgetScript(); + this.widgetService.applyScriptHeaders(req, res); + return res.send(this.widgetService.generateWidgetScript()); } @Get('iframe') - async getIframeScript( + getIframeScript( @Query('apiKey') apiKey: string, @Req() req: Request, - @Res() res, + @Res() res: Response, ) { - if (!apiKey || apiKey !== 'si24samanwebsite') { + if (!this.widgetService.isValidApiKey(apiKey)) { throw new BadRequestException('API key is invalid or missing'); } - res.set({ - 'Content-Type': 'application/javascript', - 'Access-Control-Allow-Origin': 'https://staging.hdmplus.ir/, https://si24.ir', - 'X-Frame-Options': 'ALLOW-FROM https://staging.hdmplus.ir/ https://si24.ir', - 'Content-Security-Policy': "frame-ancestors 'self' https://staging.hdmplus.ir/ https://si24.ir", - 'X-Content-Type-Options': 'nosniff', - }); - - const script = this.widgetService.generateWidgetScriptWithoutStyle(); - return res.send(script); + this.widgetService.applyScriptHeaders(req, res); + return res.send(this.widgetService.generateWidgetScriptWithoutStyle()); } -} + + @Get('demo') + getWidgetDemo(@Res() res: Response) { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Cache-Control', 'no-store'); + return res.send(this.widgetService.generateDemoHtml()); + } + + @Get('frame') + getWidgetFrame(@Res() res: Response) { + this.widgetService.applyFrameHeaders(res); + return res.send(this.widgetService.generateFrameHtml()); + } +} diff --git a/src/widget/widget.service.ts b/src/widget/widget.service.ts index 0ef8992..4f8236b 100644 --- a/src/widget/widget.service.ts +++ b/src/widget/widget.service.ts @@ -1,27 +1,90 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { Request, Response } from 'express'; @Injectable() export class WidgetService { + /** Full iframe src, may include a path (e.g. /widget/frame). */ private readonly frontendUrl: string; - constructor(private readonly configService: ConfigService) { - this.frontendUrl = this.configService.get('FRONTEND_URL') || 'https://chatbot.si24.ir'; - // Khahesh mikonam - // 1. az env estefadeh konid - // 2. agar chizi hardcode hast hamahang konid - // -------- az env estefade shude --------- - // c------ chiziam hardcode nist ---- + /** Origin only — required by postMessage targetOrigin. */ + private readonly frontendOrigin: string; + private readonly apiKey: string; + private readonly iconUrl: string; + private readonly allowedParentOrigins: string[]; + constructor(private readonly configService: ConfigService) { + this.frontendUrl = WidgetService.requireUrl( + this.configService.get('FRONTEND_URL'), + 'FRONTEND_URL', + ); + this.frontendOrigin = new URL(this.frontendUrl).origin; + this.apiKey = WidgetService.requireValue( + this.configService.get('WIDGET_API_KEY'), + 'WIDGET_API_KEY', + ); + this.iconUrl = + WidgetService.optionalValue( + this.configService.get('WIDGET_ICON_URL'), + ) || WidgetService.defaultIconDataUri(); + this.allowedParentOrigins = WidgetService.parseOrigins( + this.configService.get('WIDGET_ALLOWED_ORIGINS'), + ); + } + + isValidApiKey(apiKey?: string): boolean { + return Boolean(apiKey) && apiKey === this.apiKey; + } + + /** + * Browsers accept only a single origin in Access-Control-Allow-Origin. + * Echo the request Origin when it is in WIDGET_ALLOWED_ORIGINS (or FRONTEND_URL). + * + * Do not set CSP frame-ancestors here. That header answers "who may iframe + * THIS response?" Nobody iframes the JS file. Who may iframe the SPA is + * decided by chatbot HTML/nginx, e.g.: + * Content-Security-Policy: frame-ancestors https://anothersite.com + * + * Previous loader header (wrong place): + * res.setHeader( + * 'Content-Security-Policy', + * `frame-ancestors 'self' ${this.allowedParentOrigins.join(' ')}`, + * ); + */ + applyScriptHeaders(req: Request, res: Response): void { + res.setHeader('Content-Type', 'application/javascript'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + + const origin = WidgetService.normalizeOrigin(req.headers.origin); + if (origin && this.corsOrigins().includes(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } + res.setHeader('Cache-Control', 'public, max-age=300'); + } + + /** + * Headers for the document that sits inside the widget iframe. + * Global middleware sets X-Frame-Options: DENY; that must be removed + * or the panel stays blank. + */ + applyFrameHeaders(res: Response): void { + res.removeHeader('X-Frame-Options'); + const ancestors = ["'self'", ...this.allowedParentOrigins].join(' '); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Content-Security-Policy', `frame-ancestors ${ancestors}`); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Cache-Control', 'no-store'); } generateIframe(): string { + const frontendUrl = JSON.stringify(this.frontendUrl); return ` (function () { if (window.samanChatbotLoaded || document.getElementById('saman-widget-iframe')) return; const iframe = document.createElement('iframe'); iframe.id = 'saman-widget-iframe'; - iframe.src = '${this.frontendUrl}'; + iframe.src = ${frontendUrl}; iframe.style.position = 'fixed'; iframe.style.top = '0'; iframe.style.left = '0'; @@ -40,7 +103,51 @@ export class WidgetService { })(); `; } + /** + * Previous iframe attributes (kept for rollback if third-party cookies + * break again). Added while trying to make login/storage work when the + * SPA is embedded on another site. `credentialless="false"` is a boolean + * HTML attribute: presence turns it ON, so it could strip cookies. + * The second `allow` also overwrote microphone/camera. + * + * iframe.allow = 'microphone; camera; storage-access-api'; + * iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups allow-storage-access-by-user-activation'; + * iframe.setAttribute('loading', 'lazy'); + * iframe.setAttribute('credentialless', 'false'); + * iframe.setAttribute('allow', 'storage-access-api *'); + * + * Parent-document Storage Access API (removed). Parent site + * (e.g. anothername.com) auth is unrelated to chatbot iframe auth. + * requestStorageAccess() only helps if the iframe document calls it. + * The loader still postMessages REQUEST_STORAGE_ACCESS into the iframe. + * + * if (event.data.type === 'REQUEST_STORAGE_ACCESS' && + * 'requestStorageAccess' in document && + * typeof document.requestStorageAccess === 'function') { + * document.requestStorageAccess().then(() => { + * iframe.contentWindow.postMessage({ type: 'STORAGE_ACCESS_GRANTED' }, event.origin); + * }).catch(() => { + * iframe.contentWindow.postMessage({ type: 'STORAGE_ACCESS_DENIED' }, event.origin); + * }); + * } + * + * Eager iframe (removed): iframe was created and src set on script load. + * loading=lazy does not help a position:fixed iframe. SPA now loads on + * first bubble click via ensureIframe(). + * + * Parent navigation (removed). Widget must not send anothersite.com + * elsewhere via postMessage: + * if (event.data.type === 'REDIRECT_REQUEST') { + * window.top.location.href = event.data.url; + * } + */ generateWidgetScript(): string { + const frontendUrl = JSON.stringify(this.frontendUrl); + const frontendOrigin = JSON.stringify(this.frontendOrigin); + const iconHtml = JSON.stringify( + `chat`, + ); + const allowedOrigins = JSON.stringify(this.messageOrigins()); return ` (function () { if (window.samanChatbotLoaded) return; @@ -105,29 +212,44 @@ export class WidgetService { const btn = document.createElement('button'); btn.id = 'saman-widget-btn'; - btn.innerHTML = 'chat'; + btn.innerHTML = ${iconHtml}; document.body.appendChild(btn); - const iframe = document.createElement('iframe'); - iframe.id = 'saman-widget-iframe'; - iframe.src = '${this.frontendUrl}'; - iframe.allow = 'microphone; camera; storage-access-api'; - iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups allow-storage-access-by-user-activation'; - iframe.setAttribute('loading', 'lazy'); - iframe.setAttribute('credentialless', 'false'); - iframe.setAttribute('allow', 'storage-access-api *'); - document.body.appendChild(iframe); - + let iframe = null; let closeBtn; + const allowedOrigins = ${allowedOrigins}; + + function ensureIframe() { + if (iframe) return iframe; + + iframe = document.createElement('iframe'); + iframe.id = 'saman-widget-iframe'; + iframe.allow = 'microphone; camera; storage-access-api'; + iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups allow-storage-access-by-user-activation'; + iframe.addEventListener('load', function() { + if (!iframe.contentWindow) return; + iframe.contentWindow.postMessage({ + type: 'IFRAME_LOADED' + }, ${frontendOrigin}); + iframe.contentWindow.postMessage({ + type: 'REQUEST_STORAGE_ACCESS' + }, ${frontendOrigin}); + }); + document.body.appendChild(iframe); + iframe.src = ${frontendUrl}; + return iframe; + } function openWidget() { - iframe.classList.add('visible'); + const alreadyCreated = Boolean(iframe); + const frame = ensureIframe(); + frame.classList.add('visible'); if (window.innerWidth < 768) { - iframe.style.top = '0'; - iframe.style.left = '0'; - iframe.style.width = '100%'; - iframe.style.height = '100%'; - iframe.style.borderRadius = '0'; + frame.style.top = '0'; + frame.style.left = '0'; + frame.style.width = '100%'; + frame.style.height = '100%'; + frame.style.borderRadius = '0'; closeBtn = document.createElement('button'); closeBtn.id = 'saman-close-btn'; @@ -142,20 +264,16 @@ export class WidgetService { btn.style.setProperty('pointer-events', 'none', 'important'); } - - setTimeout(() => { - if (iframe.contentWindow && - 'requestStorageAccess' in document && - typeof document.requestStorageAccess === 'function') { - iframe.contentWindow.postMessage({ - type: 'REQUEST_STORAGE_ACCESS' - }, '${this.frontendUrl}'); - } - }, 1000); + + if (alreadyCreated && frame.contentWindow) { + frame.contentWindow.postMessage({ + type: 'REQUEST_STORAGE_ACCESS' + }, ${frontendOrigin}); + } } function closeWidget() { - iframe.classList.remove('visible'); + if (iframe) iframe.classList.remove('visible'); if (closeBtn) { closeBtn.remove(); closeBtn = null; @@ -163,21 +281,19 @@ export class WidgetService { document.body.style.overflow = ''; document.documentElement.style.overflow = ''; - // Show the widget button again when closing btn.style.setProperty('visibility', 'visible', 'important'); btn.style.setProperty('opacity', '1', 'important'); btn.style.setProperty('pointer-events', 'auto', 'important'); -} + } btn.addEventListener('click', function () { - if (iframe.classList.contains('visible')) closeWidget(); + if (iframe && iframe.classList.contains('visible')) closeWidget(); else openWidget(); }); - - const allowedOrigins = ['https://chatbot.si24.ir', 'https://staging.hdmplus.ir']; window.addEventListener('message', function(event) { if (!allowedOrigins.includes(event.origin)) return; + if (!iframe || !iframe.contentWindow) return; if (event.data.type === 'REQUEST_DATA') { iframe.contentWindow.postMessage({ @@ -185,37 +301,15 @@ export class WidgetService { payload: event.data.payload }, event.origin); } - - if (event.data.type === 'REDIRECT_REQUEST') { - window.top.location.href = event.data.url; - } - - if (event.data.type === 'REQUEST_STORAGE_ACCESS' && - 'requestStorageAccess' in document && - typeof document.requestStorageAccess === 'function') { - document.requestStorageAccess().then(() => { - iframe.contentWindow.postMessage({ - type: 'STORAGE_ACCESS_GRANTED' - }, event.origin); - }).catch(() => { - iframe.contentWindow.postMessage({ - type: 'STORAGE_ACCESS_DENIED' - }, event.origin); - }); - } - }); - - iframe.addEventListener('load', function() { - setTimeout(() => { - iframe.contentWindow.postMessage({ - type: 'IFRAME_LOADED' - }, '${this.frontendUrl}'); - }, 500); }); })(); `; } generateWidgetScriptWithoutStyle(): string { + const frontendUrl = JSON.stringify(this.frontendUrl); + const iconHtml = JSON.stringify( + ``, + ); return ` (function() { if (window.samanChatbotLoaded) return; @@ -223,24 +317,31 @@ export class WidgetService { const btn = document.createElement('button'); btn.style.cssText = "position:fixed;bottom:16px;right:16px;width:45px;height:45px;border-radius:50%;border:none;background:linear-gradient(135deg,#0062ff,#007bff);cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2147483647;transition:width 0.3s ease,height 0.3s ease"; - btn.innerHTML = ''; + btn.innerHTML = ${iconHtml}; document.body.appendChild(btn); - const iframe = document.createElement('iframe'); - iframe.src = '${this.frontendUrl}'; - iframe.allow = 'microphone;camera'; - iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups'; - iframe.style.cssText = "position:fixed;bottom:80px;right:16px;width:380px;height:600px;border:none;border-radius:12px;display:none;z-index:2147483646;box-shadow:0 10px 40px rgba(0,0,0,0.2)"; - document.body.appendChild(iframe); - + let iframe = null; let closeBtn; + function ensureIframe() { + if (iframe) return iframe; + + iframe = document.createElement('iframe'); + iframe.allow = 'microphone;camera'; + iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups'; + iframe.style.cssText = "position:fixed;bottom:80px;right:16px;width:380px;height:600px;border:none;border-radius:12px;display:none;z-index:2147483646;box-shadow:0 10px 40px rgba(0,0,0,0.2)"; + document.body.appendChild(iframe); + iframe.src = ${frontendUrl}; + return iframe; + } + function openWidget() { - iframe.style.display = 'block'; + const frame = ensureIframe(); + frame.style.display = 'block'; if (window.innerWidth < 768) { - iframe.style.width = '100%'; - iframe.style.height = '100%'; - iframe.style.borderRadius = '0'; + frame.style.width = '100%'; + frame.style.height = '100%'; + frame.style.borderRadius = '0'; closeBtn = document.createElement('button'); closeBtn.innerHTML = '✕'; @@ -258,7 +359,7 @@ export class WidgetService { } function closeWidget() { - iframe.style.display = 'none'; + if (iframe) iframe.style.display = 'none'; if (closeBtn) { closeBtn.remove(); closeBtn = null; } document.body.style.overflow = ''; document.documentElement.style.overflow = ''; @@ -268,7 +369,7 @@ export class WidgetService { } btn.addEventListener('click', function() { - if (iframe.style.display === 'block') closeWidget(); + if (iframe && iframe.style.display === 'block') closeWidget(); else openWidget(); }); })(); @@ -280,4 +381,149 @@ export class WidgetService { getScript(): string { return ``; } + + generateDemoHtml(): string { + const scriptSrc = `/widget/script?apiKey=${encodeURIComponent(this.apiKey)}`; + return ` + + + + + Widget parent demo (anothersite.com) + + + +
anothersite.com — fake customer page
+
+

Parent site

+

This page is the host (like example.com). Auth here is unrelated to chat login inside the widget.

+
+

Look at the bottom-right bubble. Click it.

+
    +
  • Bubble only on load — pass
  • +
  • Panel opens on click — pass
  • +
  • This URL stays /widget/demo — pass (widget must not navigate the parent)
  • +
+

Iframe src is ${this.escapeHtml(this.frontendUrl)}

+
+
+ + +`; + } + + generateFrameHtml(): string { + return ` + + + + + Widget iframe stub + + + +
+

Chat iframe loaded

+

If you see this after clicking the bubble, the loader works. This stub is not the production SPA.

+

Set FRONTEND_URL to the real chatbot app when embedding on a customer site. That app must send frame-ancestors for the parent origin.

+
waiting for postMessage…
+
+ + +`; + } + + private escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + private messageOrigins(): string[] { + return Array.from( + new Set([this.frontendOrigin, ...this.allowedParentOrigins]), + ); + } + + private corsOrigins(): string[] { + return this.messageOrigins(); + } + + private static requireUrl(raw: string | undefined, name: string): string { + const value = WidgetService.normalizeOrigin(raw); + if (!value || !/^https?:\/\//i.test(value)) { + throw new Error(`${name} environment variable not configured`); + } + return value; + } + + private static requireValue(raw: string | undefined, name: string): string { + const value = WidgetService.optionalValue(raw); + if (!value) { + throw new Error(`${name} environment variable not configured`); + } + return value; + } + + private static optionalValue(raw: string | undefined): string { + if (raw == null) return ''; + let value = String(raw).trim(); + if ( + (value.startsWith("'") && value.endsWith("'")) || + (value.startsWith('"') && value.endsWith('"')) + ) { + value = value.slice(1, -1).trim(); + } + return value; + } + + private static parseOrigins(raw: string | undefined): string[] { + const value = WidgetService.optionalValue(raw); + if (!value) return []; + return Array.from( + new Set( + value + .split(',') + .map((item) => WidgetService.normalizeOrigin(item)) + .filter((item): item is string => Boolean(item)), + ), + ); + } + + private static normalizeOrigin(raw: string | undefined): string { + const value = WidgetService.optionalValue(raw); + if (!value) return ''; + return value.replace(/\/+$/, ''); + } + + private static defaultIconDataUri(): string { + const svg = + ''; + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + } }