fix(thank-you): serve GiroCode as signed PNG URL instead of data URL

This commit is contained in:
Gerhard Scheikl
2026-05-09 21:14:47 +02:00
parent f59c981ff4
commit 3fb8600402
5 changed files with 141 additions and 12 deletions
+84
View File
@@ -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": "*",
},
});
};
+8 -10
View File
@@ -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,
+12
View File
@@ -61,3 +61,15 @@ export async function buildGiroCodeDataUrl(
width: 256,
});
}
export async function buildGiroCodePngBuffer(
input: GiroCodeInput,
): Promise<Buffer> {
const payload = buildGiroCodePayload(input);
return QRCode.toBuffer(payload, {
errorCorrectionLevel: "M",
margin: 1,
width: 256,
type: "png",
});
}
+35
View File
@@ -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 };
}
@@ -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() {
<s-section heading={data.heading}>
<s-paragraph>{data.instructions}</s-paragraph>
<s-stack direction="inline" gap="base" align-items="start">
<s-image src={data.giroCodeDataUrl} alt="GiroCode" inline-size="200px" />
<s-image src={data.giroCodeUrl} alt="GiroCode" inline-size="200px" />
<s-stack direction="block" gap="small-200">
<s-text>{data.labels.recipient}: {data.recipient}</s-text>
{data.bankName ? (