diff --git a/README.md b/README.md index d5bb44d..214bcd0 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,10 @@ In theory, any API route may have a corresponding action route. - [ ] `<form method="dialog">` (on hold, see https://github.com/whatwg/html/issues/9625) - [ ] Tests - [X] Form with redirects - - [ ] Form with files + - [X] Form with files - [ ] Documentation - [ ] Remix support - [ ] `accept-charset=""` attribute support - [X] Method override support -- Integration with Next router (iceform-next) +- [ ] Integration with Next router (iceform-next) +- [ ] Investigate bug of not cleaning response body cache and flash messages via cookies diff --git a/packages/iceform-next-sandbox/next.config.js b/packages/iceform-next-sandbox/next.config.js index 500f490..96572f2 100644 --- a/packages/iceform-next-sandbox/next.config.js +++ b/packages/iceform-next-sandbox/next.config.js @@ -4,6 +4,11 @@ const nextConfig = { experimental: { optimizeCss: true, }, + webpack: (config) => { + config.resolve.fallback = { fs: false }; + + return config; + }, }; module.exports = nextConfig; diff --git a/packages/iceform-next-sandbox/src/handlers/note.ts b/packages/iceform-next-sandbox/src/handlers/note.ts index 55ed12f..5d6e18f 100644 --- a/packages/iceform-next-sandbox/src/handlers/note.ts +++ b/packages/iceform-next-sandbox/src/handlers/note.ts @@ -51,7 +51,7 @@ const patchNote: NextApiHandler = async (req, res) => { return; } - const { title, content } = req.body; + const { title, content, image } = req.body; const dataRaw = await fs.readFile('.db/notes.jsonl', { encoding: 'utf-8', @@ -75,6 +75,7 @@ const patchNote: NextApiHandler = async (req, res) => { ...note, title: title ?? note.title, content: content ?? note.content, + image: image ?? note.image, }; data[noteIndex] = updatedNote; @@ -185,7 +186,7 @@ export interface NoteCollectionParams { } const createNote = (params: NoteCollectionParams): NextApiHandler => async (req, res) => { - const { title, content } = req.body; + const { title, content, image } = req.body; if (typeof title !== 'string' || typeof content !== 'string') { res.status(400).send('Bad Request'); @@ -202,6 +203,7 @@ const createNote = (params: NoteCollectionParams): NextApiHandler => async (req, id: newId, title, content, + image: `data:${image.type};base64,${image.toString('base64')}`, })}\n`, { flag: 'a', @@ -223,6 +225,7 @@ const createNote = (params: NoteCollectionParams): NextApiHandler => async (req, id: newId, title, content, + image, }); }; diff --git a/packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx b/packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx index 23d2fbd..3608d16 100644 --- a/packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx +++ b/packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx @@ -72,6 +72,10 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ /> </div> <div> + <img + src={body.image as string} + alt={body.title as string} + /> <label> <span className="after:block">Image</span> <input type="file" name="image" /> diff --git a/packages/iceform-next-sandbox/src/pages/notes/index.tsx b/packages/iceform-next-sandbox/src/pages/notes/index.tsx index 372b3d0..f7788e4 100644 --- a/packages/iceform-next-sandbox/src/pages/notes/index.tsx +++ b/packages/iceform-next-sandbox/src/pages/notes/index.tsx @@ -108,13 +108,24 @@ const NotesPage: NextPage<NotesPageProps> = ({ </div> </Iceform.Form> </div> - <div - className="font-bold" - > - {note.title} - </div> - <div> - {note.content} + <div className="grid grid-cols-3 gap-4"> + <div> + <img + className="w-full" + src={note.image} + alt={note.title} + /> + </div> + <div className="col-span-2"> + <div + className="font-bold" + > + {note.title} + </div> + <div> + {note.content} + </div> + </div> </div> </div> ))} diff --git a/packages/iceform-next/.eslintrc b/packages/iceform-next/.eslintrc index 722c768..c028872 100644 --- a/packages/iceform-next/.eslintrc +++ b/packages/iceform-next/.eslintrc @@ -10,7 +10,9 @@ "react/jsx-indent": ["error", "tab"], "react/jsx-props-no-spreading": "off", "@typescript-eslint/no-misused-promises": "off", - "@typescript-eslint/no-namespace": "off" + "@typescript-eslint/no-namespace": "off", + "max-classes-per-file": "off", + "import/prefer-default-export": "off" }, "parserOptions": { "project": "./tsconfig.eslint.json" diff --git a/packages/iceform-next/package.json b/packages/iceform-next/package.json index a4c3557..89a75fc 100644 --- a/packages/iceform-next/package.json +++ b/packages/iceform-next/package.json @@ -68,13 +68,11 @@ }, "dependencies": { "@theoryofnekomata/formxtra": "^1.0.3", + "@web-std/file": "^3.0.3", "busboy": "^1.6.0", "nookies": "^2.5.2", - "seroval": "^0.9.0" + "seroval": "^0.10.2" }, - "types": "./dist/types/index.d.ts", - "main": "./dist/cjs/production/index.js", - "module": "./dist/esm/production/index.js", "exports": { ".": { "development": { @@ -88,5 +86,8 @@ }, "typesVersions": { "*": {} - } + }, + "types": "./dist/types/index.d.ts", + "main": "./dist/cjs/production/index.js", + "module": "./dist/esm/production/index.js" } diff --git a/packages/iceform-next/pridepack.json b/packages/iceform-next/pridepack.json index 841fb58..0bc7a8f 100644 --- a/packages/iceform-next/pridepack.json +++ b/packages/iceform-next/pridepack.json @@ -1,3 +1,3 @@ { "target": "es2018" -} \ No newline at end of file +} diff --git a/packages/iceform-next/src/server/action.ts b/packages/iceform-next/src/server/action.ts deleted file mode 100644 index 75d1265..0000000 --- a/packages/iceform-next/src/server/action.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { - GetServerSideProps, - NextApiHandler, - PageConfig, - NextApiRequest as DefaultNextApiRequest, PreviewData, GetServerSidePropsContext, -} from 'next'; -import { ParsedUrlQuery } from 'querystring'; -import { deserializeBody, EncTypeDeserializerMap } from '../utils/serialization'; -import { IceformNextServerResponse } from './response'; -import { - BODY_COOKIE_KEY, CONTENT_TYPE_COOKIE_KEY, - CookieManager, - STATUS_CODE_COOKIE_KEY, - STATUS_MESSAGE_COOKIE_KEY, -} from '../utils/cookies'; -import { - METHOD_FORM_KEY, METHODS_WITH_BODY, - PREVENT_REDIRECT_FORM_KEY, -} from '../common/constants'; -import { - ACTION_STATUS_CODE, - DEFAULT_METHOD, -} from './constants'; - -export const getApiConfig = (customConfig = {} as PageConfig) => ({ - api: { - ...(customConfig.api ?? {}), - bodyParser: false, - }, -}); - -export type OnActionFunction< - Props extends Record<string, unknown> = Record<string, unknown>, - Params extends ParsedUrlQuery = ParsedUrlQuery, - Preview extends PreviewData = PreviewData, -> = ( - actionReq: DefaultNextApiRequest, - actionRes: IceformNextServerResponse, - context: GetServerSidePropsContext<Params, Preview>, -) => Promise<Awaited<ReturnType<GetServerSideProps<Props, Params, Preview>>> | undefined>; - -export interface ActionWrapperOptions { - fn: NextApiHandler, - onAction?: OnActionFunction, - deserializers?: EncTypeDeserializerMap, - /** - * Maps the Location header from the handler response to an accessible URL. - * @param url - */ - mapLocationToRedirectDestination?: (referer: string, url: string) => string, -} - -export const wrapApiHandler = ( - options: ActionWrapperOptions, -): NextApiHandler => async (req, res) => { - const reqMut = req as unknown as Record<string, unknown>; - if (METHODS_WITH_BODY.includes(req.method?.toUpperCase() as typeof METHODS_WITH_BODY[number])) { - reqMut.body = await deserializeBody({ - req, - deserializers: options.deserializers, - }); - } - return options.fn(reqMut as unknown as DefaultNextApiRequest, res); -}; - -export const getServerSideProps = ( - options: ActionWrapperOptions, -): GetServerSideProps => async (ctx) => { - const { referer = '/' } = ctx.req.headers; - const deserialized = await deserializeBody({ - req: ctx.req, - deserializers: options.deserializers, - }); - - const defaultMethod = ctx.req.method ?? DEFAULT_METHOD; - let effectiveMethod = defaultMethod; - let mockReqBody: unknown = deserialized; - if (typeof deserialized === 'object' && deserialized !== null) { - const { - [METHOD_FORM_KEY]: theMethod, - ...theMockReqBody - } = deserialized as { - [METHOD_FORM_KEY]?: string, - [key: string]: unknown, - }; - effectiveMethod = theMethod ?? defaultMethod; - mockReqBody = theMockReqBody; - } - - const mockReq = { - ...ctx.req, - body: mockReqBody, - query: { - ...ctx.query, - ...(ctx.params ?? {}), - }, - // ?: how to prevent malicious method spoofing? - method: effectiveMethod, - } as DefaultNextApiRequest; - - const mockRes = new IceformNextServerResponse(ctx.req); - await options.fn(mockReq, mockRes); - - const cookieManager = new CookieManager(ctx); - cookieManager.setCookie(STATUS_CODE_COOKIE_KEY, mockRes.statusCode.toString()); - cookieManager.setCookie(STATUS_MESSAGE_COOKIE_KEY, mockRes.statusMessage); - if (mockRes.data) { - cookieManager.setCookie(BODY_COOKIE_KEY, mockRes.data as string); - if (mockRes.contentType) { - cookieManager.setCookie(CONTENT_TYPE_COOKIE_KEY, mockRes.contentType); - } - } - - if (typeof options.onAction === 'function') { - const onActionResult = await options.onAction( - mockReq, - mockRes, - ctx, - ); - if (onActionResult) { - return onActionResult; - } - } - - const preventRedirect = ( - typeof mockReq.body === 'object' - && mockReq.body !== null - && PREVENT_REDIRECT_FORM_KEY in mockReq.body - ); - const redirectDestination = ( - mockRes.location - && typeof options.mapLocationToRedirectDestination === 'function' - && !preventRedirect - ) - ? options.mapLocationToRedirectDestination(referer, mockRes.location) - : referer; - - return { - redirect: { - destination: redirectDestination, - statusCode: ACTION_STATUS_CODE, - }, - props: { - query: ctx.query, - body: mockRes.data, - }, - }; -}; diff --git a/packages/iceform-next/src/server/action/api.ts b/packages/iceform-next/src/server/action/api.ts new file mode 100644 index 0000000..6114cb7 --- /dev/null +++ b/packages/iceform-next/src/server/action/api.ts @@ -0,0 +1,34 @@ +import { NextApiHandler, PageConfig } from 'next'; +import { NextApiRequest as DefaultNextApiRequest } from 'next/dist/shared/lib/utils'; +import { ActionWrapperOptions } from './common'; +import { METHODS_WITH_BODY } from '../../common/constants'; +import { deserializeFormObjectBody } from '../../utils/serialization'; + +/** + * Wraps the API handler's `config` export to support all content types. + * @param customConfig + */ +export const getApiConfig = (customConfig = {} as PageConfig) => ({ + api: { + ...(customConfig.api ?? {}), + bodyParser: false, + }, +}); + +/** + * Wraps the API handler to support script. + * @param options + */ +export const wrapApiHandler = (options: ActionWrapperOptions): NextApiHandler => async ( + req, + res, +) => { + const reqMut = req as unknown as Record<string, unknown>; + if (METHODS_WITH_BODY.includes(req.method?.toUpperCase() as typeof METHODS_WITH_BODY[number])) { + reqMut.body = await deserializeFormObjectBody({ + req, + deserializers: options.deserializers, + }); + } + return options.fn(reqMut as unknown as DefaultNextApiRequest, res); +}; diff --git a/packages/iceform-next/src/server/action/common.ts b/packages/iceform-next/src/server/action/common.ts new file mode 100644 index 0000000..45bbc9f --- /dev/null +++ b/packages/iceform-next/src/server/action/common.ts @@ -0,0 +1,31 @@ +import { + GetServerSideProps, + GetServerSidePropsContext, + NextApiHandler, + PreviewData, +} from 'next'; +import { ParsedUrlQuery } from 'querystring'; +import { NextApiRequest as DefaultNextApiRequest } from 'next/dist/shared/lib/utils'; +import { EncTypeDeserializerMap } from '../../utils/serialization'; +import { IceformNextServerResponse } from '../response'; + +export type OnActionFunction< + Props extends Record<string, unknown> = Record<string, unknown>, + Params extends ParsedUrlQuery = ParsedUrlQuery, + Preview extends PreviewData = PreviewData, +> = ( + actionReq: DefaultNextApiRequest, + actionRes: IceformNextServerResponse, + context: GetServerSidePropsContext<Params, Preview>, +) => Promise<Awaited<ReturnType<GetServerSideProps<Props, Params, Preview>>> | undefined>; + +export interface ActionWrapperOptions { + fn: NextApiHandler, + onAction?: OnActionFunction, + deserializers?: EncTypeDeserializerMap, + /** + * Maps the Location header from the handler response to an accessible URL. + * @param url + */ + mapLocationToRedirectDestination?: (referer: string, url: string) => string, +} diff --git a/packages/iceform-next/src/server/action/gssp.ts b/packages/iceform-next/src/server/action/gssp.ts new file mode 100644 index 0000000..68f53c1 --- /dev/null +++ b/packages/iceform-next/src/server/action/gssp.ts @@ -0,0 +1,135 @@ +import { IncomingMessage } from 'http'; +import { GetServerSideProps } from 'next'; +import { NextApiRequest as DefaultNextApiRequest } from 'next/dist/shared/lib/utils'; +import crypto from 'crypto'; +import { + DEFAULT_ENCTYPE_DESERIALIZERS, + deserializeFormObjectBody, + EncTypeDeserializerMap, +} from '../../utils/serialization'; +import { METHOD_FORM_KEY, PREVENT_REDIRECT_FORM_KEY } from '../../common/constants'; +import { ACTION_STATUS_CODE, DEFAULT_METHOD } from '../constants'; +import { getBody } from '../../utils/request'; +import { + ENCTYPE_APPLICATION_OCTET_STREAM, + ENCTYPE_MULTIPART_FORM_DATA, +} from '../../common/enctypes'; +import { ActionWrapperOptions } from './common'; +import { IceformNextServerResponse } from '../response'; +import { + REQUEST_ID_COOKIE_KEY, + CookieManager, +} from '../../utils/cookies'; +import { cacheResponse } from '../cache'; + +const getFormObjectMethodAndBody = async ( + req: IncomingMessage, + deserializers: EncTypeDeserializerMap, +) => { + const deserialized = await deserializeFormObjectBody({ + req, + deserializers, + }); + const { + [METHOD_FORM_KEY]: method = req.method ?? DEFAULT_METHOD, + ...body + } = deserialized as { + [METHOD_FORM_KEY]?: string, + [key: string]: unknown, + }; + + return { + body, + method, + }; +}; + +const getBinaryMethodAndBody = async ( + req: IncomingMessage, +) => { + const body = await getBody(req); + + return { + body, + method: req.method ?? DEFAULT_METHOD, + }; +}; + +const isContentTypeFormObject = ( + contentType: string, + deserializers: EncTypeDeserializerMap, +) => ( + contentType?.startsWith(`${ENCTYPE_MULTIPART_FORM_DATA};`) + || Object.keys(deserializers).includes(contentType) +); + +/** + * Wraps the `getServerSideProps` function to support no-script. + * @param options + */ +export const getServerSideProps = (options: ActionWrapperOptions): GetServerSideProps => async ( + ctx, +) => { + const { + referer = '/', + 'content-type': contentType = ENCTYPE_APPLICATION_OCTET_STREAM, + } = ctx.req.headers; + const { deserializers = DEFAULT_ENCTYPE_DESERIALIZERS } = options; + const methodAndBodyFn = ( + isContentTypeFormObject(contentType, deserializers) + ? getFormObjectMethodAndBody + : getBinaryMethodAndBody + ); + const { body, method } = await methodAndBodyFn(ctx.req, deserializers); + const req = { + ...ctx.req, + body, + query: { + ...ctx.query, + ...(ctx.params ?? {}), + }, + // ?: how to prevent malicious method spoofing? + method, + } as DefaultNextApiRequest; + const res = new IceformNextServerResponse(ctx.req); + await options.fn(req, res); + const requestId = crypto.randomUUID(); + const cookieManager = new CookieManager(ctx); + cookieManager.setCookie(REQUEST_ID_COOKIE_KEY, requestId); + await cacheResponse(requestId, res); + + if (typeof options.onAction === 'function') { + const onActionResult = await options.onAction( + req, + res, + ctx, + ); + if (onActionResult) { + return onActionResult; + } + } + + const preventRedirect = ( + typeof req.body === 'object' + && req.body !== null + && PREVENT_REDIRECT_FORM_KEY in req.body + ); + const redirectDestination = ( + res.location + && typeof options.mapLocationToRedirectDestination === 'function' + && !preventRedirect + ) + ? options.mapLocationToRedirectDestination(referer, res.location) + : referer; + + return { + redirect: { + destination: redirectDestination, + statusCode: ACTION_STATUS_CODE, + }, + props: { + query: ctx.query, + body: res.data, + }, + }; +}; diff --git a/packages/iceform-next/src/server/action/index.ts b/packages/iceform-next/src/server/action/index.ts new file mode 100644 index 0000000..ab1cf5c --- /dev/null +++ b/packages/iceform-next/src/server/action/index.ts @@ -0,0 +1,3 @@ +export * from './api'; +export * from './common'; +export * from './gssp'; diff --git a/packages/iceform-next/src/server/cache.ts b/packages/iceform-next/src/server/cache.ts new file mode 100644 index 0000000..be4dca3 --- /dev/null +++ b/packages/iceform-next/src/server/cache.ts @@ -0,0 +1,48 @@ +import { createWriteStream } from 'fs'; +import { readFile, unlink } from 'fs/promises'; + +interface CacheableResponse { + statusCode: number; + statusMessage?: string; + contentType?: string; + data?: unknown; +} + +const getFilePathFromRequestId = (requestId: string) => `${requestId}`; + +export const cacheResponse = async (requestId: string, res: CacheableResponse) => { + const filePath = getFilePathFromRequestId(requestId); + const cacheStream = createWriteStream(filePath, { encoding: 'utf-8' }); + cacheStream.write(`${res.statusCode.toString()} ${res.statusMessage || ''}\n`); + if (res.contentType) { + cacheStream.write(`Content-Type: ${res.contentType}\n`); + } + if (res.data) { + cacheStream.write('\n'); + cacheStream.write(res.data as string); + } + + return new Promise((resolve) => { + cacheStream.close(resolve); + }); +}; + +export const retrieveCache = async (requestId: string) => { + const filePath = getFilePathFromRequestId(requestId); + const requestBuffer = await readFile(filePath, 'utf-8'); + await unlink(filePath); + const [statusLine, ...headersAndBody] = requestBuffer.split('\n'); + const [statusCode, ...statusMessageWords] = statusLine.split(' '); + const statusMessage = statusMessageWords.join(' '); + const bodyStart = headersAndBody.findIndex((line) => line === ''); + const headers = headersAndBody.slice(0, bodyStart); + const body = headersAndBody.slice(bodyStart + 1).join('\n'); + const contentTypeHeader = headers.find((header) => header.toLowerCase().startsWith('content-type:')); + const contentType = contentTypeHeader?.split(':')[1].trim(); + return { + statusCode: parseInt(statusCode, 10), + statusMessage, + contentType, + body, + }; +}; diff --git a/packages/iceform-next/src/server/destination.ts b/packages/iceform-next/src/server/destination.ts index ec48946..5d79cc5 100644 --- a/packages/iceform-next/src/server/destination.ts +++ b/packages/iceform-next/src/server/destination.ts @@ -2,20 +2,12 @@ import { ParsedUrlQuery } from 'querystring'; import { GetServerSideProps, GetServerSidePropsContext, PreviewData } from 'next'; import { deserialize } from 'seroval'; import { NextApiRequest, NextApiResponse } from '../common/types'; -import { - DEFAULT_ENCODING, - DEFAULT_METHOD, - DEFAULT_RESPONSE_STATUS_CODE, -} from './constants'; +import { DEFAULT_ENCODING, DEFAULT_METHOD } from './constants'; import { getBody } from '../utils/request'; -import { - BODY_COOKIE_KEY, CONTENT_TYPE_COOKIE_KEY, - CookieManager, - STATUS_CODE_COOKIE_KEY, - STATUS_MESSAGE_COOKIE_KEY, -} from '../utils/cookies'; +import { REQUEST_ID_COOKIE_KEY, CookieManager } from '../utils/cookies'; import { ENCTYPE_APPLICATION_JSON, ENCTYPE_APPLICATION_OCTET_STREAM } from '../common/enctypes'; import { METHODS_WITH_BODY } from '../common/constants'; +import { retrieveCache } from './cache'; export type DestinationGetServerSideProps< Props extends Record<string, unknown> = Record<string, unknown>, @@ -48,24 +40,20 @@ export const getServerSideProps = ( req.body = body.toString(DEFAULT_ENCODING); } - const cookieManager = new CookieManager(ctx); - // TODO how to properly remove cookies without leftovers? - if (cookieManager.hasCookie(STATUS_CODE_COOKIE_KEY)) { - ctx.res.statusCode = Number( - cookieManager.getCookie(STATUS_CODE_COOKIE_KEY) || DEFAULT_RESPONSE_STATUS_CODE, - ); - cookieManager.unsetCookie(STATUS_CODE_COOKIE_KEY); - } - - if (cookieManager.hasCookie(STATUS_MESSAGE_COOKIE_KEY)) { - ctx.res.statusMessage = cookieManager.getCookie(STATUS_MESSAGE_COOKIE_KEY) || ''; - cookieManager.unsetCookie(STATUS_MESSAGE_COOKIE_KEY); - } - const res: NextApiResponse = {}; - if (cookieManager.hasCookie(BODY_COOKIE_KEY)) { - const resBody = cookieManager.getCookie(BODY_COOKIE_KEY); - const contentType = cookieManager.getCookie(CONTENT_TYPE_COOKIE_KEY); + + const cookieManager = new CookieManager(ctx); + const resRequestId = cookieManager.getCookie(REQUEST_ID_COOKIE_KEY); + cookieManager.unsetCookie(REQUEST_ID_COOKIE_KEY); + if (resRequestId) { + const { + statusCode, + statusMessage, + contentType, + body: resBody, + } = await retrieveCache(resRequestId); + ctx.res.statusCode = statusCode; + ctx.res.statusMessage = statusMessage; if (contentType === ENCTYPE_APPLICATION_JSON) { res.body = deserialize(resBody); } else if (contentType === ENCTYPE_APPLICATION_OCTET_STREAM) { @@ -75,8 +63,6 @@ export const getServerSideProps = ( c.warn('Could not parse response body, returning nothing'); res.body = null; } - cookieManager.unsetCookie(BODY_COOKIE_KEY); - cookieManager.unsetCookie(CONTENT_TYPE_COOKIE_KEY); } const gspResult = ( diff --git a/packages/iceform-next/src/server/response.ts b/packages/iceform-next/src/server/response.ts index 9d9bc6f..47f77b4 100644 --- a/packages/iceform-next/src/server/response.ts +++ b/packages/iceform-next/src/server/response.ts @@ -1,9 +1,13 @@ import { ServerResponse } from 'http'; import { NextApiResponse as DefaultNextApiResponse } from 'next/dist/shared/lib/utils'; import { serialize } from 'seroval'; + import { ENCTYPE_APPLICATION_JSON, ENCTYPE_APPLICATION_OCTET_STREAM } from '../common/enctypes'; -import { ACTION_STATUS_CODE, CONTENT_TYPE_HEADER_KEY, LOCATION_HEADER_KEY } from './constants'; +import { + ACTION_STATUS_CODE, CONTENT_TYPE_HEADER_KEY, LOCATION_HEADER_KEY, DEFAULT_RESPONSE_STATUS_CODE, +} from './constants'; +// for client-side class DummyServerResponse {} const EffectiveServerResponse = ServerResponse ?? DummyServerResponse; @@ -19,6 +23,11 @@ export class IceformNextServerResponse private readonly revalidateResponse = Promise.resolve(undefined); + constructor(...args: ConstructorParameters<typeof EffectiveServerResponse>) { + super(...args); + this.statusCode = DEFAULT_RESPONSE_STATUS_CODE; + } + setHeader(name: string, value: number | string | readonly string[]): this { super.setHeader(name, value); @@ -38,9 +47,13 @@ export class IceformNextServerResponse return this as unknown as DefaultNextApiResponse; } + private static serialize(body: unknown) { + return serialize(body); + } + json(body: unknown): void { this.contentType = ENCTYPE_APPLICATION_JSON; - this.data = serialize(body); + this.data = IceformNextServerResponse.serialize(body); } revalidate(): Promise<void> { @@ -59,7 +72,7 @@ export class IceformNextServerResponse this.contentType = ENCTYPE_APPLICATION_OCTET_STREAM; } - this.data = serialize(body); + this.data = IceformNextServerResponse.serialize(body); } setDraftMode(): DefaultNextApiResponse { diff --git a/packages/iceform-next/src/utils/cookies.ts b/packages/iceform-next/src/utils/cookies.ts index 4616db9..59ba349 100644 --- a/packages/iceform-next/src/utils/cookies.ts +++ b/packages/iceform-next/src/utils/cookies.ts @@ -1,5 +1,6 @@ import { IncomingMessage, ServerResponse } from 'http'; import * as nookies from 'nookies'; +import * as crypto from 'crypto'; const COMMON_COOKIE_CONFIG = { path: '/', @@ -13,11 +14,7 @@ const COMMON_SET_COOKIE_CONFIG = { const cookieKeys: Record<string, string> = {}; -export const BODY_COOKIE_KEY = 'b' as const; -export const STATUS_CODE_COOKIE_KEY = 'sc' as const; -export const STATUS_MESSAGE_COOKIE_KEY = 'sm' as const; -export const CONTENT_TYPE_COOKIE_KEY = 'ct' as const; - +export const REQUEST_ID_COOKIE_KEY = 'b' as const; export class CookieManager { private readonly ctx: { req: IncomingMessage, res: ServerResponse<IncomingMessage> }; @@ -27,13 +24,17 @@ export class CookieManager { } private static generateCookieKey(key: string) { - return `if${key}${Date.now()}`; + const random = crypto.randomBytes(16).toString('hex'); + return `if${key}${random}`; } setCookie(key: string, value: string) { + // cleanup previous cookie + this.unsetCookie(key); + cookieKeys[key] = CookieManager.generateCookieKey(key); nookies.setCookie( this.ctx, - cookieKeys[key] = CookieManager.generateCookieKey(key), + cookieKeys[key], value, COMMON_SET_COOKIE_CONFIG, ); diff --git a/packages/iceform-next/src/utils/helpers.ts b/packages/iceform-next/src/utils/helpers.ts new file mode 100644 index 0000000..b7d957e --- /dev/null +++ b/packages/iceform-next/src/utils/helpers.ts @@ -0,0 +1,44 @@ +interface AbstractFormDataItem<T> { + value: T | null; +} + +export interface FileItem extends AbstractFormDataItem<Buffer> { + kind: 'file'; + mimeType: string; + filename: string; +} + +export interface FieldItem extends AbstractFormDataItem<string> { + kind: 'field'; +} + +export type FormDataItem = FileItem | FieldItem; + +export type FormDataMap = Record<string, FormDataItem>; + +export const addFormObjectBodyHelpers = (deserialized: Record<string, unknown>): FormDataMap => ( + Object.fromEntries( + Object + .entries(deserialized) + .filter(([, value]) => typeof value !== 'undefined') + .map(([key, value]) => [ + key, + { + kind: 'field', + value: value?.toString() ?? null, + }, + ] satisfies [string, FieldItem]), + ) +); + +export const removeFormObjectBodyHelpers = (formObject: FormDataMap): Record<string, unknown> => ( + Object.fromEntries( + Object + .entries(formObject) + .filter(([, value]) => typeof value !== 'undefined') + .map(([key, valueWithHelper]) => [ + key, + valueWithHelper.value, + ]), + ) +); diff --git a/packages/iceform-next/src/utils/request.ts b/packages/iceform-next/src/utils/request.ts index f147a80..c233715 100644 --- a/packages/iceform-next/src/utils/request.ts +++ b/packages/iceform-next/src/utils/request.ts @@ -28,24 +28,26 @@ export const parseMultipartFormData = async ( bb.on('file', (name, file, info) => { const { filename, - mimeType: mimetype, + mimeType, } = info; - - let fileData = Buffer.from(''); + let buffer = Buffer.from(''); file.on('data', (data) => { - fileData = Buffer.concat([fileData, data]); + buffer = Buffer.concat([buffer, data]); }); file.on('close', () => { - const newFile = fileData.buffer as unknown as Record<string, unknown>; - newFile.name = filename; - newFile.type = mimetype; - body[name] = newFile; + const bufferMut = buffer as unknown as Record<string, unknown>; + bufferMut.name = filename; + bufferMut.type = mimeType; + bufferMut.size = buffer.length; + + body[name] = bufferMut; }); }); bb.on('field', (name, value) => { + // TODO max length for long values, convert to reference instead body[name] = value; }); diff --git a/packages/iceform-next/src/utils/serialization.ts b/packages/iceform-next/src/utils/serialization.ts index 007c034..73e89a2 100644 --- a/packages/iceform-next/src/utils/serialization.ts +++ b/packages/iceform-next/src/utils/serialization.ts @@ -7,6 +7,7 @@ import { } from '../common/enctypes'; import { getBody, parseMultipartFormData } from './request'; import { METHOD_FORM_KEY, PREVENT_REDIRECT_FORM_KEY } from '../common/constants'; +import { DEFAULT_ENCODING } from '../server/constants'; export type EncTypeSerializer = (data: unknown) => string; @@ -43,9 +44,16 @@ export const serializeBody = (params: SerializeBodyParams) => { if (encType === ENCTYPE_MULTIPART_FORM_DATA) { // type error when provided a submitter element for some reason... const FormDataUnknown = FormData as unknown as { - new(formElement?: HTMLElement, submitter?: HTMLElement): FormData; + new(formElement?: HTMLElement, submitter?: HTMLElement): FormData & { + entries: () => IterableIterator<[string, unknown]>; + }; }; const formData = new FormDataUnknown(form, options?.submitter); + const emptyFiles = Array.from(formData.entries()) + .filter(([, value]) => ( + value instanceof File && value.size === 0 && value.name === '' + )); + emptyFiles.forEach(([key]) => formData.delete(key)); formData.delete(METHOD_FORM_KEY); formData.delete(PREVENT_REDIRECT_FORM_KEY); return formData; @@ -75,9 +83,12 @@ export interface DeserializeBodyParams { export const DEFAULT_ENCTYPE_DESERIALIZERS: EncTypeDeserializerMap = { [ENCTYPE_APPLICATION_JSON]: (data: string) => JSON.parse(data) as Record<string, unknown>, + [ENCTYPE_X_WWW_FORM_URLENCODED]: (data: string) => Object.fromEntries( + new URLSearchParams(data).entries(), + ), }; -export const deserializeBody = async (params: DeserializeBodyParams): Promise<unknown> => { +export const deserializeFormObjectBody = async (params: DeserializeBodyParams) => { const { req, deserializers = DEFAULT_ENCTYPE_DESERIALIZERS } = params; const contentType = req.headers['content-type'] ?? ENCTYPE_APPLICATION_OCTET_STREAM; @@ -85,18 +96,16 @@ export const deserializeBody = async (params: DeserializeBodyParams): Promise<un return parseMultipartFormData(req); } - const encoding = (req.headers['content-encoding'] ?? 'utf-8') as BufferEncoding; const bodyRaw = await getBody(req); + const encoding = ( + req.headers['content-encoding'] ?? DEFAULT_ENCODING + ) as BufferEncoding; - if (contentType === ENCTYPE_X_WWW_FORM_URLENCODED) { - return Object.fromEntries( - new URLSearchParams(bodyRaw.toString(encoding)).entries(), - ); - } + const { [contentType]: theDeserializer } = deserializers; - if (typeof deserializers[contentType] === 'function') { - return deserializers[contentType](bodyRaw.toString(encoding)); + if (typeof theDeserializer !== 'function') { + throw new Error(`Could not deserialize body with content type: ${contentType}`); } - return bodyRaw.toString('binary'); + return theDeserializer(bodyRaw.toString(encoding)) as Record<string, unknown>; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9502088..4c1efe2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@theoryofnekomata/formxtra': specifier: ^1.0.3 version: 1.0.3 + '@web-std/file': + specifier: ^3.0.3 + version: 3.0.3 busboy: specifier: ^1.6.0 version: 1.6.0 @@ -18,8 +21,8 @@ importers: specifier: ^2.5.2 version: 2.5.2 seroval: - specifier: ^0.9.0 - version: 0.9.0 + specifier: ^0.10.2 + version: 0.10.2 devDependencies: '@testing-library/jest-dom': specifier: ^5.16.5 @@ -1306,6 +1309,7 @@ packages: typescript: 4.9.5 transitivePeerDependencies: - supports-color + dev: true /@typescript-eslint/parser@6.7.0(eslint@8.49.0)(typescript@5.2.2): resolution: {integrity: sha512-jZKYwqNpNm5kzPVP5z1JXAuxjtl2uG+5NpaMocFPTNC2EdYIgbXIPImObOkhbONxtFTTdoZstLZefbaK+wXZng==} @@ -1334,6 +1338,7 @@ packages: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 + dev: true /@typescript-eslint/scope-manager@6.7.0: resolution: {integrity: sha512-lAT1Uau20lQyjoLUQ5FUMSX/dS07qux9rYd5FGzKz/Kf8W8ccuvMyldb8hadHdK/qOI7aikvQWqulnEq2nCEYA==} @@ -1366,6 +1371,7 @@ packages: /@typescript-eslint/types@5.62.0: resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true /@typescript-eslint/types@6.7.0: resolution: {integrity: sha512-ihPfvOp7pOcN/ysoj0RpBPOx3HQTJTrIN8UZK+WFd3/iDeFHHqeyYxa4hQk4rMhsz9H9mXpR61IzwlBVGXtl9Q==} @@ -1391,6 +1397,7 @@ packages: typescript: 4.9.5 transitivePeerDependencies: - supports-color + dev: true /@typescript-eslint/typescript-estree@6.7.0(typescript@5.2.2): resolution: {integrity: sha512-dPvkXj3n6e9yd/0LfojNU8VMUGHWiLuBZvbM6V6QYD+2qxqInE7J+J/ieY2iGwR9ivf/R/haWGkIj04WVUeiSQ==} @@ -1439,6 +1446,7 @@ packages: dependencies: '@typescript-eslint/types': 5.62.0 eslint-visitor-keys: 3.4.3 + dev: true /@typescript-eslint/visitor-keys@6.7.0: resolution: {integrity: sha512-/C1RVgKFDmGMcVGeD8HjKv2bd72oI1KxQDeY8uc66gw9R0OK0eMq48cA+jv9/2Ag6cdrsUGySm1yzYmfz0hxwQ==} @@ -1507,6 +1515,31 @@ packages: pretty-format: 29.7.0 dev: true + /@web-std/blob@3.0.5: + resolution: {integrity: sha512-Lm03qr0eT3PoLBuhkvFBLf0EFkAsNz/G/AYCzpOdi483aFaVX86b4iQs0OHhzHJfN5C15q17UtDbyABjlzM96A==} + dependencies: + '@web-std/stream': 1.0.0 + web-encoding: 1.1.5 + dev: false + + /@web-std/file@3.0.3: + resolution: {integrity: sha512-X7YYyvEERBbaDfJeC9lBKC5Q5lIEWYCP1SNftJNwNH/VbFhdHm+3neKOQP+kWEYJmosbDFq+NEUG7+XIvet/Jw==} + dependencies: + '@web-std/blob': 3.0.5 + dev: false + + /@web-std/stream@1.0.0: + resolution: {integrity: sha512-jyIbdVl+0ZJyKGTV0Ohb9E6UnxP+t7ZzX4Do3AHjZKxUXKMs9EmqnBDQgHF7bEw0EzbQygOjtt/7gvtmi//iCQ==} + dependencies: + web-streams-polyfill: 3.2.1 + dev: false + + /@zxing/text-encoding@0.9.0: + resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} + requiresBuild: true + dev: false + optional: true + /abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} dev: true @@ -2677,6 +2710,7 @@ packages: - eslint-import-resolver-node - eslint-import-resolver-webpack - supports-color + dev: true /eslint-import-resolver-typescript@3.6.0(@typescript-eslint/parser@6.7.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@8.49.0): resolution: {integrity: sha512-QTHR9ddNnn35RTxlaEnx2gCxqFlF2SEN0SE2d17SqwyM7YOSI2GHWRYp5BiRkObTUNYPupC/3Fq2a0PpT+EKpg==} @@ -2729,6 +2763,7 @@ packages: eslint-import-resolver-typescript: 3.6.0(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@8.49.0) transitivePeerDependencies: - supports-color + dev: true /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.7.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.0)(eslint@8.49.0): resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} @@ -2809,6 +2844,7 @@ packages: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color + dev: true /eslint-plugin-import@2.28.1(@typescript-eslint/parser@6.7.0)(eslint-import-resolver-typescript@3.6.0)(eslint@8.49.0): resolution: {integrity: sha512-9I9hFlITvOV55alzoKBI+K9q74kv0iKMeY6av5+umsNwayt59fz692daGyjR+oStBQgx6nwR9rXldDev3Clw+A==} @@ -2829,7 +2865,7 @@ packages: doctrine: 2.1.0 eslint: 8.49.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.0)(eslint@8.49.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.7.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.0)(eslint@8.49.0) has: 1.0.3 is-core-module: 2.13.0 is-glob: 4.0.3 @@ -3542,7 +3578,6 @@ packages: dependencies: call-bind: 1.0.2 has-tostringtag: 1.0.0 - dev: true /is-array-buffer@3.0.2: resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==} @@ -4892,8 +4927,8 @@ packages: - supports-color dev: true - /seroval@0.9.0: - resolution: {integrity: sha512-Ttr96/8czi3SXjbFFzpRc2Xpp1wvBufmaNuTviUL8eGQhUr1mdeiQ6YYSaLnMwMc4YWSeBggq72bKEBVu6/IFA==} + /seroval@0.10.2: + resolution: {integrity: sha512-aa9Tmthjs1wdSdtwr+USjeKHtML55ZJw2wPR6qjzsW9o+Og3eDNL+vJhjkIBH/pQT4bm4TUJ8+DcLlZq8RwzCg==} engines: {node: '>=10'} dev: false @@ -5278,6 +5313,7 @@ packages: /tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + dev: true /tslib@2.5.0: resolution: {integrity: sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==} @@ -5290,6 +5326,7 @@ packages: dependencies: tslib: 1.14.1 typescript: 4.9.5 + dev: true /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} @@ -5358,6 +5395,7 @@ packages: resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} engines: {node: '>=4.2.0'} hasBin: true + dev: true /typescript@5.2.2: resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} @@ -5419,6 +5457,16 @@ packages: /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + /util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + dependencies: + inherits: 2.0.4 + is-arguments: 1.1.1 + is-generator-function: 1.0.10 + is-typed-array: 1.1.12 + which-typed-array: 1.1.11 + dev: false + /utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -5600,6 +5648,19 @@ packages: defaults: 1.0.4 dev: true + /web-encoding@1.1.5: + resolution: {integrity: sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==} + dependencies: + util: 0.12.5 + optionalDependencies: + '@zxing/text-encoding': 0.9.0 + dev: false + + /web-streams-polyfill@3.2.1: + resolution: {integrity: sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==} + engines: {node: '>= 8'} + dev: false + /webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'}