fix(thank-you): serve GiroCode as signed PNG URL instead of data URL
This commit is contained in:
@@ -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": "*",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user