v0.1.2: checkout fixes for CMS v0.2.0 (#18)
Co-authored-by: Michael Czechowski <mail@librete.ch>
This commit was merged in pull request #18.
This commit is contained in:
1 parent
60b8f1ab58
commit
fccff147ba
29 files changed
+1108
-71
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
@@ -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")
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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 }
|
||||
});
|
||||
Reference in new issue
Block a user