import { IncomingMessage, ServerResponse } from 'http' import { parse } from 'next/dist/compiled/content-type' import { CookieSerializeOptions } from 'next/dist/compiled/cookie' import generateETag from 'next/dist/compiled/etag' import fresh from 'next/dist/compiled/fresh' import getRawBody from 'next/dist/compiled/raw-body' import { PageConfig } from 'next/types' import { Stream } from 'stream' import { isResSent, NextApiRequest, NextApiResponse } from '../lib/utils' import { decryptWithSecret, encryptWithSecret } from './crypto-utils' import { interopDefault } from './load-components' import { Params } from './router' export type NextApiRequestCookies = { [key: string]: string } export type NextApiRequestQuery = { [key: string]: string | string[] } export type __ApiPreviewProps = { previewModeId: string previewModeEncryptionKey: string previewModeSigningKey: string } export async function apiResolver( req: IncomingMessage, res: ServerResponse, query: any, resolverModule: any, apiContext: __ApiPreviewProps, propagateError: boolean, onError?: ({ err }: { err: any }) => Promise ) { const apiReq = req as NextApiRequest const apiRes = res as NextApiResponse try { if (!resolverModule) { res.statusCode = 404 res.end('Not Found') return } const config: PageConfig = resolverModule.config || {} const bodyParser = config.api?.bodyParser !== false const externalResolver = config.api?.externalResolver || false // Parsing of cookies setLazyProp({ req: apiReq }, 'cookies', getCookieParser(req)) // Parsing query string apiReq.query = query // Parsing preview data setLazyProp({ req: apiReq }, 'previewData', () => tryGetPreviewData(req, res, apiContext) ) // Checking if preview mode is enabled setLazyProp({ req: apiReq }, 'preview', () => apiReq.previewData !== false ? true : undefined ) // Parsing of body if (bodyParser) { apiReq.body = await parseBody( apiReq, config.api && config.api.bodyParser && config.api.bodyParser.sizeLimit ? config.api.bodyParser.sizeLimit : '1mb' ) } apiRes.status = (statusCode) => sendStatusCode(apiRes, statusCode) apiRes.send = (data) => sendData(apiReq, apiRes, data) apiRes.json = (data) => sendJson(apiRes, data) apiRes.redirect = (statusOrUrl: number | string, url?: string) => redirect(apiRes, statusOrUrl, url) apiRes.setPreviewData = (data, options = {}) => setPreviewData(apiRes, data, Object.assign({}, apiContext, options)) apiRes.clearPreviewData = () => clearPreviewData(apiRes) const resolver = interopDefault(resolverModule) let wasPiped = false if (process.env.NODE_ENV !== 'production') { // listen for pipe event and don't show resolve warning res.once('pipe', () => (wasPiped = true)) } // Call API route method await resolver(req, res) if ( process.env.NODE_ENV !== 'production' && !externalResolver && !isResSent(res) && !wasPiped ) { console.warn( `API resolved without sending a response for ${req.url}, this may result in stalled requests.` ) } } catch (err) { if (err instanceof ApiError) { sendError(apiRes, err.statusCode, err.message) } else { console.error(err) if (onError) await onError({ err }) if (propagateError) { throw err } sendError(apiRes, 500, 'Internal Server Error') } } } /** * Parse incoming message like `json` or `urlencoded` * @param req request object */ export async function parseBody( req: NextApiRequest, limit: string | number ): Promise { const contentType = parse(req.headers['content-type'] || 'text/plain') const { type, parameters } = contentType const encoding = parameters.charset || 'utf-8' let buffer try { buffer = await getRawBody(req, { encoding, limit }) } catch (e) { if (e.type === 'entity.too.large') { throw new ApiError(413, `Body exceeded ${limit} limit`) } else { throw new ApiError(400, 'Invalid body') } } const body = buffer.toString() if (type === 'application/json' || type === 'application/ld+json') { return parseJson(body) } else if (type === 'application/x-www-form-urlencoded') { const qs = require('querystring') return qs.decode(body) } else { return body } } /** * Parse `JSON` and handles invalid `JSON` strings * @param str `JSON` string */ function parseJson(str: string): object { if (str.length === 0) { // special-case empty json body, as it's a common client-side mistake return {} } try { return JSON.parse(str) } catch (e) { throw new ApiError(400, 'Invalid JSON') } } /** * Parsing query arguments from request `url` string * @param url of request * @returns Object with key name of query argument and its value */ export function getQueryParser({ url }: IncomingMessage) { return function parseQuery(): NextApiRequestQuery { const { URL } = require('url') // we provide a placeholder base url because we only want searchParams const params = new URL(url, 'https://n').searchParams const query: { [key: string]: string | string[] } = {} for (const [key, value] of params) { if (query[key]) { if (Array.isArray(query[key])) { ;(query[key] as string[]).push(value) } else { query[key] = [query[key], value] } } else { query[key] = value } } return query } } /** * Parse cookies from `req` header * @param req request object */ export function getCookieParser(req: IncomingMessage) { return function parseCookie(): NextApiRequestCookies { const header: undefined | string | string[] = req.headers.cookie if (!header) { return {} } const { parse: parseCookieFn } = require('next/dist/compiled/cookie') return parseCookieFn(Array.isArray(header) ? header.join(';') : header) } } /** * * @param res response object * @param statusCode `HTTP` status code of response */ export function sendStatusCode( res: NextApiResponse, statusCode: number ): NextApiResponse { res.statusCode = statusCode return res } /** * * @param res response object * @param [statusOrUrl] `HTTP` status code of redirect * @param url URL of redirect */ export function redirect( res: NextApiResponse, statusOrUrl: string | number, url?: string ): NextApiResponse { if (typeof statusOrUrl === 'string') { url = statusOrUrl statusOrUrl = 307 } if (typeof statusOrUrl !== 'number' || typeof url !== 'string') { throw new Error( `Invalid redirect arguments. Please use a single argument URL, e.g. res.redirect('/destination') or use a status code and URL, e.g. res.redirect(307, '/destination').` ) } res.writeHead(statusOrUrl, { Location: url }).end() return res } function sendEtagResponse( req: NextApiRequest, res: NextApiResponse, body: string | Buffer ): boolean { const etag = generateETag(body) if (fresh(req.headers, { etag })) { res.statusCode = 304 res.end() return true } res.setHeader('ETag', etag) return false } /** * Send `any` body to response * @param req request object * @param res response object * @param body of response */ export function sendData( req: NextApiRequest, res: NextApiResponse, body: any ): void { if (body === null) { res.end() return } const contentType = res.getHeader('Content-Type') if (body instanceof Stream) { if (!contentType) { res.setHeader('Content-Type', 'application/octet-stream') } body.pipe(res) return } const isJSONLike = ['object', 'number', 'boolean'].includes(typeof body) const stringifiedBody = isJSONLike ? JSON.stringify(body) : body if (sendEtagResponse(req, res, stringifiedBody)) { return } if (Buffer.isBuffer(body)) { if (!contentType) { res.setHeader('Content-Type', 'application/octet-stream') } res.setHeader('Content-Length', body.length) res.end(body) return } if (isJSONLike) { res.setHeader('Content-Type', 'application/json; charset=utf-8') } res.setHeader('Content-Length', Buffer.byteLength(stringifiedBody)) res.end(stringifiedBody) } /** * Send `JSON` object * @param res response object * @param jsonBody of data */ export function sendJson(res: NextApiResponse, jsonBody: any): void { // Set header to application/json res.setHeader('Content-Type', 'application/json; charset=utf-8') // Use send to handle request res.send(jsonBody) } const COOKIE_NAME_PRERENDER_BYPASS = `__prerender_bypass` const COOKIE_NAME_PRERENDER_DATA = `__next_preview_data` export const SYMBOL_PREVIEW_DATA = Symbol(COOKIE_NAME_PRERENDER_DATA) const SYMBOL_CLEARED_COOKIES = Symbol(COOKIE_NAME_PRERENDER_BYPASS) export function tryGetPreviewData( req: IncomingMessage, res: ServerResponse, options: __ApiPreviewProps ): object | string | false { // Read cached preview data if present if (SYMBOL_PREVIEW_DATA in req) { return (req as any)[SYMBOL_PREVIEW_DATA] as any } const getCookies = getCookieParser(req) let cookies: NextApiRequestCookies try { cookies = getCookies() } catch { // TODO: warn return false } const hasBypass = COOKIE_NAME_PRERENDER_BYPASS in cookies const hasData = COOKIE_NAME_PRERENDER_DATA in cookies // Case: neither cookie is set. if (!(hasBypass || hasData)) { return false } // Case: one cookie is set, but not the other. if (hasBypass !== hasData) { clearPreviewData(res as NextApiResponse) return false } // Case: preview session is for an old build. if (cookies[COOKIE_NAME_PRERENDER_BYPASS] !== options.previewModeId) { clearPreviewData(res as NextApiResponse) return false } const tokenPreviewData = cookies[COOKIE_NAME_PRERENDER_DATA] const jsonwebtoken = require('next/dist/compiled/jsonwebtoken') as typeof import('jsonwebtoken') let encryptedPreviewData: { data: string } try { encryptedPreviewData = jsonwebtoken.verify( tokenPreviewData, options.previewModeSigningKey ) as typeof encryptedPreviewData } catch { // TODO: warn clearPreviewData(res as NextApiResponse) return false } const decryptedPreviewData = decryptWithSecret( Buffer.from(options.previewModeEncryptionKey), encryptedPreviewData.data ) try { // TODO: strict runtime type checking const data = JSON.parse(decryptedPreviewData) // Cache lookup Object.defineProperty(req, SYMBOL_PREVIEW_DATA, { value: data, enumerable: false, }) return data } catch { return false } } function setPreviewData( res: NextApiResponse, data: object | string, // TODO: strict runtime type checking options: { maxAge?: number } & __ApiPreviewProps ): NextApiResponse { if ( typeof options.previewModeId !== 'string' || options.previewModeId.length < 16 ) { throw new Error('invariant: invalid previewModeId') } if ( typeof options.previewModeEncryptionKey !== 'string' || options.previewModeEncryptionKey.length < 16 ) { throw new Error('invariant: invalid previewModeEncryptionKey') } if ( typeof options.previewModeSigningKey !== 'string' || options.previewModeSigningKey.length < 16 ) { throw new Error('invariant: invalid previewModeSigningKey') } const jsonwebtoken = require('next/dist/compiled/jsonwebtoken') as typeof import('jsonwebtoken') const payload = jsonwebtoken.sign( { data: encryptWithSecret( Buffer.from(options.previewModeEncryptionKey), JSON.stringify(data) ), }, options.previewModeSigningKey, { algorithm: 'HS256', ...(options.maxAge !== undefined ? { expiresIn: options.maxAge } : undefined), } ) // limit preview mode cookie to 2KB since we shouldn't store too much // data here and browsers drop cookies over 4KB if (payload.length > 2048) { throw new Error( `Preview data is limited to 2KB currently, reduce how much data you are storing as preview data to continue` ) } const { serialize, } = require('next/dist/compiled/cookie') as typeof import('cookie') const previous = res.getHeader('Set-Cookie') res.setHeader(`Set-Cookie`, [ ...(typeof previous === 'string' ? [previous] : Array.isArray(previous) ? previous : []), serialize(COOKIE_NAME_PRERENDER_BYPASS, options.previewModeId, { httpOnly: true, sameSite: process.env.NODE_ENV !== 'development' ? 'none' : 'lax', secure: process.env.NODE_ENV !== 'development', path: '/', ...(options.maxAge !== undefined ? ({ maxAge: options.maxAge } as CookieSerializeOptions) : undefined), }), serialize(COOKIE_NAME_PRERENDER_DATA, payload, { httpOnly: true, sameSite: process.env.NODE_ENV !== 'development' ? 'none' : 'lax', secure: process.env.NODE_ENV !== 'development', path: '/', ...(options.maxAge !== undefined ? ({ maxAge: options.maxAge } as CookieSerializeOptions) : undefined), }), ]) return res } function clearPreviewData(res: NextApiResponse): NextApiResponse { if (SYMBOL_CLEARED_COOKIES in res) { return res } const { serialize, } = require('next/dist/compiled/cookie') as typeof import('cookie') const previous = res.getHeader('Set-Cookie') res.setHeader(`Set-Cookie`, [ ...(typeof previous === 'string' ? [previous] : Array.isArray(previous) ? previous : []), serialize(COOKIE_NAME_PRERENDER_BYPASS, '', { // To delete a cookie, set `expires` to a date in the past: // https://tools.ietf.org/html/rfc6265#section-4.1.1 // `Max-Age: 0` is not valid, thus ignored, and the cookie is persisted. expires: new Date(0), httpOnly: true, sameSite: process.env.NODE_ENV !== 'development' ? 'none' : 'lax', secure: process.env.NODE_ENV !== 'development', path: '/', }), serialize(COOKIE_NAME_PRERENDER_DATA, '', { // To delete a cookie, set `expires` to a date in the past: // https://tools.ietf.org/html/rfc6265#section-4.1.1 // `Max-Age: 0` is not valid, thus ignored, and the cookie is persisted. expires: new Date(0), httpOnly: true, sameSite: process.env.NODE_ENV !== 'development' ? 'none' : 'lax', secure: process.env.NODE_ENV !== 'development', path: '/', }), ]) Object.defineProperty(res, SYMBOL_CLEARED_COOKIES, { value: true, enumerable: false, }) return res } /** * Custom error class */ export class ApiError extends Error { readonly statusCode: number constructor(statusCode: number, message: string) { super(message) this.statusCode = statusCode } } /** * Sends error in `response` * @param res response object * @param statusCode of response * @param message of response */ export function sendError( res: NextApiResponse, statusCode: number, message: string ): void { res.statusCode = statusCode res.statusMessage = message res.end(message) } interface LazyProps { req: NextApiRequest params?: Params | boolean } /** * Execute getter function only if its needed * @param LazyProps `req` and `params` for lazyProp * @param prop name of property * @param getter function to get data */ export function setLazyProp( { req, params }: LazyProps, prop: string, getter: () => T ): void { const opts = { configurable: true, enumerable: true } const optsReset = { ...opts, writable: true } Object.defineProperty(req, prop, { ...opts, get: () => { let value = getter() if (params && typeof params !== 'boolean') { value = { ...value, ...params } } // we set the property on the object to avoid recalculating it Object.defineProperty(req, prop, { ...optsReset, value }) return value }, set: (value) => { Object.defineProperty(req, prop, { ...optsReset, value }) }, }) }