security hardening
This commit is contained in:
@@ -8,9 +8,24 @@ node_modules
|
|||||||
!.env.production.example
|
!.env.production.example
|
||||||
prisma/dev.sqlite
|
prisma/dev.sqlite
|
||||||
prisma/dev.sqlite-journal
|
prisma/dev.sqlite-journal
|
||||||
|
# Any local SQLite DB / journal / WAL must never enter the image.
|
||||||
|
prisma/*.sqlite*
|
||||||
|
prisma/dev.sqlite*
|
||||||
|
data/
|
||||||
.shopify
|
.shopify
|
||||||
.git
|
.git
|
||||||
.github
|
.github
|
||||||
*.log
|
*.log
|
||||||
extensions/*/dist
|
extensions/*/dist
|
||||||
|
|
||||||
|
# Dev-only tooling / docs — not needed at build or runtime.
|
||||||
|
# NOTE: prisma/schema.prisma and prisma/migrations are intentionally NOT
|
||||||
|
# excluded (required by `prisma generate` and `prisma migrate deploy`).
|
||||||
|
tests/
|
||||||
|
scripts/
|
||||||
|
.cursor/
|
||||||
|
.gemini/
|
||||||
|
.vscode/
|
||||||
|
*.md
|
||||||
|
**/*.md
|
||||||
|
|
||||||
|
|||||||
+70
-5
@@ -1,18 +1,83 @@
|
|||||||
FROM node:20-alpine
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Base image pin
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pinned to a specific minor (20.19) so rebuilds are reproducible and satisfy
|
||||||
|
# the package.json `engines` constraint (">=20.19 <22 || >=22.12").
|
||||||
|
# A digest pin is PREFERRED for full immutability, e.g.:
|
||||||
|
# FROM node:20.19-alpine@sha256:<real-digest>
|
||||||
|
# Add the real sha256 (from `docker buildx imagetools inspect node:20.19-alpine`)
|
||||||
|
# when you have network access. We do NOT invent a fake digest here.
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Stage 1 — builder: install ALL deps, generate Prisma client, build the app
|
||||||
|
# ===========================================================================
|
||||||
|
FROM node:20.19-alpine AS builder
|
||||||
|
|
||||||
|
# openssl is required by Prisma's engines.
|
||||||
RUN apk add --no-cache openssl
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
EXPOSE 3000
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install the full dependency tree (incl. devDependencies needed by the
|
||||||
|
# Vite / React Router build toolchain). NODE_ENV is intentionally left unset
|
||||||
|
# here so `npm ci` does not prune devDependencies.
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy the rest of the source and produce the production build + Prisma client.
|
||||||
|
COPY . .
|
||||||
|
RUN npx prisma generate \
|
||||||
|
&& npm run build
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Stage 2 — runtime: pruned prod deps + only the artifacts needed to run
|
||||||
|
# ===========================================================================
|
||||||
|
FROM node:20.19-alpine AS runtime
|
||||||
|
|
||||||
|
# openssl for Prisma engines at runtime (migrate deploy / query engine).
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
# Keep npm + Prisma incidental writes off the (dev) read-only root filesystem:
|
||||||
|
# both are redirected to the tmpfs-backed /tmp mount.
|
||||||
|
ENV NPM_CONFIG_CACHE=/tmp/.npm
|
||||||
|
ENV CHECKPOINT_DISABLE=1
|
||||||
|
|
||||||
|
# Install ONLY production dependencies.
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
RUN npm ci --omit=dev && npm cache clean --force
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
COPY . .
|
# Prisma schema + migrations are needed for `prisma generate` (below) and for
|
||||||
|
# `prisma migrate deploy` on container start.
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
# Bake the generated Prisma client into the image so the container start
|
||||||
|
# command only has to run migrations (no generate at runtime → compatible with
|
||||||
|
# a read-only root filesystem).
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
RUN npm run build
|
# Application artifacts required at runtime.
|
||||||
|
COPY --from=builder /app/build ./build
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/server.js ./server.js
|
||||||
|
|
||||||
|
# Run as the unprivileged, pre-existing `node` user (uid/gid 1000). Ensure the
|
||||||
|
# app tree is owned by it. NOTE: the SQLite DB is written to the /data bind
|
||||||
|
# mount — the HOST directory mounted at /data MUST be chown'd to uid 1000
|
||||||
|
# (see deploy/README.md), otherwise migrations/writes will fail as non-root.
|
||||||
|
RUN chown -R node:node /app
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Container-level health probe hitting the unauthenticated /healthz route.
|
||||||
|
# busybox wget ships with node:alpine.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
|
||||||
|
|
||||||
|
# Functionally equivalent to today's start: runs DB migrations then the server.
|
||||||
|
# (`docker-start` no longer needs `prisma generate` — the client is baked above.)
|
||||||
CMD ["npm", "run", "docker-start"]
|
CMD ["npm", "run", "docker-start"]
|
||||||
|
|||||||
@@ -89,7 +89,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "image/png",
|
"Content-Type": "image/png",
|
||||||
"Cache-Control": "private, max-age=300",
|
"Cache-Control": "private, max-age=300",
|
||||||
"Access-Control-Allow-Origin": "*",
|
// 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.
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
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";
|
||||||
@@ -88,11 +89,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
}
|
}
|
||||||
if (!orderInfo && lastErr) throw lastErr;
|
if (!orderInfo && lastErr) throw lastErr;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = (err as Error)?.message ?? String(err);
|
// Log the upstream detail server-side only — never echo internal error
|
||||||
console.warn(`payment-info: failed to load order ${orderGid} for ${shop}:`, err);
|
// messages (which may contain Admin API internals / order data) to the
|
||||||
|
// public client.
|
||||||
|
console.error(`payment-info: failed to load order ${orderGid} for ${shop}:`, err);
|
||||||
return cors(
|
return cors(
|
||||||
Response.json(
|
Response.json(
|
||||||
{ showPaymentInstructions: false, error: "order-load-failed", detail: msg.slice(0, 500) },
|
{ showPaymentInstructions: false, error: "order-load-failed" },
|
||||||
{ status: 502 },
|
{ status: 502 },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -110,12 +113,28 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
// Without this, any authenticated buyer of the shop could enumerate
|
// Without this, any authenticated buyer of the shop could enumerate
|
||||||
// arbitrary orderIds and harvest the shop's bank details / amounts.
|
// arbitrary orderIds and harvest the shop's bank details / amounts.
|
||||||
//
|
//
|
||||||
|
// Token claims available (see @shopify/shopify-api `JwtPayload`): only the
|
||||||
|
// standard JWT fields — iss, dest, aud, sub, exp, nbf, iat, jti, sid. There
|
||||||
|
// is NO order- or checkout-scoped claim in either the checkout or the
|
||||||
|
// customer-account session token, so we cannot bind the token to the
|
||||||
|
// requested orderId directly. We therefore bind by customer identity where
|
||||||
|
// possible and fall back to a tightened recency window for true guests.
|
||||||
|
//
|
||||||
// - customerAccount tokens always carry a customer GID in `sub`. We require
|
// - customerAccount tokens always carry a customer GID in `sub`. We require
|
||||||
// that the order's customer matches.
|
// that the order's customer matches (strong binding — preferred path).
|
||||||
// - Checkout (thank-you page) tokens are issued at the end of checkout and
|
// - Checkout (thank-you page) tokens for logged-in buyers also carry the
|
||||||
// are short-lived. For logged-in buyers we still bind to the customer
|
// customer GID in `sub`; we bind to it identically.
|
||||||
// GID; for guest checkouts (no customer on the order) we fall back to a
|
// - For guest checkouts (no customer on the order, checkout source only) we
|
||||||
// recency window (the order must have been placed in the last hour).
|
// have nothing in the token to bind against. We accept only when the order
|
||||||
|
// was placed within a SHORT window — the thank-you page is rendered
|
||||||
|
// immediately after checkout, so a few minutes is ample for the legitimate
|
||||||
|
// flow. Residual risk: within this small window an attacker holding ANY
|
||||||
|
// valid checkout token for this shop could enumerate the numeric ids of
|
||||||
|
// very-recently-placed guest orders and read their amount/reference. This
|
||||||
|
// is mitigated (not eliminated) by (a) the short window, (b) per-IP rate
|
||||||
|
// limiting on /api/public/* (see server.js), and (c) numeric order ids
|
||||||
|
// being unguessable in the short window. The customer-account path remains
|
||||||
|
// the preferred, fully-bound surface.
|
||||||
const tokenSub = (sessionToken?.sub ?? "").toString();
|
const tokenSub = (sessionToken?.sub ?? "").toString();
|
||||||
const tokenCustomerNumeric = tokenSub.replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, "");
|
const tokenCustomerNumeric = tokenSub.replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, "");
|
||||||
const orderCustomerNumeric = (orderInfo.customerId ?? "").replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, "");
|
const orderCustomerNumeric = (orderInfo.customerId ?? "").replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, "");
|
||||||
@@ -124,17 +143,24 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
if (orderCustomerNumeric && tokenCustomerNumeric) {
|
if (orderCustomerNumeric && tokenCustomerNumeric) {
|
||||||
ownershipOk = orderCustomerNumeric === tokenCustomerNumeric;
|
ownershipOk = orderCustomerNumeric === tokenCustomerNumeric;
|
||||||
} else if (authSource === "checkout" && !orderCustomerNumeric) {
|
} else if (authSource === "checkout" && !orderCustomerNumeric) {
|
||||||
// Guest checkout: no customer to bind against. Accept only if the order
|
// Guest checkout: no customer to bind against. Accept only if the order is
|
||||||
// is fresh (i.e. the buyer has just completed checkout for it).
|
// very fresh (the buyer has just completed checkout for it). Kept short to
|
||||||
|
// shrink the enumeration window — see residual-risk note above.
|
||||||
const placedAtMs = orderInfo.processedAtMs ?? 0;
|
const placedAtMs = orderInfo.processedAtMs ?? 0;
|
||||||
const RECENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
const RECENT_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
ownershipOk = placedAtMs > 0 && Date.now() - placedAtMs <= RECENT_WINDOW_MS;
|
ownershipOk = placedAtMs > 0 && Date.now() - placedAtMs <= RECENT_WINDOW_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ownershipOk) {
|
if (!ownershipOk) {
|
||||||
|
// Minimal correlation log: never log raw customer identifiers (PII). Hash
|
||||||
|
// the token subject (sha256, truncated) so repeated abuse from the same
|
||||||
|
// principal is still correlatable without storing the GID itself.
|
||||||
|
const subHash = tokenSub
|
||||||
|
? crypto.createHash("sha256").update(tokenSub).digest("hex").slice(0, 12)
|
||||||
|
: "-";
|
||||||
console.warn(
|
console.warn(
|
||||||
`payment-info: ownership check failed for shop=${shop} order=${orderGid} ` +
|
`payment-info: ownership check failed for shop=${shop} order=${orderGid} ` +
|
||||||
`authSource=${authSource} tokenSub=${tokenSub || "-"}`,
|
`authSource=${authSource} subHash=${subHash}`,
|
||||||
);
|
);
|
||||||
return cors(
|
return cors(
|
||||||
Response.json(
|
Response.json(
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import {
|
|||||||
normaliseIban,
|
normaliseIban,
|
||||||
} from "../services/invoice/validation";
|
} from "../services/invoice/validation";
|
||||||
import { STORED_LOGO_SENTINEL } from "../services/invoice/logoCache.constants";
|
import { STORED_LOGO_SENTINEL } from "../services/invoice/logoCache.constants";
|
||||||
|
import { validateMerchantHttpsUrl } from "../services/invoice/safeFetch.server";
|
||||||
import {
|
import {
|
||||||
deleteStoredLogo,
|
deleteStoredLogo,
|
||||||
storeUploadedLogo,
|
storeUploadedLogo,
|
||||||
} from "../services/invoice/logoCache.server";
|
} from "../services/invoice/logoCache.server";
|
||||||
|
import { encryptField } from "../services/crypto/fieldCrypto.server";
|
||||||
import { RichTextEditor } from "../components/RichTextEditor";
|
import { RichTextEditor } from "../components/RichTextEditor";
|
||||||
import {
|
import {
|
||||||
DEFAULT_EMAIL_BODY_DE,
|
DEFAULT_EMAIL_BODY_DE,
|
||||||
@@ -122,6 +124,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
select: { logoUrl: true },
|
select: { logoUrl: true },
|
||||||
});
|
});
|
||||||
const submittedLogoUrl = str("logoUrl");
|
const submittedLogoUrl = str("logoUrl");
|
||||||
|
// Validate any merchant-supplied external logo URL at the trust boundary:
|
||||||
|
// require a syntactically valid https URL whose host is a domain name, not
|
||||||
|
// an IP literal (SSRF defence-in-depth; safeFetch is the runtime backstop).
|
||||||
|
if (submittedLogoUrl && submittedLogoUrl !== STORED_LOGO_SENTINEL) {
|
||||||
|
const urlError = validateMerchantHttpsUrl(submittedLogoUrl);
|
||||||
|
if (urlError) errors.logo = urlError;
|
||||||
|
}
|
||||||
const removeLogo = bool("removeLogo");
|
const removeLogo = bool("removeLogo");
|
||||||
const logoFile = form.get("logoFile");
|
const logoFile = form.get("logoFile");
|
||||||
const hasUpload =
|
const hasUpload =
|
||||||
@@ -154,13 +163,17 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
const submittedSmtpPassword = str("smtpPassword");
|
const submittedSmtpPassword = str("smtpPassword");
|
||||||
let nextSmtpPassword: string;
|
let nextSmtpPassword: string;
|
||||||
if (submittedSmtpPassword === SMTP_PASSWORD_SENTINEL) {
|
if (submittedSmtpPassword === SMTP_PASSWORD_SENTINEL) {
|
||||||
|
// Unchanged: keep the stored value as-is (already encrypted at rest).
|
||||||
const current = await db.shopSettings.findUnique({
|
const current = await db.shopSettings.findUnique({
|
||||||
where: { shopDomain: session.shop },
|
where: { shopDomain: session.shop },
|
||||||
select: { smtpPassword: true },
|
select: { smtpPassword: true },
|
||||||
});
|
});
|
||||||
nextSmtpPassword = current?.smtpPassword ?? "";
|
nextSmtpPassword = current?.smtpPassword ?? "";
|
||||||
} else {
|
} else {
|
||||||
nextSmtpPassword = submittedSmtpPassword;
|
// New password (including "" to clear). Encrypt non-empty values at rest.
|
||||||
|
nextSmtpPassword = submittedSmtpPassword
|
||||||
|
? encryptField(submittedSmtpPassword)
|
||||||
|
: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
generateAndEmailInvoice,
|
generateAndEmailInvoice,
|
||||||
isManualPaymentOrder,
|
isManualPaymentOrder,
|
||||||
} from "../services/invoice/automations.server";
|
} 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";
|
import { runWebhookInBackground } from "../services/webhooks/background.server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,24 +18,35 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
const { shop, topic, payload, session, admin } = await authenticate.webhook(request);
|
const { shop, topic, payload, session, admin } = await authenticate.webhook(request);
|
||||||
console.log(`Received ${topic} webhook for ${shop}`);
|
console.log(`Received ${topic} webhook for ${shop}`);
|
||||||
|
|
||||||
// Drop Shopify retries we've already processed (prevents the duplicate
|
// Reserve this delivery (status="processing"). `null` => already
|
||||||
// invoice email we saw when the first delivery exceeded Shopify's 5s ack
|
// done/in-flight, so short-circuit. The reservation is committed only after
|
||||||
// timeout — the work still completed, but Shopify resent the webhook).
|
// the background work succeeds, and released on failure so Shopify's retry
|
||||||
if (await isDuplicateWebhook(request, shop, topic)) return new Response();
|
// 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;
|
const orderId = payload?.id;
|
||||||
if (orderId == null) return new Response();
|
if (orderId == null) {
|
||||||
|
await reservation.commit();
|
||||||
|
return new Response();
|
||||||
|
}
|
||||||
|
|
||||||
const customerLocale =
|
const customerLocale =
|
||||||
typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined;
|
typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined;
|
||||||
|
|
||||||
// Respond 200 immediately and run the (slow) PDF + email work in the
|
// Respond 200 immediately and run the (slow) PDF + email work in the
|
||||||
// background — keeps us well under Shopify's ~5s ack timeout. Dedupe
|
// background — keeps us well under Shopify's ~5s ack timeout. The queue
|
||||||
// above guarantees we don't double-process a retry while the first
|
// commits the reservation on success and releases it on failure.
|
||||||
// delivery is still working.
|
runWebhookInBackground(
|
||||||
runWebhookInBackground(`${topic} order=${orderId} shop=${shop}`, async () => {
|
`${topic} order=${orderId} shop=${shop}`,
|
||||||
|
async () => {
|
||||||
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
|
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
|
||||||
if (!settings?.autoEmailOnWireTransferPlaced) return;
|
if (!settings?.autoEmailOnWireTransferPlaced) return;
|
||||||
|
|
||||||
@@ -49,11 +60,15 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
customerLocale,
|
customerLocale,
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
console.warn(
|
// 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}`,
|
`auto-email (wire-transfer placed) failed for order ${orderId} on ${shop}: ${result.reason}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
reservation,
|
||||||
|
);
|
||||||
|
|
||||||
return new Response();
|
return new Response();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
generateAndEmailInvoice,
|
generateAndEmailInvoice,
|
||||||
isManualPaymentOrder,
|
isManualPaymentOrder,
|
||||||
} from "../services/invoice/automations.server";
|
} 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";
|
import { runWebhookInBackground } from "../services/webhooks/background.server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,23 +18,32 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
const { shop, topic, payload, session, admin } = await authenticate.webhook(request);
|
const { shop, topic, payload, session, admin } = await authenticate.webhook(request);
|
||||||
console.log(`Received ${topic} webhook for ${shop}`);
|
console.log(`Received ${topic} webhook for ${shop}`);
|
||||||
|
|
||||||
// Idempotency against Shopify retries — see webhooks/dedupe.server.ts.
|
// Reserve/commit dedupe — see webhooks/dedupe.server.ts. `null` => already
|
||||||
if (await isDuplicateWebhook(request, shop, topic)) return new Response();
|
// done/in-flight; commit on success / release on failure happen in the
|
||||||
|
// background queue so a failed send is retried by Shopify, not dropped.
|
||||||
|
const reservation = await reserveWebhook(request, shop, topic);
|
||||||
|
if (!reservation) return new Response();
|
||||||
|
|
||||||
if (!session || !admin) {
|
if (!session || !admin) {
|
||||||
// App was uninstalled before the webhook drained — nothing to do.
|
// App was uninstalled before the webhook drained — nothing to do.
|
||||||
|
await reservation.commit();
|
||||||
return new Response();
|
return new Response();
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderId = payload?.id;
|
const orderId = payload?.id;
|
||||||
if (orderId == null) return new Response();
|
if (orderId == null) {
|
||||||
|
await reservation.commit();
|
||||||
|
return new Response();
|
||||||
|
}
|
||||||
|
|
||||||
const customerLocale =
|
const customerLocale =
|
||||||
typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined;
|
typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined;
|
||||||
|
|
||||||
// Respond fast; do the heavy lifting after the response (see notes in
|
// Respond fast; do the heavy lifting after the response (see notes in
|
||||||
// webhooks.orders.create.tsx for the rationale).
|
// webhooks.orders.create.tsx for the rationale).
|
||||||
runWebhookInBackground(`${topic} order=${orderId} shop=${shop}`, async () => {
|
runWebhookInBackground(
|
||||||
|
`${topic} order=${orderId} shop=${shop}`,
|
||||||
|
async () => {
|
||||||
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
|
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
|
||||||
if (!settings?.autoEmailOnFulfilledNonWireTransfer) return;
|
if (!settings?.autoEmailOnFulfilledNonWireTransfer) return;
|
||||||
|
|
||||||
@@ -51,9 +60,14 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
customerLocale,
|
customerLocale,
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
console.warn(`auto-email (fulfilled) failed for order ${orderId} on ${shop}: ${result.reason}`);
|
// Throw so the reservation is released and Shopify retries.
|
||||||
|
throw new Error(
|
||||||
|
`auto-email (fulfilled) failed for order ${orderId} on ${shop}: ${result.reason}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
reservation,
|
||||||
|
);
|
||||||
|
|
||||||
return new Response();
|
return new Response();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import { isDuplicateWebhook } from "../services/webhooks/dedupe.server";
|
import { reserveWebhook } from "../services/webhooks/dedupe.server";
|
||||||
|
|
||||||
// Acknowledged but not yet acted on. Future: invalidate cached invoice
|
// Acknowledged but not yet acted on. Future: invalidate cached invoice
|
||||||
// snapshots when a relevant field on the order changes.
|
// snapshots when a relevant field on the order changes.
|
||||||
@@ -8,6 +8,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
const { shop, topic } = await authenticate.webhook(request);
|
const { shop, topic } = await authenticate.webhook(request);
|
||||||
console.log(`Received ${topic} webhook for ${shop}`);
|
console.log(`Received ${topic} webhook for ${shop}`);
|
||||||
// Idempotency against Shopify retries — see webhooks/dedupe.server.ts.
|
// Idempotency against Shopify retries — see webhooks/dedupe.server.ts.
|
||||||
if (await isDuplicateWebhook(request, shop, topic)) return new Response();
|
const reservation = await reserveWebhook(request, shop, topic);
|
||||||
|
if (!reservation) return new Response();
|
||||||
|
// No side-effect work yet, so the delivery is immediately complete.
|
||||||
|
await reservation.commit();
|
||||||
return new Response();
|
return new Response();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Small env-access helpers that fail closed.
|
||||||
|
*
|
||||||
|
* `requireEnv` throws a clear error (without ever printing the secret value)
|
||||||
|
* when a required environment variable is missing or empty. `optionalEnv`
|
||||||
|
* returns the trimmed value or undefined.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the value of `name` from `process.env`, throwing if it is unset or
|
||||||
|
* empty (after trimming). The secret value itself is never included in the
|
||||||
|
* error message.
|
||||||
|
*/
|
||||||
|
export function requireEnv(name: string): string {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (raw === undefined || raw.trim() === "") {
|
||||||
|
throw new Error(
|
||||||
|
`Missing required environment variable "${name}". ` +
|
||||||
|
`Set it before starting the app (see deploy/.env.dev.example).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the value of `name` from `process.env`, or `undefined` if it is
|
||||||
|
* unset or empty (after trimming).
|
||||||
|
*/
|
||||||
|
export function optionalEnv(name: string): string | undefined {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (raw === undefined || raw.trim() === "") return undefined;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Field-level encryption at rest using AES-256-GCM.
|
||||||
|
*
|
||||||
|
* Output format: `enc:v1:<base64(iv)>:<base64(tag)>:<base64(ciphertext)>`
|
||||||
|
*
|
||||||
|
* `decryptField` is backward-compatible: values that do not carry the
|
||||||
|
* `enc:v1:` prefix are assumed to be legacy plaintext and returned unchanged,
|
||||||
|
* so an existing (dev) database keeps working without a data migration.
|
||||||
|
*/
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
import { requireEnv } from "../config/env.server";
|
||||||
|
|
||||||
|
const PREFIX = "enc:v1:";
|
||||||
|
const IV_BYTES = 12;
|
||||||
|
const KEY_BYTES = 32;
|
||||||
|
|
||||||
|
let cachedKey: Buffer | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads and validates the 32-byte AES key from `DATA_ENCRYPTION_KEY`
|
||||||
|
* (base64-encoded). Cached after first use. Throws if unset or wrong length.
|
||||||
|
*/
|
||||||
|
function getKey(): Buffer {
|
||||||
|
if (cachedKey) return cachedKey;
|
||||||
|
const b64 = requireEnv("DATA_ENCRYPTION_KEY");
|
||||||
|
let key: Buffer;
|
||||||
|
try {
|
||||||
|
key = Buffer.from(b64, "base64");
|
||||||
|
} catch {
|
||||||
|
throw new Error('DATA_ENCRYPTION_KEY must be valid base64 of 32 bytes.');
|
||||||
|
}
|
||||||
|
if (key.length !== KEY_BYTES) {
|
||||||
|
throw new Error(
|
||||||
|
`DATA_ENCRYPTION_KEY must decode to ${KEY_BYTES} bytes (got ${key.length}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cachedKey = key;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypts `plaintext` and returns the `enc:v1:...` envelope. */
|
||||||
|
export function encryptField(plaintext: string): string {
|
||||||
|
const key = getKey();
|
||||||
|
const iv = crypto.randomBytes(IV_BYTES);
|
||||||
|
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
||||||
|
const ciphertext = Buffer.concat([
|
||||||
|
cipher.update(plaintext, "utf8"),
|
||||||
|
cipher.final(),
|
||||||
|
]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return (
|
||||||
|
PREFIX +
|
||||||
|
iv.toString("base64") +
|
||||||
|
":" +
|
||||||
|
tag.toString("base64") +
|
||||||
|
":" +
|
||||||
|
ciphertext.toString("base64")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts an `enc:v1:...` envelope. If `value` is not in that format it is
|
||||||
|
* assumed to be legacy plaintext and returned unchanged.
|
||||||
|
*/
|
||||||
|
export function decryptField(value: string): string {
|
||||||
|
if (!value.startsWith(PREFIX)) return value;
|
||||||
|
const parts = value.slice(PREFIX.length).split(":");
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new Error("Malformed encrypted field (expected iv:tag:ciphertext).");
|
||||||
|
}
|
||||||
|
const [ivB64, tagB64, dataB64] = parts;
|
||||||
|
const key = getKey();
|
||||||
|
const iv = Buffer.from(ivB64, "base64");
|
||||||
|
const tag = Buffer.from(tagB64, "base64");
|
||||||
|
const ciphertext = Buffer.from(dataB64, "base64");
|
||||||
|
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
const plaintext = Buffer.concat([
|
||||||
|
decipher.update(ciphertext),
|
||||||
|
decipher.final(),
|
||||||
|
]);
|
||||||
|
return plaintext.toString("utf8");
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
} from "./emailTemplates";
|
} from "./emailTemplates";
|
||||||
import { STORED_LOGO_SENTINEL } from "./logoCache.constants";
|
import { STORED_LOGO_SENTINEL } from "./logoCache.constants";
|
||||||
import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server";
|
import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server";
|
||||||
|
import { decryptField } from "../crypto/fieldCrypto.server";
|
||||||
|
import { optionalEnv } from "../config/env.server";
|
||||||
|
|
||||||
export interface SendInvoiceEmailArgs {
|
export interface SendInvoiceEmailArgs {
|
||||||
shopDomain: string;
|
shopDomain: string;
|
||||||
@@ -86,7 +88,7 @@ export async function sendInvoiceEmail(
|
|||||||
const customSubject =
|
const customSubject =
|
||||||
(language === "en" ? settings.emailSubjectEn : settings.emailSubjectDe) ||
|
(language === "en" ? settings.emailSubjectEn : settings.emailSubjectDe) ||
|
||||||
(language === "en" ? DEFAULT_EMAIL_SUBJECT_EN : DEFAULT_EMAIL_SUBJECT_DE);
|
(language === "en" ? DEFAULT_EMAIL_SUBJECT_EN : DEFAULT_EMAIL_SUBJECT_DE);
|
||||||
const subject = renderTemplate(customSubject, vars);
|
const subject = renderTemplate(customSubject, vars, { html: false });
|
||||||
|
|
||||||
const customBodyHtml =
|
const customBodyHtml =
|
||||||
(language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe) ||
|
(language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe) ||
|
||||||
@@ -124,10 +126,13 @@ export async function sendInvoiceEmail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Optional archival BCC. Off by default for privacy/GDPR; set INVOICE_BCC
|
||||||
|
// to a comma-separated address list to opt in.
|
||||||
|
const bcc = optionalEnv("INVOICE_BCC");
|
||||||
const info = await transporter.sendMail({
|
const info = await transporter.sendMail({
|
||||||
from: `"${fromName}" <${fromEmail}>`,
|
from: `"${fromName}" <${fromEmail}>`,
|
||||||
to,
|
to,
|
||||||
bcc: "shop@linumiq.com",
|
...(bcc ? { bcc } : {}),
|
||||||
replyTo: settings.smtpReplyTo || undefined,
|
replyTo: settings.smtpReplyTo || undefined,
|
||||||
subject,
|
subject,
|
||||||
text: body.text,
|
text: body.text,
|
||||||
@@ -180,7 +185,7 @@ function buildTransport(settings: ShopSettings): Transporter {
|
|||||||
port: settings.smtpPort,
|
port: settings.smtpPort,
|
||||||
secure: settings.smtpSecure,
|
secure: settings.smtpSecure,
|
||||||
auth: settings.smtpUser
|
auth: settings.smtpUser
|
||||||
? { user: settings.smtpUser, pass: settings.smtpPassword }
|
? { user: settings.smtpUser, pass: decryptField(settings.smtpPassword) }
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -286,13 +291,49 @@ function buildTemplateVars(args: {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Substitutes {{token}} placeholders in `template`. Unknown tokens are left
|
* Substitutes {{token}} placeholders in `template`. Unknown tokens are left
|
||||||
* in place so the user notices typos instead of silent blanks. Values are
|
* in place so the user notices typos instead of silent blanks.
|
||||||
* inserted verbatim — callers are responsible for HTML-escaping if needed.
|
*
|
||||||
|
* For HTML output (the default), every interpolated value is HTML-escaped to
|
||||||
|
* prevent stored-XSS from merchant- or customer-derived data bleeding into the
|
||||||
|
* email body. URL-valued tokens that land inside `href` attributes are scheme-
|
||||||
|
* validated first: `shopWebsite` must be an `https:` URL and `shopEmail` must
|
||||||
|
* look like a bare email address (rendered after a `mailto:` prefix); anything
|
||||||
|
* else renders empty so a hostile `javascript:`/`data:` value can't be planted.
|
||||||
|
*
|
||||||
|
* Pass `{ html: false }` for plain-text contexts (e.g. the subject line), where
|
||||||
|
* the raw value is substituted without HTML entity encoding.
|
||||||
*/
|
*/
|
||||||
function renderTemplate(template: string, vars: TemplateVars): string {
|
const URL_TOKENS = new Set(["shopWebsite"]);
|
||||||
|
const EMAIL_TOKENS = new Set(["shopEmail"]);
|
||||||
|
|
||||||
|
function safeHttpsUrl(value: string): string {
|
||||||
|
const v = value.trim();
|
||||||
|
try {
|
||||||
|
return new URL(v).protocol === "https:" ? v : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeEmailAddress(value: string): string {
|
||||||
|
const v = value.trim();
|
||||||
|
return /^[^\s@<>"'/\\]+@[^\s@<>"'/\\]+\.[^\s@<>"'/\\]+$/.test(v) ? v : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplate(
|
||||||
|
template: string,
|
||||||
|
vars: TemplateVars,
|
||||||
|
opts: { html?: boolean } = {},
|
||||||
|
): string {
|
||||||
|
const html = opts.html !== false; // HTML-escape by default
|
||||||
return template.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (full, key) => {
|
return template.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (full, key) => {
|
||||||
const v = (vars as unknown as Record<string, string | undefined>)[key];
|
const raw = (vars as unknown as Record<string, string | undefined>)[key];
|
||||||
return v === undefined ? full : v;
|
if (raw === undefined) return full;
|
||||||
|
if (!html) return raw;
|
||||||
|
let value = raw;
|
||||||
|
if (URL_TOKENS.has(key)) value = safeHttpsUrl(raw);
|
||||||
|
else if (EMAIL_TOKENS.has(key)) value = safeEmailAddress(raw);
|
||||||
|
return escapeHtml(value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ export interface GiroCodeInput {
|
|||||||
remittance: string;
|
remittance: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces CR/LF in a free-text EPC field with a single space and collapses
|
||||||
|
* runs of whitespace, so the line-delimited payload can't be tampered with by
|
||||||
|
* smuggling newlines into user-supplied text (beneficiary name / remittance).
|
||||||
|
*/
|
||||||
|
function sanitizeEpcField(value: string): string {
|
||||||
|
return value.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function buildGiroCodePayload(input: GiroCodeInput): string {
|
export function buildGiroCodePayload(input: GiroCodeInput): string {
|
||||||
const currency = input.currency || "EUR";
|
const currency = input.currency || "EUR";
|
||||||
if (currency !== "EUR") {
|
if (currency !== "EUR") {
|
||||||
@@ -25,12 +34,14 @@ export function buildGiroCodePayload(input: GiroCodeInput): string {
|
|||||||
console.warn(`GiroCode: non-EUR currency ${currency} is non-standard.`);
|
console.warn(`GiroCode: non-EUR currency ${currency} is non-standard.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Beneficiary name max 70 chars per spec.
|
// Beneficiary name max 70 chars per spec. Strip CR/LF first so injected
|
||||||
const name = input.beneficiaryName.slice(0, 70);
|
// newlines can't forge/add EPC fields (the payload is line-delimited).
|
||||||
|
const name = sanitizeEpcField(input.beneficiaryName).slice(0, 70);
|
||||||
const iban = input.iban.replace(/\s+/g, "").toUpperCase();
|
const iban = input.iban.replace(/\s+/g, "").toUpperCase();
|
||||||
const bic = (input.bic || "").replace(/\s+/g, "").toUpperCase();
|
const bic = (input.bic || "").replace(/\s+/g, "").toUpperCase();
|
||||||
const amount = input.amount.toFixed(2);
|
const amount = input.amount.toFixed(2);
|
||||||
const remittance = input.remittance.slice(0, 140);
|
// Unstructured remittance max 140 chars; strip CR/LF for the same reason.
|
||||||
|
const remittance = sanitizeEpcField(input.remittance).slice(0, 140);
|
||||||
|
|
||||||
// Field order is fixed; trailing fields can be empty.
|
// Field order is fixed; trailing fields can be empty.
|
||||||
// Service tag SCT = SEPA Credit Transfer.
|
// Service tag SCT = SEPA Credit Transfer.
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ const TEXT_DARK = "#1F2933";
|
|||||||
const TEXT_MUTED = "#6B7280";
|
const TEXT_MUTED = "#6B7280";
|
||||||
const TABLE_BORDER = "#E5E7EB";
|
const TABLE_BORDER = "#E5E7EB";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true only for syntactically valid http(s) URLs. Used to gate
|
||||||
|
* carrier/fulfillment-supplied tracking URLs before embedding them as PDF
|
||||||
|
* link annotations, so non-http schemes (javascript:, file:, data:, …) can't
|
||||||
|
* be smuggled into the document.
|
||||||
|
*/
|
||||||
|
function isHttpUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(value);
|
||||||
|
return u.protocol === "https:" || u.protocol === "http:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: {
|
page: {
|
||||||
paddingTop: 40,
|
paddingTop: 40,
|
||||||
@@ -348,7 +363,7 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
|||||||
{t.trackingLabel}
|
{t.trackingLabel}
|
||||||
{tr.company ? ` (${tr.company})` : ""}
|
{tr.company ? ` (${tr.company})` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
{tr.url ? (
|
{tr.url && isHttpUrl(tr.url) ? (
|
||||||
<Link src={tr.url} style={styles.metaValue}>{tr.number}</Link>
|
<Link src={tr.url} style={styles.metaValue}>{tr.number}</Link>
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.metaValue}>{tr.number}</Text>
|
<Text style={styles.metaValue}>{tr.number}</Text>
|
||||||
|
|||||||
@@ -7,9 +7,14 @@
|
|||||||
* hammering the network when regenerating an invoice multiple times.
|
* hammering the network when regenerating an invoice multiple times.
|
||||||
*/
|
*/
|
||||||
import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server";
|
import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server";
|
||||||
|
import pLimit from "p-limit";
|
||||||
|
|
||||||
const MAX_BYTES = 2 * 1024 * 1024; // 2 MB cap per image
|
const MAX_BYTES = 2 * 1024 * 1024; // 2 MB cap per image
|
||||||
const CACHE_MAX_ENTRIES = 200;
|
const CACHE_MAX_ENTRIES = 200;
|
||||||
|
/** Max images fetched/embedded per invoice (DoS bound for large carts). */
|
||||||
|
const MAX_IMAGES_PER_INVOICE = 100;
|
||||||
|
/** Max concurrent image fetches per invoice. */
|
||||||
|
const IMAGE_FETCH_CONCURRENCY = 6;
|
||||||
|
|
||||||
const cache = new Map<string, string>(); // url -> data URL
|
const cache = new Map<string, string>(); // url -> data URL
|
||||||
|
|
||||||
@@ -76,17 +81,28 @@ export async function fetchProductImageDataUrl(url: string): Promise<string | un
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves images for every line in parallel, mutating `imageDataUrl` in place.
|
* Resolves images for every line, mutating `imageDataUrl` in place. Fetches
|
||||||
* Failures are swallowed (the row simply renders without an icon).
|
* run with bounded concurrency and a hard cap on the number of images
|
||||||
|
* embedded per invoice, so a large cart (Shopify allows hundreds of line
|
||||||
|
* items) can't trigger an unbounded fan-out of network requests. Failures are
|
||||||
|
* swallowed (the row simply renders without an icon).
|
||||||
*/
|
*/
|
||||||
export async function attachLineItemImages(
|
export async function attachLineItemImages(
|
||||||
lines: { imageUrl?: string; imageDataUrl?: string }[],
|
lines: { imageUrl?: string; imageDataUrl?: string }[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await Promise.all(
|
const limit = pLimit(IMAGE_FETCH_CONCURRENCY);
|
||||||
lines.map(async (line) => {
|
let budget = MAX_IMAGES_PER_INVOICE;
|
||||||
if (!line.imageUrl) return;
|
const tasks: Promise<void>[] = [];
|
||||||
const dataUrl = await fetchProductImageDataUrl(line.imageUrl);
|
for (const line of lines) {
|
||||||
|
if (!line.imageUrl) continue;
|
||||||
|
if (budget <= 0) break; // cap reached — remaining rows render iconless
|
||||||
|
budget -= 1;
|
||||||
|
tasks.push(
|
||||||
|
limit(async () => {
|
||||||
|
const dataUrl = await fetchProductImageDataUrl(line.imageUrl!);
|
||||||
if (dataUrl) line.imageDataUrl = dataUrl;
|
if (dataUrl) line.imageDataUrl = dataUrl;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await Promise.all(tasks);
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { Agent as HttpAgent } from "node:http";
|
|||||||
import { Agent as HttpsAgent } from "node:https";
|
import { Agent as HttpsAgent } from "node:https";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import https from "node:https";
|
import https from "node:https";
|
||||||
|
import ipaddr from "ipaddr.js";
|
||||||
|
|
||||||
export interface SafeFetchOptions {
|
export interface SafeFetchOptions {
|
||||||
/** Hard cap in bytes; the read aborts as soon as this is exceeded. */
|
/** Hard cap in bytes; the read aborts as soon as this is exceeded. */
|
||||||
@@ -60,43 +61,62 @@ export class SafeFetchError extends Error {
|
|||||||
const DEFAULT_TIMEOUT_MS = 5_000;
|
const DEFAULT_TIMEOUT_MS = 5_000;
|
||||||
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
|
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
|
||||||
|
|
||||||
function isPrivateIpv4(ip: string): boolean {
|
/**
|
||||||
const parts = ip.split(".").map((n) => parseInt(n, 10));
|
* Default-deny address classifier backed by the well-vetted `ipaddr.js`
|
||||||
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) {
|
* library. An address is considered safe to connect to ONLY if it is a
|
||||||
// Treat malformed addresses as unsafe.
|
* clearly public, globally-routable unicast address. Everything else —
|
||||||
return true;
|
* loopback, private (RFC1918), link-local, unique-local, multicast,
|
||||||
}
|
* reserved, unspecified, broadcast, carrier-grade NAT, plus the various
|
||||||
const [a, b] = parts;
|
* IPv4-in-IPv6 tunnelling/transition forms — is rejected.
|
||||||
if (a === 10) return true;
|
*
|
||||||
if (a === 127) return true;
|
* This closes IPv6 bypasses that string-prefix checks miss, e.g.:
|
||||||
if (a === 0) return true;
|
* - `::ffff:7f00:1` (IPv4-mapped HEX form of 127.0.0.1)
|
||||||
if (a === 169 && b === 254) return true; // link-local + AWS metadata
|
* - `::7f00:1` (deprecated IPv4-compatible ::127.0.0.1)
|
||||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
* - `fe90::` / `fea0::` / `feb0::` (link-local is fe80::/10, not just fe80:)
|
||||||
if (a === 192 && b === 168) return true;
|
*/
|
||||||
if (a === 192 && b === 0) return true; // 192.0.0.0/24, 192.0.2.0/24
|
function isSafePublicAddress(ip: string): boolean {
|
||||||
if (a === 198 && (b === 18 || b === 19)) return true;
|
let addr: ipaddr.IPv4 | ipaddr.IPv6;
|
||||||
if (a >= 224) return true; // multicast / reserved
|
try {
|
||||||
|
addr = ipaddr.parse(ip);
|
||||||
|
} catch {
|
||||||
|
// Unparseable => treat as unsafe.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPrivateIpv6(ip: string): boolean {
|
if (addr.kind() === "ipv4") {
|
||||||
const lower = ip.toLowerCase();
|
// Only globally-routable unicast IPv4 is allowed. `range()` returns
|
||||||
if (lower === "::1" || lower === "::") return true;
|
// 'unicast' exclusively for public space; private/loopback/linkLocal/
|
||||||
if (lower.startsWith("fe80:")) return true; // link-local
|
// carrierGradeNat/reserved/broadcast/multicast/unspecified are all denied.
|
||||||
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // ULA
|
return (addr as ipaddr.IPv4).range() === "unicast";
|
||||||
if (lower.startsWith("ff")) return true; // multicast
|
|
||||||
// IPv4-mapped: ::ffff:a.b.c.d — apply IPv4 rules
|
|
||||||
if (lower.startsWith("::ffff:")) {
|
|
||||||
const v4 = lower.slice(7);
|
|
||||||
if (v4.includes(".")) return isPrivateIpv4(v4);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPrivateAddress(ip: string, family: number): boolean {
|
const v6 = addr as ipaddr.IPv6;
|
||||||
if (family === 4) return isPrivateIpv4(ip);
|
|
||||||
if (family === 6) return isPrivateIpv6(ip);
|
// Unwrap IPv4-mapped (::ffff:a.b.c.d, incl. hex form ::ffff:7f00:1) and
|
||||||
return true;
|
// validate the embedded IPv4 against the v4 policy.
|
||||||
|
if (v6.isIPv4MappedAddress()) {
|
||||||
|
return v6.toIPv4Address().range() === "unicast";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated IPv4-compatible addresses live in ::/96 (first 96 bits zero,
|
||||||
|
// e.g. ::7f00:1 == ::127.0.0.1). ipaddr.js classifies these as plain
|
||||||
|
// 'unicast', so unwrap the trailing 32 bits and validate as IPv4. This
|
||||||
|
// also covers :: (unspecified) and ::1 (loopback), which map to
|
||||||
|
// 0.0.0.0 / 0.0.0.1 and are denied by the IPv4 policy.
|
||||||
|
const p = v6.parts;
|
||||||
|
if (p[0] === 0 && p[1] === 0 && p[2] === 0 && p[3] === 0 && p[4] === 0 && p[5] === 0) {
|
||||||
|
const v4 = new ipaddr.IPv4([(p[6] >> 8) & 0xff, p[6] & 0xff, (p[7] >> 8) & 0xff, p[7] & 0xff]);
|
||||||
|
return v4.range() === "unicast";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else: only true global unicast is allowed. This rejects
|
||||||
|
// loopback, linkLocal (fe80::/10), uniqueLocal (fc00::/7), multicast,
|
||||||
|
// reserved, 6to4, teredo, rfc6145/rfc6052 transition ranges, etc.
|
||||||
|
return v6.range() === "unicast";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateAddress(ip: string): boolean {
|
||||||
|
return !isSafePublicAddress(ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostMatchesAllowlist(hostname: string, allowed: string[] | undefined): boolean {
|
function hostMatchesAllowlist(hostname: string, allowed: string[] | undefined): boolean {
|
||||||
@@ -117,7 +137,7 @@ async function resolveSafeAddress(hostname: string): Promise<{ address: string;
|
|||||||
// If the hostname is already an IP literal, validate it directly.
|
// If the hostname is already an IP literal, validate it directly.
|
||||||
if (net.isIP(hostname)) {
|
if (net.isIP(hostname)) {
|
||||||
const family = net.isIPv6(hostname) ? 6 : 4;
|
const family = net.isIPv6(hostname) ? 6 : 4;
|
||||||
if (isPrivateAddress(hostname, family)) {
|
if (isPrivateAddress(hostname)) {
|
||||||
throw new SafeFetchError("blocked-address", `Refusing to connect to private address ${hostname}`);
|
throw new SafeFetchError("blocked-address", `Refusing to connect to private address ${hostname}`);
|
||||||
}
|
}
|
||||||
return { address: hostname, family };
|
return { address: hostname, family };
|
||||||
@@ -129,7 +149,7 @@ async function resolveSafeAddress(hostname: string): Promise<{ address: string;
|
|||||||
throw new SafeFetchError("dns-failed", `DNS lookup failed for ${hostname}: ${(err as Error).message}`);
|
throw new SafeFetchError("dns-failed", `DNS lookup failed for ${hostname}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
if (isPrivateAddress(r.address, r.family)) {
|
if (isPrivateAddress(r.address)) {
|
||||||
throw new SafeFetchError("blocked-address", `${hostname} resolves to private address ${r.address}`);
|
throw new SafeFetchError("blocked-address", `${hostname} resolves to private address ${r.address}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,3 +289,30 @@ export async function safeFetch(rawUrl: string, opts: SafeFetchOptions = {}): Pr
|
|||||||
|
|
||||||
/** Common allowlist for Shopify-served assets (CDN + Files). */
|
/** Common allowlist for Shopify-served assets (CDN + Files). */
|
||||||
export const SHOPIFY_CDN_HOSTS = ["cdn.shopify.com", "shopifycdn.com", "shopify.com"];
|
export const SHOPIFY_CDN_HOSTS = ["cdn.shopify.com", "shopifycdn.com", "shopify.com"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boundary validation for merchant-supplied URLs (e.g. the logo URL saved in
|
||||||
|
* settings). Requires a syntactically valid `https:` URL whose host is a DNS
|
||||||
|
* name rather than an IP literal (v4 or v6). Returns a user-facing error
|
||||||
|
* string when the URL is unacceptable, or `null` when it is fine to store.
|
||||||
|
*
|
||||||
|
* This is a defence-in-depth boundary check; `safeFetch` remains the runtime
|
||||||
|
* backstop that re-validates the resolved address at fetch time.
|
||||||
|
*/
|
||||||
|
export function validateMerchantHttpsUrl(raw: string): string | null {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
return "Enter a valid URL including the https:// prefix.";
|
||||||
|
}
|
||||||
|
if (url.protocol !== "https:") {
|
||||||
|
return "Logo URL must use https://.";
|
||||||
|
}
|
||||||
|
// URL.hostname wraps IPv6 literals in brackets; strip them before checking.
|
||||||
|
const host = url.hostname.replace(/^\[/, "").replace(/\]$/, "");
|
||||||
|
if (net.isIP(host) !== 0) {
|
||||||
|
return "Logo URL must point to a domain name, not an IP address.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
const SECRET = process.env.SHOPIFY_API_SECRET || "";
|
import { optionalEnv } from "../config/env.server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the GiroCode URL signing key lazily (per call, not at module load)
|
||||||
|
* so the process can boot even when only the fallback secret is present.
|
||||||
|
*
|
||||||
|
* Prefers the dedicated `GIROCODE_SIGNING_KEY`; falls back to
|
||||||
|
* `SHOPIFY_API_SECRET` ONLY when the dedicated key is unset, so existing
|
||||||
|
* signed URLs and deployments keep working. Throws if neither is set
|
||||||
|
* (fail closed) — an empty key would make signatures forgeable.
|
||||||
|
*/
|
||||||
|
function getSigningKey(): string {
|
||||||
|
const key = optionalEnv("GIROCODE_SIGNING_KEY") ?? optionalEnv("SHOPIFY_API_SECRET");
|
||||||
|
if (!key) {
|
||||||
|
throw new Error(
|
||||||
|
"GiroCode signing key missing: set GIROCODE_SIGNING_KEY (preferred) " +
|
||||||
|
"or SHOPIFY_API_SECRET.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
function hmac(payload: string): string {
|
function hmac(payload: string): string {
|
||||||
return crypto.createHmac("sha256", SECRET).update(payload).digest("hex");
|
return crypto.createHmac("sha256", getSigningKey()).update(payload).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GiroCodeUrlParams {
|
export interface GiroCodeUrlParams {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Thin wrapper around `@shopify/shopify-app-session-storage-prisma` that
|
||||||
|
* encrypts `accessToken` / `refreshToken` at rest using field-level AES-256-GCM.
|
||||||
|
*
|
||||||
|
* Tokens are encrypted before being handed to the underlying storage and
|
||||||
|
* decrypted after they are loaded back out. `decryptField` is backward
|
||||||
|
* compatible, so any legacy plaintext tokens already in the DB keep working.
|
||||||
|
*/
|
||||||
|
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
|
||||||
|
import type { Session } from "@shopify/shopify-api";
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
import { decryptField, encryptField } from "../crypto/fieldCrypto.server";
|
||||||
|
|
||||||
|
type SessionTokenFields = {
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function encryptTokens(session: Session): Session {
|
||||||
|
const s = session as Session & SessionTokenFields;
|
||||||
|
if (s.accessToken) s.accessToken = encryptField(s.accessToken);
|
||||||
|
if (s.refreshToken) s.refreshToken = encryptField(s.refreshToken);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptTokens(session: Session): Session {
|
||||||
|
const s = session as Session & SessionTokenFields;
|
||||||
|
if (s.accessToken) s.accessToken = decryptField(s.accessToken);
|
||||||
|
if (s.refreshToken) s.refreshToken = decryptField(s.refreshToken);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clones a Session so we never mutate the caller's instance when encrypting
|
||||||
|
* for storage. The Prisma session storage only reads plain properties, so a
|
||||||
|
* shallow structured copy via the Session class is sufficient.
|
||||||
|
*/
|
||||||
|
function cloneSession(session: Session): Session {
|
||||||
|
return Object.assign(
|
||||||
|
Object.create(Object.getPrototypeOf(session)),
|
||||||
|
session,
|
||||||
|
) as Session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EncryptedPrismaSessionStorage extends PrismaSessionStorage<PrismaClient> {
|
||||||
|
async storeSession(session: Session): Promise<boolean> {
|
||||||
|
return super.storeSession(encryptTokens(cloneSession(session)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSession(id: string): Promise<Session | undefined> {
|
||||||
|
const session = await super.loadSession(id);
|
||||||
|
return session ? decryptTokens(session) : session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSessionsByShop(shop: string): Promise<Session[]> {
|
||||||
|
const sessions = await super.findSessionsByShop(shop);
|
||||||
|
return sessions.map((s) => decryptTokens(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,118 @@
|
|||||||
|
import pLimit from "p-limit";
|
||||||
|
import type { WebhookReservation } from "./dedupe.server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fire-and-forget runner for webhook side-effects.
|
* Background runner for webhook side-effects.
|
||||||
*
|
*
|
||||||
* Shopify expects a 200 response within ~5 seconds, otherwise it considers
|
* Shopify expects a 200 response within ~5 seconds, otherwise it considers
|
||||||
* the delivery failed and retries it. Heavy automation work (PDF render,
|
* the delivery failed and retries it. Heavy automation work (PDF render,
|
||||||
* Shopify Files upload, SMTP send) routinely exceeded that budget, which
|
* Shopify Files upload, SMTP send) routinely exceeded that budget, which
|
||||||
* caused duplicate invoice emails before we added the dedupe table.
|
* caused duplicate invoice emails before we added the dedupe table.
|
||||||
*
|
*
|
||||||
* Returning the response immediately and letting the work finish in the
|
* Returning the response immediately and finishing the work afterwards keeps
|
||||||
* background keeps Shopify happy. Combined with the dedupe table this is
|
* Shopify happy. Two problems with a naive `void work()`:
|
||||||
* defence-in-depth: dedupe ensures *correctness* even if a retry sneaks
|
|
||||||
* through, while async processing makes retries unlikely in the first
|
|
||||||
* place.
|
|
||||||
*
|
*
|
||||||
* Errors are caught and logged \u2014 they cannot reach a dispatcher because
|
* 1. DoS / resource exhaustion — an order burst would spawn unbounded
|
||||||
* the HTTP response is already gone.
|
* concurrent PDF renders + SMTP sends. We cap concurrency with a small
|
||||||
|
* in-process queue (`p-limit`); excess tasks queue instead of piling up.
|
||||||
|
* 2. Data loss on restart — `void work()` is invisible to shutdown, so a
|
||||||
|
* container stop (SIGTERM) killed in-flight invoice work mid-send. We
|
||||||
|
* track in-flight tasks and drain them (bounded) on SIGTERM/SIGINT.
|
||||||
|
*
|
||||||
|
* Reserve/commit dedupe (see dedupe.server.ts) is integrated here: on success
|
||||||
|
* we `commit()` the reservation (permanently deduped); on failure we
|
||||||
|
* `release()` it so Shopify's retry re-runs the work instead of being dropped
|
||||||
|
* as a duplicate.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const CONCURRENCY = Math.max(1, Number(process.env.WEBHOOK_CONCURRENCY) || 4);
|
||||||
|
const DRAIN_TIMEOUT_MS = Math.max(
|
||||||
|
1000,
|
||||||
|
Number(process.env.WEBHOOK_DRAIN_TIMEOUT_MS) || 25_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const limit = pLimit(CONCURRENCY);
|
||||||
|
const inFlight = new Set<Promise<unknown>>();
|
||||||
|
let draining = false;
|
||||||
|
|
||||||
export function runWebhookInBackground(
|
export function runWebhookInBackground(
|
||||||
description: string,
|
description: string,
|
||||||
work: () => Promise<unknown>,
|
work: () => Promise<unknown>,
|
||||||
|
reservation?: WebhookReservation | null,
|
||||||
): void {
|
): void {
|
||||||
// `void` so we don't accidentally `await` the floating promise; the
|
if (draining) {
|
||||||
// node event loop keeps the task alive until it settles.
|
// The process is shutting down. We still enqueue so the drain awaits this
|
||||||
void work().catch((err) => {
|
// task — the server has already stopped listening, so this is at most the
|
||||||
|
// tail end of the last accepted request.
|
||||||
|
console.warn(`[webhook-queue] enqueuing task during shutdown drain: ${description}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = limit(async () => {
|
||||||
|
try {
|
||||||
|
await work();
|
||||||
|
await reservation?.commit();
|
||||||
|
} catch (err) {
|
||||||
console.error(`background webhook task '${description}' failed:`, err);
|
console.error(`background webhook task '${description}' failed:`, err);
|
||||||
|
// Drop the dedupe reservation so Shopify's retry re-runs the work.
|
||||||
|
try {
|
||||||
|
await reservation?.release();
|
||||||
|
} catch (releaseErr) {
|
||||||
|
console.error(
|
||||||
|
`background webhook task '${description}': failed to release dedupe reservation:`,
|
||||||
|
releaseErr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inFlight.add(task);
|
||||||
|
void task.finally(() => inFlight.delete(task));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop accepting new work (best-effort) and await in-flight + queued tasks,
|
||||||
|
* bounded by `timeoutMs`, so a container stop drains invoice work instead of
|
||||||
|
* killing it mid-send. Idempotent.
|
||||||
|
*/
|
||||||
|
export async function drainWebhookQueue(timeoutMs = DRAIN_TIMEOUT_MS): Promise<void> {
|
||||||
|
draining = true;
|
||||||
|
if (inFlight.size === 0) return;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[webhook-queue] draining ${inFlight.size} in-flight webhook task(s) (timeout ${timeoutMs}ms)...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const timeout = new Promise<void>((resolve) => {
|
||||||
|
timer = setTimeout(resolve, timeoutMs);
|
||||||
|
if (typeof timer.unref === "function") timer.unref();
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.race([Promise.allSettled([...inFlight]), timeout]);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
|
||||||
|
if (inFlight.size > 0) {
|
||||||
|
console.warn(
|
||||||
|
`[webhook-queue] drain timed out with ${inFlight.size} task(s) still running`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("[webhook-queue] drain complete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bridge for the custom server (server.js), which loads only the bundled
|
||||||
|
// build and cannot import this module directly. It awaits this drain before
|
||||||
|
// calling process.exit during graceful shutdown.
|
||||||
|
type DrainGlobal = typeof globalThis & {
|
||||||
|
__linumiqWebhookDrain?: typeof drainWebhookQueue;
|
||||||
|
};
|
||||||
|
(globalThis as DrainGlobal).__linumiqWebhookDrain = drainWebhookQueue;
|
||||||
|
|
||||||
|
// Safety net for runtimes that don't go through server.js (e.g. `shopify app
|
||||||
|
// dev`): stop accepting work and best-effort drain. The custom server awaits
|
||||||
|
// the same (idempotent) drain before exiting.
|
||||||
|
for (const signal of ["SIGTERM", "SIGINT"] as const) {
|
||||||
|
process.once(signal, () => {
|
||||||
|
void drainWebhookQueue();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import db from "../../db.server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodic TTL cleanup for the `ProcessedWebhook` idempotency table.
|
||||||
|
*
|
||||||
|
* The table grows by one row per Shopify webhook delivery and is never read
|
||||||
|
* after the retry window closes, so without pruning it grows unbounded —
|
||||||
|
* eventually a disk/space DoS. We only need rows for as long as Shopify might
|
||||||
|
* retry a delivery (hours), so a generous retention window of a few days is
|
||||||
|
* ample while keeping the table small.
|
||||||
|
*/
|
||||||
|
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
const INTERVAL_MS = 60 * 60 * 1000; // hourly
|
||||||
|
|
||||||
|
export interface CleanupDeps {
|
||||||
|
db: {
|
||||||
|
processedWebhook: {
|
||||||
|
deleteMany: (args: {
|
||||||
|
where: { receivedAt: { lt: Date } };
|
||||||
|
}) => Promise<{ count: number }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let scheduled = false;
|
||||||
|
|
||||||
|
async function runCleanup(deps: CleanupDeps): Promise<void> {
|
||||||
|
try {
|
||||||
|
const cutoff = new Date(Date.now() - RETENTION_MS);
|
||||||
|
const { count } = await deps.db.processedWebhook.deleteMany({
|
||||||
|
where: { receivedAt: { lt: cutoff } },
|
||||||
|
});
|
||||||
|
if (count > 0) {
|
||||||
|
console.log(`webhook-cleanup: removed ${count} ProcessedWebhook row(s) older than 7d`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Best-effort housekeeping — never throw into the caller.
|
||||||
|
console.warn("webhook-cleanup: prune failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotently schedule the hourly cleanup. Safe to call on every webhook —
|
||||||
|
* the first call starts a single unref'd interval and runs an immediate
|
||||||
|
* sweep; subsequent calls are no-ops.
|
||||||
|
*
|
||||||
|
* Because this is only ever invoked while handling a live webhook request, it
|
||||||
|
* never runs during `prisma generate` / `react-router build` or other CLI
|
||||||
|
* contexts. The interval is `unref`'d so it can never keep the process alive.
|
||||||
|
*/
|
||||||
|
export function ensureWebhookCleanupScheduled(deps: CleanupDeps = { db }): void {
|
||||||
|
if (scheduled) return;
|
||||||
|
scheduled = true;
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
void runCleanup(deps);
|
||||||
|
}, INTERVAL_MS);
|
||||||
|
// Don't let the housekeeping interval keep the event loop alive on shutdown.
|
||||||
|
if (typeof timer.unref === "function") timer.unref();
|
||||||
|
|
||||||
|
// Kick off an immediate sweep so a long-lived process prunes promptly.
|
||||||
|
void runCleanup(deps);
|
||||||
|
}
|
||||||
@@ -1,67 +1,204 @@
|
|||||||
import db from "../../db.server";
|
import db from "../../db.server";
|
||||||
|
import { ensureWebhookCleanupScheduled } from "./cleanup.server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal shape of the Prisma client surface we use — declared inline so
|
* How long a `status="processing"` reservation is considered "live" before we
|
||||||
* the helper can be unit-tested with a tiny stub instead of pulling in a
|
* assume the worker that claimed it crashed mid-process. After this window a
|
||||||
* real database.
|
* stale reservation may be reclaimed and the work retried.
|
||||||
|
*/
|
||||||
|
const STALE_LEASE_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
interface ProcessedRow {
|
||||||
|
webhookId: string;
|
||||||
|
status: string;
|
||||||
|
receivedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal shape of the Prisma client surface we use — declared inline so the
|
||||||
|
* helper can be unit-tested with a tiny stub instead of a real database.
|
||||||
*/
|
*/
|
||||||
export interface DedupeDeps {
|
export interface DedupeDeps {
|
||||||
db: {
|
db: {
|
||||||
processedWebhook: {
|
processedWebhook: {
|
||||||
create: (args: {
|
create: (args: {
|
||||||
data: { webhookId: string; topic: string; shopDomain: string };
|
data: { webhookId: string; topic: string; shopDomain: string; status: string };
|
||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
|
findUnique: (args: { where: { webhookId: string } }) => Promise<ProcessedRow | null>;
|
||||||
|
update: (args: {
|
||||||
|
where: { webhookId: string };
|
||||||
|
data: { status?: string; receivedAt?: Date };
|
||||||
|
}) => Promise<unknown>;
|
||||||
|
delete: (args: { where: { webhookId: string } }) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns `true` when this Shopify webhook delivery has already been
|
* A claim on a single Shopify webhook delivery. Obtained from
|
||||||
* processed and the caller should short-circuit without doing the work.
|
* {@link reserveWebhook}. The caller MUST eventually `commit()` (work
|
||||||
|
* succeeded — the delivery is permanently deduped) or `release()` (work
|
||||||
|
* failed — drop the reservation so Shopify's retry re-runs the work).
|
||||||
*
|
*
|
||||||
* Shopify retries webhook deliveries when it doesn't receive a 200 within
|
* `commit`/`release` are no-ops for reservations without a webhook id (unit
|
||||||
* its (~5s) timeout window. Without dedupe this caused us to email an
|
* tests / non-Shopify callers) and for the fail-open path.
|
||||||
* invoice twice for the same order: the first slow delivery completed its
|
|
||||||
* work but Shopify timed out and re-sent the webhook, which then ran the
|
|
||||||
* automation a second time.
|
|
||||||
*
|
|
||||||
* We key on the `X-Shopify-Webhook-Id` header — Shopify guarantees the same
|
|
||||||
* value for retries of the same delivery, but a new value for genuinely
|
|
||||||
* new events. The insert is the lock: a unique-constraint violation
|
|
||||||
* (Prisma error code `P2002`) means another delivery already claimed this
|
|
||||||
* id.
|
|
||||||
*/
|
*/
|
||||||
export async function isDuplicateWebhook(
|
export interface WebhookReservation {
|
||||||
|
webhookId: string | null;
|
||||||
|
commit: () => Promise<void>;
|
||||||
|
release: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function noopReservation(webhookId: string | null): WebhookReservation {
|
||||||
|
return {
|
||||||
|
webhookId,
|
||||||
|
commit: async () => {},
|
||||||
|
release: async () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isP2002(err: unknown): boolean {
|
||||||
|
// Duck-typed so callers can stub the db without pulling in the real
|
||||||
|
// `Prisma` namespace. P2002 = unique-constraint violation.
|
||||||
|
return (err as { code?: string } | null)?.code === "P2002";
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeReservation(
|
||||||
|
webhookId: string,
|
||||||
|
shop: string,
|
||||||
|
topic: string,
|
||||||
|
deps: DedupeDeps,
|
||||||
|
): WebhookReservation {
|
||||||
|
return {
|
||||||
|
webhookId,
|
||||||
|
commit: async () => {
|
||||||
|
try {
|
||||||
|
await deps.db.processedWebhook.update({
|
||||||
|
where: { webhookId },
|
||||||
|
data: { status: "done" },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// The work already succeeded; a failed commit just risks a later
|
||||||
|
// duplicate (which the side-effect code is expected to tolerate).
|
||||||
|
console.warn(`dedupe: failed to commit webhook ${webhookId} (${topic}/${shop}):`, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
release: async () => {
|
||||||
|
try {
|
||||||
|
await deps.db.processedWebhook.delete({ where: { webhookId } });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`dedupe: failed to release webhook ${webhookId} (${topic}/${shop}):`, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserve this Shopify webhook delivery for processing.
|
||||||
|
*
|
||||||
|
* Shopify retries a delivery (re-using the same `X-Shopify-Webhook-Id`) when
|
||||||
|
* it doesn't receive a 200 within its ~5s timeout. Naively recording the id as
|
||||||
|
* "processed" *before* doing the work meant that if the heavy background work
|
||||||
|
* later failed (SMTP/GraphQL/PDF error), Shopify's retry was dropped as a
|
||||||
|
* duplicate and the invoice was never sent.
|
||||||
|
*
|
||||||
|
* This uses a two-phase reserve/commit keyed on the webhook id, with the
|
||||||
|
* unique `webhookId` primary key as the concurrency lock:
|
||||||
|
*
|
||||||
|
* - RESERVE: insert a `status="processing"` row. A unique-constraint
|
||||||
|
* violation (`P2002`) means the id is already claimed; we then inspect the
|
||||||
|
* existing row:
|
||||||
|
* - `done` → genuine duplicate → return `null` (skip).
|
||||||
|
* - `processing`, fresh → another delivery is in flight → `null`.
|
||||||
|
* - `processing`, stale → previous worker crashed → reclaim & retry.
|
||||||
|
* - COMMIT (caller, on success) → flip the row to `status="done"`.
|
||||||
|
* - RELEASE (caller, on failure) → delete the row so a retry reprocesses.
|
||||||
|
*
|
||||||
|
* Returns a {@link WebhookReservation} when the caller should process the
|
||||||
|
* delivery, or `null` when it must short-circuit (duplicate / concurrent).
|
||||||
|
*
|
||||||
|
* Fail-open: a dedupe-table error (other than P2002) never silently drops a
|
||||||
|
* webhook — we return a no-op reservation and let the work run.
|
||||||
|
*/
|
||||||
|
export async function reserveWebhook(
|
||||||
request: Request,
|
request: Request,
|
||||||
shop: string,
|
shop: string,
|
||||||
topic: string,
|
topic: string,
|
||||||
deps: DedupeDeps = { db },
|
deps: DedupeDeps = { db },
|
||||||
): Promise<boolean> {
|
): Promise<WebhookReservation | null> {
|
||||||
|
// Opportunistically schedule TTL cleanup (runtime-only; never in build/CLI
|
||||||
|
// since this is reached only while handling a live webhook request).
|
||||||
|
ensureWebhookCleanupScheduled();
|
||||||
|
|
||||||
const webhookId = request.headers.get("x-shopify-webhook-id");
|
const webhookId = request.headers.get("x-shopify-webhook-id");
|
||||||
if (!webhookId) {
|
if (!webhookId) {
|
||||||
// Defensive: in unit tests / non-Shopify callers there is no id.
|
// No id (unit tests / non-Shopify callers): process without dedupe.
|
||||||
// Don't dedupe — that would silently drop legitimate calls.
|
return noopReservation(null);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reservation = makeReservation(webhookId, shop, topic, deps);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deps.db.processedWebhook.create({
|
await deps.db.processedWebhook.create({
|
||||||
data: { webhookId, topic, shopDomain: shop },
|
data: { webhookId, topic, shopDomain: shop, status: "processing" },
|
||||||
});
|
});
|
||||||
return false;
|
return reservation;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Duck-typed P2002 check so callers can stub the db without pulling
|
if (!isP2002(err)) {
|
||||||
// in the real `Prisma` namespace.
|
// Don't fail (or silently drop) a webhook on a logging-table issue.
|
||||||
if ((err as { code?: string } | null)?.code === "P2002") {
|
console.warn(`dedupe: failed to reserve webhook ${webhookId} (${topic}/${shop}):`, err);
|
||||||
|
return noopReservation(webhookId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A row already exists. Classify it.
|
||||||
|
let existing: ProcessedRow | null = null;
|
||||||
|
try {
|
||||||
|
existing = await deps.db.processedWebhook.findUnique({ where: { webhookId } });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`dedupe: failed to load existing webhook ${webhookId} (${topic}/${shop}):`, err);
|
||||||
|
// Another worker owns the row and we can't classify it — be safe and skip.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
// Raced with a release/delete between create() and findUnique(); reclaim.
|
||||||
|
return reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.status === "done") {
|
||||||
console.log(
|
console.log(
|
||||||
`dedupe: skipping duplicate ${topic} delivery for ${shop} (webhookId=${webhookId})`,
|
`dedupe: skipping already-processed ${topic} for ${shop} (webhookId=${webhookId})`,
|
||||||
);
|
);
|
||||||
return true;
|
return null;
|
||||||
}
|
}
|
||||||
// Don't fail the webhook on a logging-table issue; just process it.
|
|
||||||
console.warn(
|
const age = Date.now() - new Date(existing.receivedAt).getTime();
|
||||||
`dedupe: failed to record webhook ${webhookId} (${topic}/${shop}):`,
|
if (age > STALE_LEASE_MS) {
|
||||||
err,
|
// The worker that reserved this crashed mid-process (or left a stale row).
|
||||||
|
// Renew the lease and retry the work.
|
||||||
|
try {
|
||||||
|
await deps.db.processedWebhook.update({
|
||||||
|
where: { webhookId },
|
||||||
|
data: { status: "processing", receivedAt: new Date() },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`dedupe: failed to reclaim stale webhook ${webhookId}:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`dedupe: reclaiming stale ${topic} reservation for ${shop} ` +
|
||||||
|
`(webhookId=${webhookId}, age=${Math.round(age / 1000)}s)`,
|
||||||
);
|
);
|
||||||
return false;
|
return reservation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A fresh "processing" row: another delivery is actively working on it.
|
||||||
|
// Skip this concurrent delivery. Shopify will retry; if the active worker
|
||||||
|
// fails it releases the reservation so a later retry reprocesses.
|
||||||
|
console.log(
|
||||||
|
`dedupe: ${topic} for ${shop} already in-flight (webhookId=${webhookId}); ` +
|
||||||
|
`skipping concurrent delivery`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,18 @@ import {
|
|||||||
AppDistribution,
|
AppDistribution,
|
||||||
shopifyApp,
|
shopifyApp,
|
||||||
} from "@shopify/shopify-app-react-router/server";
|
} from "@shopify/shopify-app-react-router/server";
|
||||||
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
|
|
||||||
import prisma from "./db.server";
|
import prisma from "./db.server";
|
||||||
|
import { requireEnv } from "./services/config/env.server";
|
||||||
|
import { EncryptedPrismaSessionStorage } from "./services/session/encryptedSessionStorage.server";
|
||||||
|
|
||||||
const shopify = shopifyApp({
|
const shopify = shopifyApp({
|
||||||
apiKey: process.env.SHOPIFY_API_KEY,
|
apiKey: requireEnv("SHOPIFY_API_KEY"),
|
||||||
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
|
apiSecretKey: requireEnv("SHOPIFY_API_SECRET"),
|
||||||
apiVersion: ApiVersion.October25,
|
apiVersion: ApiVersion.October25,
|
||||||
scopes: process.env.SCOPES?.split(","),
|
scopes: process.env.SCOPES?.split(","),
|
||||||
appUrl: process.env.SHOPIFY_APP_URL || "",
|
appUrl: requireEnv("SHOPIFY_APP_URL"),
|
||||||
authPathPrefix: "/auth",
|
authPathPrefix: "/auth",
|
||||||
sessionStorage: new PrismaSessionStorage(prisma),
|
sessionStorage: new EncryptedPrismaSessionStorage(prisma),
|
||||||
distribution: AppDistribution.SingleMerchant,
|
distribution: AppDistribution.SingleMerchant,
|
||||||
future: {
|
future: {
|
||||||
expiringOfflineAccessTokens: true,
|
expiringOfflineAccessTokens: true,
|
||||||
|
|||||||
@@ -16,8 +16,24 @@ ALLOWED_SHOP=linumiq-dev.myshopify.com
|
|||||||
# Must match `scopes` in shopify.app.dev.toml.
|
# Must match `scopes` in shopify.app.dev.toml.
|
||||||
SCOPES=read_orders,write_orders,read_all_orders,read_customers,read_companies,read_files,write_files
|
SCOPES=read_orders,write_orders,read_all_orders,read_customers,read_companies,read_files,write_files
|
||||||
|
|
||||||
|
# --- Secrets at rest ---
|
||||||
|
# Field-level encryption key for secrets stored in the DB (SMTP password,
|
||||||
|
# Shopify session access/refresh tokens). Must be base64 of exactly 32 bytes.
|
||||||
|
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||||
|
DATA_ENCRYPTION_KEY=REPLACE_ME_BASE64_32_BYTES
|
||||||
|
|
||||||
|
# Dedicated HMAC key for signing public GiroCode URLs. base64 of 32 bytes.
|
||||||
|
# If unset, the app falls back to SHOPIFY_API_SECRET (kept for backward compat).
|
||||||
|
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||||
|
GIROCODE_SIGNING_KEY=REPLACE_ME_BASE64_32_BYTES
|
||||||
|
|
||||||
# --- Runtime ---
|
# --- Runtime ---
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# DATABASE_URL is set in docker-compose.dev.yml (file:/data/prod.sqlite on the bind mount).
|
# DATABASE_URL is set in docker-compose.dev.yml (file:/data/prod.sqlite on the bind mount).
|
||||||
|
|
||||||
|
# --- Email (optional) ---
|
||||||
|
# Archival BCC for every invoice email. Off by default for privacy/GDPR.
|
||||||
|
# Set to a single address or a comma-separated list to opt in.
|
||||||
|
# INVOICE_BCC=archive@example.com
|
||||||
|
|||||||
@@ -8,6 +8,15 @@
|
|||||||
# DEV — installed on linumiq-dev.myshopify.com
|
# DEV — installed on linumiq-dev.myshopify.com
|
||||||
invoice-app-dev.linumiq.com {
|
invoice-app-dev.linumiq.com {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
# Security response headers. NOTE: deliberately no X-Frame-Options here —
|
||||||
|
# this is an embedded Shopify app, and framing is governed by the
|
||||||
|
# Content-Security-Policy `frame-ancestors` directive that the Shopify
|
||||||
|
# library injects via addDocumentResponseHeaders (see app/entry.server.tsx).
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
}
|
||||||
reverse_proxy linumiq-invoice-dev:3000
|
reverse_proxy linumiq-invoice-dev:3000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,25 @@ docker compose up -d --build
|
|||||||
|
|
||||||
Append `Caddyfile.snippet` to your Caddy config and `docker exec caddy caddy reload --config /etc/caddy/Caddyfile`.
|
Append `Caddyfile.snippet` to your Caddy config and `docker exec caddy caddy reload --config /etc/caddy/Caddyfile`.
|
||||||
|
|
||||||
|
## Container runs as a non-root user (uid 1000)
|
||||||
|
|
||||||
|
The image runs as the unprivileged `node` user (uid/gid **1000**), not root. The
|
||||||
|
SQLite database is written to the `/data` bind mount, so the **host** directory
|
||||||
|
mounted at `/data` (e.g. `/docker/linumiq-invoice/dev/data` and
|
||||||
|
`…/prod/data`) must be writable by uid 1000, otherwise `prisma migrate deploy`
|
||||||
|
and DB writes fail on startup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown -R 1000:1000 /docker/linumiq-invoice/dev/data
|
||||||
|
sudo chown -R 1000:1000 /docker/linumiq-invoice/prod/data
|
||||||
|
```
|
||||||
|
|
||||||
|
The dev container additionally runs with a **read-only root filesystem**
|
||||||
|
(`read_only: true` + `tmpfs: /tmp`), `no-new-privileges`, all Linux capabilities
|
||||||
|
dropped, and memory/pids/cpu limits. The app only writes to the `/data` bind
|
||||||
|
mount and the tmpfs `/tmp`, so this is safe. (The prod compose is intentionally
|
||||||
|
left unchanged.)
|
||||||
|
|
||||||
## Day-to-day redeploy
|
## Day-to-day redeploy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -6,6 +6,24 @@ services:
|
|||||||
image: linumiq-invoice:dev
|
image: linumiq-invoice:dev
|
||||||
container_name: linumiq-invoice-dev
|
container_name: linumiq-invoice-dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# --- Container hardening (DEV) ---------------------------------------
|
||||||
|
# Prevent privilege escalation and drop all Linux capabilities (the app
|
||||||
|
# is a plain Node HTTP server — it needs none).
|
||||||
|
security_opt:
|
||||||
|
- "no-new-privileges:true"
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
# Read-only root filesystem: the app never writes to the image at runtime
|
||||||
|
# (Prisma client is baked at build; the SQLite DB lives on the /data bind
|
||||||
|
# mount; logo/image caches live in the DB or in-memory). npm/Prisma
|
||||||
|
# incidental writes are redirected to the tmpfs /tmp (see Dockerfile env).
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
# Resource limits (Compose v2 / docker compose, non-swarm).
|
||||||
|
mem_limit: 512m
|
||||||
|
pids_limit: 256
|
||||||
|
cpus: 1.5
|
||||||
env_file:
|
env_file:
|
||||||
- .env.dev
|
- .env.dev
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
Generated
+58
-36
@@ -29,9 +29,12 @@
|
|||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"express": "^4.22.1",
|
"express": "^4.22.1",
|
||||||
|
"express-rate-limit": "^8.5.2",
|
||||||
|
"ipaddr.js": "^2.4.0",
|
||||||
"isbot": "^5.1.31",
|
"isbot": "^5.1.31",
|
||||||
"morgan": "^1.10.1",
|
"morgan": "^1.10.1",
|
||||||
"nodemailer": "^8.0.7",
|
"nodemailer": "^8.0.7",
|
||||||
|
"p-limit": "^3.1.0",
|
||||||
"prisma": "^6.16.3",
|
"prisma": "^6.16.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -5939,21 +5942,6 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/body-parser/node_modules/qs": {
|
|
||||||
"version": "6.15.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
|
||||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"side-channel": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||||
@@ -7864,14 +7852,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.22.1",
|
"version": "4.22.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
"body-parser": "~1.20.3",
|
"body-parser": "~1.20.5",
|
||||||
"content-disposition": "~0.5.4",
|
"content-disposition": "~0.5.4",
|
||||||
"content-type": "~1.0.4",
|
"content-type": "~1.0.4",
|
||||||
"cookie": "~0.7.1",
|
"cookie": "~0.7.1",
|
||||||
@@ -7890,7 +7878,7 @@
|
|||||||
"parseurl": "~1.3.3",
|
"parseurl": "~1.3.3",
|
||||||
"path-to-regexp": "~0.1.12",
|
"path-to-regexp": "~0.1.12",
|
||||||
"proxy-addr": "~2.0.7",
|
"proxy-addr": "~2.0.7",
|
||||||
"qs": "~6.14.0",
|
"qs": "~6.15.1",
|
||||||
"range-parser": "~1.2.1",
|
"range-parser": "~1.2.1",
|
||||||
"safe-buffer": "5.2.1",
|
"safe-buffer": "5.2.1",
|
||||||
"send": "~0.19.0",
|
"send": "~0.19.0",
|
||||||
@@ -7909,6 +7897,24 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "8.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||||
|
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ip-address": "^10.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/express/node_modules/debug": {
|
"node_modules/express/node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -8842,9 +8848,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/graphql-config/node_modules/brace-expansion": {
|
"node_modules/graphql-config/node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -9300,13 +9306,22 @@
|
|||||||
"resolved": "extensions/invoice-thank-you-payment",
|
"resolved": "extensions/invoice-thank-you-payment",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ip-address": {
|
||||||
"version": "1.9.1",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.10"
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ipaddr.js": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-absolute": {
|
"node_modules/is-absolute": {
|
||||||
@@ -10983,7 +10998,6 @@
|
|||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"yocto-queue": "^0.1.0"
|
"yocto-queue": "^0.1.0"
|
||||||
@@ -11540,6 +11554,15 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-addr/node_modules/ipaddr.js": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -11688,9 +11711,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.2",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
@@ -14225,9 +14248,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.20.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -14318,7 +14341,6 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
|
|||||||
+8
-5
@@ -10,7 +10,7 @@
|
|||||||
"config:use": "shopify app config use",
|
"config:use": "shopify app config use",
|
||||||
"env": "shopify app env",
|
"env": "shopify app env",
|
||||||
"start": "node ./server.js",
|
"start": "node ./server.js",
|
||||||
"docker-start": "npm run setup && npm run start",
|
"docker-start": "prisma migrate deploy && npm run start",
|
||||||
"setup": "prisma generate && prisma migrate deploy",
|
"setup": "prisma generate && prisma migrate deploy",
|
||||||
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
|
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
|
||||||
"shopify": "shopify",
|
"shopify": "shopify",
|
||||||
@@ -28,13 +28,10 @@
|
|||||||
"@prisma/client": "^6.16.3",
|
"@prisma/client": "^6.16.3",
|
||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@react-router/dev": "^7.12.0",
|
"@react-router/dev": "^7.12.0",
|
||||||
|
"@react-router/express": "^7.14.2",
|
||||||
"@react-router/fs-routes": "^7.12.0",
|
"@react-router/fs-routes": "^7.12.0",
|
||||||
"@react-router/node": "^7.12.0",
|
"@react-router/node": "^7.12.0",
|
||||||
"@react-router/express": "^7.14.2",
|
|
||||||
"@react-router/serve": "^7.12.0",
|
"@react-router/serve": "^7.12.0",
|
||||||
"compression": "^1.8.1",
|
|
||||||
"express": "^4.22.1",
|
|
||||||
"morgan": "^1.10.1",
|
|
||||||
"@shopify/app-bridge-react": "^4.2.4",
|
"@shopify/app-bridge-react": "^4.2.4",
|
||||||
"@shopify/shopify-app-react-router": "^1.1.0",
|
"@shopify/shopify-app-react-router": "^1.1.0",
|
||||||
"@shopify/shopify-app-session-storage-prisma": "^8.0.0",
|
"@shopify/shopify-app-session-storage-prisma": "^8.0.0",
|
||||||
@@ -46,8 +43,14 @@
|
|||||||
"@tiptap/react": "^3.23.1",
|
"@tiptap/react": "^3.23.1",
|
||||||
"@tiptap/starter-kit": "^3.23.1",
|
"@tiptap/starter-kit": "^3.23.1",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
|
"compression": "^1.8.1",
|
||||||
|
"express": "^4.22.1",
|
||||||
|
"express-rate-limit": "^8.5.2",
|
||||||
|
"ipaddr.js": "^2.4.0",
|
||||||
"isbot": "^5.1.31",
|
"isbot": "^5.1.31",
|
||||||
|
"morgan": "^1.10.1",
|
||||||
"nodemailer": "^8.0.7",
|
"nodemailer": "^8.0.7",
|
||||||
|
"p-limit": "^3.1.0",
|
||||||
"prisma": "^6.16.3",
|
"prisma": "^6.16.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_ProcessedWebhook" (
|
||||||
|
"webhookId" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"topic" TEXT NOT NULL,
|
||||||
|
"shopDomain" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'done',
|
||||||
|
"receivedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
INSERT INTO "new_ProcessedWebhook" ("receivedAt", "shopDomain", "topic", "webhookId") SELECT "receivedAt", "shopDomain", "topic", "webhookId" FROM "ProcessedWebhook";
|
||||||
|
DROP TABLE "ProcessedWebhook";
|
||||||
|
ALTER TABLE "new_ProcessedWebhook" RENAME TO "ProcessedWebhook";
|
||||||
|
CREATE INDEX "ProcessedWebhook_shopDomain_topic_idx" ON "ProcessedWebhook"("shopDomain", "topic");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -188,6 +188,13 @@ model ProcessedWebhook {
|
|||||||
webhookId String @id
|
webhookId String @id
|
||||||
topic String
|
topic String
|
||||||
shopDomain String
|
shopDomain String
|
||||||
|
// Reserve/commit lifecycle for at-least-once side-effect processing:
|
||||||
|
// "processing" — reserved, side-effect work in flight (acts as the lock)
|
||||||
|
// "done" — work completed successfully; future retries are dropped
|
||||||
|
// A "processing" row older than the stale lease (see dedupe.server.ts) is
|
||||||
|
// treated as a crashed reservation and may be reclaimed. Existing rows
|
||||||
|
// migrated from the old (record-before-work) design default to "done".
|
||||||
|
status String @default("done")
|
||||||
receivedAt DateTime @default(now())
|
receivedAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([shopDomain, topic])
|
@@index([shopDomain, topic])
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { createRequestHandler } from "@react-router/express";
|
|||||||
import compression from "compression";
|
import compression from "compression";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import morgan from "morgan";
|
import morgan from "morgan";
|
||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Console timestamps — patch BEFORE anything else logs.
|
// Console timestamps — patch BEFORE anything else logs.
|
||||||
@@ -33,6 +34,11 @@ const build = buildModule.default ?? buildModule;
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.disable("x-powered-by");
|
app.disable("x-powered-by");
|
||||||
|
// The app runs behind a single reverse proxy (Caddy) that sets
|
||||||
|
// X-Forwarded-For. Trust ONLY the first proxy hop so req.ip reflects the real
|
||||||
|
// client IP for rate limiting, without trusting arbitrary client-supplied
|
||||||
|
// forwarding headers (which `trust proxy: true` would).
|
||||||
|
app.set("trust proxy", 1);
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
|
|
||||||
// Static assets emitted by the React Router build.
|
// Static assets emitted by the React Router build.
|
||||||
@@ -46,15 +52,43 @@ app.use(express.static("public", { maxAge: "1h" }));
|
|||||||
// Access log: ISO timestamp + standard request info; suppress healthy
|
// Access log: ISO timestamp + standard request info; suppress healthy
|
||||||
// /healthz polls so real traffic stays visible.
|
// /healthz polls so real traffic stays visible.
|
||||||
morgan.token("isotime", () => new Date().toISOString());
|
morgan.token("isotime", () => new Date().toISOString());
|
||||||
|
// Redacted URL: strip sensitive query parameters (the GiroCode HMAC `sig`
|
||||||
|
// and any `token`) from the logged URL so signed-URL secrets / bearer tokens
|
||||||
|
// never land in access logs. Other query params are preserved.
|
||||||
|
morgan.token("safeurl", (req) => {
|
||||||
|
const raw = req.originalUrl || req.url || "";
|
||||||
|
const qIdx = raw.indexOf("?");
|
||||||
|
if (qIdx === -1) return raw;
|
||||||
|
const path = raw.slice(0, qIdx);
|
||||||
|
const params = new URLSearchParams(raw.slice(qIdx + 1));
|
||||||
|
for (const key of ["sig", "token"]) {
|
||||||
|
if (params.has(key)) params.set(key, "REDACTED");
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs ? `${path}?${qs}` : path;
|
||||||
|
});
|
||||||
app.use(
|
app.use(
|
||||||
morgan(
|
morgan(
|
||||||
":isotime :method :url :status :res[content-length] - :response-time ms",
|
":isotime :method :safeurl :status :res[content-length] - :response-time ms",
|
||||||
{
|
{
|
||||||
skip: (req, res) => req.url === "/healthz" && res.statusCode < 400,
|
skip: (req, res) => req.url === "/healthz" && res.statusCode < 400,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Per-IP rate limiting for the public, unauthenticated-at-the-edge API
|
||||||
|
// surface only (/api/public/*). Shopify webhooks (/webhooks/*) and the
|
||||||
|
// embedded admin are intentionally NOT rate limited here — webhooks can burst
|
||||||
|
// legitimately and are already HMAC-verified.
|
||||||
|
const publicApiLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
limit: 60, // 60 requests / minute / IP
|
||||||
|
standardHeaders: true, // RateLimit-* headers
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: "rate-limited" },
|
||||||
|
});
|
||||||
|
app.use("/api/public", publicApiLimiter);
|
||||||
|
|
||||||
app.all(
|
app.all(
|
||||||
"*",
|
"*",
|
||||||
createRequestHandler({ build, mode: process.env.NODE_ENV }),
|
createRequestHandler({ build, mode: process.env.NODE_ENV }),
|
||||||
@@ -71,11 +105,22 @@ const server = HOST
|
|||||||
: app.listen(PORT, onListen);
|
: app.listen(PORT, onListen);
|
||||||
|
|
||||||
for (const signal of ["SIGTERM", "SIGINT"]) {
|
for (const signal of ["SIGTERM", "SIGINT"]) {
|
||||||
process.once(signal, () => {
|
process.once(signal, async () => {
|
||||||
console.log(`[server] received ${signal}, shutting down`);
|
console.log(`[server] received ${signal}, shutting down`);
|
||||||
|
// Stop accepting new connections.
|
||||||
server.close((err) => {
|
server.close((err) => {
|
||||||
if (err) console.error("[server] close error:", err);
|
if (err) console.error("[server] close error:", err);
|
||||||
|
});
|
||||||
|
// Drain in-flight background webhook work (PDF render / SMTP send) before
|
||||||
|
// exiting so a container stop doesn't lose invoice work mid-send. The
|
||||||
|
// background queue exposes this bridge because server.js loads only the
|
||||||
|
// bundled build and can't import the module directly.
|
||||||
|
try {
|
||||||
|
const drain = globalThis.__linumiqWebhookDrain;
|
||||||
|
if (typeof drain === "function") await drain();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[server] webhook drain error:", err);
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { strict as assert } from "node:assert";
|
|||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
import { pickLanguage } from "../app/services/invoice/i18n";
|
import { pickLanguage } from "../app/services/invoice/i18n";
|
||||||
import { isDuplicateWebhook, type DedupeDeps } from "../app/services/webhooks/dedupe.server";
|
import { reserveWebhook, type DedupeDeps } from "../app/services/webhooks/dedupe.server";
|
||||||
|
|
||||||
describe("pickLanguage", () => {
|
describe("pickLanguage", () => {
|
||||||
it("returns 'de' only for explicit German locales", () => {
|
it("returns 'de' only for explicit German locales", () => {
|
||||||
@@ -38,8 +38,21 @@ function makeRequest(headers: Record<string, string> = {}): Request {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeDeps(behaviour: "ok" | "p2002" | "boom"): DedupeDeps {
|
type ExistingRow = { webhookId: string; status: string; receivedAt: Date } | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a DedupeDeps stub.
|
||||||
|
* - "ok" : create() succeeds (fresh reservation).
|
||||||
|
* - "p2002" : create() conflicts; findUnique() returns `existing`.
|
||||||
|
* - "boom" : create() throws a non-P2002 error (fail-open).
|
||||||
|
*/
|
||||||
|
function makeDeps(
|
||||||
|
behaviour: "ok" | "p2002" | "boom",
|
||||||
|
existing: ExistingRow = null,
|
||||||
|
): DedupeDeps & { calls: { commit: number; release: number; update: number } } {
|
||||||
|
const calls = { commit: 0, release: 0, update: 0 };
|
||||||
return {
|
return {
|
||||||
|
calls,
|
||||||
db: {
|
db: {
|
||||||
processedWebhook: {
|
processedWebhook: {
|
||||||
create: async () => {
|
create: async () => {
|
||||||
@@ -51,29 +64,82 @@ function makeDeps(behaviour: "ok" | "p2002" | "boom"): DedupeDeps {
|
|||||||
}
|
}
|
||||||
throw new Error("DB unavailable");
|
throw new Error("DB unavailable");
|
||||||
},
|
},
|
||||||
|
findUnique: async () => existing,
|
||||||
|
update: async () => {
|
||||||
|
calls.update += 1;
|
||||||
|
calls.commit += 1;
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
delete: async () => {
|
||||||
|
calls.release += 1;
|
||||||
|
return {};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("isDuplicateWebhook", () => {
|
describe("reserveWebhook", () => {
|
||||||
it("returns false on first delivery (insert succeeds)", async () => {
|
it("returns a reservation on first delivery (insert succeeds)", async () => {
|
||||||
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
|
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
|
||||||
assert.equal(await isDuplicateWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("ok")), false);
|
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("ok"));
|
||||||
|
assert.ok(res, "expected a reservation");
|
||||||
|
assert.equal(res!.webhookId, "abc-123");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns true on a retried delivery (P2002)", async () => {
|
it("returns null for an already-processed (done) delivery", async () => {
|
||||||
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
|
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
|
||||||
assert.equal(await isDuplicateWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("p2002")), true);
|
const deps = makeDeps("p2002", {
|
||||||
|
webhookId: "abc-123",
|
||||||
|
status: "done",
|
||||||
|
receivedAt: new Date(),
|
||||||
|
});
|
||||||
|
assert.equal(await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", deps), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false (and proceeds) when the dedupe table itself errors \u2014 fail-open, never drop a webhook silently", async () => {
|
it("returns null for a fresh in-flight (processing) delivery", async () => {
|
||||||
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
|
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
|
||||||
assert.equal(await isDuplicateWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("boom")), false);
|
const deps = makeDeps("p2002", {
|
||||||
|
webhookId: "abc-123",
|
||||||
|
status: "processing",
|
||||||
|
receivedAt: new Date(), // fresh lease
|
||||||
|
});
|
||||||
|
assert.equal(await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", deps), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false when the X-Shopify-Webhook-Id header is missing (test harness / non-Shopify caller)", async () => {
|
it("reclaims a stale (crashed) processing reservation", async () => {
|
||||||
|
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
|
||||||
|
const deps = makeDeps("p2002", {
|
||||||
|
webhookId: "abc-123",
|
||||||
|
status: "processing",
|
||||||
|
receivedAt: new Date(Date.now() - 10 * 60 * 1000), // 10 min ago > 5 min lease
|
||||||
|
});
|
||||||
|
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", deps);
|
||||||
|
assert.ok(res, "expected to reclaim the stale reservation");
|
||||||
|
assert.equal(deps.calls.update, 1, "stale reclaim should renew the lease via update()");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commit() flips the row to done; release() deletes it", async () => {
|
||||||
|
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
|
||||||
|
const deps = makeDeps("ok");
|
||||||
|
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", deps);
|
||||||
|
await res!.commit();
|
||||||
|
assert.equal(deps.calls.commit, 1);
|
||||||
|
await res!.release();
|
||||||
|
assert.equal(deps.calls.release, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails open (returns a no-op reservation) when the dedupe table errors", async () => {
|
||||||
|
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
|
||||||
|
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("boom"));
|
||||||
|
assert.ok(res, "fail-open must still process — never silently drop a webhook");
|
||||||
|
assert.equal(res!.webhookId, "abc-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a no-op reservation when the X-Shopify-Webhook-Id header is missing", async () => {
|
||||||
const req = makeRequest();
|
const req = makeRequest();
|
||||||
assert.equal(await isDuplicateWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("ok")), false);
|
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("ok"));
|
||||||
|
assert.ok(res, "missing id => process without dedupe");
|
||||||
|
assert.equal(res!.webhookId, null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user