fix(shopify): use app proxy for storefront chat injector

Serve widget injector JS via Shopify App Proxy and point ScriptTag src to /apps/juno-chat to avoid ngrok/ORB/CSP script load failures.

Made-with: Cursor
This commit is contained in:
talatops 2026-04-02 15:53:55 +05:00
parent 676ef09f4f
commit 7fe7ac9cac
2 changed files with 171 additions and 18 deletions

View File

@ -0,0 +1,163 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import crypto from "crypto"
function verifyShopifyProxyHmac(params: URLSearchParams, secret: string) {
const provided = params.get("hmac") || ""
if (!provided) return false
const pairs: string[] = []
params.forEach((value, key) => {
if (key === "hmac" || key === "signature") return
pairs.push(`${key}=${value}`)
})
pairs.sort()
const message = pairs.join("&")
const digest = crypto.createHmac("sha256", secret).update(message).digest("hex")
return crypto.timingSafeEqual(Buffer.from(digest, "utf8"), Buffer.from(provided, "utf8"))
}
function getAppBaseUrl() {
return (
process.env.NEXT_PUBLIC_APP_URL ||
process.env.AUTH_URL ||
process.env.NEXTAUTH_URL ||
""
)
.trim()
.replace(/\/$/, "")
}
export async function GET(req: Request, { params }: { params: Promise<{ path: string[] }> }) {
try {
const { path } = await params
const pathname = (path || []).join("/")
const url = new URL(req.url)
const shop = (url.searchParams.get("shop") || "").trim()
if (!shop) {
return new NextResponse("Missing shop", { status: 400 })
}
// Lookup store by shopifyDomain (myshopify domain).
const store = await prisma.store.findFirst({
where: { shopifyDomain: shop },
select: { id: true, shopifyApiSecret: true },
})
if (!store?.shopifyApiSecret) {
return new NextResponse("Unknown store", { status: 404 })
}
// Verify app proxy signature (prevents third parties from hitting this endpoint).
if (!verifyShopifyProxyHmac(url.searchParams, store.shopifyApiSecret)) {
return new NextResponse("Invalid signature", { status: 401 })
}
// Only serve injector JS for now.
if (pathname !== "widget-injector.js") {
return new NextResponse("Not found", { status: 404 })
}
const appBaseUrl = getAppBaseUrl()
if (!appBaseUrl) {
return new NextResponse("App base URL not configured", { status: 500 })
}
// App proxy returns JS that opens our hosted widget in a popup/new tab.
const js = `
(function () {
try {
var appBaseUrl = ${JSON.stringify(appBaseUrl)};
var shop = ${JSON.stringify(shop)};
var containerId = "juno-storefront-chat-container";
var buttonId = "juno-storefront-chat-button";
function upsertButton() {
var container = document.getElementById(containerId);
if (!container) {
container = document.createElement("div");
container.id = containerId;
container.style.position = "fixed";
container.style.bottom = "16px";
container.style.right = "16px";
container.style.zIndex = "2147483647";
container.style.width = "56px";
container.style.height = "56px";
document.body.appendChild(container);
}
var existing = document.getElementById(buttonId);
if (existing) return;
var btn = document.createElement("button");
btn.id = buttonId;
btn.type = "button";
btn.setAttribute("aria-label", "Open chat");
btn.style.width = "56px";
btn.style.height = "56px";
btn.style.borderRadius = "16px";
btn.style.border = "0";
btn.style.cursor = "pointer";
btn.style.boxShadow = "0 12px 28px rgba(0,0,0,0.20)";
btn.style.background = "#4f46e5";
btn.style.color = "#fff";
btn.style.display = "flex";
btn.style.alignItems = "center";
btn.style.justifyContent = "center";
btn.style.fontFamily = "system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif";
btn.style.fontWeight = "800";
btn.style.letterSpacing = "-0.02em";
btn.innerHTML = "Chat";
btn.addEventListener("click", function () {
try {
var url = appBaseUrl + "/storefront-chat/widget?shop=" + encodeURIComponent(shop);
var w = Math.min(420, Math.max(320, window.innerWidth));
var h = Math.min(640, Math.max(480, window.innerHeight));
var left = Math.max(0, Math.round(window.screenX + (window.outerWidth - w)));
var top = Math.max(0, Math.round(window.screenY + (window.outerHeight - h)));
var popup = window.open(url, "juno_storefront_chat", "popup=yes,width=" + w + ",height=" + h + ",left=" + left + ",top=" + top);
if (!popup) window.open(url, "_blank");
} catch (e) {
window.open(appBaseUrl + "/storefront-chat/widget?shop=" + encodeURIComponent(shop), "_blank");
}
});
container.appendChild(btn);
}
function removeWidget() {
var container = document.getElementById(containerId);
if (container && container.parentNode) container.parentNode.removeChild(container);
}
fetch(appBaseUrl + "/api/storefront-chat/config?shop=" + encodeURIComponent(shop))
.then(function (r) { return r.json().catch(function(){ return {}; }); })
.then(function (data) {
var enabled = !!(data && data.enabled);
if (enabled) upsertButton();
else removeWidget();
})
.catch(function () { removeWidget(); });
} catch (e) {}
})();
`.trimStart()
// If disabled, still return JS (it will remove/avoid rendering).
// This keeps the ScriptTag simple and lets our own config control visibility.
return new NextResponse(js, {
status: 200,
headers: {
"Content-Type": "application/javascript; charset=utf-8",
"Cache-Control": "public, max-age=60",
},
})
} catch (error) {
console.error("[APP_PROXY_WIDGET_INJECTOR]", error)
return new NextResponse("Internal Error", { status: 500 })
}
}

