Files
linumiq-invoice/app/routes/api.public.girocode[.png].tsx
T
Gerhard Scheikl 01b4734477 security hardening
2026-05-31 09:35:31 +02:00

101 lines
3.5 KiB
TypeScript

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";
import { resolveOrderRemittance } from "../services/invoice/remittance.server";
/**
* 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
number
currencyCode
totalPriceSet { shopMoney { amount } }
totalOutstandingSet { shopMoney { amount } }
}
}`,
{ variables: { id: orderGid } },
);
const json = (await res.json()) as {
data?: {
order?: {
name?: string;
number?: number | null;
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;
// Use the canonical invoice number printed on the PDF — keeping the QR
// and the customer-facing thank-you/account page in lockstep so the
// bank treats both as one and the same payment.
const remittance = await resolveOrderRemittance({
shopDomain: shop,
orderGid,
orderNumber: typeof o.number === "number" ? o.number : null,
settings,
});
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",
// No CORS header: the PNG is rendered via an <s-image> tag in the
// checkout/customer-account extensions (see extensions/*/src/*.tsx),
// i.e. a plain image load, which is not subject to CORS. Dropping the
// previous `Access-Control-Allow-Origin: *` removes the ability for any
// origin to fetch() these bytes cross-origin while keeping the
// legitimate <img>-style loads working.
},
});
};