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:
parent
676ef09f4f
commit
7fe7ac9cac
163
src/app/api/shopify/app-proxy/[...path]/route.ts
Normal file
163
src/app/api/shopify/app-proxy/[...path]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -144,14 +144,7 @@ export async function PATCH(
|
||||
// Even if ScriptTag operations fail (missing scopes), the widget still
|
||||
// won’t render because /api/storefront-chat/config returns the flag.
|
||||
try {
|
||||
const appBaseUrl = (
|
||||
process.env.NEXT_PUBLIC_APP_URL ||
|
||||
process.env.AUTH_URL ||
|
||||
process.env.NEXTAUTH_URL ||
|
||||
""
|
||||
).trim()
|
||||
const apiVersion = "2024-01"
|
||||
const injectorBase = appBaseUrl ? appBaseUrl.replace(/\/$/, "") : ""
|
||||
|
||||
const cleanDomain = (store.shopifyDomain || "")
|
||||
.trim()
|
||||
@ -161,16 +154,14 @@ export async function PATCH(
|
||||
|
||||
const accessToken = store.shopifyAccessToken
|
||||
|
||||
if (injectorBase && cleanDomain && accessToken) {
|
||||
const injectorPath = `${injectorBase}/storefront-chat/widget-injector`
|
||||
// ngrok often serves an HTML interstitial to third-party script requests unless this param is present.
|
||||
const needsNgrokSkip =
|
||||
injectorBase.includes("ngrok-free.dev") ||
|
||||
injectorBase.includes("ngrok.app") ||
|
||||
injectorBase.includes("ngrok.io")
|
||||
const injectorSrc = needsNgrokSkip
|
||||
? `${injectorPath}?ngrok-skip-browser-warning=1`
|
||||
: injectorPath
|
||||
// Use Shopify App Proxy URL for the ScriptTag src to avoid CSP/ORB/ngrok issues.
|
||||
// Requires one-time App Proxy setup in Shopify admin for the app:
|
||||
// - App proxy prefix: apps
|
||||
// - Subpath: juno-chat
|
||||
// - Proxy URL: <APP_URL>/api/shopify/app-proxy
|
||||
if (cleanDomain && accessToken) {
|
||||
const injectorPath = `https://${cleanDomain}/apps/juno-chat/widget-injector.js`
|
||||
const injectorSrc = injectorPath
|
||||
|
||||
// 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.
|
||||
@ -256,7 +247,6 @@ export async function PATCH(
|
||||
} else {
|
||||
console.warn("[STORE_FRONT_CHAT_SCRIPTTAG_SKIP]", {
|
||||
storeId,
|
||||
hasInjectorBase: Boolean(appBaseUrl),
|
||||
cleanDomain,
|
||||
hasAccessToken: Boolean(accessToken)
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user