security hardening

This commit is contained in:
Gerhard Scheikl
2026-05-31 09:35:31 +02:00
parent d7d437a871
commit 01b4734477
31 changed files with 1234 additions and 238 deletions
+42 -27
View File
@@ -5,7 +5,7 @@ import {
generateAndEmailInvoice,
isManualPaymentOrder,
} from "../services/invoice/automations.server";
import { isDuplicateWebhook } from "../services/webhooks/dedupe.server";
import { reserveWebhook } from "../services/webhooks/dedupe.server";
import { runWebhookInBackground } from "../services/webhooks/background.server";
/**
@@ -18,42 +18,57 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload, session, admin } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
// Drop Shopify retries we've already processed (prevents the duplicate
// invoice email we saw when the first delivery exceeded Shopify's 5s ack
// timeout — the work still completed, but Shopify resent the webhook).
if (await isDuplicateWebhook(request, shop, topic)) return new Response();
// Reserve this delivery (status="processing"). `null` => already
// done/in-flight, so short-circuit. The reservation is committed only after
// the background work succeeds, and released on failure so Shopify's retry
// re-runs it (prevents the silent invoice loss we'd get if we recorded the
// id as processed before the slow PDF/email work).
const reservation = await reserveWebhook(request, shop, topic);
if (!reservation) return new Response();
if (!session || !admin) return new Response();
if (!session || !admin) {
// App uninstalled before the webhook drained — nothing to do.
await reservation.commit();
return new Response();
}
const orderId = payload?.id;
if (orderId == null) return new Response();
if (orderId == null) {
await reservation.commit();
return new Response();
}
const customerLocale =
typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined;
// Respond 200 immediately and run the (slow) PDF + email work in the
// background — keeps us well under Shopify's ~5s ack timeout. Dedupe
// above guarantees we don't double-process a retry while the first
// delivery is still working.
runWebhookInBackground(`${topic} order=${orderId} shop=${shop}`, async () => {
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
if (!settings?.autoEmailOnWireTransferPlaced) return;
// background — keeps us well under Shopify's ~5s ack timeout. The queue
// commits the reservation on success and releases it on failure.
runWebhookInBackground(
`${topic} order=${orderId} shop=${shop}`,
async () => {
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
if (!settings?.autoEmailOnWireTransferPlaced) return;
const orderGid = `gid://shopify/Order/${String(orderId).replace(/^.*\//, "")}`;
if (!(await isManualPaymentOrder(admin, orderGid))) return;
const orderGid = `gid://shopify/Order/${String(orderId).replace(/^.*\//, "")}`;
if (!(await isManualPaymentOrder(admin, orderGid))) return;
const result = await generateAndEmailInvoice({
shopDomain: shop,
admin,
orderId,
customerLocale,
});
if (!result.ok) {
console.warn(
`auto-email (wire-transfer placed) failed for order ${orderId} on ${shop}: ${result.reason}`,
);
}
});
const result = await generateAndEmailInvoice({
shopDomain: shop,
admin,
orderId,
customerLocale,
});
if (!result.ok) {
// Throw so the reservation is released and Shopify retries — don't
// swallow the failure (which would leave the invoice unsent forever).
throw new Error(
`auto-email (wire-transfer placed) failed for order ${orderId} on ${shop}: ${result.reason}`,
);
}
},
reservation,
);
return new Response();
};