forked from Chatbot/v3-api
widget improvement + install for customer sites guideline
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
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 { Public } from 'src/auth/auth.decorator';
|
||||||
import { WidgetService } from './widget.service';
|
import { WidgetService } from './widget.service';
|
||||||
|
|
||||||
@@ -8,52 +9,46 @@ export class WidgetController {
|
|||||||
constructor(private readonly widgetService: WidgetService) {}
|
constructor(private readonly widgetService: WidgetService) {}
|
||||||
|
|
||||||
@Get('script')
|
@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(
|
getWidgetScript(
|
||||||
@Query('apiKey') apiKey: string,
|
@Query('apiKey') apiKey: string,
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
): string {
|
@Res() res: Response,
|
||||||
if (!apiKey) {
|
) {
|
||||||
throw new BadRequestException('API key is required');
|
if (!this.widgetService.isValidApiKey(apiKey)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
apiKey ? 'Invalid API key' : 'API key is required',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidApiKey =
|
this.widgetService.applyScriptHeaders(req, res);
|
||||||
Buffer.from(apiKey).length === Buffer.from('si24samanwebsite').length &&
|
return res.send(this.widgetService.generateWidgetScript());
|
||||||
Buffer.from(apiKey).compare(Buffer.from('si24samanwebsite')) === 0;
|
|
||||||
|
|
||||||
if (!isValidApiKey) {
|
|
||||||
throw new BadRequestException('Invalid API key');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.widgetService.generateWidgetScript();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('iframe')
|
@Get('iframe')
|
||||||
async getIframeScript(
|
getIframeScript(
|
||||||
@Query('apiKey') apiKey: string,
|
@Query('apiKey') apiKey: string,
|
||||||
@Req() req: Request,
|
@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');
|
throw new BadRequestException('API key is invalid or missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
res.set({
|
this.widgetService.applyScriptHeaders(req, res);
|
||||||
'Content-Type': 'application/javascript',
|
return res.send(this.widgetService.generateWidgetScriptWithoutStyle());
|
||||||
'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();
|
@Get('demo')
|
||||||
return res.send(script);
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,90 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WidgetService {
|
export class WidgetService {
|
||||||
|
/** Full iframe src, may include a path (e.g. /widget/frame). */
|
||||||
private readonly frontendUrl: string;
|
private readonly frontendUrl: string;
|
||||||
constructor(private readonly configService: ConfigService) {
|
/** Origin only — required by postMessage targetOrigin. */
|
||||||
this.frontendUrl = this.configService.get<string>('FRONTEND_URL') || 'https://chatbot.si24.ir';
|
private readonly frontendOrigin: string;
|
||||||
// Khahesh mikonam
|
private readonly apiKey: string;
|
||||||
// 1. az env estefadeh konid
|
private readonly iconUrl: string;
|
||||||
// 2. agar chizi hardcode hast hamahang konid
|
private readonly allowedParentOrigins: string[];
|
||||||
// -------- az env estefade shude ---------
|
|
||||||
// c------ chiziam hardcode nist ----
|
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.frontendUrl = WidgetService.requireUrl(
|
||||||
|
this.configService.get<string>('FRONTEND_URL'),
|
||||||
|
'FRONTEND_URL',
|
||||||
|
);
|
||||||
|
this.frontendOrigin = new URL(this.frontendUrl).origin;
|
||||||
|
this.apiKey = WidgetService.requireValue(
|
||||||
|
this.configService.get<string>('WIDGET_API_KEY'),
|
||||||
|
'WIDGET_API_KEY',
|
||||||
|
);
|
||||||
|
this.iconUrl =
|
||||||
|
WidgetService.optionalValue(
|
||||||
|
this.configService.get<string>('WIDGET_ICON_URL'),
|
||||||
|
) || WidgetService.defaultIconDataUri();
|
||||||
|
this.allowedParentOrigins = WidgetService.parseOrigins(
|
||||||
|
this.configService.get<string>('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 {
|
generateIframe(): string {
|
||||||
|
const frontendUrl = JSON.stringify(this.frontendUrl);
|
||||||
return `
|
return `
|
||||||
(function () {
|
(function () {
|
||||||
if (window.samanChatbotLoaded || document.getElementById('saman-widget-iframe')) return;
|
if (window.samanChatbotLoaded || document.getElementById('saman-widget-iframe')) return;
|
||||||
|
|
||||||
const iframe = document.createElement('iframe');
|
const iframe = document.createElement('iframe');
|
||||||
iframe.id = 'saman-widget-iframe';
|
iframe.id = 'saman-widget-iframe';
|
||||||
iframe.src = '${this.frontendUrl}';
|
iframe.src = ${frontendUrl};
|
||||||
iframe.style.position = 'fixed';
|
iframe.style.position = 'fixed';
|
||||||
iframe.style.top = '0';
|
iframe.style.top = '0';
|
||||||
iframe.style.left = '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 {
|
generateWidgetScript(): string {
|
||||||
|
const frontendUrl = JSON.stringify(this.frontendUrl);
|
||||||
|
const frontendOrigin = JSON.stringify(this.frontendOrigin);
|
||||||
|
const iconHtml = JSON.stringify(
|
||||||
|
`<img src="${this.iconUrl}" alt="chat">`,
|
||||||
|
);
|
||||||
|
const allowedOrigins = JSON.stringify(this.messageOrigins());
|
||||||
return `
|
return `
|
||||||
(function () {
|
(function () {
|
||||||
if (window.samanChatbotLoaded) return;
|
if (window.samanChatbotLoaded) return;
|
||||||
@@ -105,29 +212,44 @@ export class WidgetService {
|
|||||||
|
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.id = 'saman-widget-btn';
|
btn.id = 'saman-widget-btn';
|
||||||
btn.innerHTML = '<img src="https://cdn-icons-png.flaticon.com/512/4712/4712035.png" alt="chat">';
|
btn.innerHTML = ${iconHtml};
|
||||||
document.body.appendChild(btn);
|
document.body.appendChild(btn);
|
||||||
|
|
||||||
const iframe = document.createElement('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.id = 'saman-widget-iframe';
|
||||||
iframe.src = '${this.frontendUrl}';
|
|
||||||
iframe.allow = 'microphone; camera; storage-access-api';
|
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.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups allow-storage-access-by-user-activation';
|
||||||
iframe.setAttribute('loading', 'lazy');
|
iframe.addEventListener('load', function() {
|
||||||
iframe.setAttribute('credentialless', 'false');
|
if (!iframe.contentWindow) return;
|
||||||
iframe.setAttribute('allow', 'storage-access-api *');
|
iframe.contentWindow.postMessage({
|
||||||
|
type: 'IFRAME_LOADED'
|
||||||
|
}, ${frontendOrigin});
|
||||||
|
iframe.contentWindow.postMessage({
|
||||||
|
type: 'REQUEST_STORAGE_ACCESS'
|
||||||
|
}, ${frontendOrigin});
|
||||||
|
});
|
||||||
document.body.appendChild(iframe);
|
document.body.appendChild(iframe);
|
||||||
|
iframe.src = ${frontendUrl};
|
||||||
let closeBtn;
|
return iframe;
|
||||||
|
}
|
||||||
|
|
||||||
function openWidget() {
|
function openWidget() {
|
||||||
iframe.classList.add('visible');
|
const alreadyCreated = Boolean(iframe);
|
||||||
|
const frame = ensureIframe();
|
||||||
|
frame.classList.add('visible');
|
||||||
if (window.innerWidth < 768) {
|
if (window.innerWidth < 768) {
|
||||||
iframe.style.top = '0';
|
frame.style.top = '0';
|
||||||
iframe.style.left = '0';
|
frame.style.left = '0';
|
||||||
iframe.style.width = '100%';
|
frame.style.width = '100%';
|
||||||
iframe.style.height = '100%';
|
frame.style.height = '100%';
|
||||||
iframe.style.borderRadius = '0';
|
frame.style.borderRadius = '0';
|
||||||
|
|
||||||
closeBtn = document.createElement('button');
|
closeBtn = document.createElement('button');
|
||||||
closeBtn.id = 'saman-close-btn';
|
closeBtn.id = 'saman-close-btn';
|
||||||
@@ -143,19 +265,15 @@ export class WidgetService {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
if (alreadyCreated && frame.contentWindow) {
|
||||||
if (iframe.contentWindow &&
|
frame.contentWindow.postMessage({
|
||||||
'requestStorageAccess' in document &&
|
|
||||||
typeof document.requestStorageAccess === 'function') {
|
|
||||||
iframe.contentWindow.postMessage({
|
|
||||||
type: 'REQUEST_STORAGE_ACCESS'
|
type: 'REQUEST_STORAGE_ACCESS'
|
||||||
}, '${this.frontendUrl}');
|
}, ${frontendOrigin});
|
||||||
}
|
}
|
||||||
}, 1000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeWidget() {
|
function closeWidget() {
|
||||||
iframe.classList.remove('visible');
|
if (iframe) iframe.classList.remove('visible');
|
||||||
if (closeBtn) {
|
if (closeBtn) {
|
||||||
closeBtn.remove();
|
closeBtn.remove();
|
||||||
closeBtn = null;
|
closeBtn = null;
|
||||||
@@ -163,21 +281,19 @@ export class WidgetService {
|
|||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
document.documentElement.style.overflow = '';
|
document.documentElement.style.overflow = '';
|
||||||
|
|
||||||
// Show the widget button again when closing
|
|
||||||
btn.style.setProperty('visibility', 'visible', 'important');
|
btn.style.setProperty('visibility', 'visible', 'important');
|
||||||
btn.style.setProperty('opacity', '1', 'important');
|
btn.style.setProperty('opacity', '1', 'important');
|
||||||
btn.style.setProperty('pointer-events', 'auto', 'important');
|
btn.style.setProperty('pointer-events', 'auto', 'important');
|
||||||
}
|
}
|
||||||
|
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
if (iframe.classList.contains('visible')) closeWidget();
|
if (iframe && iframe.classList.contains('visible')) closeWidget();
|
||||||
else openWidget();
|
else openWidget();
|
||||||
});
|
});
|
||||||
|
|
||||||
const allowedOrigins = ['https://chatbot.si24.ir', 'https://staging.hdmplus.ir'];
|
|
||||||
|
|
||||||
window.addEventListener('message', function(event) {
|
window.addEventListener('message', function(event) {
|
||||||
if (!allowedOrigins.includes(event.origin)) return;
|
if (!allowedOrigins.includes(event.origin)) return;
|
||||||
|
if (!iframe || !iframe.contentWindow) return;
|
||||||
|
|
||||||
if (event.data.type === 'REQUEST_DATA') {
|
if (event.data.type === 'REQUEST_DATA') {
|
||||||
iframe.contentWindow.postMessage({
|
iframe.contentWindow.postMessage({
|
||||||
@@ -185,37 +301,15 @@ export class WidgetService {
|
|||||||
payload: event.data.payload
|
payload: event.data.payload
|
||||||
}, event.origin);
|
}, 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 {
|
generateWidgetScriptWithoutStyle(): string {
|
||||||
|
const frontendUrl = JSON.stringify(this.frontendUrl);
|
||||||
|
const iconHtml = JSON.stringify(
|
||||||
|
`<img src="${this.iconUrl}" style="width:22px;height:22px">`,
|
||||||
|
);
|
||||||
return `
|
return `
|
||||||
(function() {
|
(function() {
|
||||||
if (window.samanChatbotLoaded) return;
|
if (window.samanChatbotLoaded) return;
|
||||||
@@ -223,24 +317,31 @@ export class WidgetService {
|
|||||||
|
|
||||||
const btn = document.createElement('button');
|
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.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 = '<img src="https://cdn-icons-png.flaticon.com/512/4712/4712035.png" style="width:22px;height:22px">';
|
btn.innerHTML = ${iconHtml};
|
||||||
document.body.appendChild(btn);
|
document.body.appendChild(btn);
|
||||||
|
|
||||||
const iframe = document.createElement('iframe');
|
let iframe = null;
|
||||||
iframe.src = '${this.frontendUrl}';
|
let closeBtn;
|
||||||
|
|
||||||
|
function ensureIframe() {
|
||||||
|
if (iframe) return iframe;
|
||||||
|
|
||||||
|
iframe = document.createElement('iframe');
|
||||||
iframe.allow = 'microphone;camera';
|
iframe.allow = 'microphone;camera';
|
||||||
iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups';
|
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)";
|
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);
|
document.body.appendChild(iframe);
|
||||||
|
iframe.src = ${frontendUrl};
|
||||||
let closeBtn;
|
return iframe;
|
||||||
|
}
|
||||||
|
|
||||||
function openWidget() {
|
function openWidget() {
|
||||||
iframe.style.display = 'block';
|
const frame = ensureIframe();
|
||||||
|
frame.style.display = 'block';
|
||||||
if (window.innerWidth < 768) {
|
if (window.innerWidth < 768) {
|
||||||
iframe.style.width = '100%';
|
frame.style.width = '100%';
|
||||||
iframe.style.height = '100%';
|
frame.style.height = '100%';
|
||||||
iframe.style.borderRadius = '0';
|
frame.style.borderRadius = '0';
|
||||||
|
|
||||||
closeBtn = document.createElement('button');
|
closeBtn = document.createElement('button');
|
||||||
closeBtn.innerHTML = '✕';
|
closeBtn.innerHTML = '✕';
|
||||||
@@ -258,7 +359,7 @@ export class WidgetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeWidget() {
|
function closeWidget() {
|
||||||
iframe.style.display = 'none';
|
if (iframe) iframe.style.display = 'none';
|
||||||
if (closeBtn) { closeBtn.remove(); closeBtn = null; }
|
if (closeBtn) { closeBtn.remove(); closeBtn = null; }
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
document.documentElement.style.overflow = '';
|
document.documentElement.style.overflow = '';
|
||||||
@@ -268,7 +369,7 @@ export class WidgetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
btn.addEventListener('click', function() {
|
btn.addEventListener('click', function() {
|
||||||
if (iframe.style.display === 'block') closeWidget();
|
if (iframe && iframe.style.display === 'block') closeWidget();
|
||||||
else openWidget();
|
else openWidget();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -280,4 +381,149 @@ export class WidgetService {
|
|||||||
getScript(): string {
|
getScript(): string {
|
||||||
return `<script>${this.generateWidgetScript()}</script>`;
|
return `<script>${this.generateWidgetScript()}</script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
generateDemoHtml(): string {
|
||||||
|
const scriptSrc = `/widget/script?apiKey=${encodeURIComponent(this.apiKey)}`;
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Widget parent demo (anothersite.com)</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; margin: 0; background: #f4f4f5; color: #18181b; }
|
||||||
|
header { background: #fff; padding: 16px 24px; border-bottom: 1px solid #e4e4e7; }
|
||||||
|
main { max-width: 720px; margin: 40px auto; padding: 0 24px; }
|
||||||
|
h1 { font-size: 22px; }
|
||||||
|
p { line-height: 1.5; color: #3f3f46; }
|
||||||
|
code { background: #e4e4e7; padding: 2px 6px; border-radius: 4px; }
|
||||||
|
.note { background: #fff; border: 1px solid #e4e4e7; padding: 16px; border-radius: 8px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>anothersite.com — fake customer page</header>
|
||||||
|
<main>
|
||||||
|
<h1>Parent site</h1>
|
||||||
|
<p>This page is the host (like example.com). Auth here is unrelated to chat login inside the widget.</p>
|
||||||
|
<div class="note">
|
||||||
|
<p>Look at the bottom-right bubble. Click it.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Bubble only on load — pass</li>
|
||||||
|
<li>Panel opens on click — pass</li>
|
||||||
|
<li>This URL stays <code>/widget/demo</code> — pass (widget must not navigate the parent)</li>
|
||||||
|
</ul>
|
||||||
|
<p>Iframe src is <code>${this.escapeHtml(this.frontendUrl)}</code></p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="${this.escapeHtml(scriptSrc)}" async></script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateFrameHtml(): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Widget iframe stub</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; margin: 0; background: #0f766e; color: #fff; }
|
||||||
|
.panel { padding: 16px; }
|
||||||
|
h1 { font-size: 16px; margin: 0 0 8px; }
|
||||||
|
p, li { font-size: 13px; line-height: 1.4; }
|
||||||
|
#log { margin-top: 12px; background: rgba(0,0,0,0.2); padding: 8px; border-radius: 6px; font-family: monospace; font-size: 12px; min-height: 48px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="panel">
|
||||||
|
<h1>Chat iframe loaded</h1>
|
||||||
|
<p>If you see this after clicking the bubble, the loader works. This stub is not the production SPA.</p>
|
||||||
|
<p>Set <code>FRONTEND_URL</code> to the real chatbot app when embedding on a customer site. That app must send <code>frame-ancestors</code> for the parent origin.</p>
|
||||||
|
<div id="log">waiting for postMessage…</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const log = document.getElementById('log');
|
||||||
|
window.addEventListener('message', function (event) {
|
||||||
|
const type = event.data && event.data.type;
|
||||||
|
log.textContent = 'from ' + event.origin + ' → ' + (type || JSON.stringify(event.data));
|
||||||
|
if (type === 'REQUEST_STORAGE_ACCESS' && document.requestStorageAccess) {
|
||||||
|
document.requestStorageAccess().catch(function () {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.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 =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M20 2H4a2 2 0 00-2 2v18l4-4h14a2 2 0 002-2V4a2 2 0 00-2-2z"/></svg>';
|
||||||
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user