diff --git a/.changeset/hot-maps-unite.md b/.changeset/hot-maps-unite.md new file mode 100644 index 0000000000..5c54474173 --- /dev/null +++ b/.changeset/hot-maps-unite.md @@ -0,0 +1,23 @@ +--- +"@vue-storefront/middleware": minor +--- + +**[ADDED]** Support for file uploads +Now you can upload files to the server with a `multipart/form-data` content type. Files are available in the `req.files` object. + +```ts +// Example of an endpoint that handles file uploads +export const upload = (context) => { + // Files are available in the `req.files` object + const { files } = context.req; + + // Do something with files + + return Promise.resolve({ + status: 200, + message: "ok", + }); +}; +``` + +Please, read the [Getting Started guide](https://docs.alokai.com/middleware/guides/getting-started#file-upload-configuration) for more information about file uploads. diff --git a/.eslintrc.js b/.eslintrc.js index a5b0be5d2e..ce531fdd37 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,5 +2,7 @@ module.exports = { extends: "@vue-storefront/eslint-config-integrations", rules: { "class-methods-use-this": "off", + "@typescript-eslint/no-restricted-types": "off", + "@typescript-eslint/ban-types": "off", }, }; diff --git a/docs/content/3.middleware/2.guides/2.getting-started.md b/docs/content/3.middleware/2.guides/2.getting-started.md index 34ee8037b1..3280072874 100644 --- a/docs/content/3.middleware/2.guides/2.getting-started.md +++ b/docs/content/3.middleware/2.guides/2.getting-started.md @@ -1,10 +1,10 @@ # Installation -If you're building your Alokai application from scratch, you'll need to set up the middleware to connect to your backend services. +If you're building your Alokai application from scratch, you'll need to set up the middleware to connect to your backend services. ## Creating the Application -Since the middleware is a separate app, it should be built outside of your frontend Alokai application. We recommend using a monorepo to keep both applications in a single repository. +Since the middleware is a separate app, it should be built outside of your frontend Alokai application. We recommend using a monorepo to keep both applications in a single repository. Our storefronts use an `apps/server` to store the middleware application, so most examples in our documentation will use this folder structure. @@ -19,18 +19,22 @@ apps/ To start, you need to install the `@vue-storefront/middleware` package. It contains the core functionality of the middleware that you can extend with integrations. ::code-group + ```sh[npm] npm i @vue-storefront/middleware consola ts-node-dev ``` + ```sh[yarn] yarn add @vue-storefront/middleware consola ts-node-dev ``` + ```sh[pnpm] pnpm i @vue-storefront/middleware consola ts-node-dev ``` + :: -If you just made a new folder for your middleware, this command will also create a `package.json` file to your project's root directory. +If you just made a new folder for your middleware, this command will also create a `package.json` file to your project's root directory. ## Running the Application @@ -38,15 +42,14 @@ The `@vue-storefront/middleware` package exposes a `createServer` function that The `createServer` function accepts an `integrations` object and returns an Express.js application that can be used to listen on a port. - ```ts [src/index.ts] -import { createServer } from '@vue-storefront/middleware'; -import consola from 'consola'; -import config from '../middleware.config'; +import { createServer } from "@vue-storefront/middleware"; +import consola from "consola"; +import config from "../middleware.config"; (async () => { const app = await createServer({ integrations: config.integrations }); - const host = process.argv[2] ?? '0.0.0.0'; + const host = process.argv[2] ?? "0.0.0.0"; const port = Number(process.argv[3]) || 4000; app.listen(port, host, () => { @@ -65,19 +68,130 @@ With our middleware file set up, we can use `ts-node-dev` to run our application } ``` +## Configuration Options + +The `createServer` function accepts a second parameter for configuring various middleware options: + +```ts [src/index.ts] +const app = await createServer( + { integrations: config.integrations }, + { + // CORS configuration + cors: { + origin: "http://localhost:3000", + credentials: true, + }, + // Body parser configuration + bodyParser: { + limit: "50mb", + }, + // Cookie parser configuration + cookieParser: { + secret: "secret", + }, + // File upload configuration + fileUpload: { + enabled: true, // Enable/disable file upload functionality + maxFileSize: 5242880, // Maximum file size in bytes (default: 5MB) + maxFiles: 5, // Maximum number of files per upload + allowedMimeTypes: ["image/*", "application/pdf"], // Allowed file types + fieldNames: [], // Accepted form field names for file uploads + }, + } +); +``` + +### CORS Configuration + +Configure Cross-Origin Resource Sharing (CORS) settings. By default, `http://localhost:4000` is included in the allowed origins. + +### Body Parser Configuration +Configure the body-parser middleware settings for handling request bodies. + +### Cookie Parser Configuration + +Configure the cookie-parser middleware settings for handling cookies. + +### File Upload Configuration + +Configure file upload handling for `multipart/form-data` requests. You can either provide static options or a function that returns configuration based on the request: + +```ts +fileUpload: (req) => ({ + enabled: req.headers["x-enable-upload"] === "true", + maxFileSize: 5242880, + maxFiles: 5, + allowedMimeTypes: ["image/*", "application/pdf"], + fieldNames: [], +}); +``` + +Available options: + +- `enabled`: Enable/disable file upload functionality (default: `true`) +- `maxFileSize`: Maximum file size in bytes (default: 5MB) // Maximum file size is limited to 10MB +- `maxFiles`: Maximum number of files per upload (default: 5) +- `allowedMimeTypes`: Array of allowed MIME types (default: `["image/*", "application/pdf"]`) +- `fieldNames`: Array of accepted form field names for file uploads (default: `[]`) + +When file uploads are enabled, uploaded files are available in the `req.files` object within your API endpoints: + +```ts +export const upload = (context) => { + const { files } = context.req; + // Handle uploaded files + return Promise.resolve({ + status: 200, + message: "ok", + }); +}; +``` + +You can also dynamically control file upload behavior on a per-request basis. This is particularly useful when you want to enable uploads only for specific scenarios, such as: + +- Authenticated requests +- Requests with specific headers +- Requests from certain origins +- Different file size limits for different endpoints + +Here's an example of dynamic configuration based on request headers: + +```ts [src/index.ts] +const app = await createServer( + { integrations: config.integrations }, + { + fileUpload: (req) => ({ + enabled: req.headers["x-enable-upload"] === "true", + maxFileSize: req.headers["x-upload-size"] + ? parseInt(req.headers["x-upload-size"]) + : 5242880, + maxFiles: 5, + allowedMimeTypes: ["image/*", "application/pdf"], + fieldNames: [], + }), + } +); +``` + +In this example: + +- File uploads are only enabled when the `x-enable-upload: true` header is present +- The maximum file size can be controlled via the `x-upload-size` header +- Other options remain static but could also be made dynamic based on your needs ## Adding Integrations -Integrations contain code extend the middleware with additional functionality to make it easy to work with different third-party services. +Integrations contain code extend the middleware with additional functionality to make it easy to work with different third-party services. Alokai has available integrations that you can use out of the box, but you can also create your own integrations to connect to any service that you need. Most integrations are made up of two parts: + 1. An SDK module that extends the Alokai SDK to add methods to your frontend 2. An API Client that extends the middleware to add new API endpoints or modify the Express.js application itself -We recommend creating a `middleware.config.js` file located at the project's root that you can use to configure API Clients for your integrations. +We recommend creating a `middleware.config.js` file located at the project's root that you can use to configure API Clients for your integrations. Each integration will be an object with a unique key that you can find in each integration's installation guide. This key is used for communication with the middleware, so changing it might cause the integration to break. @@ -91,7 +205,7 @@ The object for each integration can contain the following properties: ```ts [middleware.config.ts] export const integrations = { example: { - location: '@vue-storefront/example-api/server', + location: "@vue-storefront/example-api/server", configuration: { // configuration for the integration (see the integration's installation guide for details) }, @@ -99,9 +213,9 @@ export const integrations = { ...baseExtensions, // your additional extensions ], - customQueries: {} - } -} + customQueries: {}, + }, +}; ``` ## Local Development @@ -112,7 +226,7 @@ If you want to have the same `tsconfig.json` options as our boilerplate, you can ### Using `nodemon` -To make local development smoother, you can use [`nodemon`](https://www.npmjs.com/package/nodemon) to watch for changes in your middleware application and restart the server automatically. +To make local development smoother, you can use [`nodemon`](https://www.npmjs.com/package/nodemon) to watch for changes in your middleware application and restart the server automatically. ```sh npm i -D nodemon @@ -126,7 +240,6 @@ Finally, we can create a `nodemon.json` file to set the files to watch and the c "ext": "ts", "exec": "ts-node-dev src/index.ts" } - ``` Then, you can add a script to your `package.json` file to run the middleware with `nodemon`. @@ -142,4 +255,3 @@ Then, you can add a script to your `package.json` file to run the middleware wit ## Next Steps :card{to="/middleware/guides/extensions" title="Creating Extensions" description="Use extensions to customize or extend the middleware to match your needs." icon="gridicons:customize"} - diff --git a/packages/middleware/__tests__/integration/bootstrap/api/index.ts b/packages/middleware/__tests__/integration/bootstrap/api/index.ts index 78ad801130..9ae51edfca 100644 --- a/packages/middleware/__tests__/integration/bootstrap/api/index.ts +++ b/packages/middleware/__tests__/integration/bootstrap/api/index.ts @@ -5,3 +5,4 @@ export { error } from "./error"; export { throwAxiosError } from "./throwAxiosError"; export { throwError } from "./throwError"; export { getConfig } from "./getConfig"; +export { upload } from "./upload"; diff --git a/packages/middleware/__tests__/integration/bootstrap/api/upload/index.ts b/packages/middleware/__tests__/integration/bootstrap/api/upload/index.ts new file mode 100644 index 0000000000..6d42c1a72c --- /dev/null +++ b/packages/middleware/__tests__/integration/bootstrap/api/upload/index.ts @@ -0,0 +1,10 @@ +export const upload = (context) => { + const { files } = context.req; + + return Promise.resolve({ + status: 200, + message: "ok", + error: false, + files, + }); +}; diff --git a/packages/middleware/__tests__/integration/bootstrap/serverWithUpload.ts b/packages/middleware/__tests__/integration/bootstrap/serverWithUpload.ts new file mode 100644 index 0000000000..4619134fee --- /dev/null +++ b/packages/middleware/__tests__/integration/bootstrap/serverWithUpload.ts @@ -0,0 +1,16 @@ +import { apiClientFactory } from "../../../src/apiClientFactory"; +import * as api from "./api"; + +const onCreate = async (config: Record = {}) => { + return { + config, + client: null, + }; +}; + +const { createApiClient } = apiClientFactory({ + onCreate, + api, +}); + +export { createApiClient }; diff --git a/packages/middleware/__tests__/integration/loggerScopes.spec.ts b/packages/middleware/__tests__/integration/loggerScopes.spec.ts index c0387558c3..40d04c5d86 100644 --- a/packages/middleware/__tests__/integration/loggerScopes.spec.ts +++ b/packages/middleware/__tests__/integration/loggerScopes.spec.ts @@ -67,7 +67,7 @@ const testingExtension = { ]); }, }, - hooks(req, res, alokai) { + hooks(_req, _res, alokai) { const logger = getLogger(alokai); logger.info("hooks"); return { diff --git a/packages/middleware/__tests__/integration/upload.spec.ts b/packages/middleware/__tests__/integration/upload.spec.ts new file mode 100644 index 0000000000..c9524d4e4f --- /dev/null +++ b/packages/middleware/__tests__/integration/upload.spec.ts @@ -0,0 +1,121 @@ +import { Server } from "http"; +import request from "supertest"; +import { createServer } from "../../src/index"; + +describe("[Integration] Create server", () => { + let app: Server; + + const getServer = async (isUploadEnabled: boolean = true) => { + return await createServer( + { + integrations: { + test_integration: { + location: "./__tests__/integration/bootstrap/serverWithUpload", + configuration: {}, + }, + }, + }, + { + fileUpload: { + enabled: isUploadEnabled, + }, + } + ); + }; + + it("should allow to upload files if fileUpload is enabled", async () => { + app = await getServer(); + + const { status, body } = await request(app) + .post("/test_integration/upload") + .set("Content-Type", "multipart/form-data") + .attach("files", Buffer.from("test file content"), "test.jpg") + .expect(200); + + expect(status).toBe(200); + expect(body).toBeDefined(); + expect(body.files).toEqual([ + { + fieldname: "files", + originalname: "test.jpg", + encoding: "7bit", + mimetype: "image/jpeg", + buffer: expect.any(Object), + size: 17, + }, + ]); + }); + + it("should not allow to upload files if fileUpload is disabled", async () => { + app = await getServer(false); + + const { status, body } = await request(app) + .post("/test_integration/upload") + .set("Content-Type", "multipart/form-data") + .expect(200); + + expect(status).toBe(200); + expect(body).toBeDefined(); + expect(body.files).toBeUndefined(); + }); + + it("should allow sending non multipart/form-data requests when fileUpload is enabled", async () => { + app = await getServer(); + + const { body } = await request(app) + .post("/test_integration/upload") + .expect(200); + + expect(body).toBeDefined(); + expect(body.files).toBeUndefined(); + expect(body.message).toBe("ok"); + }); + + // add test for options provided from the request + it("should allow to provide options from the request", async () => { + app = await createServer( + { + integrations: { + test_integration: { + location: "./__tests__/integration/bootstrap/serverWithUpload", + configuration: {}, + }, + }, + }, + { + fileUpload: (req) => ({ + enabled: req.headers["x-enable-upload"] === "true", + }), + } + ); + + // Test with uploads enabled via header + const uploadEnabled = await request(app) + .post("/test_integration/upload") + .set("Content-Type", "multipart/form-data") + .set("x-enable-upload", "true") + .attach("files", Buffer.from("test file content"), "test.jpg") + .expect(200); + + expect(uploadEnabled.body.files).toEqual([ + { + fieldname: "files", + originalname: "test.jpg", + encoding: "7bit", + mimetype: "image/jpeg", + buffer: expect.any(Object), + size: 17, + }, + ]); + + // Test with uploads disabled via header + const uploadDisabled = await request(app) + .post("/test_integration/upload") + .set("Content-Type", "multipart/form-data") + .set("x-enable-upload", "false") + .attach("files", Buffer.from("test file content"), "test.jpg") + .expect(200); + + expect(uploadDisabled.body.files).toBeUndefined(); + }); +}); diff --git a/packages/middleware/__tests__/unit/services/fileUpload.spec.ts b/packages/middleware/__tests__/unit/services/fileUpload.spec.ts new file mode 100644 index 0000000000..c216154374 --- /dev/null +++ b/packages/middleware/__tests__/unit/services/fileUpload.spec.ts @@ -0,0 +1,129 @@ +import express from "express"; +import multer from "multer"; +import { createMulterMiddleware } from "../../../src/services/fileUpload"; + +jest.mock("multer", () => { + const mockMulter = jest.fn(() => ({ + fields: jest.fn(), + any: jest.fn(), + })) as jest.Mock & { memoryStorage: jest.Mock }; + mockMulter.memoryStorage = jest.fn(); + return mockMulter; +}); + +describe("configureFileUpload", () => { + let app: express.Express; + + beforeEach(() => { + app = express(); + app.use = jest.fn(); + jest.clearAllMocks(); + }); + + it("should not configure upload when enabled is false", () => { + const middleware = createMulterMiddleware({ enabled: false }); + expect(middleware).toBeUndefined(); + expect(multer).not.toHaveBeenCalled(); + }); + + it("should configure upload with default options when enabled", () => { + const mockStorage = {}; + (multer.memoryStorage as jest.Mock).mockReturnValue(mockStorage); + + createMulterMiddleware({ enabled: true }); + + expect(multer.memoryStorage).toHaveBeenCalled(); + expect(multer).toHaveBeenCalledWith( + expect.objectContaining({ + storage: mockStorage, + limits: { + fileSize: 5 * 1024 * 1024, + files: 5, + }, + }) + ); + }); + + it("should configure upload with custom options", () => { + const customOptions = { + enabled: true, + maxFileSize: 10 * 1024 * 1024, + maxFiles: 3, + allowedMimeTypes: ["image/jpeg"], + }; + + createMulterMiddleware(customOptions); + + expect(multer).toHaveBeenCalledWith( + expect.objectContaining({ + limits: { + fileSize: 10 * 1024 * 1024, + files: 3, + }, + }) + ); + }); + + it("should use fields() when fieldNames are provided", () => { + createMulterMiddleware({ + enabled: true, + fieldNames: ["avatar", "document"], + }); + + const expectedFields = [ + { name: "avatar", maxCount: 1 }, + { name: "document", maxCount: 1 }, + ]; + + const multerInstance = (multer as unknown as jest.Mock).mock.results[0] + .value; + expect(multerInstance.fields).toHaveBeenCalledWith(expectedFields); + }); + + it("should use any() when no fieldNames are provided", () => { + createMulterMiddleware({ enabled: true }); + + const multerInstance = (multer as unknown as jest.Mock).mock.results[0] + .value; + expect(multerInstance.any).toHaveBeenCalled(); + }); + + describe("fileFilter", () => { + let fileFilter: (req: any, file: any, cb: any) => void; + + beforeEach(() => { + createMulterMiddleware({ + enabled: true, + allowedMimeTypes: ["image/*", "application/pdf"], + }); + fileFilter = (multer as unknown as jest.Mock).mock.calls[0][0].fileFilter; + }); + + it("should accept files when no mime types are specified", () => { + const cb = jest.fn(); + createMulterMiddleware({ enabled: true, allowedMimeTypes: [] }); + fileFilter = (multer as unknown as jest.Mock).mock.calls[1][0].fileFilter; + + fileFilter(null, { mimetype: "anything" }, cb); + expect(cb).toHaveBeenCalledWith(null, true); + }); + + it("should accept files matching exact mime type", () => { + const cb = jest.fn(); + fileFilter(null, { mimetype: "application/pdf" }, cb); + expect(cb).toHaveBeenCalledWith(null, true); + }); + + it("should accept files matching wildcard mime type", () => { + const cb = jest.fn(); + fileFilter(null, { mimetype: "image/jpeg" }, cb); + expect(cb).toHaveBeenCalledWith(null, true); + }); + + it("should reject files with unallowed mime types", () => { + const cb = jest.fn(); + fileFilter(null, { mimetype: "text/plain" }, cb); + expect(cb).toHaveBeenCalledWith(null, false); + }); + }); +}); diff --git a/packages/middleware/package.json b/packages/middleware/package.json index e077bceac7..1b69bd6b15 100644 --- a/packages/middleware/package.json +++ b/packages/middleware/package.json @@ -27,7 +27,9 @@ "helmet": "^5.1.1", "@godaddy/terminus": "^4.12.1", "lodash.merge": "^4.6.2", - "xss": "^1.0.15" + "xss": "^1.0.15", + "multer": "^1.4.5-lts.1", + "@types/multer": "^1.4.7" }, "devDependencies": { "@types/body-parser": "^1.19.2", diff --git a/packages/middleware/src/createServer.ts b/packages/middleware/src/createServer.ts index 2c4c1f223a..2a11d4a862 100644 --- a/packages/middleware/src/createServer.ts +++ b/packages/middleware/src/createServer.ts @@ -15,7 +15,7 @@ import type { CreateServerOptions, } from "./types"; import { LoggerManager, injectMetadata, lockLogger } from "./logger"; - +import { prepareFileUpload } from "./handlers/prepareFileUpload"; import { prepareApiFunction, prepareErrorHandler, @@ -50,6 +50,7 @@ async function createServer< const app = express(); + // app.use(createMulterMiddleware(options.fileUpload)); app.use(express.json(options.bodyParser)); app.use( options.cookieParser @@ -88,6 +89,7 @@ async function createServer< app.all( "/:integrationName/:extensionName?/:functionName", validateParams(integrations), + prepareFileUpload(options), prepareLogger(loggerManager), prepareApiFunction(integrations), prepareErrorHandler(integrations), diff --git a/packages/middleware/src/errors/defaultErrorHandler.ts b/packages/middleware/src/errors/defaultErrorHandler.ts index 2ea33e53c4..8f9cdba261 100644 --- a/packages/middleware/src/errors/defaultErrorHandler.ts +++ b/packages/middleware/src/errors/defaultErrorHandler.ts @@ -1,6 +1,6 @@ import type { Request } from "express"; import { getAgnosticStatusCode } from "../helpers"; -import type { ResponseWithAlokaiLocals } from "../types"; +import type { AlokaiResponse } from "../types"; type ClientSideError = { message?: string; @@ -16,7 +16,7 @@ type ClientSideError = { export const defaultErrorHandler = ( error: ClientSideError, req: Request, - res: ResponseWithAlokaiLocals + res: AlokaiResponse ) => { const status = getAgnosticStatusCode(error); res.status(status); diff --git a/packages/middleware/src/handlers/callApiFunction/index.ts b/packages/middleware/src/handlers/callApiFunction/index.ts index 22ecc6d95d..69373da07f 100644 --- a/packages/middleware/src/handlers/callApiFunction/index.ts +++ b/packages/middleware/src/handlers/callApiFunction/index.ts @@ -1,12 +1,9 @@ import type { Request } from "express"; import { LogScope } from "../../types"; import { getLogger, injectMetadata } from "../../logger"; -import type { ResponseWithAlokaiLocals } from "../../types"; +import type { AlokaiResponse } from "../../types"; -export async function callApiFunction( - req: Request, - res: ResponseWithAlokaiLocals -) { +export async function callApiFunction(req: Request, res: AlokaiResponse) { const { apiFunction, args, errorHandler } = res.locals; try { diff --git a/packages/middleware/src/handlers/prepareArguments/index.ts b/packages/middleware/src/handlers/prepareArguments/index.ts index 71a12492b0..218c773a81 100644 --- a/packages/middleware/src/handlers/prepareArguments/index.ts +++ b/packages/middleware/src/handlers/prepareArguments/index.ts @@ -1,9 +1,9 @@ import type { Request, NextFunction } from "express"; -import type { ResponseWithAlokaiLocals } from "../../types"; +import type { AlokaiResponse } from "../../types"; export function prepareArguments( req: Request, - res: ResponseWithAlokaiLocals, + res: AlokaiResponse, next: NextFunction ) { const { method, query, body } = req; diff --git a/packages/middleware/src/handlers/prepareFileUpload/index.ts b/packages/middleware/src/handlers/prepareFileUpload/index.ts new file mode 100644 index 0000000000..89b62f436e --- /dev/null +++ b/packages/middleware/src/handlers/prepareFileUpload/index.ts @@ -0,0 +1,30 @@ +import type { RequestHandler } from "express"; +import { createMulterMiddleware } from "../../services/fileUpload"; +import type { CreateServerOptions } from "../../types"; + +/** + * Prepare file upload middleware + * Resolves file upload options from the request or server options + * and creates multer middleware. + * + * @param options - Server options + * @returns Request handler + */ +export const prepareFileUpload = ( + options: CreateServerOptions +): RequestHandler => { + return (req, res, next) => { + const fileUploadOptions = + typeof options.fileUpload === "function" + ? options.fileUpload(req) + : options.fileUpload; + + if (!fileUploadOptions?.enabled) { + return next(); + } + + const multerMiddleware = createMulterMiddleware(fileUploadOptions); + + return multerMiddleware(req, res, next); + }; +}; diff --git a/packages/middleware/src/handlers/prepareLogger/index.ts b/packages/middleware/src/handlers/prepareLogger/index.ts index 8f24a307d3..0d40199782 100644 --- a/packages/middleware/src/handlers/prepareLogger/index.ts +++ b/packages/middleware/src/handlers/prepareLogger/index.ts @@ -1,13 +1,9 @@ import type { Request, NextFunction } from "express"; import { LoggerManager, injectMetadata } from "../../logger"; -import { ResponseWithAlokaiLocals } from "../../types"; +import { AlokaiResponse } from "../../types"; export function prepareLogger(loggerManager: LoggerManager) { - return function ( - req: Request, - res: ResponseWithAlokaiLocals, - next: NextFunction - ) { + return function (req: Request, res: AlokaiResponse, next: NextFunction) { if (!res.locals) { res.locals = {}; } diff --git a/packages/middleware/src/logger/getLogger.ts b/packages/middleware/src/logger/getLogger.ts index 93c30ee4d4..bc7ef94c1b 100644 --- a/packages/middleware/src/logger/getLogger.ts +++ b/packages/middleware/src/logger/getLogger.ts @@ -1,9 +1,9 @@ import type { LoggerInterface } from "@vue-storefront/logger"; -import type { AlokaiContainer, ResponseWithAlokaiLocals } from "../types"; +import type { AlokaiContainer, AlokaiResponse } from "../types"; import { fallbackLogger } from "./fallbackLogger"; -type ContextSubset = { res: ResponseWithAlokaiLocals }; -type LoggerSource = AlokaiContainer | ResponseWithAlokaiLocals | ContextSubset; +type ContextSubset = { res: AlokaiResponse }; +type LoggerSource = AlokaiContainer | AlokaiResponse | ContextSubset; function isAlokaiContainer(source: LoggerSource): source is AlokaiContainer { return "logger" in source; } diff --git a/packages/middleware/src/services/fileUpload.ts b/packages/middleware/src/services/fileUpload.ts new file mode 100644 index 0000000000..b10075302f --- /dev/null +++ b/packages/middleware/src/services/fileUpload.ts @@ -0,0 +1,56 @@ +import multer from "multer"; +import { RequestHandler } from "express"; +import type { FileUploadOptions } from "../types"; + +const DEFAULT_OPTIONS: FileUploadOptions = { + enabled: true, + maxFileSize: 5 * 1024 * 1024, // 5MB + maxFiles: 5, + allowedMimeTypes: ["image/*", "application/pdf"], +}; + +export function createMulterMiddleware( + options?: FileUploadOptions +): RequestHandler | undefined { + const config = { ...DEFAULT_OPTIONS, ...options }; + + if (!config.enabled) { + return undefined; + } + + const storage = multer.memoryStorage(); + + const maxFileSizeLimit = Math.min( + config.maxFileSize, + 10 * 1024 * 1024 // 10MB absolute maximum + ); + + const upload = multer({ + storage, + limits: { + fileSize: maxFileSizeLimit, + files: config.maxFiles, + }, + fileFilter: (_req, file, cb) => { + if (!config.allowedMimeTypes?.length) { + return cb(null, true); + } + + const isAllowed = config.allowedMimeTypes.some((type) => { + if (type.endsWith("/*")) { + const mainType = type.split("/")[0]; + return file.mimetype.startsWith(`${mainType}/`); + } + return type === file.mimetype; + }); + + cb(null, isAllowed); + }, + }); + + if (config.fieldNames?.length) { + const fields = config.fieldNames.map((name) => ({ name, maxCount: 1 })); + return upload.fields(fields); + } + return upload.any(); +} diff --git a/packages/middleware/src/types/common.ts b/packages/middleware/src/types/common.ts index 782378aaee..6835828058 100644 --- a/packages/middleware/src/types/common.ts +++ b/packages/middleware/src/types/common.ts @@ -8,21 +8,30 @@ import { CustomQueryFunction, TObject, } from "./base"; -import { WithRequired } from "./index"; +import { UploadedFile, WithRequired } from "./index"; import { ApiClient, ApiClientConfig, ApiClientFactory } from "./server"; -export type ResponseWithAlokaiLocals = Response< +export type AlokaiResponse = Response< any, { alokai?: { logger: LoggerInterface; }; - apiFunction?: Function; + apiFunction?: (...args: any[]) => any; fnOrigin?: string; [key: string]: any; } >; +export type AlokaiRequest = Request & { + files?: + | { + [fieldname: string]: UploadedFile[]; + } + | UploadedFile[] + | undefined; +}; + export type ExtensionEndpointHandler = ApiClientMethod & { _extensionName?: string; }; @@ -96,8 +105,8 @@ export interface ApiClientExtension { logger: LoggerInterface; }) => Promise | void; hooks?: ( - req: Request, - res: ResponseWithAlokaiLocals, + req: AlokaiRequest, + res: AlokaiResponse, hooksContext: AlokaiContainer ) => ApiClientExtensionHooks; } @@ -119,8 +128,8 @@ export interface Integration< initConfig?: TObject; errorHandler?: ( error: unknown, - req: Request, - res: ResponseWithAlokaiLocals + req: AlokaiRequest, + res: AlokaiResponse ) => void; } @@ -138,11 +147,7 @@ export interface IntegrationLoaded< configuration: CONFIG; extensions: ApiClientExtension[]; customQueries?: Record; - errorHandler: ( - error: unknown, - req: Request, - res: ResponseWithAlokaiLocals - ) => void; + errorHandler: (error: unknown, req: Request, res: AlokaiResponse) => void; } export interface LoadInitConfigProps { @@ -158,8 +163,8 @@ export type IntegrationsLoaded< > = Record>; export interface MiddlewareContext { - req: Request; - res: ResponseWithAlokaiLocals; + req: AlokaiRequest; + res: AlokaiResponse; extensions: ApiClientExtension[]; customQueries: Record; integrations: IntegrationsLoaded; diff --git a/packages/middleware/src/types/fileUpload.ts b/packages/middleware/src/types/fileUpload.ts new file mode 100644 index 0000000000..7ba87325fa --- /dev/null +++ b/packages/middleware/src/types/fileUpload.ts @@ -0,0 +1,75 @@ +import { IncomingHttpHeaders } from "node:http"; + +/** + * Options for file upload middleware + */ +export interface FileUploadOptions { + /** + * Whether file upload is enabled + * @default true + */ + enabled?: boolean; + /** + * Maximum file size in bytes + * @default 5MB + */ + maxFileSize?: number; + /** + * Maximum number of files + * @default 5 + */ + maxFiles?: number; + /** + * Allowed MIME types + * @default ["image/*", "application/pdf"] + */ + allowedMimeTypes?: string[]; + /** + * Specific field names to accept + * @default [] + */ + fieldNames?: string[]; +} +/** + * Request object for file upload middleware + */ +export interface FileUploadRequest { + /** + * Request headers + */ + headers: IncomingHttpHeaders; +} + +/** + * Uploaded file object + */ +export interface UploadedFile { + /** + * Field name + * Identifies the field name of the file + */ + fieldname: string; + /** + * Original file name + */ + originalname: string; + /** + * Encoding + * eg. 7bit, 8bit + */ + encoding: string; + /** + * MIME type + * eg. image/jpeg, application/pdf + */ + mimetype: string; + /** + * File size in bytes + */ + size: number; + /** + * File buffer + * @see https://nodejs.org/api/buffer.html + */ + buffer: Buffer; +} diff --git a/packages/middleware/src/types/index.ts b/packages/middleware/src/types/index.ts index 1d3037c3ce..4d492a9ed5 100644 --- a/packages/middleware/src/types/index.ts +++ b/packages/middleware/src/types/index.ts @@ -39,3 +39,4 @@ export * from "./base"; export * from "./common"; export * from "./server"; export * from "./config"; +export * from "./fileUpload"; diff --git a/packages/middleware/src/types/server.ts b/packages/middleware/src/types/server.ts index e956f0b76a..3a6b132b3f 100644 --- a/packages/middleware/src/types/server.ts +++ b/packages/middleware/src/types/server.ts @@ -9,6 +9,7 @@ import { ApiMethodsFactory, MiddlewareContext, } from "./common"; +import { FileUploadOptions, FileUploadRequest } from "./fileUpload"; export interface ClientContext { client: CLIENT; @@ -173,4 +174,12 @@ export interface CreateServerOptions { * @see https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-readiness-probes */ readinessProbes?: ReadinessProbe[]; + + /** + * Configuration options for handling file uploads. + * @see FileUploadOptions + */ + fileUpload?: + | FileUploadOptions + | ((req: FileUploadRequest) => FileUploadOptions); } diff --git a/yarn.lock b/yarn.lock index 36bdf6f63a..6dc83ebb59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2370,6 +2370,13 @@ dependencies: "@types/node" "*" +"@types/multer@^1.4.7": + version "1.4.12" + resolved "https://registrynpm.storefrontcloud.io/@types/multer/-/multer-1.4.12.tgz#da67bd0c809f3a63fe097c458c0d4af1fea50ab7" + integrity sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg== + dependencies: + "@types/express" "*" + "@types/node@*": version "18.0.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.0.1.tgz#e91bd73239b338557a84d1f67f7b9e0f25643870" @@ -2883,6 +2890,11 @@ anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +append-field@^1.0.0: + version "1.0.0" + resolved "https://registrynpm.storefrontcloud.io/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" + integrity sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY= + "aproba@^1.0.3 || ^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" @@ -3813,6 +3825,13 @@ builtins@^5.0.0: dependencies: semver "^7.0.0" +busboy@^1.0.0: + version "1.6.0" + resolved "https://registrynpm.storefrontcloud.io/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + byline@5.x: version "5.0.0" resolved "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz#741c5216468eadc457b03410118ad77de8c1ddb1" @@ -4321,6 +4340,16 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +concat-stream@^1.5.2: + version "1.6.2" + resolved "https://registrynpm.storefrontcloud.io/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + concurrently@^7.6.0: version "7.6.0" resolved "https://registry.npmjs.org/concurrently/-/concurrently-7.6.0.tgz#531a6f5f30cf616f355a4afb8f8fcb2bba65a49a" @@ -8921,7 +8950,7 @@ mkdirp-infer-owner@^2.0.0: infer-owner "^1.0.4" mkdirp "^1.0.3" -mkdirp@^0.5.1: +mkdirp@^0.5.1, mkdirp@^0.5.4: version "0.5.6" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== @@ -8963,6 +8992,19 @@ ms@2.1.3, ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +multer@^1.4.5-lts.1: + version "1.4.5-lts.1" + resolved "https://registrynpm.storefrontcloud.io/multer/-/multer-1.4.5-lts.1.tgz#803e24ad1984f58edffbc79f56e305aec5cfd1ac" + integrity sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ== + dependencies: + append-field "^1.0.0" + busboy "^1.0.0" + concat-stream "^1.5.2" + mkdirp "^0.5.4" + object-assign "^4.1.1" + type-is "^1.6.4" + xtend "^4.0.0" + multimatch@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-5.0.0.tgz#932b800963cea7a31a033328fa1e0c3a1874dbe6" @@ -10150,7 +10192,7 @@ read-yaml-file@^1.1.0: pify "^4.0.1" strip-bom "^3.0.0" -readable-stream@^2.0.2, readable-stream@^2.3.5: +readable-stream@^2.0.2, readable-stream@^2.2.2, readable-stream@^2.3.5: version "2.3.8" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== @@ -11041,6 +11083,11 @@ stream-transform@^2.1.3: dependencies: mixme "^0.5.1" +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registrynpm.storefrontcloud.io/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + string-argv@0.3.2, string-argv@~0.3.1: version "0.3.2" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" @@ -11756,7 +11803,7 @@ type-fest@^1.0.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== -type-is@~1.6.18: +type-is@^1.6.4, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== @@ -11847,6 +11894,11 @@ typed-array-length@^1.0.6: is-typed-array "^1.1.13" possible-typed-array-names "^1.0.0" +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registrynpm.storefrontcloud.io/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= + typescript-eslint@8.13.0: version "8.13.0" resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.13.0.tgz#c7d92cc06188176c7d0e3825e10305b9c22fb102"