diff --git a/package-lock.json b/package-lock.json index 0cccda634..a62653b0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,7 +57,8 @@ "uuid": "^3.3.2", "viem": "^2.23.1", "web3-utils": "^1.2.11", - "xml": "^1.0.1" + "xml": "^1.0.1", + "zod": "^3.25.76" }, "devDependencies": { "@types/cookie-parser": "^1.4.3", @@ -18830,6 +18831,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } }, "dependencies": { @@ -32412,6 +32422,11 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" + }, + "zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" } } } diff --git a/package.json b/package.json index 8f9c87dce..08bac53c2 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,8 @@ "uuid": "^3.3.2", "viem": "^2.23.1", "web3-utils": "^1.2.11", - "xml": "^1.0.1" + "xml": "^1.0.1", + "zod": "^3.25.76" }, "devDependencies": { "@types/cookie-parser": "^1.4.3", diff --git a/src/middleware/validate.ts b/src/middleware/validate.ts new file mode 100644 index 000000000..ec4212e69 --- /dev/null +++ b/src/middleware/validate.ts @@ -0,0 +1,32 @@ +import type { RequestHandler } from 'express'; +import type { ZodIssue, ZodSchema } from 'zod'; +import { ValidationError } from '../util/ValidationError'; + +type Target = 'body' | 'query' | 'params'; + +function formatIssues(issues: ZodIssue[]) { + return issues.map((i) => ({ + path: i.path, + code: i.code, + message: i.message, + })); +} + +function makeValidator(target: Target) { + return (schema: ZodSchema): RequestHandler => (req, _res, next) => { + const result = schema.safeParse(req[target]); + if (!result.success) { + next(new ValidationError('INVALID_INPUT', 400, { + target, + issues: formatIssues(result.error.issues), + })); + return; + } + (req as any)[target] = result.data; + next(); + }; +} + +export const validateBody = makeValidator('body'); +export const validateQuery = makeValidator('query'); +export const validateParams = makeValidator('params'); diff --git a/src/routes/likernft/book/store.ts b/src/routes/likernft/book/store.ts index 3f10bc1a6..1fa54d9a6 100644 --- a/src/routes/likernft/book/store.ts +++ b/src/routes/likernft/book/store.ts @@ -7,14 +7,20 @@ import { listNftBookInfoByModeratorWallet, newNftBookInfo, updateNftBookInfo, - validatePrice, - validatePrices, getLocalizedTextWithFallback, createStripeProductFromNFTBookPrice, checkIsAuthorized, syncNFTBookInfoWithISCN, getStripeProductMetadata, } from '../../../util/api/likernft/book'; +import { + ImageUploadBodySchema, + ListingSettingsBodySchema, + NewListingBodySchema, + PriceMutationBodySchema, + PriceReorderBodySchema, +} from '../../../util/api/likernft/book/schemas'; +import { validateBody } from '../../../middleware/validate'; import { getNFTClassDataById as getEVMNFTClassDataById, getNFTClassOwner as getEVMNFTClassOwner, @@ -213,14 +219,11 @@ router.get(['/:classId/price/:priceIndex', '/class/:classId/price/:priceIndex'], } }); -router.post(['/:classId/price/:priceIndex', '/class/:classId/price/:priceIndex'], jwtAuth('write:nftbook'), async (req, res, next) => { +router.post(['/:classId/price/:priceIndex', '/class/:classId/price/:priceIndex'], jwtAuth('write:nftbook'), validateBody(PriceMutationBodySchema), async (req, res, next) => { try { const { classId, priceIndex: priceIndexString } = req.params; const priceIndex = Number(priceIndexString); - const { - price: inputPrice, - } = req.body; - const price = validatePrice(inputPrice); + const { price } = req.body; const bookInfo = await getNftBookInfo(classId); const { @@ -282,13 +285,10 @@ router.post(['/:classId/price/:priceIndex', '/class/:classId/price/:priceIndex'] } }); -router.put(['/:classId/price/:priceIndex', '/class/:classId/price/:priceIndex'], jwtAuth('write:nftbook'), async (req, res, next) => { +router.put(['/:classId/price/:priceIndex', '/class/:classId/price/:priceIndex'], jwtAuth('write:nftbook'), validateBody(PriceMutationBodySchema), async (req, res, next) => { try { const { classId, priceIndex: priceIndexString } = req.params; - const { - price: inputPrice, - } = req.body; - const price = validatePrice(inputPrice); + const { price } = req.body; const priceIndex = Number(priceIndexString); const bookInfo = await getNftBookInfo(classId); @@ -380,7 +380,7 @@ router.put(['/:classId/price/:priceIndex', '/class/:classId/price/:priceIndex'], } }); -router.put(['/:classId/price/:priceIndex/order', '/class/:classId/price/:priceIndex/order'], jwtAuth('write:nftbook'), async (req, res, next) => { +router.put(['/:classId/price/:priceIndex/order', '/class/:classId/price/:priceIndex/order'], jwtAuth('write:nftbook'), validateBody(PriceReorderBodySchema), async (req, res, next) => { try { const { classId } = req.params; const bookInfo = await getNftBookInfo(classId); @@ -397,9 +397,8 @@ router.put(['/:classId/price/:priceIndex/order', '/class/:classId/price/:priceIn const isAuthorized = checkIsAuthorized({ ownerWallet, moderatorWallets }, req); if (!isAuthorized) throw new ValidationError('NOT_OWNER_OF_NFT_CLASS', 403); - const { order: newOrderString } = req.body; - const newOrder = Number(newOrderString); - if (newOrder < 0 || newOrder >= prices.length) { + const { order: newOrder } = req.body; + if (newOrder >= prices.length) { throw new ValidationError('INVALID_NEW_PRICE_ORDER', 400); } const oldOrder = priceInfo.order; @@ -460,13 +459,13 @@ router.post('/class/:classId/refresh', jwtAuth('write:nftbook'), async (req, res } }); -router.post(['/:classId/new', '/class/:classId/new'], jwtAuth('write:nftbook'), async (req, res, next) => { +router.post(['/:classId/new', '/class/:classId/new'], jwtAuth('write:nftbook'), validateBody(NewListingBodySchema), async (req, res, next) => { try { const { classId } = req.params; const { successUrl, cancelUrl, - prices: inputPrices = [], + prices, moderatorWallets = [], connectedWallets, mustClaimToView = false, @@ -491,11 +490,12 @@ router.post(['/:classId/new', '/class/:classId/new'], jwtAuth('write:nftbook'), const isAuthorized = checkIsAuthorized({ ownerWallet, moderatorWallets: [] }, req); if (!isAuthorized) throw new ValidationError('NOT_OWNER_OF_NFT_CLASS', 403); - const { - prices, - autoDeliverTotalStock, - manualDeliverTotalStock, - } = validatePrices(inputPrices); + let autoDeliverTotalStock = 0; + let manualDeliverTotalStock = 0; + for (const p of prices) { + if (p.isAutoDeliver) autoDeliverTotalStock += p.stock; + else manualDeliverTotalStock += p.stock; + } if (connectedWallets) await validateConnectedWallets(connectedWallets); const { @@ -656,7 +656,7 @@ router.post(['/:classId/new', '/class/:classId/new'], jwtAuth('write:nftbook'), } }); -router.post(['/:classId/settings', '/class/:classId/settings'], jwtAuth('write:nftbook'), async (req, res, next) => { +router.post(['/:classId/settings', '/class/:classId/settings'], jwtAuth('write:nftbook'), validateBody(ListingSettingsBodySchema), async (req, res, next) => { try { const { classId } = req.params; const { @@ -725,6 +725,7 @@ router.post( { name: 'signImage', maxCount: 1 }, { name: 'memoImage', maxCount: 1 }, ]), + validateBody(ImageUploadBodySchema), async (req, res, next) => { try { const { classId } = req.params; diff --git a/src/util/api/likernft/book/index.ts b/src/util/api/likernft/book/index.ts index f180fbd63..5cab80e32 100644 --- a/src/util/api/likernft/book/index.ts +++ b/src/util/api/likernft/book/index.ts @@ -9,12 +9,10 @@ import { } from '../../../firebase'; import { LIKER_NFT_BOOK_GLOBAL_READONLY_MODERATOR_ADDRESSES } from '../../../../../config/config'; import { - MIN_BOOK_PRICE_DECIMAL, NFT_BOOK_TEXT_DEFAULT_LOCALE, NFT_BOOK_TEXT_LOCALES, NFT_BOOKSTORE_HOSTNAME, } from '../../../../constant'; - import { getNFTClassDataById as getEVMNftClassDataById, isEVMClassId, @@ -560,77 +558,6 @@ export async function listNftBookInfoByModeratorWallet( }); } -export function validatePrice(price: NFTBookPrice) { - const { - autoMemo, - order, - stock, - name = {}, - description = {}, - isAllowCustomPrice, - isAutoDeliver, - isUnlisted, - priceInDecimal, - } = price; - if (!( - typeof priceInDecimal === 'number' - && priceInDecimal >= 0 - && (priceInDecimal === 0 || priceInDecimal >= MIN_BOOK_PRICE_DECIMAL) - )) { - throw new ValidationError('INVALID_PRICE'); - } - if (!(typeof stock === 'number' && stock >= 0)) { - throw new ValidationError('INVALID_PRICE_STOCK'); - } - if (!(typeof name[NFT_BOOK_TEXT_DEFAULT_LOCALE] === 'string' - && Object.values(name).every((n) => typeof n === 'string'))) { - throw new ValidationError('INVALID_PRICE_NAME'); - } - if (!(typeof description[NFT_BOOK_TEXT_DEFAULT_LOCALE] === 'string' - && Object.values(description).every((n) => typeof n === 'string'))) { - throw new ValidationError('INVALID_PRICE_DESCRIPTION'); - } - return { - autoMemo, - order, - priceInDecimal, - stock, - name, - description, - isAutoDeliver, - isUnlisted, - isAllowCustomPrice, - }; -} - -export function validatePrices(prices: NFTBookPrice[]) { - if (!prices.length) throw new ValidationError('PRICES_ARE_EMPTY'); - let i = 0; - let autoDeliverTotalStock = 0; - let manualDeliverTotalStock = 0; - const outputPrices: NFTBookPrice[] = []; - try { - for (i = 0; i < prices.length; i += 1) { - const inputPrice = prices[i]; - const price = validatePrice(inputPrice); - outputPrices.push(price); - if (price.isAutoDeliver) { - autoDeliverTotalStock += price.stock; - } else { - manualDeliverTotalStock += price.stock; - } - } - } catch (err) { - const errorMessage = `${(err as Error).message}_in_${i}`; - throw new ValidationError(errorMessage); - } - return { - prices: outputPrices, - autoDeliverTotalStock, - manualDeliverTotalStock, - }; -} - export function getNFTBookStoreClassPageURL(classId: string) { return `https://${NFT_BOOKSTORE_HOSTNAME}/my-books/status/${classId}`; } diff --git a/src/util/api/likernft/book/schemas.ts b/src/util/api/likernft/book/schemas.ts new file mode 100644 index 000000000..c49609849 --- /dev/null +++ b/src/util/api/likernft/book/schemas.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; +import { + MIN_BOOK_PRICE_DECIMAL, + NFT_BOOK_TEXT_DEFAULT_LOCALE, +} from '../../../../constant'; + +const LocalizedTextMap = z.record(z.string(), z.string()) + .refine( + (m) => typeof m[NFT_BOOK_TEXT_DEFAULT_LOCALE] === 'string', + { message: `default locale "${NFT_BOOK_TEXT_DEFAULT_LOCALE}" is required` }, + ); + +export const NFTBookPriceSchema = z.object({ + priceInDecimal: z.number() + .int() + .min(0) + .refine( + (v) => v === 0 || v >= MIN_BOOK_PRICE_DECIMAL, + { message: `priceInDecimal must be 0 or >= ${MIN_BOOK_PRICE_DECIMAL}` }, + ), + stock: z.number().int().min(0), + name: LocalizedTextMap, + description: LocalizedTextMap, + isAllowCustomPrice: z.boolean().optional(), + isAutoDeliver: z.boolean().optional(), + isUnlisted: z.boolean().optional(), + autoMemo: z.string().optional(), + order: z.number().int().optional(), +}); + +export const NFTBookPricesSchema = z.array(NFTBookPriceSchema).min(1); + +export const PriceMutationBodySchema = z.object({ + price: NFTBookPriceSchema, +}); + +export const PriceReorderBodySchema = z.object({ + order: z.coerce.number().int().min(0), +}); + +const ConnectedWalletsSchema = z.record(z.string(), z.number().int().min(0)); + +export const ListingSettingsBodySchema = z.object({ + moderatorWallets: z.array(z.string()).optional(), + connectedWallets: ConnectedWalletsSchema.nullish(), + mustClaimToView: z.boolean().optional(), + hideDownload: z.boolean().optional(), + hideAudio: z.boolean().optional(), + hideUpsell: z.boolean().optional(), + enableCustomMessagePage: z.boolean().optional(), + tableOfContents: z.string().optional(), + isAdultOnly: z.boolean().optional(), + isPlusReadingEnabled: z.boolean().optional(), +}); + +export const NewListingBodySchema = ListingSettingsBodySchema.extend({ + successUrl: z.string().optional(), + cancelUrl: z.string().optional(), + prices: NFTBookPricesSchema, +}); + +export const ImageUploadBodySchema = z.object({ + signedMessageText: z.string().optional(), +});