Skip to content

Commit

Permalink
Validate product and collection handles to be URL safe (#7310)
Browse files Browse the repository at this point in the history
* fix: allow URL safe characters for product handle

Fixes: CORE-2072
  • Loading branch information
thetutlage authored May 15, 2024
1 parent 7c4f4d7 commit 17486cd
Show file tree
Hide file tree
Showing 8 changed files with 146 additions and 10 deletions.
36 changes: 36 additions & 0 deletions packages/core/utils/src/common/__tests__/is-valid-handle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { isValidHandle } from "../validate-handle"

describe("normalizeHandle", function () {
it("should generate URL friendly handles", function () {
const expectations = [
{
input: "the-fan-boy's-club",
isValid: false,
},
{
input: "@t-the-sky",
isValid: false,
},
{
input: "nouvelles-annees",
isValid: true,
},
{
input: "@t-the-sky",
isValid: false,
},
{
input: "user.product",
isValid: false,
},
{
input: 'sky"bar',
isValid: false,
},
]

expectations.forEach((expectation) => {
expect(isValidHandle(expectation.input)).toEqual(expectation.isValid)
})
})
})
44 changes: 44 additions & 0 deletions packages/core/utils/src/common/__tests__/to-handle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { toHandle } from "../to-handle"

describe("normalizeHandle", function () {
it("should generate URL friendly handles", function () {
const expectations = [
{
input: "The fan boy's club",
output: "the-fan-boys-club",
},
{
input: "nouvelles années",
output: "nouvelles-annees",
},
{
input: "25% OFF",
output: "25-off",
},
{
input: "25% de réduction",
output: "25-de-reduction",
},
{
input: "-first-product",
output: "-first-product",
},
{
input: "user.product",
output: "userproduct",
},
{
input: "_first-product",
output: "-first-product",
},
{
input: "_HELLO_WORLD",
output: "-hello-world",
},
]

expectations.forEach((expectation) => {
expect(toHandle(expectation.input)).toEqual(expectation.output)
})
})
})
2 changes: 2 additions & 0 deletions packages/core/utils/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,5 @@ export * from "./transaction"
export * from "./trim-zeros"
export * from "./upper-case-first"
export * from "./wrap-handler"
export * from "./to-handle"
export * from "./validate-handle"
18 changes: 18 additions & 0 deletions packages/core/utils/src/common/to-handle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { kebabCase } from "./to-kebab-case"

/**
* Helper method to create a to be URL friendly "handle" from
* a string value.
*
* - Works by converting the value to lowercase
* - Splits and remove accents from characters
* - Removes all unallowed characters like a '"%$ and so on.
*/
export const toHandle = (value: string): string => {
return kebabCase(
value
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
).replace(/[^a-z0-9A-Z-_]/g, "")
}
2 changes: 1 addition & 1 deletion packages/core/utils/src/common/to-kebab-case.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const kebabCase = (string) =>
export const kebabCase = (string: string): string =>
string
.replace(/([a-z])([A-Z])/g, "$1-$2")
.replace(/[\s_]+/g, "-")
Expand Down
9 changes: 9 additions & 0 deletions packages/core/utils/src/common/validate-handle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { kebabCase } from "./to-kebab-case"

/**
* Helper method to validate entity "handle" to be URL
* friendly.
*/
export const isValidHandle = (value: string): boolean => {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)
}
4 changes: 2 additions & 2 deletions packages/modules/product/src/models/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ import {
createPsqlIndexStatementHelper,
DALUtils,
generateEntityId,
kebabCase,
ProductUtils,
Searchable,
toHandle,
} from "@medusajs/utils"
import ProductCategory from "./product-category"
import ProductCollection from "./product-collection"
Expand Down Expand Up @@ -216,7 +216,7 @@ class Product {
this.collection_id ??= this.collection?.id ?? null

if (!this.handle && this.title) {
this.handle = kebabCase(this.title)
this.handle = toHandle(this.title)
}
}
}
Expand Down
41 changes: 34 additions & 7 deletions packages/modules/product/src/services/product-module-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
ProductStatus,
promiseAll,
removeUndefined,
isValidHandle,
toHandle,
} from "@medusajs/utils"
import {
ProductCategoryEventData,
Expand Down Expand Up @@ -1165,9 +1167,14 @@ export default class ProductModuleService<
@MedusaContext() sharedContext: Context = {}
): Promise<TProduct[]> {
const normalizedInput = await promiseAll(
data.map(
async (d) => await this.normalizeCreateProductInput(d, sharedContext)
)
data.map(async (d) => {
const normalized = await this.normalizeCreateProductInput(
d,
sharedContext
)
this.validateProductPayload(normalized)
return normalized
})
)

const productData = await this.productService_.upsertWithReplace(
Expand Down Expand Up @@ -1223,9 +1230,14 @@ export default class ProductModuleService<
@MedusaContext() sharedContext: Context = {}
): Promise<TProduct[]> {
const normalizedInput = await promiseAll(
data.map(
async (d) => await this.normalizeUpdateProductInput(d, sharedContext)
)
data.map(async (d) => {
const normalized = await this.normalizeUpdateProductInput(
d,
sharedContext
)
this.validateProductPayload(normalized)
return normalized
})
)

const productData = await this.productService_.upsertWithReplace(
Expand Down Expand Up @@ -1306,6 +1318,21 @@ export default class ProductModuleService<
return productData
}

/**
* Validates the manually provided handle value of the product
* to be URL-safe
*/
protected validateProductPayload(
productData: UpdateProductInput | ProductTypes.CreateProductDTO
) {
if (productData.handle && !isValidHandle(productData.handle)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Invalid product handle. It must contain URL safe characters"
)
}
}

protected async normalizeCreateProductInput(
product: ProductTypes.CreateProductDTO,
@MedusaContext() sharedContext: Context = {}
Expand All @@ -1316,7 +1343,7 @@ export default class ProductModuleService<
)) as ProductTypes.CreateProductDTO

if (!productData.handle && productData.title) {
productData.handle = kebabCase(productData.title)
productData.handle = toHandle(productData.title)
}

if (!productData.status) {
Expand Down

0 comments on commit 17486cd

Please sign in to comment.