Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions src/app/api/app/load/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,6 @@ const jwtSchema = z.object({
});

export async function GET(request: NextRequest) {
function appendExchangeToken(url: string, token: string): string {
const delimiter = new URL(url, env.APP_ORIGIN).search ? '&' : '?';

return `${url}${delimiter}exchangeToken=${token}`;
}

const parsedParams = queryParamSchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams)
);
Expand Down Expand Up @@ -65,7 +59,14 @@ export async function GET(request: NextRequest) {

const exchangeToken = await db.saveClientToken(clientToken);

return NextResponse.redirect(new URL(appendExchangeToken(path, exchangeToken), env.APP_ORIGIN), {
// IMPORTANT: product names can contain '#' and BigCommerce may include them in the `url`.
// If we append query params by string concatenation, we can accidentally place `exchangeToken`
// after the '#' fragment, which browsers do not send to the server. Always mutate `searchParams`
// on a URL object so the token is guaranteed to be in the query string.
const redirectUrl = new URL(path, env.APP_ORIGIN);
redirectUrl.searchParams.set('exchangeToken', exchangeToken);

return NextResponse.redirect(redirectUrl, {
status: 302,
statusText: 'Found',
});
Expand Down
14 changes: 13 additions & 1 deletion src/app/productDescription/[productId]/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import Loader from '~/components/Loader';
import { useAppContext } from '~/context/AppContext';
import { useTracking } from '~/hooks/useTracking';

const sanitizeProductNameForGeneration = (name: string) =>
name.replaceAll('#', '').replace(/\s{2,}/g, ' ').trim();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sure we have a test case validating this regex.


export default function Form({
product,
csrfToken,
Expand Down Expand Up @@ -43,10 +46,19 @@ export default function Form({

const handleGenerateDescription = async () => {
setIsLoading(true);

// BigCommerce product names may contain '#' (e.g. for internal naming). For the
// /productDescription/${id} generator flow we strip those symbols only for the AI
// generation request, then keep the original name unchanged everywhere else.
const productForGeneration = {
...product,
name: sanitizeProductNameForGeneration(product.name),
} satisfies Product | NewProduct;

const res = await fetch(`/api/generateDescription`, {
method: 'POST',
body: JSON.stringify(
prepareAiPromptAttributes(currentAttributes, product)
prepareAiPromptAttributes(currentAttributes, productForGeneration)
),
headers: {
'X-CSRF-Token': csrfToken,
Expand Down
9 changes: 8 additions & 1 deletion src/app/productDescription/[productId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@ import { headers } from 'next/headers';

interface PageProps {
params: { productId: string };
searchParams: { product_name: string; exchangeToken: string };
searchParams: { product_name?: string; exchangeToken?: string };
}

export default async function Page(props: PageProps) {
const { productId } = props.params;
const { product_name: name, exchangeToken } = props.searchParams;

if (!exchangeToken) {
// This typically happens when a product name contains an unencoded '#', which turns the rest of
// the URL into a fragment (not sent to the server). The /api/app/load redirect now ensures the
// exchangeToken is always appended before any fragment, but keep this guard to avoid a hard crash.
throw new Error('Missing exchange token. Try to re-open the app.');
}

const authToken = await db.getClientTokenMaybeAndDelete(exchangeToken) || 'missing';

const authorized = authorize(authToken);
Expand Down
Loading