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 { WidgetService } from './widget.service';
|
||||
|
||||
@@ -8,52 +9,46 @@ export class WidgetController {
|
||||
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',
|
||||
});
|
||||
this.widgetService.applyScriptHeaders(req, res);
|
||||
return res.send(this.widgetService.generateWidgetScriptWithoutStyle());
|
||||
}
|
||||
|
||||
const script = this.widgetService.generateWidgetScriptWithoutStyle();
|
||||
return res.send(script);
|
||||
@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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>('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<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 {
|
||||
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(
|
||||
`<img src="${this.iconUrl}" alt="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 = '<img src="https://cdn-icons-png.flaticon.com/512/4712/4712035.png" alt="chat">';
|
||||
btn.innerHTML = ${iconHtml};
|
||||
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.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 *');
|
||||
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);
|
||||
|
||||
let closeBtn;
|
||||
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';
|
||||
@@ -143,19 +265,15 @@ export class WidgetService {
|
||||
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (iframe.contentWindow &&
|
||||
'requestStorageAccess' in document &&
|
||||
typeof document.requestStorageAccess === 'function') {
|
||||
iframe.contentWindow.postMessage({
|
||||
if (alreadyCreated && frame.contentWindow) {
|
||||
frame.contentWindow.postMessage({
|
||||
type: 'REQUEST_STORAGE_ACCESS'
|
||||
}, '${this.frontendUrl}');
|
||||
}, ${frontendOrigin});
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
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(
|
||||
`<img src="${this.iconUrl}" style="width:22px;height:22px">`,
|
||||
);
|
||||
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 = '<img src="https://cdn-icons-png.flaticon.com/512/4712/4712035.png" style="width:22px;height:22px">';
|
||||
btn.innerHTML = ${iconHtml};
|
||||
document.body.appendChild(btn);
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = '${this.frontendUrl}';
|
||||
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);
|
||||
|
||||
let closeBtn;
|
||||
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 `<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