v0.1.2: checkout fixes for CMS v0.2.0 (#18)
build / test (push) Successful in 10s
build / build (push) Successful in 1m5s

Co-authored-by: Michael Czechowski <mail@librete.ch>
This commit was merged in pull request #18.
This commit is contained in:
libretech authored and Michael Czechowski committed 2026-10-15 10:23:27 +02:00
1 parent 60b8f1ab58
commit fccff147ba
29 files changed
+1108 -71

No files matched your search

+17
View File
@@ -1,3 +1,5 @@
import { cmsErrorLogLine, shopErrorFromCms } from "./cmsError";
/**
* Server-side CMS API utility.
* Use this for direct CMS access during SSR.
@@ -15,3 +17,18 @@ export async function fetchCms<T>(endpoint: string, options: Parameters<typeof $
}
});
}
/**
* fetchCms for a route that forwards the browser's request to the CMS: a CMS error answers the browser with the CMS's status
* and the safe fields of its error (cmsError.ts), instead of an unhandled error, whose message and data Nitro hides.
*/
export async function forwardToCms<T>(endpoint: string, options: Parameters<typeof $fetch>[1] = {}): Promise<T> {
try {
return await fetchCms<T>(endpoint, options);
} catch (error) {
const shopError = shopErrorFromCms(error);
if (!shopError) throw error;
if (shopError.statusCode >= 500) console.error(cmsErrorLogLine(String(options.method ?? "GET"), endpoint, shopError));
throw createError(shopError);
}
}
+81
View File
@@ -0,0 +1,81 @@
// How a failed CMS request answers the browser: with the CMS's status and only the fields of its error that are safe to show.
// The CMS (Strapi) answers an error as { data: null, error: { status, name, message, details } }, which $fetch throws as ofetch's
// FetchError with the response's status and parsed body. Thrown on as it is, Nitro treats it as unhandled: it hides the message and
// the data from the browser and logs the CMS URL. forwardToCms (cmsApi.ts) throws createError(shopErrorFromCms(error)) instead.
// Pure: no Nuxt, Nitro or h3 imports, tested in tests/unit/cmsError.test.ts.
/** What the browser receives as the data of the error. errors and missing are the CMS's details.errors and details.missing. */
export type ShopErrorData = { message: string; errors?: string[]; missing?: string[] };
/** The argument for h3's createError. */
export type ShopError = { statusCode: number; statusMessage: string; data: ShopErrorData };
// The statuses passed on: the CMS's answers about the order and its payment. Any other status, such as 401 or 403 for a wrong
// API token, is the shop's own fault and answered 500.
const STATUS_TEXTS = new Map<number, string>([
[400, "Bad Request"],
[404, "Not Found"],
[409, "Conflict"],
[500, "Internal Server Error"],
[502, "Bad Gateway"],
[503, "Service Unavailable"],
[504, "Gateway Timeout"]
]);
const MAX_MESSAGE_LENGTH = 200;
const MAX_STATUS_MESSAGE_LENGTH = 100;
const MAX_FIELD_LENGTH = 64;
const MAX_LIST_LENGTH = 20;
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const cut = (text: string, maxLength: number): string => (text.length > maxLength ? `${text.slice(0, maxLength)}…` : text);
// The strings of a list in the CMS's details, e.g. ["data.email: must be an email address of at most 254 characters"].
const stringList = (value: unknown, maxLength: number): string[] | undefined => {
if (!Array.isArray(value)) return undefined;
const list = value
.filter((item): item is string => typeof item === "string")
.slice(0, MAX_LIST_LENGTH)
.map((item) => cut(item, maxLength));
return list.length > 0 ? list : undefined;
};
// The status message is the HTTP reason phrase, which allows printable ASCII only.
const isReasonPhrase = (text: string): boolean => text.length <= MAX_STATUS_MESSAGE_LENGTH && /^[\x20-\x7e]+$/.test(text);
/**
* The error to throw to the browser for an error of $fetch to the CMS, or undefined if the error is not ofetch's FetchError
* (a bug, to rethrow as it is). The CMS's status is kept for 400, 404, 409, 500, 502, 503 and 504, any other is answered 500,
* and a CMS that does not answer 503. The data holds the CMS's message, details.errors and details.missing, and nothing else.
*/
export const shopErrorFromCms = (error: unknown): ShopError | undefined => {
if (!(error instanceof Error) || error.name !== "FetchError") return undefined;
const { status, data } = error as Error & { status?: unknown; data?: unknown };
if (typeof status !== "number") {
return { statusCode: 503, statusMessage: "Service Unavailable", data: { message: "The CMS did not answer" } };
}
const statusText = STATUS_TEXTS.get(status);
if (statusText === undefined) {
return { statusCode: 500, statusMessage: "Internal Server Error", data: { message: "Internal Server Error" } };
}
const cmsError: Record<string, unknown> = isObject(data) && isObject(data.error) ? data.error : {};
const details: Record<string, unknown> = isObject(cmsError.details) ? cmsError.details : {};
const message = typeof cmsError.message === "string" && cmsError.message !== "" ? cut(cmsError.message, MAX_MESSAGE_LENGTH) : statusText;
const errors = stringList(details.errors, MAX_MESSAGE_LENGTH);
const missing = stringList(details.missing, MAX_FIELD_LENGTH);
return {
statusCode: status,
statusMessage: isReasonPhrase(message) ? message : statusText,
data: { message, ...(errors ? { errors } : {}), ...(missing ? { missing } : {}) }
};
};
/** The log line for a failed CMS request: without its query, and with order uuids replaced, since a uuid opens its order. */
export const cmsErrorLogLine = (method: string, endpoint: string, error: ShopError): string =>
`[cms] ${method.toUpperCase()} ${endpoint.replace(/\?.*$/, "").replace(UUID, ":uuid")}: ${error.statusCode} ${error.data.message}`;
+56
View File
@@ -0,0 +1,56 @@
// The mail of the contact form (components/ContactForm.vue → POST /api/contact). The mail service (libreshop/mail src/app.py)
// sends it from POST /v1/send/message, whose JSON body is { to_email, subject, message, html? }. It sets no Reply-To, so the
// sender's address is in the text. Pure: no Nuxt, Nitro or h3 imports, tested in tests/unit/contactMail.test.ts.
export const CONTACT_RECIPIENT = "paperwork@muellerprints.de";
export const MAIL_SEND_PATH = "/v1/send/message";
/** The body of POST /v1/send/message. */
export type ContactMail = { to_email: string; subject: string; message: string };
/** The mail to send, or the German message of the 400 for the form. */
export type ContactMailResult = { ok: true; mail: ContactMail } | { ok: false; message: string };
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const isFilled = (value: unknown): value is string => typeof value === "string" && value.trim() !== "";
// A line break in the subject would end its header and start another one.
const oneLine = (text: string): string => text.replace(/[\r\n]+/g, " ").trim();
/** The URL of the mail service's send endpoint, from its base URL (runtimeConfig.mailApiUrl). */
export const mailSendUrl = (mailApiUrl: string): string => `${mailApiUrl.replace(/\/+$/, "")}${MAIL_SEND_PATH}`;
/** Checks the form's body { name, email, subject?, message } and builds the mail to the shop. */
export const contactMail = (body: unknown): ContactMailResult => {
const fields: Record<string, unknown> = isObject(body) ? body : {};
const { name, email, subject, message } = fields;
if (!isFilled(name) || !isFilled(email) || !isFilled(message)) {
return { ok: false, message: "Name, E-Mail und Nachricht sind erforderlich" };
}
if (!EMAIL.test(email)) {
return { ok: false, message: "Ungültige E-Mail-Adresse" };
}
const topic = typeof subject === "string" ? oneLine(subject) : "";
return {
ok: true,
mail: {
to_email: CONTACT_RECIPIENT,
subject: `Kontaktanfrage: ${topic || "Anfrage über Website"}`,
message: [
`Name: ${name}`,
`E-Mail: ${email}`,
`Betreff: ${topic || "Kontaktanfrage über Website"}`,
"",
"Nachricht:",
message,
"",
"---",
"Diese Nachricht wurde über das Kontaktformular auf muellerprints.de gesendet."
].join("\n")
}
};
};
+79
View File
@@ -0,0 +1,79 @@
// What PUT /api/orders/:uuid forwards to the CMS: the fields of the checkout's steps, and nothing else.
// Step 1 (pages/checkout/1.vue) sends { data: { email, acceptedTermsAndConditionsAt } }, step 2 (pages/checkout/2.vue) sends
// { data: { invoiceAddress, deliveryAddress, invoiceAddressStructured, deliveryAddressStructured, delivery } }.
// The CMS checks the values and stays the authority (libreshop/cms src/checkout/customer-update.ts). This check is defence in depth:
// a body with any other field is answered 400 without calling the CMS, in the format of the CMS's own answer.
// Pure: no Nuxt, Nitro or h3 imports, tested in tests/unit/customerUpdate.test.ts.
import type { ShopError } from "./cmsError";
/** The order fields a customer may set, in the order of the checkout's steps. */
export const CUSTOMER_UPDATE_FIELDS = [
"email",
"acceptedTermsAndConditionsAt",
"invoiceAddress",
"deliveryAddress",
"invoiceAddressStructured",
"deliveryAddressStructured",
"delivery"
] as const;
export type CustomerUpdateField = (typeof CUSTOMER_UPDATE_FIELDS)[number];
/** The fields to forward, with the values the browser sent: the CMS checks them. */
export type CustomerUpdate = Partial<Record<CustomerUpdateField, unknown>>;
/**
* unknown: the names of the rejected fields, such as "data.paymentAuthorised", or "email" for a field sent beside data.
* errors: one message per rejected field or malformed part, in the CMS's format "name: reason".
*/
export type PickedCustomerUpdate = { ok: true; data: CustomerUpdate } | { ok: false; unknown: string[]; errors: string[] };
export const INVALID_ORDER_UPDATE = "Invalid order update";
// A field name is chosen by the client and ends up in the answer, so a long one is cut.
const MAX_NAME_LENGTH = 64;
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const isCustomerUpdateField = (key: string): key is CustomerUpdateField => (CUSTOMER_UPDATE_FIELDS as readonly string[]).includes(key);
const fieldName = (key: string): string => (key.length > MAX_NAME_LENGTH ? `${key.slice(0, MAX_NAME_LENGTH)}…` : key);
/** Picks the fields a customer may set from the body { data: { … } }. Any other field, or another shape, rejects the whole body. */
export const pickCustomerUpdate = (body: unknown): PickedCustomerUpdate => {
if (!isObject(body)) {
return { ok: false, unknown: [], errors: ["body: must be an object of the form { data: { … } }"] };
}
const unknown: string[] = [];
const errors: string[] = [];
for (const key of Object.keys(body)) {
if (key === "data") continue;
unknown.push(fieldName(key));
errors.push(`${fieldName(key)}: not accepted, the fields belong in data`);
}
const fields = body.data;
const data: CustomerUpdate = {};
if (!isObject(fields)) {
errors.push(fields === undefined ? "data: missing" : "data: must be an object");
} else {
for (const key of Object.keys(fields)) {
if (isCustomerUpdateField(key)) {
data[key] = fields[key];
} else {
unknown.push(`data.${fieldName(key)}`);
errors.push(`data.${fieldName(key)}: not a field the customer may set`);
}
}
}
return errors.length > 0 ? { ok: false, unknown, errors } : { ok: true, data };
};
/** The 400 for a rejected body: the shape the shop answers for the CMS's own 400 (cmsError.ts). */
export const invalidOrderUpdate = (errors: string[]): ShopError => ({
statusCode: 400,
statusMessage: INVALID_ORDER_UPDATE,
data: { message: INVALID_ORDER_UPDATE, errors }
});