View File

@ -144,14 +144,7 @@ export async function PATCH(
// Even if ScriptTag operations fail (missing scopes), the widget still // Even if ScriptTag operations fail (missing scopes), the widget still
// won’t render because /api/storefront-chat/config returns the flag. // won’t render because /api/storefront-chat/config returns the flag.
try { try {
const appBaseUrl = (
process.env.NEXT_PUBLIC_APP_URL ||
process.env.AUTH_URL ||
process.env.NEXTAUTH_URL ||
""
).trim()
const apiVersion = "2024-01" const apiVersion = "2024-01"
const injectorBase = appBaseUrl ? appBaseUrl.replace(/\/$/, "") : ""
const cleanDomain = (store.shopifyDomain || "") const cleanDomain = (store.shopifyDomain || "")
.trim() .trim()
@ -161,16 +154,14 @@ export async function PATCH(
const accessToken = store.shopifyAccessToken const accessToken = store.shopifyAccessToken
if (injectorBase && cleanDomain && accessToken) { // Use Shopify App Proxy URL for the ScriptTag src to avoid CSP/ORB/ngrok issues.
const injectorPath = `${injectorBase}/storefront-chat/widget-injector` // Requires one-time App Proxy setup in Shopify admin for the app:
// ngrok often serves an HTML interstitial to third-party script requests unless this param is present. // - App proxy prefix: apps
const needsNgrokSkip = // - Subpath: juno-chat
injectorBase.includes("ngrok-free.dev") || // - Proxy URL: <APP_URL>/api/shopify/app-proxy
injectorBase.includes("ngrok.app") || if (cleanDomain && accessToken) {
injectorBase.includes("ngrok.io") const injectorPath = `https://${cleanDomain}/apps/juno-chat/widget-injector.js`
const injectorSrc = needsNgrokSkip const injectorSrc = injectorPath
? `${injectorPath}?ngrok-skip-browser-warning=1`
: injectorPath
// Shopify's `src` filter is exact-match; we want to delete any older variants too. // Shopify's `src` filter is exact-match; we want to delete any older variants too.
// So we list a small page and filter by prefix instead. // So we list a small page and filter by prefix instead.
@ -256,7 +247,6 @@ export async function PATCH(
} else { } else {
console.warn("[STORE_FRONT_CHAT_SCRIPTTAG_SKIP]", { console.warn("[STORE_FRONT_CHAT_SCRIPTTAG_SKIP]", {
storeId, storeId,
hasInjectorBase: Boolean(appBaseUrl),
cleanDomain, cleanDomain,
hasAccessToken: Boolean(accessToken) hasAccessToken: Boolean(accessToken)
}) })