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 type { LoaderFunctionArgs } from "react-router";
|
||||||
import { authenticate, unauthenticated } from "../shopify.server";
|
import { authenticate, unauthenticated } from "../shopify.server";
|
||||||
import db from "../db.server";
|
import db from "../db.server";
|
||||||
import { buildGiroCodeDataUrl } from "../services/invoice/girocode";
|
|
||||||
import { formatMoney, formatDate, addDays } from "../services/invoice/format";
|
import { formatMoney, formatDate, addDays } from "../services/invoice/format";
|
||||||
import { getStrings, pickLanguage } from "../services/invoice/i18n";
|
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
|
* 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 amount = orderInfo.outstandingAmount > 0 ? orderInfo.outstandingAmount : orderInfo.totalAmount;
|
||||||
const remittance = orderInfo.orderName || orderGid.split("/").pop() || "";
|
const remittance = orderInfo.orderName || orderGid.split("/").pop() || "";
|
||||||
|
|
||||||
const giroCodeDataUrl = await buildGiroCodeDataUrl({
|
const giroCodeUrl = (() => {
|
||||||
beneficiaryName: [settings.companyName, settings.legalForm].filter(Boolean).join(" "),
|
const exp = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour
|
||||||
iban: settings.iban,
|
const origin = new URL(request.url).origin;
|
||||||
bic: settings.bic,
|
const qs = signGiroCodeUrl({ shop, orderId: numericId, exp });
|
||||||
amount,
|
return `${origin}/api/public/girocode.png?${qs}`;
|
||||||
currency: orderInfo.currency,
|
})();
|
||||||
remittance,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dueDate = settings.paymentTermDays > 0
|
const dueDate = settings.paymentTermDays > 0
|
||||||
? addDays(new Date(), settings.paymentTermDays)
|
? addDays(new Date(), settings.paymentTermDays)
|
||||||
@@ -110,7 +108,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
payload: {
|
payload: {
|
||||||
language,
|
language,
|
||||||
heading: t.giroCodeCaption,
|
heading: t.giroCodeCaption,
|
||||||
giroCodeDataUrl,
|
giroCodeUrl,
|
||||||
recipient: [settings.companyName, settings.legalForm].filter(Boolean).join(" "),
|
recipient: [settings.companyName, settings.legalForm].filter(Boolean).join(" "),
|
||||||
bankName: settings.bankName,
|
bankName: settings.bankName,
|
||||||
iban: settings.iban,
|
iban: settings.iban,
|
||||||
|
|||||||
@@ -61,3 +61,15 @@ export async function buildGiroCodeDataUrl(
|
|||||||
width: 256,
|
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 {
|
interface PaymentInstructions {
|
||||||
language: "de" | "en";
|
language: "de" | "en";
|
||||||
heading: string;
|
heading: string;
|
||||||
giroCodeDataUrl: string;
|
giroCodeUrl: string;
|
||||||
recipient: string;
|
recipient: string;
|
||||||
bankName: string;
|
bankName: string;
|
||||||
iban: string;
|
iban: string;
|
||||||
@@ -103,7 +103,7 @@ function Extension() {
|
|||||||
<s-section heading={data.heading}>
|
<s-section heading={data.heading}>
|
||||||
<s-paragraph>{data.instructions}</s-paragraph>
|
<s-paragraph>{data.instructions}</s-paragraph>
|
||||||
<s-stack direction="inline" gap="base" align-items="start">
|
<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-stack direction="block" gap="small-200">
|
||||||
<s-text>{data.labels.recipient}: {data.recipient}</s-text>
|
<s-text>{data.labels.recipient}: {data.recipient}</s-text>
|
||||||
{data.bankName ? (
|
{data.bankName ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user