From 3fb8600402200f0ba78c5f58659af893e8f8cd25 Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Sat, 9 May 2026 21:14:47 +0200 Subject: [PATCH] fix(thank-you): serve GiroCode as signed PNG URL instead of data URL --- app/routes/api.public.girocode[.png].tsx | 84 +++++++++++++++++++ app/routes/api.public.payment-info.tsx | 18 ++-- app/services/invoice/girocode.ts | 12 +++ app/services/invoice/signedUrl.ts | 35 ++++++++ .../src/Checkout.tsx | 4 +- 5 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 app/routes/api.public.girocode[.png].tsx create mode 100644 app/services/invoice/signedUrl.ts diff --git a/app/routes/api.public.girocode[.png].tsx b/app/routes/api.public.girocode[.png].tsx new file mode 100644 index 0000000..8347aff --- /dev/null +++ b/app/routes/api.public.girocode[.png].tsx @@ -0,0 +1,84 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { unauthenticated } from "../shopify.server"; +import db from "../db.server"; +import { buildGiroCodePngBuffer } from "../services/invoice/girocode"; +import { verifyGiroCodeUrl } from "../services/invoice/signedUrl"; + +/** + * Public PNG endpoint that returns the GiroCode QR image bytes for an order. + * Auth: short-lived HMAC-signed URL (issued by /api/public/payment-info). + * + * Required query params: shop, orderId, exp, sig. + */ +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const verified = verifyGiroCodeUrl(url.searchParams); + if (!verified.ok) { + return new Response(`unauthorized: ${verified.reason ?? "invalid"}`, { status: 401 }); + } + const { shop, orderId } = verified; + if (!shop || !orderId) { + return new Response("bad request", { status: 400 }); + } + + const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } }); + if (!settings?.iban) { + return new Response("not found", { status: 404 }); + } + + // Recompute payload server-side from order + settings (don't trust client). + const numericId = orderId.replace(/[^0-9]/g, ""); + const orderGid = `gid://shopify/Order/${numericId}`; + + const { admin } = await unauthenticated.admin(shop); + const res = await admin.graphql( + `#graphql + query GiroCodeOrderInfo($id: ID!) { + order(id: $id) { + name + currencyCode + totalPriceSet { shopMoney { amount } } + totalOutstandingSet { shopMoney { amount } } + } + }`, + { variables: { id: orderGid } }, + ); + const json = (await res.json()) as { + data?: { + order?: { + name?: string; + currencyCode?: string; + totalPriceSet?: { shopMoney: { amount: string } }; + totalOutstandingSet?: { shopMoney: { amount: string } }; + } | null; + }; + }; + const o = json.data?.order; + if (!o) { + return new Response("not found", { status: 404 }); + } + + const total = parseFloat(o.totalPriceSet?.shopMoney.amount ?? "0"); + const outstanding = parseFloat(o.totalOutstandingSet?.shopMoney.amount ?? "0"); + const amount = outstanding > 0 ? outstanding : total; + const remittance = o.name ?? numericId; + + const png = await buildGiroCodePngBuffer({ + beneficiaryName: [settings.companyName, settings.legalForm].filter(Boolean).join(" "), + iban: settings.iban, + bic: settings.bic, + amount, + currency: o.currencyCode ?? "EUR", + remittance, + }); + + const body = new Uint8Array(png); + return new Response(body, { + status: 200, + headers: { + "Content-Type": "image/png", + "Cache-Control": "private, max-age=300", + "Access-Control-Allow-Origin": "*", + }, + }); +}; diff --git a/app/routes/api.public.payment-info.tsx b/app/routes/api.public.payment-info.tsx index 638dedd..9c88543 100644 --- a/app/routes/api.public.payment-info.tsx +++ b/app/routes/api.public.payment-info.tsx @@ -1,9 +1,9 @@ import type { LoaderFunctionArgs } from "react-router"; import { authenticate, unauthenticated } from "../shopify.server"; import db from "../db.server"; -import { buildGiroCodeDataUrl } from "../services/invoice/girocode"; import { formatMoney, formatDate, addDays } from "../services/invoice/format"; import { getStrings, pickLanguage } from "../services/invoice/i18n"; +import { signGiroCodeUrl } from "../services/invoice/signedUrl"; /** * Public endpoint consumed by the checkout / thank-you UI extension to fetch @@ -91,14 +91,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const amount = orderInfo.outstandingAmount > 0 ? orderInfo.outstandingAmount : orderInfo.totalAmount; const remittance = orderInfo.orderName || orderGid.split("/").pop() || ""; - const giroCodeDataUrl = await buildGiroCodeDataUrl({ - beneficiaryName: [settings.companyName, settings.legalForm].filter(Boolean).join(" "), - iban: settings.iban, - bic: settings.bic, - amount, - currency: orderInfo.currency, - remittance, - }); + const giroCodeUrl = (() => { + const exp = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour + const origin = new URL(request.url).origin; + const qs = signGiroCodeUrl({ shop, orderId: numericId, exp }); + return `${origin}/api/public/girocode.png?${qs}`; + })(); const dueDate = settings.paymentTermDays > 0 ? addDays(new Date(), settings.paymentTermDays) @@ -110,7 +108,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { payload: { language, heading: t.giroCodeCaption, - giroCodeDataUrl, + giroCodeUrl, recipient: [settings.companyName, settings.legalForm].filter(Boolean).join(" "), bankName: settings.bankName, iban: settings.iban, diff --git a/app/services/invoice/girocode.ts b/app/services/invoice/girocode.ts index 49347e5..e11943e 100644 --- a/app/services/invoice/girocode.ts +++ b/app/services/invoice/girocode.ts @@ -61,3 +61,15 @@ export async function buildGiroCodeDataUrl( width: 256, }); } + +export async function buildGiroCodePngBuffer( + input: GiroCodeInput, +): Promise { + const payload = buildGiroCodePayload(input); + return QRCode.toBuffer(payload, { + errorCorrectionLevel: "M", + margin: 1, + width: 256, + type: "png", + }); +} diff --git a/app/services/invoice/signedUrl.ts b/app/services/invoice/signedUrl.ts new file mode 100644 index 0000000..f90534b --- /dev/null +++ b/app/services/invoice/signedUrl.ts @@ -0,0 +1,35 @@ +import crypto from "node:crypto"; + +const SECRET = process.env.SHOPIFY_API_SECRET || ""; + +function hmac(payload: string): string { + return crypto.createHmac("sha256", SECRET).update(payload).digest("hex"); +} + +export interface GiroCodeUrlParams { + shop: string; + orderId: string; + exp: number; // unix seconds +} + +export function signGiroCodeUrl(params: GiroCodeUrlParams): string { + const base = `shop=${params.shop}&orderId=${params.orderId}&exp=${params.exp}`; + const sig = hmac(base); + return `${base}&sig=${sig}`; +} + +export function verifyGiroCodeUrl(query: URLSearchParams): { ok: boolean; shop?: string; orderId?: string; reason?: string } { + const shop = query.get("shop") || ""; + const orderId = query.get("orderId") || ""; + const exp = parseInt(query.get("exp") || "0", 10); + const sig = query.get("sig") || ""; + if (!shop || !orderId || !exp || !sig) return { ok: false, reason: "missing-params" }; + if (Date.now() / 1000 > exp) return { ok: false, reason: "expired" }; + const expected = hmac(`shop=${shop}&orderId=${orderId}&exp=${exp}`); + // timing-safe compare + const a = Buffer.from(sig); + const b = Buffer.from(expected); + if (a.length !== b.length) return { ok: false, reason: "bad-sig" }; + if (!crypto.timingSafeEqual(a, b)) return { ok: false, reason: "bad-sig" }; + return { ok: true, shop, orderId }; +} diff --git a/extensions/invoice-thank-you-payment/src/Checkout.tsx b/extensions/invoice-thank-you-payment/src/Checkout.tsx index 22fad37..3a6dabc 100644 --- a/extensions/invoice-thank-you-payment/src/Checkout.tsx +++ b/extensions/invoice-thank-you-payment/src/Checkout.tsx @@ -16,7 +16,7 @@ function resolveAppUrl(shopify: any): string { interface PaymentInstructions { language: "de" | "en"; heading: string; - giroCodeDataUrl: string; + giroCodeUrl: string; recipient: string; bankName: string; iban: string; @@ -103,7 +103,7 @@ function Extension() { {data.instructions} - + {data.labels.recipient}: {data.recipient} {data.bankName ? (