From a0684c1f05577116d333fe902850312fd3ea1548 Mon Sep 17 00:00:00 2001 From: Tim Fabian Date: Fri, 5 Dec 2025 10:47:15 +0100 Subject: [PATCH] added http client --- .gitignore | 3 +- README.md | 2 +- eslint.config.mjs | 2 +- package-lock.json | 536 +++++++++--------- package.json | 14 +- sandbox/src/data-sources/db/db.data-source.ts | 3 +- .../mocks/entities/company.entity.ts | 3 +- src/application.ts | 2 +- .../2fa/methods/otp/otp-credentials.model.ts | 3 +- src/auth/auth-service.interface.ts | 2 +- src/auth/auth.service.ts | 2 +- src/auth/decorators/belongs-to.decorator.ts | 2 +- src/auth/models/base-user.model.ts | 3 +- src/auth/models/belongs-to-metadata.model.ts | 2 +- .../strategies/auth-strategy.interface.ts | 2 +- .../strategies/jwt/jwt-credentials.model.ts | 3 +- .../strategies/jwt/jwt-refresh-token.model.ts | 3 +- src/auth/strategies/jwt/jwt.auth-strategy.ts | 2 +- src/backup/backup-entity.model.ts | 3 +- src/backup/backup-resource-entity.model.ts | 3 +- src/backup/backup-service.test.ts | 3 +- .../models/change-set-entity.model.ts | 2 +- src/change-sets/models/change-set.model.ts | 3 +- src/change-sets/models/change.model.ts | 3 +- src/cron/cron-job-entity.model.ts | 3 +- src/data-source/base-data-source.model.ts | 3 +- src/data-source/cascade-delete.test.ts | 2 +- src/data-source/data-source.service.ts | 2 +- .../migration/migration-entity.model.ts | 3 +- src/data-source/migration/migration.model.ts | 6 +- src/data-source/migration/migration.test.ts | 3 +- .../options/delete-all-options.model.ts | 2 +- .../models/options/find-all-options.model.ts | 2 +- .../find-all-paginated-options.model.ts | 2 +- .../options/find-by-id-options.model.ts | 2 +- .../models/options/find-one-options.model.ts | 2 +- ...e-filter-to-find-options-where.function.ts | 3 +- src/data-source/repository.ts | 4 +- .../transaction/transaction.test.ts | 3 +- .../decorators/inject-repository.decorator.ts | 2 +- src/di/default/zibri-di-providers.default.ts | 7 +- src/di/default/zibri-di-tokens.default.ts | 3 +- .../models/mailing-list-subscriber.model.ts | 3 +- ...t-subscription-confirmation-token.model.ts | 3 +- .../mailing-list/models/mailing-list.model.ts | 3 +- src/email/models/email.model.ts | 3 +- src/entity/decorators/property.decorator.ts | 8 +- src/entity/index.ts | 3 +- .../models/array-property-metadata.model.ts | 6 +- .../models/file-property-metadata.model.ts | 11 +- src/entity/models/index.ts | 1 - .../errors/content-too-large.error.ts | 12 + src/error-handling/errors/index.ts | 3 +- .../errors/missing-entities.error.ts | 2 +- src/error-handling/errors/validation.error.ts | 2 +- src/global/global-registry.ts | 2 +- src/http-client/http-client-response.model.ts | 49 ++ src/http-client/http-client.interface.ts | 171 ++++++ src/http-client/http-client.test.ts | 119 ++++ src/http-client/http-client.ts | 270 +++++++++ src/http-client/index.ts | 3 + src/http/http-status.enum.ts | 2 +- src/index.ts | 3 + src/logging/log.model.ts | 3 +- .../models/thread-job-entity.model.ts | 3 +- src/open-api/open-api.service.ts | 9 +- src/parsing/body-parser.interface.ts | 11 +- src/parsing/form-data/file.model.ts | 11 +- .../form-data/form-data.body-parser.ts | 318 ++++++++--- src/parsing/functions/parse-array.test.ts | 3 +- src/parsing/json/json.body-parser.ts | 84 ++- src/parsing/parser.interface.ts | 10 +- src/parsing/parser.ts | 34 +- src/plugin/invoicing/models/invoice.model.ts | 3 +- .../invoicing/models/number-invoices.model.ts | 3 +- .../peppol-conformance.service.test.ts | 2 +- .../x-rechnung-conformance.service.test.ts | 2 +- .../services/invoice-number.service.test.ts | 2 +- .../services/invoice-pdf.service.test.ts | 2 +- src/routing/decorators/body.decorator.ts | 67 ++- src/routing/models/crud-controller.model.ts | 2 +- src/routing/param-metdata.helpers.ts | 2 + src/routing/resolve-route-params.function.ts | 4 +- src/routing/route-configuration.model.ts | 4 +- src/routing/router.ts | 8 +- src/utilities/metadata.utilities.ts | 3 +- .../validate-entities-registered.function.ts | 2 +- .../functions/validate-file.function.ts | 6 +- src/validation/validation-problem.model.ts | 4 +- .../validation-service.interface.ts | 4 +- src/validation/validation.service.ts | 5 +- .../decorators/websocket-body.decorator.ts | 13 +- .../models/websocket-channel.model.ts | 3 +- .../models/websocket-message.model.ts | 3 +- 94 files changed, 1453 insertions(+), 516 deletions(-) create mode 100644 src/error-handling/errors/content-too-large.error.ts create mode 100644 src/http-client/http-client-response.model.ts create mode 100644 src/http-client/http-client.interface.ts create mode 100644 src/http-client/http-client.test.ts create mode 100644 src/http-client/http-client.ts create mode 100644 src/http-client/index.ts diff --git a/.gitignore b/.gitignore index efbf220..9a6ace3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ coverage *.hbs.ts docs/generated file-output -.vscode \ No newline at end of file +.vscode +src/di/default/temp \ No newline at end of file diff --git a/README.md b/README.md index f881207..d0a613c 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ With Zibri you can rely on a strong foundation of battle tested libraries that h - typeorm for handling everything database related - nodemailer for sending emails - handlebars for templating -- multer for file uploads +- busboy for file uploads (the foundation of multer) ### 🪖 Making "shooting your foot" as hard as possible All the public facing classes, interfaces and functions are as strictly typed as possible. Zibri even includes a custom parser for handlebar files, that automtically infer the type of the data that is needed to render the template. diff --git a/eslint.config.mjs b/eslint.config.mjs index c6c3cd5..243ffbc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,7 @@ import { configs } from 'eslint-config-service-soft'; /** @type {import('eslint').Linter.Config} */ export default [ ...configs, - { ignores: ['tsconfig.json', 'tsup.config.ts', 'sandbox', 'docs'] }, + { ignores: ['tsconfig.json', 'tsup.config.ts', 'sandbox', 'docs', 'src/di/default/temp'] }, { files: ['**/__testing__/**/*.ts'], rules: { diff --git a/package-lock.json b/package-lock.json index d47db2e..c727db5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "zibri", - "version": "2.0.1", + "version": "2.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zibri", - "version": "2.0.1", + "version": "2.0.2", "license": "MIT", "dependencies": { + "@fastify/busboy": "^3.2.0", "cors": "^2.8.5", "express": "^5.1.0", - "glob": "^11.0.3", - "multer": "^2.0.2", + "glob": "^13.0.0", "node-cron": "^4.2.1", "nodemailer": "^7.0.6", "pg": "^8.16.3", @@ -30,7 +30,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/jsonwebtoken": "^9.0.10", - "@types/multer": "^2.0.0", "@types/node": "^24.5.2", "@types/nodemailer": "^7.0.1", "@types/pdfmake": "^0.2.11", @@ -49,6 +48,7 @@ "node": ">=20" }, "peerDependencies": { + "axios": "^1.13.2", "bcryptjs": "^3.0.2", "bignumber.js": "^9.3.1", "handlebars": "^4.7.8", @@ -60,7 +60,7 @@ "socket.io": "^4.8.1", "ts-node": "^10.9.2", "uuid": "^11.1.0", - "xmlbuilder2": "^3.1.1" + "xmlbuilder2": "^4.0.3" } }, "node_modules/@angular-devkit/architect": { @@ -2837,6 +2837,12 @@ "npm": ">=9.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, "node_modules/@foliojs-fork/fontkit": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@foliojs-fork/fontkit/-/fontkit-1.9.2.tgz", @@ -3191,9 +3197,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -3487,9 +3493,9 @@ } }, "node_modules/@jest/reporters/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -3831,55 +3837,55 @@ } }, "node_modules/@oozcitak/dom": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.10.tgz", - "integrity": "sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-2.0.2.tgz", + "integrity": "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==", "license": "MIT", "peer": true, "dependencies": { - "@oozcitak/infra": "1.0.8", - "@oozcitak/url": "1.0.4", - "@oozcitak/util": "8.3.8" + "@oozcitak/infra": "^2.0.2", + "@oozcitak/url": "^3.0.0", + "@oozcitak/util": "^10.0.0" }, "engines": { - "node": ">=8.0" + "node": ">=20.0" } }, "node_modules/@oozcitak/infra": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.8.tgz", - "integrity": "sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-2.0.2.tgz", + "integrity": "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==", "license": "MIT", "peer": true, "dependencies": { - "@oozcitak/util": "8.3.8" + "@oozcitak/util": "^10.0.0" }, "engines": { - "node": ">=6.0" + "node": ">=20.0" } }, "node_modules/@oozcitak/url": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.4.tgz", - "integrity": "sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-3.0.0.tgz", + "integrity": "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==", "license": "MIT", "peer": true, "dependencies": { - "@oozcitak/infra": "1.0.8", - "@oozcitak/util": "8.3.8" + "@oozcitak/infra": "^2.0.2", + "@oozcitak/util": "^10.0.0" }, "engines": { - "node": ">=8.0" + "node": ">=20.0" } }, "node_modules/@oozcitak/util": { - "version": "8.3.8", - "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.8.tgz", - "integrity": "sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-10.0.0.tgz", + "integrity": "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==", "license": "MIT", "peer": true, "engines": { - "node": ">=8.0" + "node": ">=20.0" } }, "node_modules/@opentelemetry/api": { @@ -5503,16 +5509,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/multer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", - "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, "node_modules/@types/node": { "version": "24.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", @@ -6562,12 +6558,6 @@ "node": ">= 6.0.0" } }, - "node_modules/append-field": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", - "license": "MIT" - }, "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", @@ -6650,9 +6640,9 @@ } }, "node_modules/archiver-utils/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -6794,7 +6784,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -6977,6 +6966,13 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT", + "peer": true + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -7002,6 +6998,18 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "peer": true, + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -7313,23 +7321,43 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/bowser": { @@ -7475,6 +7503,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, "license": "MIT" }, "node_modules/buildcheck": { @@ -7516,17 +7545,6 @@ "esbuild": ">=0.18" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/byline": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", @@ -7953,6 +7971,19 @@ "color-support": "bin.js" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "peer": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -8056,21 +8087,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -8614,6 +8630,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -9091,7 +9117,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9887,6 +9912,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -10346,6 +10372,27 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -10389,6 +10436,46 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "peer": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -10682,21 +10769,15 @@ "peer": true }, "node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", - "license": "ISC", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", + "minimatch": "^10.1.1", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, "engines": { "node": "20 || >=22" }, @@ -10717,21 +10798,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob/node_modules/lru-cache": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", @@ -10742,10 +10808,10 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", - "license": "ISC", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/brace-expansion": "^5.0.0" }, @@ -11005,19 +11071,23 @@ "peer": true }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-proxy-agent": { @@ -11077,6 +11147,7 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -12071,9 +12142,9 @@ } }, "node_modules/jest-config/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -12404,9 +12475,9 @@ } }, "node_modules/jest-runtime/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -12600,10 +12671,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -13725,79 +13795,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", - "license": "MIT", - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.6.0", - "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" - }, - "engines": { - "node": ">= 10.16.0" - } - }, - "node_modules/multer/node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/multer/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/multer/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/multer/node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/multer/node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -13970,9 +13967,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz", - "integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -15086,6 +15083,13 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT", + "peer": true + }, "node_modules/pump": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", @@ -15180,18 +15184,34 @@ } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/rc": { @@ -15241,6 +15261,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "devOptional": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -16336,6 +16357,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/sql-highlight": { @@ -16480,9 +16502,9 @@ } }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -16515,14 +16537,6 @@ "node": ">= 0.4" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/streamx": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", @@ -16539,6 +16553,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "devOptional": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -16794,9 +16809,9 @@ } }, "node_modules/sucrase/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -17658,12 +17673,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, "node_modules/typedoc": { "version": "0.27.9", "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.27.9.tgz", @@ -17849,9 +17858,9 @@ } }, "node_modules/typeorm/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -18346,6 +18355,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "devOptional": true, "license": "MIT" }, "node_modules/uuid": { @@ -18669,43 +18679,19 @@ } }, "node_modules/xmlbuilder2": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.1.1.tgz", - "integrity": "sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz", + "integrity": "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==", "license": "MIT", "peer": true, "dependencies": { - "@oozcitak/dom": "1.15.10", - "@oozcitak/infra": "1.0.8", - "@oozcitak/util": "8.3.8", - "js-yaml": "3.14.1" + "@oozcitak/dom": "^2.0.2", + "@oozcitak/infra": "^2.0.2", + "@oozcitak/util": "^10.0.0", + "js-yaml": "^4.1.1" }, "engines": { - "node": ">=12.0" - } - }, - "node_modules/xmlbuilder2/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "peer": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/xmlbuilder2/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "license": "MIT", - "peer": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "node": ">=20.0" } }, "node_modules/xmldoc": { diff --git a/package.json b/package.json index acdde93..dcb6ca6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zibri", - "version": "2.0.2", + "version": "2.1.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "exports": { @@ -33,24 +33,25 @@ "license": "MIT", "description": "", "peerDependencies": { + "axios": "^1.13.2", "bcryptjs": "^3.0.2", "bignumber.js": "^9.3.1", "handlebars": "^4.7.8", + "hi-base32": "^0.5.1", "jsonwebtoken": "^9.0.2", + "otpauth": "^9.4.1", "pdfmake": "^0.2.2", "rxjs": "^7.8.2", "socket.io": "^4.8.1", "ts-node": "^10.9.2", "uuid": "^11.1.0", - "xmlbuilder2": "^3.1.1", - "otpauth": "^9.4.1", - "hi-base32": "^0.5.1" + "xmlbuilder2": "^4.0.3" }, "dependencies": { + "@fastify/busboy": "^3.2.0", "cors": "^2.8.5", "express": "^5.1.0", - "glob": "^11.0.3", - "multer": "^2.0.2", + "glob": "^13.0.0", "node-cron": "^4.2.1", "nodemailer": "^7.0.6", "pg": "^8.16.3", @@ -68,7 +69,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/jsonwebtoken": "^9.0.10", - "@types/multer": "^2.0.0", "@types/node": "^24.5.2", "@types/nodemailer": "^7.0.1", "@types/pdfmake": "^0.2.11", diff --git a/sandbox/src/data-sources/db/db.data-source.ts b/sandbox/src/data-sources/db/db.data-source.ts index ca3d44b..622b28e 100644 --- a/sandbox/src/data-sources/db/db.data-source.ts +++ b/sandbox/src/data-sources/db/db.data-source.ts @@ -1,11 +1,10 @@ -import { BaseDataSource, BaseEntity, DataSource, Newable, DataSourceOptions, MigrationEntity, JwtRefreshToken, JwtCredentials, PasswordResetToken, MailingList, MailingListSubscriber, MailingListSubscriptionConfirmationToken, Log, Change, ChangeSet, Invoice, NumberInvoices, Entity, OmitClass, OtpCredentials, Backup, BackupResourceEntity, BackupEntity } from 'zibri'; +import { BaseDataSource, BaseEntity, DataSource, Newable, DataSourceOptions, MigrationEntity, JwtRefreshToken, JwtCredentials, PasswordResetToken, MailingList, MailingListSubscriber, MailingListSubscriptionConfirmationToken, Log, Change, ChangeSet, Invoice, NumberInvoices, Entity, OmitClass, OtpCredentials, BackupResourceEntity, BackupEntity } from 'zibri'; import { Company, Test, User } from '../../models'; @Entity() class Test2 extends OmitClass(MigrationEntity, ['ranAt']) {} -@Backup({ transports: [] }) @DataSource() export class DbDataSource extends BaseDataSource { rootPw: string = 'password'; diff --git a/src/__testing__/mocks/entities/company.entity.ts b/src/__testing__/mocks/entities/company.entity.ts index 43de291..f23d9bd 100644 --- a/src/__testing__/mocks/entities/company.entity.ts +++ b/src/__testing__/mocks/entities/company.entity.ts @@ -1,5 +1,6 @@ import { User } from './user.entity'; -import { BaseEntity, Entity, Property } from '../../../entity'; +import { Entity, Property } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; @Entity() export class Company extends BaseEntity { diff --git a/src/application.ts b/src/application.ts index faa153c..b5a34ef 100644 --- a/src/application.ts +++ b/src/application.ts @@ -122,7 +122,7 @@ export class ZibriApplication { } } if (!this.providedOptions.bodyParsers) { - await this.logger.info('No request body parsers provided, defaults to:'); + await this.logger.info('No body parsers provided, defaults to:'); for (const bodyParser of this.options.bodyParsers) { await this.logger.info(` - ${bodyParser.name}`); } diff --git a/src/auth/2fa/methods/otp/otp-credentials.model.ts b/src/auth/2fa/methods/otp/otp-credentials.model.ts index fa5c4a9..ea012f5 100644 --- a/src/auth/2fa/methods/otp/otp-credentials.model.ts +++ b/src/auth/2fa/methods/otp/otp-credentials.model.ts @@ -1,4 +1,5 @@ -import { BaseEntity, Entity, OmitClass, Property } from '../../../../entity'; +import { Entity, OmitClass, Property } from '../../../../entity'; +import { BaseEntity } from '../../../../entity/base-entity.model'; /** * Credentials for a one time password. diff --git a/src/auth/auth-service.interface.ts b/src/auth/auth-service.interface.ts index 3fa089d..50b2e24 100644 --- a/src/auth/auth-service.interface.ts +++ b/src/auth/auth-service.interface.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../entity'; +import { BaseEntity } from '../entity/base-entity.model'; import { HttpRequest } from '../http'; import { Newable } from '../types'; import { WebsocketRequest } from '../websocket'; diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 5245409..bf6cdc8 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,6 +1,6 @@ import { inject, ZIBRI_DI_TOKENS } from '../di'; import { register } from '../di/register.function'; -import { BaseEntity } from '../entity'; +import { BaseEntity } from '../entity/base-entity.model'; import { UnauthorizedError } from '../error-handling'; import { HttpRequest } from '../http'; import { LoggerInterface } from '../logging'; diff --git a/src/auth/decorators/belongs-to.decorator.ts b/src/auth/decorators/belongs-to.decorator.ts index a68c6ea..e0fa434 100644 --- a/src/auth/decorators/belongs-to.decorator.ts +++ b/src/auth/decorators/belongs-to.decorator.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; import { Newable } from '../../types'; import { MetadataUtilities } from '../../utilities'; import { BelongsToMetadata } from '../models'; diff --git a/src/auth/models/base-user.model.ts b/src/auth/models/base-user.model.ts index 7d96f0d..6097cbd 100644 --- a/src/auth/models/base-user.model.ts +++ b/src/auth/models/base-user.model.ts @@ -1,4 +1,5 @@ -import { BaseEntity, Property } from '../../entity'; +import { Property } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; import { AnyEnum, Newable } from '../../types'; /** diff --git a/src/auth/models/belongs-to-metadata.model.ts b/src/auth/models/belongs-to-metadata.model.ts index 90e7ae5..beedf5c 100644 --- a/src/auth/models/belongs-to-metadata.model.ts +++ b/src/auth/models/belongs-to-metadata.model.ts @@ -1,6 +1,6 @@ import { AuthStrategies } from '../strategies'; import { SkipAuthMetadata } from './skip-auth-metadata.model'; -import { BaseEntity } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; import { Newable } from '../../types'; /** diff --git a/src/auth/strategies/auth-strategy.interface.ts b/src/auth/strategies/auth-strategy.interface.ts index 0a42481..72dfc1c 100644 --- a/src/auth/strategies/auth-strategy.interface.ts +++ b/src/auth/strategies/auth-strategy.interface.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; import { HttpRequest } from '../../http'; import { OpenApiSecuritySchemeObject } from '../../open-api'; import { Newable } from '../../types'; diff --git a/src/auth/strategies/jwt/jwt-credentials.model.ts b/src/auth/strategies/jwt/jwt-credentials.model.ts index 2e6a948..01d08ec 100644 --- a/src/auth/strategies/jwt/jwt-credentials.model.ts +++ b/src/auth/strategies/jwt/jwt-credentials.model.ts @@ -1,4 +1,5 @@ -import { BaseEntity, Entity, Property, OmitClass } from '../../../entity'; +import { Entity, Property, OmitClass } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; import { BaseUser } from '../../models'; /** diff --git a/src/auth/strategies/jwt/jwt-refresh-token.model.ts b/src/auth/strategies/jwt/jwt-refresh-token.model.ts index f5cb74f..a786178 100644 --- a/src/auth/strategies/jwt/jwt-refresh-token.model.ts +++ b/src/auth/strategies/jwt/jwt-refresh-token.model.ts @@ -1,4 +1,5 @@ -import { BaseEntity, Entity, OmitClass, Property } from '../../../entity'; +import { Entity, OmitClass, Property } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; /** * The jwt refresh token that gets stored in the database. diff --git a/src/auth/strategies/jwt/jwt.auth-strategy.ts b/src/auth/strategies/jwt/jwt.auth-strategy.ts index ff4e385..421be93 100644 --- a/src/auth/strategies/jwt/jwt.auth-strategy.ts +++ b/src/auth/strategies/jwt/jwt.auth-strategy.ts @@ -15,7 +15,7 @@ import { JwtUtilities } from './jwt.utilities'; import { Repository } from '../../../data-source'; import { inject, repositoryTokenFor, ZIBRI_DI_TOKENS } from '../../../di'; import { EmailPriority, EmailServiceInterface } from '../../../email'; -import { BaseEntity } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; import { TooManyRequestsError, UnauthorizedError } from '../../../error-handling'; import { GlobalRegistry } from '../../../global'; import { renderEmailTemplate } from '../../../handlebars'; diff --git a/src/backup/backup-entity.model.ts b/src/backup/backup-entity.model.ts index 4db16a4..1892e21 100644 --- a/src/backup/backup-entity.model.ts +++ b/src/backup/backup-entity.model.ts @@ -1,5 +1,6 @@ import { inject, ZIBRI_DI_TOKENS } from '../di'; -import { BaseEntity, Entity, Property } from '../entity'; +import { Entity, Property } from '../entity'; +import { BaseEntity } from '../entity/base-entity.model'; import { FormatDateFn } from '../localization'; import { OmitStrict } from '../types'; import { BackupResourceEntity, BackupResourceEntityCreateData } from './backup-resource-entity.model'; diff --git a/src/backup/backup-resource-entity.model.ts b/src/backup/backup-resource-entity.model.ts index ab82d8f..caea82c 100644 --- a/src/backup/backup-resource-entity.model.ts +++ b/src/backup/backup-resource-entity.model.ts @@ -1,4 +1,5 @@ -import { BaseEntity, Entity, Property } from '../entity'; +import { Entity, Property } from '../entity'; +import { BaseEntity } from '../entity/base-entity.model'; import { OmitStrict } from '../types'; import { BackupEntity } from './backup-entity.model'; diff --git a/src/backup/backup-service.test.ts b/src/backup/backup-service.test.ts index 29e1d5c..f6f2c49 100644 --- a/src/backup/backup-service.test.ts +++ b/src/backup/backup-service.test.ts @@ -16,7 +16,8 @@ import { DataSource } from '../data-source/decorators/data-source.decorator'; import { MigrationEntity } from '../data-source/migration/migration-entity.model'; import { Repository } from '../data-source/repository'; import { inject, ZIBRI_DI_TOKENS } from '../di'; -import { BaseEntity, Entity, Property } from '../entity'; +import { Entity, Property } from '../entity'; +import { BaseEntity } from '../entity/base-entity.model'; import { Newable } from '../types'; import { Backup } from './decorators/backup-resource.decorator'; import { FsBackupTransport } from './transports'; diff --git a/src/change-sets/models/change-set-entity.model.ts b/src/change-sets/models/change-set-entity.model.ts index a718bab..a9dd201 100644 --- a/src/change-sets/models/change-set-entity.model.ts +++ b/src/change-sets/models/change-set-entity.model.ts @@ -1,6 +1,6 @@ import { ChangeSet } from './change-set.model'; -import { BaseEntity } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; import { Newable } from '../../types'; import { MetadataUtilities } from '../../utilities'; diff --git a/src/change-sets/models/change-set.model.ts b/src/change-sets/models/change-set.model.ts index c3ce78b..8229f58 100644 --- a/src/change-sets/models/change-set.model.ts +++ b/src/change-sets/models/change-set.model.ts @@ -1,7 +1,8 @@ import { ChangeSetType } from './change-set-type.enum'; import { Change, NewChange } from './change.model'; -import { BaseEntity, Entity, Property } from '../../entity'; +import { Entity, Property } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; import { OmitStrict } from '../../types'; /** diff --git a/src/change-sets/models/change.model.ts b/src/change-sets/models/change.model.ts index 3c24677..1c31e5d 100644 --- a/src/change-sets/models/change.model.ts +++ b/src/change-sets/models/change.model.ts @@ -1,5 +1,6 @@ import { ChangeSet } from './change-set.model'; -import { BaseEntity, Entity, Property } from '../../entity'; +import { Entity, Property } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; import { OmitStrict } from '../../types'; /** diff --git a/src/cron/cron-job-entity.model.ts b/src/cron/cron-job-entity.model.ts index 274ede4..385f91c 100644 --- a/src/cron/cron-job-entity.model.ts +++ b/src/cron/cron-job-entity.model.ts @@ -1,4 +1,5 @@ -import { BaseEntity, Entity, Property } from '../entity'; +import { Entity, Property } from '../entity'; +import { BaseEntity } from '../entity/base-entity.model'; import { OmitStrict } from '../types'; /** diff --git a/src/data-source/base-data-source.model.ts b/src/data-source/base-data-source.model.ts index b96a501..20855fb 100644 --- a/src/data-source/base-data-source.model.ts +++ b/src/data-source/base-data-source.model.ts @@ -9,7 +9,8 @@ import { OnDeleteType } from 'typeorm/metadata/types/OnDeleteType'; import { OnUpdateType } from 'typeorm/metadata/types/OnUpdateType'; import { inject, repositoryTokenFor, ZIBRI_DI_TOKENS } from '../di'; -import { BaseEntity, EntityMetadata, PropertyMetadata, Relation, RelationMetadata, StringPropertyMetadata } from '../entity'; +import { EntityMetadata, PropertyMetadata, Relation, RelationMetadata, StringPropertyMetadata } from '../entity'; +import { BaseEntity } from '../entity/base-entity.model'; import { ExcludeStrict, Newable, OmitStrict, Version } from '../types'; import { compareVersion, MetadataUtilities } from '../utilities'; import { Migration, MigrationEntity } from './migration'; diff --git a/src/data-source/cascade-delete.test.ts b/src/data-source/cascade-delete.test.ts index 340cba5..4712e9b 100644 --- a/src/data-source/cascade-delete.test.ts +++ b/src/data-source/cascade-delete.test.ts @@ -2,10 +2,10 @@ import { beforeAll, afterAll, describe, it, expect } from '@jest/globals'; import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql'; import { PostgresConnectionCredentialsOptions } from 'typeorm/driver/postgres/PostgresConnectionCredentialsOptions'; -import { BaseEntity } from '../entity'; import { BaseDataSource } from './base-data-source.model'; import { DataSource } from './decorators'; import { inject } from '../di'; +import { BaseEntity } from '../entity/base-entity.model'; import { Newable } from '../types'; import { MigrationEntity } from './migration'; import { DataSourceOptions } from './models'; diff --git a/src/data-source/data-source.service.ts b/src/data-source/data-source.service.ts index 68bb2f5..dda619f 100644 --- a/src/data-source/data-source.service.ts +++ b/src/data-source/data-source.service.ts @@ -6,7 +6,7 @@ import { JwtCredentials, PasswordResetToken, JwtRefreshToken, OtpCredentials } f import { BackupEntity, BackupResourceEntity } from '../backup'; import { CronJobEntity } from '../cron'; import { Email, MailingList, MailingListSubscriber } from '../email'; -import { BaseEntity } from '../entity'; +import { BaseEntity } from '../entity/base-entity.model'; import { Log, LoggerInterface } from '../logging'; import { ThreadJobEntity } from '../multithreading'; import { Newable } from '../types'; diff --git a/src/data-source/migration/migration-entity.model.ts b/src/data-source/migration/migration-entity.model.ts index 2e242d4..3db2c44 100644 --- a/src/data-source/migration/migration-entity.model.ts +++ b/src/data-source/migration/migration-entity.model.ts @@ -1,4 +1,5 @@ -import { BaseEntity, Entity, Property } from '../../entity'; +import { Entity, Property } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; import { type Version } from '../../types'; /** diff --git a/src/data-source/migration/migration.model.ts b/src/data-source/migration/migration.model.ts index 4c8803e..20aca23 100644 --- a/src/data-source/migration/migration.model.ts +++ b/src/data-source/migration/migration.model.ts @@ -1,13 +1,15 @@ import { EntityMetadata as TOEntityMetadata, EntityTarget, TableColumn, TableColumnOptions } from 'typeorm'; import { ColumnMetadata as TOColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; +import { MigrationEntity } from './migration-entity.model'; import { inject, repositoryTokenFor } from '../../di'; -import { BaseEntity, FilePropertyMetadata, PropertyMetadata, PropertyMetadataInput, RelationMetadata } from '../../entity'; +import { PropertyMetadata, PropertyMetadataInput, RelationMetadata } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; +import { FilePropertyMetadata } from '../../entity/models/file-property-metadata.model'; import { ExcludeStrict, Newable, Version } from '../../types'; import { BaseDataSource } from '../base-data-source.model'; import { Repository } from '../repository'; import { Transaction } from '../transaction'; -import { MigrationEntity } from './migration-entity.model'; /** * Base class for a database migration. diff --git a/src/data-source/migration/migration.test.ts b/src/data-source/migration/migration.test.ts index 88a67fd..54dfc2a 100644 --- a/src/data-source/migration/migration.test.ts +++ b/src/data-source/migration/migration.test.ts @@ -4,7 +4,8 @@ import { DataSourceOptions, Table, TableColumn } from 'typeorm'; import { PostgresConnectionCredentialsOptions } from 'typeorm/driver/postgres/PostgresConnectionCredentialsOptions'; import { inject, Injectable, InjectRepository } from '../../di'; -import { BaseEntity, Entity, Property } from '../../entity'; +import { Entity, Property } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; import { Newable, Version } from '../../types'; import { BaseDataSource } from '../base-data-source.model'; import { Transaction } from '../transaction'; diff --git a/src/data-source/models/options/delete-all-options.model.ts b/src/data-source/models/options/delete-all-options.model.ts index 3e5109f..e146df5 100644 --- a/src/data-source/models/options/delete-all-options.model.ts +++ b/src/data-source/models/options/delete-all-options.model.ts @@ -1,5 +1,5 @@ import { FindAllOptions } from './find-all-options.model'; -import { BaseEntity } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; import { OmitStrict } from '../../../types'; /** diff --git a/src/data-source/models/options/find-all-options.model.ts b/src/data-source/models/options/find-all-options.model.ts index 151ec69..1af4a93 100644 --- a/src/data-source/models/options/find-all-options.model.ts +++ b/src/data-source/models/options/find-all-options.model.ts @@ -1,7 +1,7 @@ import { FindManyOptions } from 'typeorm'; import { BaseRepositoryOptions } from './base-repository-options.model'; -import { BaseEntity } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; import { OmitStrict } from '../../../types'; import { Where } from '../where'; diff --git a/src/data-source/models/options/find-all-paginated-options.model.ts b/src/data-source/models/options/find-all-paginated-options.model.ts index 88e034e..6f7d20d 100644 --- a/src/data-source/models/options/find-all-paginated-options.model.ts +++ b/src/data-source/models/options/find-all-paginated-options.model.ts @@ -1,6 +1,6 @@ import { FindAllOptions } from './find-all-options.model'; -import { BaseEntity } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; import { OmitStrict } from '../../../types'; /** diff --git a/src/data-source/models/options/find-by-id-options.model.ts b/src/data-source/models/options/find-by-id-options.model.ts index 0b34d1e..c4ab744 100644 --- a/src/data-source/models/options/find-by-id-options.model.ts +++ b/src/data-source/models/options/find-by-id-options.model.ts @@ -1,5 +1,5 @@ import { FindOneOptions } from './find-one-options.model'; -import { BaseEntity } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; import { OmitStrict } from '../../../types'; /** diff --git a/src/data-source/models/options/find-one-options.model.ts b/src/data-source/models/options/find-one-options.model.ts index 3accedc..058a6e5 100644 --- a/src/data-source/models/options/find-one-options.model.ts +++ b/src/data-source/models/options/find-one-options.model.ts @@ -1,7 +1,7 @@ import { FindOneOptions as TOFindOneOptions } from 'typeorm'; import { BaseRepositoryOptions } from './base-repository-options.model'; -import { BaseEntity } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; import { OmitStrict } from '../../../types'; import { Where } from '../where'; diff --git a/src/data-source/models/where/where-filter-to-find-options-where.function.ts b/src/data-source/models/where/where-filter-to-find-options-where.function.ts index c5772eb..ca5746b 100644 --- a/src/data-source/models/where/where-filter-to-find-options-where.function.ts +++ b/src/data-source/models/where/where-filter-to-find-options-where.function.ts @@ -7,7 +7,8 @@ import { NumberWhereFilter } from './number-where-filter.model'; import { ObjectWhereFilter } from './object-where-filter.model'; import { StringWhereFilter } from './string-where-filter.model'; import { WhereFilter, Where, WhereFilterProperty } from './where-filter.model'; -import { BaseEntity, ManyToOnePropertyMetadata, ObjectPropertyMetadata, OneToOnePropertyMetadata, PropertyMetadata, Relation } from '../../../entity'; +import { ManyToOnePropertyMetadata, ObjectPropertyMetadata, OneToOnePropertyMetadata, PropertyMetadata, Relation } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; import { ExcludeStrict, Newable } from '../../../types'; import { MetadataUtilities } from '../../../utilities'; diff --git a/src/data-source/repository.ts b/src/data-source/repository.ts index 0c8ffb8..8e48bb1 100644 --- a/src/data-source/repository.ts +++ b/src/data-source/repository.ts @@ -5,8 +5,9 @@ import { NotFoundError } from '../error-handling'; import { LoggerInterface } from '../logging'; import { DeepPartial, Newable } from '../types'; import { Transaction } from './transaction'; -import { ArrayPropertyMetadata, BaseEntity, PropertyMetadata, Relation } from '../entity'; +import { ArrayPropertyMetadata, PropertyMetadata, Relation } from '../entity'; import { CreateAllOptions, CreateOptions, DeleteAllOptions, DeleteByIdOptions, FindAllOptions, FindAllPaginatedOptions, FindByIdOptions, FindOneOptions, UpdateAllOptions, UpdateByIdOptions, Where } from './models'; +import { BaseEntity } from '../entity/base-entity.model'; import { PaginationResult } from '../open-api'; import { MetadataUtilities } from '../utilities'; import { whereFilterToFindOptionsWhere } from './models/where/where-filter-to-find-options-where.function'; @@ -74,6 +75,7 @@ export class Repository< await this.setDefaultValuesForArray(data[key as keyof Data] as unknown[], { ...property, type: 'array', + totalMaxSize: '50mb', items: { type: 'object', cls: property.target, diff --git a/src/data-source/transaction/transaction.test.ts b/src/data-source/transaction/transaction.test.ts index f25718a..dbbf5b4 100644 --- a/src/data-source/transaction/transaction.test.ts +++ b/src/data-source/transaction/transaction.test.ts @@ -4,7 +4,8 @@ import { StartedTestContainer } from 'testcontainers'; import { PostgresConnectionCredentialsOptions } from 'typeorm/driver/postgres/PostgresConnectionCredentialsOptions'; import { POSTGRES_TEST_IMAGE } from '../../__testing__'; -import { type BaseEntity, Entity, Property } from '../../entity'; +import { Entity, Property } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; import { Newable } from '../../types'; import { BaseDataSource } from '../base-data-source.model'; import { DataSource } from '../decorators'; diff --git a/src/di/decorators/inject-repository.decorator.ts b/src/di/decorators/inject-repository.decorator.ts index 6d4a95b..fd2b269 100644 --- a/src/di/decorators/inject-repository.decorator.ts +++ b/src/di/decorators/inject-repository.decorator.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; import { Newable } from '../../types'; import { MetadataUtilities } from '../../utilities'; import { DiToken } from '../models'; diff --git a/src/di/default/zibri-di-providers.default.ts b/src/di/default/zibri-di-providers.default.ts index 60bf6cc..c8d1d41 100644 --- a/src/di/default/zibri-di-providers.default.ts +++ b/src/di/default/zibri-di-providers.default.ts @@ -11,6 +11,7 @@ import { DataSourceService, DataSourceServiceInterface } from '../../data-source import { EmailConfigInput, EmailService, EmailServiceInterface, MailingListService, MailingListServiceInterface } from '../../email'; import { errorHandler, GlobalErrorHandler } from '../../error-handling'; import { HttpRequest } from '../../http'; +import { HttpClient, HttpClientInterface } from '../../http-client'; import { FormatDateFn, FormatPercentFn, FormatPriceFn, LocalizeOptions, LocalizeOptionsInput } from '../../localization'; import { formatDate } from '../../localization/formatting/format-date.function'; import { formatPercent } from '../../localization/formatting/format-percent.function'; @@ -80,7 +81,8 @@ type ZibriDiProviders = { [ZIBRI_DI_TOKENS.MULTITHREADING_SERVICE]: ZibriDiProvider, // eslint-disable-next-line typescript/no-explicit-any [ZIBRI_DI_TOKENS.WEBSOCKET_SERVICE]: ZibriDiProvider>, - [ZIBRI_DI_TOKENS.WEBSOCKET_OPTIONS]: ZibriDiProvider + [ZIBRI_DI_TOKENS.WEBSOCKET_OPTIONS]: ZibriDiProvider, + [ZIBRI_DI_TOKENS.HTTP_CLIENT]: ZibriDiProvider }; export const ZIBRI_DI_PROVIDERS: Record< @@ -154,5 +156,6 @@ export const ZIBRI_DI_PROVIDERS: Record< }, [ZIBRI_DI_TOKENS.MULTITHREADING_SERVICE]: { useClass: MultithreadingService }, [ZIBRI_DI_TOKENS.WEBSOCKET_SERVICE]: { useClass: WebsocketService }, - [ZIBRI_DI_TOKENS.WEBSOCKET_OPTIONS]: { useFactory: () => ({ timeoutInMs: Ms.SECOND * 5, isAllowedToConnect: () => true }) } + [ZIBRI_DI_TOKENS.WEBSOCKET_OPTIONS]: { useFactory: () => ({ timeoutInMs: Ms.SECOND * 5, isAllowedToConnect: () => true }) }, + [ZIBRI_DI_TOKENS.HTTP_CLIENT]: { useClass: HttpClient } } satisfies ZibriDiProviders; \ No newline at end of file diff --git a/src/di/default/zibri-di-tokens.default.ts b/src/di/default/zibri-di-tokens.default.ts index 87597ce..61655e3 100644 --- a/src/di/default/zibri-di-tokens.default.ts +++ b/src/di/default/zibri-di-tokens.default.ts @@ -42,5 +42,6 @@ export const ZIBRI_DI_TOKENS = { MULTITHREADING_SERVICE: 'zi.multithreading_service', MULTITHREADING_OPTIONS: 'zi.multithreading_options', WEBSOCKET_SERVICE: 'zi.websocket_service', - WEBSOCKET_OPTIONS: 'zi.websocket_options' + WEBSOCKET_OPTIONS: 'zi.websocket_options', + HTTP_CLIENT: 'zi.http_client' } as const satisfies Record; \ No newline at end of file diff --git a/src/email/mailing-list/models/mailing-list-subscriber.model.ts b/src/email/mailing-list/models/mailing-list-subscriber.model.ts index f61ca41..a9f998b 100644 --- a/src/email/mailing-list/models/mailing-list-subscriber.model.ts +++ b/src/email/mailing-list/models/mailing-list-subscriber.model.ts @@ -1,5 +1,6 @@ import { MailingList } from './mailing-list.model'; -import { BaseEntity, Entity, Property } from '../../../entity'; +import { Entity, Property } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; /** * Defines a subscriber to a single or multiple mailing lists. diff --git a/src/email/mailing-list/models/mailing-list-subscription-confirmation-token.model.ts b/src/email/mailing-list/models/mailing-list-subscription-confirmation-token.model.ts index bc060a3..320ae87 100644 --- a/src/email/mailing-list/models/mailing-list-subscription-confirmation-token.model.ts +++ b/src/email/mailing-list/models/mailing-list-subscription-confirmation-token.model.ts @@ -1,4 +1,5 @@ -import { BaseEntity, Entity, OmitClass, Property } from '../../../entity'; +import { Entity, OmitClass, Property } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; /** * A short lived token used to confirm a mailing list subscription. diff --git a/src/email/mailing-list/models/mailing-list.model.ts b/src/email/mailing-list/models/mailing-list.model.ts index 02563e1..5d2be52 100644 --- a/src/email/mailing-list/models/mailing-list.model.ts +++ b/src/email/mailing-list/models/mailing-list.model.ts @@ -1,5 +1,6 @@ import { MailingListSubscriber } from './mailing-list-subscriber.model'; -import { BaseEntity, Entity, Property } from '../../../entity'; +import { Entity, Property } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; /** * A mailing list like a newsletter that people can easily subscribe and unsubscribe to. diff --git a/src/email/models/email.model.ts b/src/email/models/email.model.ts index 7f7d97d..02eed73 100644 --- a/src/email/models/email.model.ts +++ b/src/email/models/email.model.ts @@ -1,7 +1,8 @@ import { EmailAttachment } from './email-attachment.model'; import { EmailPriority } from './email-priority.enum'; import { EmailStatus } from './email-status.enum'; -import { BaseEntity, Entity, Property } from '../../entity'; +import { Entity, Property } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; /** * Definition of a Email. diff --git a/src/entity/decorators/property.decorator.ts b/src/entity/decorators/property.decorator.ts index b9db545..65e16e7 100644 --- a/src/entity/decorators/property.decorator.ts +++ b/src/entity/decorators/property.decorator.ts @@ -2,8 +2,9 @@ import { warn } from '../../logging/logger.helpers'; import type { Newable } from '../../types'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; import type { BaseEntity } from '../base-entity.model'; -import type { ArrayPropertyItemMetadata, ArrayPropertyItemMetadataInput, ArrayPropertyMetadata, ArrayPropertyMetadataInput, BelongsToOnePropertyMetadataInput, BooleanPropertyMetadata, BooleanPropertyMetadataInput, DatePropertyMetadata, DatePropertyMetadataInput, FilePropertyMetadata, FilePropertyMetadataInput, HasOnePropertyMetadataInput, ManyToManyPropertyMetadata, ManyToManyPropertyMetadataInput, ManyToOnePropertyMetadata, ManyToOnePropertyMetadataInput, NumberPropertyMetadata, NumberPropertyMetadataInput, ObjectPropertyMetadata, ObjectPropertyMetadataInput, OneToManyPropertyMetadata, OneToManyPropertyMetadataInput, OneToOnePropertyMetadata, OneToOnePropertyMetadataInput, StringPropertyMetadata, StringPropertyMetadataInput, UnknownPropertyMetadata, UnknownPropertyMetadataInput } from '../models'; +import type { ArrayPropertyItemMetadata, ArrayPropertyItemMetadataInput, ArrayPropertyMetadata, ArrayPropertyMetadataInput, BelongsToOnePropertyMetadataInput, BooleanPropertyMetadata, BooleanPropertyMetadataInput, DatePropertyMetadata, DatePropertyMetadataInput, HasOnePropertyMetadataInput, ManyToManyPropertyMetadata, ManyToManyPropertyMetadataInput, ManyToOnePropertyMetadata, ManyToOnePropertyMetadataInput, NumberPropertyMetadata, NumberPropertyMetadataInput, ObjectPropertyMetadata, ObjectPropertyMetadataInput, OneToManyPropertyMetadata, OneToManyPropertyMetadataInput, OneToOnePropertyMetadata, OneToOnePropertyMetadataInput, StringPropertyMetadata, StringPropertyMetadataInput, UnknownPropertyMetadata, UnknownPropertyMetadataInput } from '../models'; import type { WithDefaultMetadata } from '../models/base-property-metadata.model'; +import type { FilePropertyMetadata, FilePropertyMetadataInput } from '../models/file-property-metadata.model'; import { Relation } from '../models/relation.enum'; /** @@ -197,6 +198,7 @@ export namespace Property { type: 'array', description: undefined, excludeFromChangeSets: false, + totalMaxSize: '50mb', ...data, items: createArrayItemPropertyMetadata(data.items, `${target.constructor.name}.${key.toString()}`) }; @@ -411,13 +413,15 @@ export function createArrayItemPropertyMetadata( }; } case 'array': { - return { + const metadata: ArrayPropertyMetadata = { required: true, description: undefined, excludeFromChangeSets: false, + totalMaxSize: '50mb', ...data, items: createArrayItemPropertyMetadata(data.items, fullPropertyKey) }; + return metadata; } case 'file': { if (data.allowedMimeTypes == undefined) { diff --git a/src/entity/index.ts b/src/entity/index.ts index 55c178b..6a080fa 100644 --- a/src/entity/index.ts +++ b/src/entity/index.ts @@ -3,5 +3,4 @@ export * from './omit-class.model'; export * from './intersection-class.model'; export * from './partial-class.model'; export * from './pick-class.model'; -export * from './models'; -export * from './base-entity.model'; \ No newline at end of file +export * from './models'; \ No newline at end of file diff --git a/src/entity/models/array-property-metadata.model.ts b/src/entity/models/array-property-metadata.model.ts index c65c3e5..730c499 100644 --- a/src/entity/models/array-property-metadata.model.ts +++ b/src/entity/models/array-property-metadata.model.ts @@ -21,7 +21,11 @@ export type ArrayPropertyMetadata = BasePropertyMetadata & { /** * The definition for the items of the property. */ - items: ArrayPropertyItemMetadata + items: ArrayPropertyItemMetadata, + /** + * The total maximum size that all files in the array combined can have. + */ + totalMaxSize: FileSize }; /** diff --git a/src/entity/models/file-property-metadata.model.ts b/src/entity/models/file-property-metadata.model.ts index 9485fed..eaa86df 100644 --- a/src/entity/models/file-property-metadata.model.ts +++ b/src/entity/models/file-property-metadata.model.ts @@ -1,6 +1,7 @@ import { BasePropertyMetadata } from './base-property-metadata.model'; import { MimeType } from '../../http'; import { OmitStrict } from '../../types'; +import { BigNumber, BigNumberUtilities } from '../../utilities'; /** * Possible file size values. @@ -12,21 +13,21 @@ export type FileSize = `${number}b` | `${number}kb` | `${number}mb` | `${number} * @param size - The file size to resolve to bytes. * @returns The amount of bytes. */ -export function fileSizeToBytes(size: FileSize): number { +export function fileSizeToBytes(size: FileSize): BigNumber { if (size.endsWith('gb')) { const [amount] = size.split('gb'); - return Number(amount) * 1073741824; + return BigNumberUtilities.new(Number(amount)).multipliedBy(1073741824); } if (size.endsWith('mb')) { const [amount] = size.split('mb'); - return Number(amount) * 1048576; + return BigNumberUtilities.new(Number(amount)).multipliedBy(1048576); } if (size.endsWith('kb')) { const [amount] = size.split('kb'); - return Number(amount) * 1024; + return BigNumberUtilities.new(Number(amount)).multipliedBy(1024); } const [amount] = size.split('b'); - return Number(amount); + return BigNumberUtilities.new(Number(amount)); } /** diff --git a/src/entity/models/index.ts b/src/entity/models/index.ts index d35b75c..6eb9ec3 100644 --- a/src/entity/models/index.ts +++ b/src/entity/models/index.ts @@ -9,5 +9,4 @@ export * from './one-to-many-property-metadata.model'; export * from './one-to-one-property-metadata.model'; export * from './many-to-many-property-metadata.model'; export * from './relation.enum'; -export * from './file-property-metadata.model'; export * from './unknown-property-metadata.model'; \ No newline at end of file diff --git a/src/error-handling/errors/content-too-large.error.ts b/src/error-handling/errors/content-too-large.error.ts new file mode 100644 index 0000000..0518c26 --- /dev/null +++ b/src/error-handling/errors/content-too-large.error.ts @@ -0,0 +1,12 @@ +import { HttpError } from './http.error'; +import { HttpStatus } from '../../http'; + +/** + * An error to throw when the user send some content that is larger than allowed. + */ +export class ContentTooLargeError extends HttpError { + constructor(message: string | string[] = 'request too large', options?: ErrorOptions) { + super(message, HttpStatus.CONTENT_TOO_LARGE, 'Content Too Large', options); + this.name = 'ContentTooLargeError'; + } +} \ No newline at end of file diff --git a/src/error-handling/errors/index.ts b/src/error-handling/errors/index.ts index e1b1f39..316c4aa 100644 --- a/src/error-handling/errors/index.ts +++ b/src/error-handling/errors/index.ts @@ -7,4 +7,5 @@ export * from './validation.error'; export * from './unauthorized.error'; export * from './too-many-requests.error'; export * from './conflict.error'; -export * from './missing-entities.error'; \ No newline at end of file +export * from './missing-entities.error'; +export * from './content-too-large.error'; \ No newline at end of file diff --git a/src/error-handling/errors/missing-entities.error.ts b/src/error-handling/errors/missing-entities.error.ts index a800524..ed30f05 100644 --- a/src/error-handling/errors/missing-entities.error.ts +++ b/src/error-handling/errors/missing-entities.error.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; import { Newable } from '../../types'; /** diff --git a/src/error-handling/errors/validation.error.ts b/src/error-handling/errors/validation.error.ts index ff29dc4..0064269 100644 --- a/src/error-handling/errors/validation.error.ts +++ b/src/error-handling/errors/validation.error.ts @@ -21,7 +21,7 @@ export class ValidationError extends BadRequestError { for (const problem of problems) { paragraphs.push(`- ${problem.key}: ${problem.message}`); } - super(startMessage[type], options); + super(paragraphs.join('\n'), options); this.name = 'ValidationError'; this.title = 'Validation Error'; this.paragraphs = paragraphs; diff --git a/src/global/global-registry.ts b/src/global/global-registry.ts index 2b69ec4..fe84ffe 100644 --- a/src/global/global-registry.ts +++ b/src/global/global-registry.ts @@ -3,7 +3,7 @@ import { UserRepositories } from '../auth'; import { BackupResourceInterface } from '../backup'; import { BaseDataSource } from '../data-source'; import { DiProvider } from '../di'; -import { BaseEntity } from '../entity'; +import { BaseEntity } from '../entity/base-entity.model'; import { BodyParserInterface } from '../parsing'; import { Newable, Version } from '../types'; diff --git a/src/http-client/http-client-response.model.ts b/src/http-client/http-client-response.model.ts new file mode 100644 index 0000000..b7c4beb --- /dev/null +++ b/src/http-client/http-client-response.model.ts @@ -0,0 +1,49 @@ +import { AxiosResponse } from 'axios'; + +import { MimeType, KnownHeader } from '../http'; +import { FormData } from '../parsing'; +import { BodyMetadata } from '../routing'; +import { OmitStrict } from '../types'; + +/** + * Definition for a response from using the http client. + */ +export type HttpClientResponse< + T = undefined, + HeaderParamsObject extends Record = Partial> +> = OmitStrict & { + /** + * The headers of the response. + */ + headers: HeaderParamsObject, + /** + * The raw body of the response as returned by. No parsing or validation has happened here. + */ + rawBody: unknown, + /** + * The parsed and validated response body. + * + * When no responseBody definition has been provided by the request than this is undefined. + */ + body: T +}; + +/** + * The response for the given response body type. + */ +export type HttpClientResponseForBodyType< + T extends object, + HeaderParamsObject extends Record, + BodyType extends BodyMetadata['type'] +> = BodyType extends MimeType.FORM_DATA + ? HttpClientResponse, HeaderParamsObject> + : HttpClientResponse; + +/** + * Checks if the given value is a HttpClientResponse. + * @param value - The value to check. + * @returns True when the value is an object with a key "rawBody", false otherwise. + */ +export function isHttpClientResponse(value: unknown): value is HttpClientResponse { + return typeof value === 'object' && value != undefined && 'rawBody' in value; +} \ No newline at end of file diff --git a/src/http-client/http-client.interface.ts b/src/http-client/http-client.interface.ts new file mode 100644 index 0000000..dfcbcc8 --- /dev/null +++ b/src/http-client/http-client.interface.ts @@ -0,0 +1,171 @@ +import { HttpClientResponseForBodyType } from './http-client-response.model'; +import { KnownHeader, MimeType } from '../http'; +import { BodyMetadata, BodyMetadataInput, HeaderMetaInputObjectToMetaObject, HeaderMetaObjectToParamsObject, HeaderParamMetadataInput } from '../routing'; +import { Newable, OmitStrict } from '../types'; + +/** + * Possible values to send as headers when using the http client. + */ +export type HttpClientHeaderValue = string | string[] | number | boolean | undefined; + +/** + * Options for sending a http request with the client. + */ +type HttpOptions< + T extends object, + QueryParamsObject extends Record, + HeaderParamsObject extends Record, + ResponseHeaderMetaInputObject extends Record, + BodyType extends BodyMetadata['type'] +> = { + /** + * The query parameters of the request, as an object. + */ + query: QueryParamsObject, + /** + * The header parameters of the request, as an object. + */ + headers: HeaderParamsObject, + /** + * The timeout for the request in ms. + */ + timeoutMs: number, + /** + * The amount of times that the request should be retried before finally failing. + */ + retries: number, + /** + * Definition on how the response body should look like. Can either be a class that defines the structure of the body or a full body metadata definition. + * + * THIS ALSO ACTUALLY VALIDATES THE RESPONSE. + */ + responseBody: Newable | OmitStrict & { + /** + * The type of the response. + */ + type: BodyType, + /** + * The class that defines the structure of the body. + */ + modelClass: Newable + }, + /** + * Definition of the expected response headers. Also handles validating them. + */ + responseHeaders: ResponseHeaderMetaInputObject +}; + +/** + * Input for creating http options. + */ +export type HttpOptionsInput< + T extends object, + QueryParamsObject extends Record = Record, + HeaderParamsObject extends Record = Partial>, + ResponseHeaderMetaInputObject extends Record = Record, + BodyType extends BodyMetadata['type'] = MimeType.JSON +> = Partial>; + +/** + * Interface for a http client. + */ +export interface HttpClientInterface { + /** + * Sends a http post request. + */ + post: < + T extends object, + QueryParamsObject extends Record = Record, + HeaderParamsObject extends Record = Partial>, + ResponseHeaderMetaInputObject extends Record = Record, + BodyType extends BodyMetadata['type'] = MimeType.JSON + >( + url: string, + body: unknown, + options?: HttpOptionsInput + ) => Promise< + HttpClientResponseForBodyType< + T, + HeaderMetaObjectToParamsObject>, + BodyType + > + >, + /** + * Sends a http get request. + */ + get: < + T extends object, + QueryParamsObject extends Record = Record, + HeaderParamsObject extends Record = Partial>, + ResponseHeaderMetaInputObject extends Record = Record, + BodyType extends BodyMetadata['type'] = MimeType.JSON + >( + url: string, + options?: HttpOptionsInput + ) => Promise< + HttpClientResponseForBodyType< + T, + HeaderMetaObjectToParamsObject>, + BodyType + > + >, + /** + * Sends a http put request. + */ + put: < + T extends object, + QueryParamsObject extends Record = Record, + HeaderParamsObject extends Record = Partial>, + ResponseHeaderMetaInputObject extends Record = Record, + BodyType extends BodyMetadata['type'] = MimeType.JSON + >( + url: string, + body: unknown, + options?: HttpOptionsInput + ) => Promise< + HttpClientResponseForBodyType< + T, + HeaderMetaObjectToParamsObject>, + BodyType + > + >, + /** + * Sends a http patch request. + */ + patch: < + T extends object, + QueryParamsObject extends Record = Record, + HeaderParamsObject extends Record = Partial>, + ResponseHeaderMetaInputObject extends Record = Record, + BodyType extends BodyMetadata['type'] = MimeType.JSON + >( + url: string, + body: unknown, + options?: HttpOptionsInput + ) => Promise< + HttpClientResponseForBodyType< + T, + HeaderMetaObjectToParamsObject>, + BodyType + > + >, + /** + * Sends a http delete request. + */ + delete: < + T extends object, + QueryParamsObject extends Record = Record, + HeaderParamsObject extends Record = Partial>, + ResponseHeaderMetaInputObject extends Record = Record, + BodyType extends BodyMetadata['type'] = MimeType.JSON + >( + url: string, + options?: HttpOptionsInput + ) => Promise< + HttpClientResponseForBodyType< + T, + HeaderMetaObjectToParamsObject>, + BodyType + > + > +} \ No newline at end of file diff --git a/src/http-client/http-client.test.ts b/src/http-client/http-client.test.ts new file mode 100644 index 0000000..e25b791 --- /dev/null +++ b/src/http-client/http-client.test.ts @@ -0,0 +1,119 @@ +import { createServer, Server } from 'http'; +import { AddressInfo } from 'net'; + +import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; +import express from 'express'; +import NodeFormData from 'form-data'; + +import { Property } from '../entity'; +import { HttpClientResponse } from './http-client-response.model'; +import { HttpClientInterface } from './http-client.interface'; +import { inject, ZIBRI_DI_TOKENS } from '../di'; +import { KnownHeader } from '../http/known-header.enum'; +import { MimeType } from '../http/mime-type.enum'; +import { File, FormData, FormDataBodyParser, JsonBodyParser, Parser } from '../parsing'; + +class Item { + @Property.string() + name!: string; + + @Property.number() + value!: number; +} + +class FormDataItem { + @Property.file({ allowedMimeTypes: [MimeType.JSON] }) + file!: File; + + @Property.array({ items: { type: 'file' }, totalMaxSize: '5mb' }) + files!: File[]; +} + +describe('post', () => { + let server: Server; + let baseUrl: string; + let http: HttpClientInterface; + + beforeAll(async () => { + // create mock api + const app: express.Express = express(); + app.post('/valid', (_req, res) => { + res.json({ name: 'ok', value: 42 }); + }); + app.post('/invalid', (_req, res) => { + res.json({ name: 'broken', value: 'not-a-number' }); + }); + app.post('/form-data/valid', (_req, res) => { + const form: NodeFormData = new NodeFormData(); + form.append('file', JSON.stringify({ hello: 'world' }), { + filename: 'payload.json', + contentType: MimeType.JSON + }); + form.append('files', JSON.stringify({ hello: 'world2' }), { + filename: 'files.json', + contentType: MimeType.JSON + }); + form.append('files', JSON.stringify({ hello: 'world3' }), { + filename: 'files.json', + contentType: MimeType.JSON + }); + const headers: NodeFormData.Headers = form.getHeaders(); + res.setHeader(KnownHeader.CONTENT_TYPE, headers['content-type'] as string); + form.pipe(res); + }); + app.post('/form-data/invalid', (_req, res) => { + const form: NodeFormData = new NodeFormData(); + form.append('file', JSON.stringify({ hello: 'world' }), { + filename: 'payload.json', + contentType: MimeType.JSON + }); + const headers: NodeFormData.Headers = form.getHeaders(); + res.setHeader(KnownHeader.CONTENT_TYPE, headers['content-type'] as string); + form.pipe(res); + }); + // eslint-disable-next-line typescript/no-misused-promises + server = createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + + const parser: Parser = inject(ZIBRI_DI_TOKENS.PARSER); + parser['bodyParsers'].push(inject(JsonBodyParser), inject(FormDataBodyParser)); + http = inject(ZIBRI_DI_TOKENS.HTTP_CLIENT); + }); + + afterAll(async () => { + await new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve())); + }); + + it('valid response should pass validation', async () => { + const res: HttpClientResponse = await http.post(`${baseUrl}/valid`, undefined, { responseBody: Item }); + expect(res).toBeDefined(); + expect(res.body.name).toBe('ok'); + expect(res.body.value).toBe(42); + }); + + it('invalid response should trigger validation error', async () => { + await expect(http.post(`${baseUrl}/invalid`, undefined, { responseBody: Item })).rejects.toThrow(); + }); + + it('valid form data response should pass validation', async () => { + const res: HttpClientResponse> = await http.post( + `${baseUrl}/form-data/valid`, + undefined, + { + responseBody: { + modelClass: FormDataItem, + type: MimeType.FORM_DATA + } + } + ); + expect(res).toBeDefined(); + expect(res.body.value.file.size).toBe(17); + expect(res.body.value.files.length).toBe(2); + }); + + it('invalid form data response should trigger validation error', async () => { + await expect(http.post(`${baseUrl}/form-data/invalid`, undefined, { responseBody: { modelClass: FormDataItem, type: MimeType.FORM_DATA } })).rejects.toThrow(); + }); +}); \ No newline at end of file diff --git a/src/http-client/http-client.ts b/src/http-client/http-client.ts new file mode 100644 index 0000000..ca7dc66 --- /dev/null +++ b/src/http-client/http-client.ts @@ -0,0 +1,270 @@ +import axios, { AxiosInstance, AxiosResponse, RawAxiosRequestConfig, ResponseType } from 'axios'; + +import { HttpClientResponse, HttpClientResponseForBodyType } from './http-client-response.model'; +import { HttpClientHeaderValue, HttpClientInterface, HttpOptionsInput } from './http-client.interface'; +import { Inject, ZIBRI_DI_TOKENS } from '../di'; +import { HttpMethod } from '../http/http-method.enum'; +import { KnownHeader } from '../http/known-header.enum'; +import { MimeType } from '../http/mime-type.enum'; +import { type ParserInterface } from '../parsing/parser.interface'; +import { BodyMetadata, HeaderMetaInputObjectToMetaObject, HeaderMetaObjectToParamsObject, HeaderParamMetadata, HeaderParamMetadataInput, resolveMaxBodySize } from '../routing'; +import { createHeaderParamMetadata } from '../routing/param-metdata.helpers'; +import { Newable } from '../types'; +import { Ms } from '../utilities'; +import { type ValidationServiceInterface } from '../validation/validation-service.interface'; + +const responseTypeForMimeType: Record = { + [MimeType.JSON]: 'json', + [MimeType.XML]: 'text', + [MimeType.HTML]: 'text', + [MimeType.TXT]: 'text', + [MimeType.FORM_DATA]: 'stream', + [MimeType.OCTET_STREAM]: 'stream', + [MimeType.PNG]: 'stream', + [MimeType.JPEG]: 'stream', + [MimeType.ZIP]: 'stream', + [MimeType.SVG]: 'stream', + [MimeType.CSS]: 'stream', + [MimeType.TTF]: 'stream', + [MimeType.PDF]: 'stream', + [MimeType.CSV]: 'stream', + [MimeType.XLSX]: 'stream', + [MimeType.DOCX]: 'stream' +}; + +/** + * Default http client implementation of Zibri. + */ +export class HttpClient implements HttpClientInterface { + private readonly axios: AxiosInstance; + + constructor( + @Inject(ZIBRI_DI_TOKENS.PARSER) + private readonly parser: ParserInterface, + @Inject(ZIBRI_DI_TOKENS.VALIDATION_SERVICE) + private readonly validationService: ValidationServiceInterface + ) { + this.axios = axios.create(); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async post< + T extends object, + QueryParamsObject extends Record = Record, + HeaderParamsObject extends Record = Partial>, + ResponseHeaderMetaInputObject extends Record = Record, + BodyType extends BodyMetadata['type'] = MimeType.JSON + >( + url: string, + body: unknown, + options: HttpOptionsInput = {} + ): Promise< + HttpClientResponseForBodyType< + T, + HeaderMetaObjectToParamsObject>, + BodyType + > + > { + return await this.request(HttpMethod.POST, url, body, options); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async get< + T extends object, + QueryParamsObject extends Record = Record, + HeaderParamsObject extends Record = Partial>, + ResponseHeaderMetaInputObject extends Record = Record, + BodyType extends BodyMetadata['type'] = MimeType.JSON + >( + url: string, + options: HttpOptionsInput = {} + ): Promise< + HttpClientResponseForBodyType< + T, + HeaderMetaObjectToParamsObject>, + BodyType + > + > { + return await this.request(HttpMethod.GET, url, undefined, options); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async put< + T extends object, + QueryParamsObject extends Record = Record, + HeaderParamsObject extends Record = Partial>, + ResponseHeaderMetaInputObject extends Record = Record, + BodyType extends BodyMetadata['type'] = MimeType.JSON + >( + url: string, + body: unknown, + options: HttpOptionsInput = {} + ): Promise< + HttpClientResponseForBodyType< + T, + HeaderMetaObjectToParamsObject>, + BodyType + > + > { + return await this.request(HttpMethod.PUT, url, body, options); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async patch< + T extends object, + QueryParamsObject extends Record = Record, + HeaderParamsObject extends Record = Partial>, + ResponseHeaderMetaInputObject extends Record = Record, + BodyType extends BodyMetadata['type'] = MimeType.JSON + >( + url: string, + body: unknown, + options: HttpOptionsInput = {} + ): Promise< + HttpClientResponseForBodyType< + T, + HeaderMetaObjectToParamsObject>, + BodyType + > + > { + return await this.request(HttpMethod.PATCH, url, body, options); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async delete< + T extends object, + QueryParamsObject extends Record = Record, + HeaderParamsObject extends Record = Partial>, + ResponseHeaderMetaInputObject extends Record = Record, + BodyType extends BodyMetadata['type'] = MimeType.JSON + >( + url: string, + options: HttpOptionsInput = {} + ): Promise< + HttpClientResponseForBodyType< + T, + HeaderMetaObjectToParamsObject>, + BodyType + > + > { + return await this.request(HttpMethod.DELETE, url, undefined, options); + } + + // eslint-disable-next-line sonar/cognitive-complexity + private async request< + T extends object, + QueryParamsObject extends Record, + HeaderParamsObject extends Record, + ResponseHeaderMetaInputObject extends Record, + BodyType extends BodyMetadata['type'] + >( + method: HttpMethod, + url: string, + requestBody: unknown, + options: HttpOptionsInput | undefined + ): Promise< + HttpClientResponseForBodyType< + T, + HeaderMetaObjectToParamsObject>, + BodyType + > + > { + const config: RawAxiosRequestConfig = { + timeout: options?.timeoutMs, + headers: options?.headers, + params: options?.query + }; + if (options?.responseBody && 'modelClass' in options.responseBody && options.responseBody.type != undefined) { + config.responseType = responseTypeForMimeType[options.responseBody.type]; + } + + let axiosResponse: AxiosResponse | undefined = undefined; + let error: unknown = undefined; + + for (let i: number = 0; i < (options?.retries ?? 1); i++) { + if (axiosResponse != undefined) { + continue; + } + try { + switch (method) { + case HttpMethod.POST: { + axiosResponse = await this.axios.post(url, requestBody, config); + break; + } + case HttpMethod.GET: { + axiosResponse = await this.axios.get(url, config); + break; + } + case HttpMethod.PUT: { + axiosResponse = await this.axios.put(url, requestBody, config); + break; + } + case HttpMethod.PATCH: { + axiosResponse = await this.axios.patch(url, requestBody, config); + break; + } + case HttpMethod.DELETE: { + axiosResponse = await this.axios.delete(url, config); + break; + } + case HttpMethod.HEAD: + case HttpMethod.OPTIONS: + case HttpMethod.TRACE: { + throw new Error('Not implemented yet.'); + } + } + } + catch (error_) { + error = error_; + } + } + + if (!axiosResponse) { + throw error instanceof Error ? error : new Error('Could not get a response'); + } + + const res: HttpClientResponseForBodyType< + T, + HeaderMetaObjectToParamsObject>, + BodyType + > = { + rawBody: axiosResponse.data, + body: undefined as unknown as T, + status: axiosResponse.status, + statusText: axiosResponse.statusText, + headers: axiosResponse.headers as HeaderParamsObject + } as HttpClientResponseForBodyType< + T, + HeaderMetaObjectToParamsObject>, + BodyType + >; + if (!options?.responseBody) { + return res; + } + const modelClass: Newable = 'modelClass' in options.responseBody ? options.responseBody.modelClass : options.responseBody; + const metadata: BodyMetadata = { + index: 0, + required: true, + description: undefined, + type: MimeType.JSON, + cleanupAfterMs: Ms.DAY, + modelClass, + maxSize: resolveMaxBodySize(modelClass, ('modelClass' in options.responseBody ? options.responseBody : {}).baseMaxSize), + ...'modelClass' in options.responseBody ? options.responseBody : {} + } as BodyMetadata; + + const responseBody: unknown = await this.parser.parseBody(res as unknown as HttpClientResponse, metadata); + this.validationService.validateBody(responseBody, metadata); + + for (const key in options.responseHeaders) { + const headerMetadata: HeaderParamMetadata = createHeaderParamMetadata(key, options.responseHeaders[key]); + (res.headers[key] as unknown) = this.parser.parseHeaderParam( + res as unknown as HttpClientResponse, + headerMetadata + ); + this.validationService.validateHeaderParam(res.headers[key], headerMetadata); + } + + return { ...res, body: responseBody }; + } +} \ No newline at end of file diff --git a/src/http-client/index.ts b/src/http-client/index.ts new file mode 100644 index 0000000..6c3e603 --- /dev/null +++ b/src/http-client/index.ts @@ -0,0 +1,3 @@ +export * from './http-client'; +export * from './http-client.interface'; +export * from './http-client-response.model'; \ No newline at end of file diff --git a/src/http/http-status.enum.ts b/src/http/http-status.enum.ts index 99c58c8..714b14f 100644 --- a/src/http/http-status.enum.ts +++ b/src/http/http-status.enum.ts @@ -35,7 +35,7 @@ export enum HttpStatus { NOT_ACCEPTABLE = 406, CONFLICT = 409, GONE = 410, - PAYLOAD_TOO_LARGE = 413, + CONTENT_TOO_LARGE = 413, URI_TOO_LONG = 414, UNSUPPORTED_MEDIA_TYPE = 415, UNPROCESSABLE_ENTITY = 422, diff --git a/src/index.ts b/src/index.ts index e9bdbbf..20ef79f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,8 @@ export * from './global'; export * from './logging'; export * from './open-api'; export * from './entity'; +export * from './entity/base-entity.model'; +export * from './entity/models/file-property-metadata.model'; export * from './parsing'; export * from './http'; export * from './validation'; @@ -27,6 +29,7 @@ export * from './localization'; export * from './multithreading'; export * from './websocket'; export * from './backup'; +export * from './http-client'; export * from './types/newable.model'; export * from './types/version.type'; diff --git a/src/logging/log.model.ts b/src/logging/log.model.ts index 13caaa5..7d0f4ea 100644 --- a/src/logging/log.model.ts +++ b/src/logging/log.model.ts @@ -1,7 +1,8 @@ -import { BaseEntity, Entity, Property } from '../entity'; +import { Entity, Property } from '../entity'; import { LogContext } from './log-context.model'; import { LogLevel } from './log-level.enum'; import { LoggedError } from './logged-error.model'; +import { BaseEntity } from '../entity/base-entity.model'; /** * The data saved for a log entry. diff --git a/src/multithreading/models/thread-job-entity.model.ts b/src/multithreading/models/thread-job-entity.model.ts index 3efc9d5..9cb1dce 100644 --- a/src/multithreading/models/thread-job-entity.model.ts +++ b/src/multithreading/models/thread-job-entity.model.ts @@ -1,4 +1,5 @@ -import { BaseEntity, Entity, Property } from '../../entity'; +import { Entity, Property } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; import type { OmitStrict, Percentage } from '../../types'; import { ThreadJob } from '../services'; import { BaseThreadJobWorkerData } from './base-thread-job-worker-data.model'; diff --git a/src/open-api/open-api.service.ts b/src/open-api/open-api.service.ts index 38f2043..56a4dc1 100644 --- a/src/open-api/open-api.service.ts +++ b/src/open-api/open-api.service.ts @@ -7,9 +7,10 @@ import { ZibriApplication } from '../application'; import { AssetServiceInterface } from '../assets'; import { AuthServiceInterface, BelongsToMetadata, HasRoleMetadata, IsLoggedInMetadata } from '../auth'; import { inject, ZIBRI_DI_TOKENS } from '../di'; -import { BaseEntity, ManyToManyPropertyMetadata, ManyToOnePropertyMetadata, OmitClass, OneToManyPropertyMetadata, OneToOnePropertyMetadata, PropertyMetadata, Relation } from '../entity'; +import { ManyToManyPropertyMetadata, ManyToOnePropertyMetadata, OmitClass, OneToManyPropertyMetadata, OneToOnePropertyMetadata, PropertyMetadata, Relation } from '../entity'; +import { BaseEntity } from '../entity/base-entity.model'; import { GlobalRegistry } from '../global'; -import { HttpMethod, HttpStatus, MimeType } from '../http'; +import { HttpMethod, HttpStatus, KnownHeader, MimeType } from '../http'; import { LoggerInterface } from '../logging'; import { BodyMetadata, ControllerRouteConfiguration, HeaderParamMetadata, PathParamMetadata, QueryParamMetadata, Route, RouteHandler } from '../routing'; import { OpenApiServiceInterface } from './open-api-service.interface'; @@ -49,7 +50,7 @@ const defaultDescriptionForHttpStatus: Record = [HttpStatus.NOT_ACCEPTABLE]: '', [HttpStatus.CONFLICT]: '', [HttpStatus.GONE]: '', - [HttpStatus.PAYLOAD_TOO_LARGE]: '', + [HttpStatus.CONTENT_TOO_LARGE]: '', [HttpStatus.URI_TOO_LONG]: '', [HttpStatus.UNSUPPORTED_MEDIA_TYPE]: '', [HttpStatus.UNPROCESSABLE_ENTITY]: '', @@ -121,7 +122,7 @@ export class OpenApiService implements OpenApiServiceInterface { ' layout: "StandaloneLayout",', ' requestInterceptor: (req) => {', ' req.headers.Accept = \'application/json\'', - ' req.headers[\'Content-Type\'] = \'application/json\'', + ` req.headers['${KnownHeader.CONTENT_TYPE}'] = \'application/json\'`, ' return req;', ' },', ' defaultModelRendering: \'model\'', diff --git a/src/parsing/body-parser.interface.ts b/src/parsing/body-parser.interface.ts index a0c689a..e934a7c 100644 --- a/src/parsing/body-parser.interface.ts +++ b/src/parsing/body-parser.interface.ts @@ -1,5 +1,6 @@ import { ZibriApplication } from '../application'; import { HttpRequest, MimeType } from '../http'; +import { HttpClientResponse } from '../http-client'; import { BodyMetadata } from '../routing'; import { WebsocketRequest } from '../websocket'; @@ -18,5 +19,13 @@ export interface BodyParserInterface { /** * Parses the body of the http request. */ - parse: (req: HttpRequest | WebsocketRequest, bodyMetadata: BodyMetadata) => Promise + parseFromHttpRequest: (req: HttpRequest, bodyMetadata: BodyMetadata) => unknown | Promise, + /** + * Parses the body of the websocket request. + */ + parseFromWebsocketRequest: (req: WebsocketRequest, bodyMetadata: BodyMetadata) => unknown | Promise, + /** + * Parses the body of the http response. + */ + parseFromHttpClientResponse: (res: HttpClientResponse, bodyMetadata: BodyMetadata) => unknown | Promise } \ No newline at end of file diff --git a/src/parsing/form-data/file.model.ts b/src/parsing/form-data/file.model.ts index ee1c957..9c54624 100644 --- a/src/parsing/form-data/file.model.ts +++ b/src/parsing/form-data/file.model.ts @@ -1,16 +1,9 @@ import { Property } from '../../entity'; -import type { OmitStrict } from '../../types'; - -/** - * The Multer file type. - */ -export type MulterFile = OmitStrict; /** * A resolved file from a multipart/form-data request. - * Has the same properties as the File from multer but adds property metadata. */ -export class File implements MulterFile { +export class File { // eslint-disable-next-line jsdoc/require-jsdoc @Property.string() fieldname: string; @@ -39,7 +32,7 @@ export class File implements MulterFile { @Property.string() path: string; - constructor(file: MulterFile) { + constructor(file: File) { this.destination = file.destination; this.fieldname = file.fieldname; this.filename = file.filename; diff --git a/src/parsing/form-data/form-data.body-parser.ts b/src/parsing/form-data/form-data.body-parser.ts index 2a96795..16b029c 100644 --- a/src/parsing/form-data/form-data.body-parser.ts +++ b/src/parsing/form-data/form-data.body-parser.ts @@ -1,21 +1,32 @@ -import { rm } from 'fs/promises'; +import { createWriteStream, WriteStream } from 'fs'; +import { mkdir, rm } from 'fs/promises'; import path from 'path'; +import { Readable } from 'stream'; +import { pipeline } from 'stream/promises'; -import { Request, RequestHandler } from 'express'; -import multer, { StorageEngine } from 'multer'; +import { Busboy, BusboyFileStream } from '@fastify/busboy'; -import { File, type MulterFile } from './file.model'; +import { File } from './file.model'; import { ZibriApplication } from '../../application'; import { inject, ZIBRI_DI_TOKENS } from '../../di'; -import { FileExtension, HttpRequest, HttpResponse, isHttpRequest, MimeType, resolveFileExtension } from '../../http'; +import { ContentTooLargeError } from '../../error-handling'; +import { FileExtension, HttpRequest, KnownHeader, MimeType, resolveFileExtension } from '../../http'; +import { HttpClientResponse } from '../../http-client'; import { BodyMetadata } from '../../routing'; import { BodyParserInterface } from '../body-parser.interface'; import { BodyParser } from '../decorators'; import { FormDataBodyParserCleanupCronJob } from './form-data-body-parser-cleanup.cron-job'; import { FormData, FormDataValue } from './form-data.model'; import { PropertyMetadata } from '../../entity'; -import { MetadataUtilities, UUIDUtilities } from '../../utilities'; -import { WebsocketRequest } from '../../websocket'; +import { BigNumberUtilities, MetadataUtilities, UUIDUtilities } from '../../utilities'; + +// eslint-disable-next-line jsdoc/require-jsdoc +type ParsedForm = { + // eslint-disable-next-line jsdoc/require-jsdoc + fields: Record, + // eslint-disable-next-line jsdoc/require-jsdoc + filesMap: Record +}; /** * Body parser for form data. @@ -31,59 +42,71 @@ export class FormDataBodyParser implements BodyParserInterface { } // eslint-disable-next-line jsdoc/require-jsdoc - async parse(req: HttpRequest | WebsocketRequest, metadata: BodyMetadata): Promise> { - if (!isHttpRequest(req)) { - throw new Error('A form data body cannot be used with websocket requests'); - } - if (req.body !== undefined) { - return req.body as FormData; + parseFromWebsocketRequest(): unknown { + throw new Error('A form data body cannot be used with websocket requests'); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async parseFromHttpRequest(req: HttpRequest, metadata: BodyMetadata): Promise { + return await this.parseFromBody(req.body, metadata, req.headers, req); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async parseFromHttpClientResponse( + res: HttpClientResponse, + metadata: BodyMetadata + ): Promise { + return await this.parseFromBody(res.body, metadata, res.headers, res.rawBody as Readable); + } + + private async parseFromBody( + body: unknown, + metadata: BodyMetadata, + headers: Partial>, + stream: Readable + ): Promise { + if (body !== undefined) { + return body as FormData; } if (metadata.type !== MimeType.FORM_DATA) { throw new Error(`${metadata.type} is not supported`); } + const contentLength: string | undefined = headers[KnownHeader.CONTENT_LENGTH] ?? headers[KnownHeader.CONTENT_LENGTH]; + if (contentLength && BigNumberUtilities.new(Number(contentLength)).isGreaterThan(metadata.maxSize)) { + throw new ContentTooLargeError(); + } + const tempFolder: string = this.getTempFolder(); + + try { + const parsed: ParsedForm = await this.parseMultipartStreamToDisk(stream, headers, tempFolder, metadata); + const formDataValue: typeof metadata.modelClass = this.requestToDataObject(parsed, metadata); + const formData: FormData = await FormData.create( + formDataValue, + tempFolder, + metadata.cleanupAfterMs + ); + return formData; + } + catch (error) { + // Clean up files on error + await this.removeTempFolder(tempFolder); + throw error; + } + } + + private getTempFolder(): string { const tempPath: string = inject(ZIBRI_DI_TOKENS.FILE_UPLOAD_TEMP_FOLDER); - const tempFolder: string = path.join(tempPath, `temp-${UUIDUtilities.generate()}`); - const storage: StorageEngine = multer.diskStorage({ - destination: tempFolder, - // eslint-disable-next-line promise/prefer-await-to-callbacks - filename: (_, file, callback) => { - const id: string = UUIDUtilities.generate(); - const ext: FileExtension | undefined = resolveFileExtension(file.mimetype); - if (ext) { - // eslint-disable-next-line promise/prefer-await-to-callbacks, unicorn/no-null - callback(null, `${id}${ext}`); - } - // eslint-disable-next-line promise/prefer-await-to-callbacks, unicorn/no-null - callback(null, id); - } - }); - const upload: RequestHandler = multer({ storage: storage }).any(); - - return new Promise>((resolve, reject) => { - // eslint-disable-next-line typescript/no-misused-promises, promise/prefer-await-to-callbacks - void upload(req as Request, {} as HttpResponse, async (err: unknown) => { - if (err != undefined) { - await this.removeTempFolder(tempFolder); - reject(err); - } - if (req.body != undefined && typeof req.body !== 'object') { - await this.removeTempFolder(tempFolder); - reject('The request body is not an object'); - } - try { - const formDataValue: object = this.requestToDataObject(req, metadata); - const formData: FormData = await FormData.create(formDataValue, tempFolder, metadata.cleanupAfterMs); - // Update cleanup cron job. - resolve(formData); - } - catch (error) { - await this.removeTempFolder(tempFolder); - reject(error); - } + return path.join(tempPath, `temp-${UUIDUtilities.generate()}`); + } - }); - }); + private getTempFileName(mimetype: string): string { + const id: string = UUIDUtilities.generate(); + const ext: FileExtension | undefined = resolveFileExtension(mimetype); + if (ext) { + return `${id}${ext}`; + } + return id; } private async removeTempFolder(tempFolder: string): Promise { @@ -95,7 +118,7 @@ export class FormDataBodyParser implements BodyParserInterface { } } - private requestToDataObject(request: HttpRequest, metadata: BodyMetadata): T { + private requestToDataObject(request: ParsedForm, metadata: BodyMetadata): T { const multiPartMap: Map = new Map(); this.addStringValuesToMap(request, multiPartMap); this.addFilesToMap(request, multiPartMap, metadata); @@ -115,55 +138,37 @@ export class FormDataBodyParser implements BodyParserInterface { } private addFilesToMap( - request: HttpRequest, + request: ParsedForm, values: Map, metadata: BodyMetadata ): void { - if (!request.files) { - return; - } - - if (Array.isArray(request.files)) { - for (const file of request.files) { - const formDataProperties: Record = MetadataUtilities.getModelProperties(metadata.modelClass); - const property: PropertyMetadata = formDataProperties[file.fieldname]; - this.addSingleFileToMap(file, values, property); - } - return; - } - - for (const key in request.files) { - this.addFileArrayToMap(key, request.files[key], values); + for (const key in request.filesMap) { + const formDataProperties: Record = MetadataUtilities.getModelProperties(metadata.modelClass); + const property: PropertyMetadata = formDataProperties[key]; + this.addFileArrayToMap(request.filesMap[key], values, property); } } - private addFileArrayToMap(key: string, multerFiles: MulterFile[], values: Map): void { - const existingValue: FormDataValue = values.get(key as keyof T); - if (typeof existingValue === 'string') { - throw new Error('Your form-data contains files and strings for the same key.'); - } - const files: File[] = multerFiles.map(f => new File(f)); - if (existingValue == undefined) { - values.set(key as keyof T, files); - return; - } - if (Array.isArray(existingValue)) { - existingValue.push(...files); - return; + private addFileArrayToMap( + rawFiles: File[], + values: Map, + propertyMetadata: PropertyMetadata + ): void { + for (const file of rawFiles) { + this.addSingleFileToMap(file, values, propertyMetadata); } - values.set(key as keyof T, [existingValue, ...files]); } private addSingleFileToMap( - multerFile: MulterFile, + rawFile: File, values: Map, propertyMetadata: PropertyMetadata ): void { - const existingValue: FormDataValue | undefined = values.get(multerFile.fieldname as keyof T); + const existingValue: FormDataValue | undefined = values.get(rawFile.fieldname as keyof T); if (typeof existingValue === 'string') { throw new Error('Your form-data contains files and strings for the same key.'); } - const file: File = new File(multerFile); + const file: File = new File(rawFile); if (existingValue == undefined) { if (propertyMetadata.type === 'array') { values.set(file.fieldname as keyof T, [file]); @@ -179,12 +184,143 @@ export class FormDataBodyParser implements BodyParserInterface { values.set(file.fieldname as keyof T, [existingValue, file]); } - private addStringValuesToMap(request: HttpRequest, values: Map): void { - if (request.body == undefined || typeof request.body !== 'object') { + private addStringValuesToMap(request: ParsedForm, values: Map): void { + if (request.fields == undefined || typeof request.fields !== 'object') { return; } - for (const key in request.body) { - values.set(key as keyof T, (request.body as Record)[key]); + for (const key in request.fields) { + values.set(key as keyof T, (request.fields as Record)[key]); } } + + private async parseMultipartStreamToDisk( + stream: Readable, + headers: Partial>, + tempFolder: string, + metadata: BodyMetadata + ): Promise { + const contentType: string | undefined = headers[KnownHeader.CONTENT_TYPE] + ?? headers[KnownHeader.CONTENT_TYPE.toLowerCase()]; + + await mkdir(tempFolder, { recursive: true }); + + return await new Promise((resolve, reject) => { + const bb: Busboy = Busboy({ headers: { 'content-type': contentType ?? 'string' } }); + + const fields: Record = {}; + const filesMap: Record = {}; + const filePromises: Promise[] = []; + + let received: BigNumber = BigNumberUtilities.new(0); + let aborted: boolean = false; + + bb.on('field', (name: string, val: string) => { + if (aborted) { + return; + } + + const bytes: number = Buffer.byteLength(val, 'utf8'); + received = BigNumberUtilities.add(received, bytes); + if (received.isGreaterThan(metadata.maxSize)) { + aborted = true; + // stop parsing and abort + stream.unpipe(bb); + bb.destroy(new ContentTooLargeError()); + // surface a nice error to caller + reject(new ContentTooLargeError()); + return; + } + + if (Object.prototype.hasOwnProperty.call(fields, name)) { + const cur: string | string[] = fields[name]; + if (Array.isArray(cur)) { + cur.push(val); + } + else { + fields[name] = [cur, val]; + } + } + else { + fields[name] = val; + } + }); + + bb.on('file', (fieldname: string, fileStream: BusboyFileStream, originalname: string, _: string, mimetype: string) => { + const filename: string = this.getTempFileName(mimetype); + const destination: string = path.join(tempFolder, filename); + const writeStream: WriteStream = createWriteStream(destination); + let size: number = 0; + + fileStream.on('data', (chunk: Buffer) => { + if (aborted) { + return; + } + + received = BigNumberUtilities.add(received, chunk.length); + if (received.isGreaterThan(metadata.maxSize)) { + aborted = true; + writeStream.destroy(); + stream.unpipe(bb); + bb.destroy(new ContentTooLargeError()); + stream.destroy(new ContentTooLargeError()); + reject(new ContentTooLargeError()); + return; + } + + size += chunk.length; + }); + + fileStream.on('error', (err) => { + writeStream.destroy(); + if (!aborted) { + reject(err); + } + }); + + writeStream.on('error', (err) => { + fileStream.resume(); + if (!aborted) { + reject(err); + } + }); + + writeStream.on('finish', () => { + if (aborted) { + return; + } + const meta: File = { + fieldname, + filename, + destination: tempFolder, + originalname, + mimetype, + size, + path: destination + }; + filesMap[fieldname] = filesMap[fieldname] ?? []; + filesMap[fieldname].push(meta); + }); + + filePromises.push(pipeline(fileStream, writeStream).catch(error => { + if (!aborted) { + reject(error); + } + })); + }); + + // eslint-disable-next-line typescript/no-misused-promises + bb.on('finish', async () => { + try { + await Promise.all(filePromises); + resolve({ fields, filesMap }); + } + catch (error) { + reject(error); + } + }); + bb.on('error', (err) => reject(err)); + + stream.pipe(bb); + }); + } } \ No newline at end of file diff --git a/src/parsing/functions/parse-array.test.ts b/src/parsing/functions/parse-array.test.ts index 768c25c..15dbced 100644 --- a/src/parsing/functions/parse-array.test.ts +++ b/src/parsing/functions/parse-array.test.ts @@ -21,7 +21,8 @@ describe('parseArray', () => { enum: undefined }, required: true, - description: undefined + description: undefined, + totalMaxSize: '100kb' }; it('returns undefined for undefined input', () => { diff --git a/src/parsing/json/json.body-parser.ts b/src/parsing/json/json.body-parser.ts index d455f07..6b6e87f 100644 --- a/src/parsing/json/json.body-parser.ts +++ b/src/parsing/json/json.body-parser.ts @@ -1,7 +1,8 @@ -import express from 'express'; - -import { BadRequestError } from '../../error-handling'; -import { HttpRequest, isHttpRequest, MimeType } from '../../http'; +import { BadRequestError, ContentTooLargeError } from '../../error-handling'; +import { HttpRequest, KnownHeader, MimeType } from '../../http'; +import { HttpClientResponse } from '../../http-client'; +import { BodyMetadata } from '../../routing'; +import { BigNumber, BigNumberUtilities } from '../../utilities'; import { WebsocketRequest } from '../../websocket'; import { BodyParserInterface } from '../body-parser.interface'; import { BodyParser } from '../decorators'; @@ -15,26 +16,71 @@ export class JsonBodyParser implements BodyParserInterface { readonly contentType: MimeType = MimeType.JSON; // eslint-disable-next-line jsdoc/require-jsdoc - async parse(req: HttpRequest | WebsocketRequest): Promise { + parseFromHttpClientResponse(res: HttpClientResponse): unknown { + return res.rawBody; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + parseFromWebsocketRequest(req: WebsocketRequest): unknown { + return req.body; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async parseFromHttpRequest(req: HttpRequest, metadata: BodyMetadata): Promise { if (req.body !== undefined) { return req.body; } - if (!isHttpRequest(req)) { - return req.body; + const contentLength: string | undefined = req.headers[KnownHeader.CONTENT_LENGTH] ?? req.headers[KnownHeader.CONTENT_LENGTH]; + if (contentLength && BigNumberUtilities.new(Number(contentLength)).isGreaterThan(metadata.maxSize)) { + throw new ContentTooLargeError(); } + + const chunks: Buffer[] = []; + let received: BigNumber = BigNumberUtilities.new(0); + + await new Promise((resolve, reject) => { + // eslint-disable-next-line typescript/typedef + const onData = (chunk: Buffer): void => { + received = BigNumberUtilities.add(received, chunk.length); + if (received.isGreaterThan(metadata.maxSize)) { + // eslint-disable-next-line typescript/no-use-before-define + cleanup(); + req.destroy(new ContentTooLargeError()); + reject(new ContentTooLargeError()); + return; + } + chunks.push(chunk); + }; + + // eslint-disable-next-line typescript/typedef + const onEnd = (): void => { + // eslint-disable-next-line typescript/no-use-before-define + cleanup(); + resolve(); + }; + + // eslint-disable-next-line typescript/typedef + const onError = (err: Error): void => { + // eslint-disable-next-line typescript/no-use-before-define + cleanup(); + reject(err); + }; + + // eslint-disable-next-line typescript/typedef + const cleanup = (): void => { + req.off('data', onData); + req.off('end', onEnd); + req.off('error', onError); + }; + + req.on('data', onData); + req.on('end', onEnd); + req.on('error', onError); + }); + try { - const res: unknown = await new Promise((resolve, reject) => { - // eslint-disable-next-line typescript/no-unsafe-argument, typescript/no-explicit-any - express.json({ strict: false })(req, {} as any, err => { - if (err != undefined) { - reject(err); - } - else { - resolve(req.body); - } - }); - }); - return res; + const raw: Buffer = Buffer.concat(chunks); + return raw.length > 0 ? JSON.parse(raw.toString('utf8')) : undefined; } catch { throw new BadRequestError('invalid JSON in request body'); diff --git a/src/parsing/parser.interface.ts b/src/parsing/parser.interface.ts index 4bb7943..0dd3d80 100644 --- a/src/parsing/parser.interface.ts +++ b/src/parsing/parser.interface.ts @@ -1,5 +1,6 @@ import { ZibriApplication } from '../application'; import { HttpRequest } from '../http'; +import { HttpClientResponse } from '../http-client'; import { BodyMetadata, HeaderParamMetadata, PathParamMetadata, QueryParamMetadata } from '../routing'; import { WebsocketRequest } from '../websocket'; @@ -8,9 +9,12 @@ import { WebsocketRequest } from '../websocket'; */ export interface ParserInterface { /** - * Parses the request body resolved from the given metadata. + * Parses the body resolved from the given metadata. */ - parseRequestBody: (req: HttpRequest | WebsocketRequest, metadata: BodyMetadata) => unknown | Promise, + parseBody: ( + req: HttpRequest | WebsocketRequest | HttpClientResponse, + metadata: BodyMetadata + ) => unknown | Promise, /** * Parses the path param resolved from the given metadata. */ @@ -22,7 +26,7 @@ export interface ParserInterface { /** * Parses the header param resolved from the given metadata. */ - parseHeaderParam: (req: HttpRequest | WebsocketRequest, metadata: HeaderParamMetadata) => unknown, + parseHeaderParam: (req: HttpRequest | WebsocketRequest | HttpClientResponse, metadata: HeaderParamMetadata) => unknown, /** * Attaches the parser to the Zibri application. */ diff --git a/src/parsing/parser.ts b/src/parsing/parser.ts index d2b47f6..b6f00fc 100644 --- a/src/parsing/parser.ts +++ b/src/parsing/parser.ts @@ -3,6 +3,7 @@ import { GlobalRegistry } from '../global'; import { BodyParserInterface } from './body-parser.interface'; import { HttpRequest, isHttpRequest, isMimeType, KnownHeader, MimeType } from '../http'; import { ParserInterface } from './parser.interface'; +import { HttpClientResponse, isHttpClientResponse } from '../http-client'; import { LoggerInterface } from '../logging'; import { BodyMetadata, HeaderParamMetadata, PathParamMetadata, QueryParamMetadata } from '../routing'; import { parseArray, parseBoolean, parseDate, parseNumber, parseObject, parseString } from './functions'; @@ -61,7 +62,7 @@ export class Parser implements ParserInterface { } // eslint-disable-next-line jsdoc/require-jsdoc - parseHeaderParam(req: HttpRequest | WebsocketRequest, metadata: HeaderParamMetadata): unknown { + parseHeaderParam(req: HttpRequest | WebsocketRequest | HttpClientResponse, metadata: HeaderParamMetadata): unknown { const rawValue: string | undefined = req.headers[metadata.name as KnownHeader]; return this.headerParamParseFunctions[metadata.type](rawValue, metadata); } @@ -79,26 +80,39 @@ export class Parser implements ParserInterface { } // eslint-disable-next-line jsdoc/require-jsdoc - async parseRequestBody(req: HttpRequest | WebsocketRequest, metadata: BodyMetadata): Promise { - let contentType: string = req.headers['Content-Type']?.split(';')[0]?.trim().toLowerCase() ?? ''; - if (!contentType.length && !isHttpRequest(req)) { - contentType = MimeType.JSON; + async parseBody(req: HttpRequest | WebsocketRequest | HttpClientResponse, metadata: BodyMetadata): Promise { + const contentTypeHeader: string | undefined = req.headers[KnownHeader.CONTENT_TYPE] + ?? req.headers[KnownHeader.CONTENT_TYPE.toLowerCase() as KnownHeader]; + let contentType: string = contentTypeHeader?.split(';')[0]?.trim().toLowerCase() ?? ''; + if (!contentType.length) { + if (isHttpClientResponse(req)) { + contentType = metadata.type; + } + else if (!isHttpRequest(req)) { + contentType = MimeType.JSON; + } } if (!isMimeType(contentType)) { - throw new Error(`Unsupported Content-Type: "${contentType}"`); + throw new Error(`Unsupported ${KnownHeader.CONTENT_TYPE}: "${contentType}"`); } if (metadata.type !== contentType) { - throw new Error(`Unsupported Content-Type: "${contentType}"`); + throw new Error(`Unsupported ${KnownHeader.CONTENT_TYPE}: "${contentType}"`); } const fittingParsers: BodyParserInterface[] = this.bodyParsers.filter(p => p.contentType === contentType); if (!fittingParsers.length) { - throw new Error(`Unsupported Content-Type: "${contentType}"`); + throw new Error(`Unsupported ${KnownHeader.CONTENT_TYPE}: "${contentType}"`); } if (fittingParsers.length > 1) { - throw new Error(`There has been more than one body parser provided for the Content-Type "${contentType}"`); + throw new Error(`There has been more than one body parser provided for the ${KnownHeader.CONTENT_TYPE} "${contentType}"`); } - return await fittingParsers[0].parse(req, metadata); + if (isHttpClientResponse(req)) { + return await fittingParsers[0].parseFromHttpClientResponse(req, metadata); + } + if (isHttpRequest(req)) { + return await fittingParsers[0].parseFromHttpRequest(req, metadata); + } + return await fittingParsers[0].parseFromWebsocketRequest(req, metadata); } // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/plugin/invoicing/models/invoice.model.ts b/src/plugin/invoicing/models/invoice.model.ts index 7793f82..56a2114 100644 --- a/src/plugin/invoicing/models/invoice.model.ts +++ b/src/plugin/invoicing/models/invoice.model.ts @@ -1,6 +1,7 @@ import { InvoiceAddress } from './invoice-address.model'; import { InvoiceItem } from './invoice-item.model'; -import { BaseEntity, Entity, Property } from '../../../entity'; +import { Entity, Property } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; import { type CurrencyCode } from '../../../localization'; /** diff --git a/src/plugin/invoicing/models/number-invoices.model.ts b/src/plugin/invoicing/models/number-invoices.model.ts index 19c20ab..639544d 100644 --- a/src/plugin/invoicing/models/number-invoices.model.ts +++ b/src/plugin/invoicing/models/number-invoices.model.ts @@ -1,4 +1,5 @@ -import { BaseEntity, Entity, Property } from '../../../entity'; +import { Entity, Property } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; /** * Contains the information about how many invoices have been created in a specific year. diff --git a/src/plugin/invoicing/services/conformance/en16931/peppol-conformance.service.test.ts b/src/plugin/invoicing/services/conformance/en16931/peppol-conformance.service.test.ts index 57b4715..aa6b7ab 100644 --- a/src/plugin/invoicing/services/conformance/en16931/peppol-conformance.service.test.ts +++ b/src/plugin/invoicing/services/conformance/en16931/peppol-conformance.service.test.ts @@ -11,7 +11,7 @@ import { PeppolConformanceService } from './peppol-conformance.service'; import { POSTGRES_TEST_IMAGE, testFileFolder } from '../../../../../__testing__'; import { BaseDataSource, DataSource, DataSourceOptions, MigrationEntity, Repository } from '../../../../../data-source'; import { XML } from '../../../../../document'; -import { BaseEntity } from '../../../../../entity'; +import { BaseEntity } from '../../../../../entity/base-entity.model'; import { Newable, OmitStrict } from '../../../../../types'; import { Ms } from '../../../../../utilities'; import { InvoicingOptions, Invoice } from '../../../models'; diff --git a/src/plugin/invoicing/services/conformance/en16931/x-rechnung-conformance.service.test.ts b/src/plugin/invoicing/services/conformance/en16931/x-rechnung-conformance.service.test.ts index 1048d5c..77cfcb4 100644 --- a/src/plugin/invoicing/services/conformance/en16931/x-rechnung-conformance.service.test.ts +++ b/src/plugin/invoicing/services/conformance/en16931/x-rechnung-conformance.service.test.ts @@ -11,7 +11,7 @@ import { XRechnungConformanceService } from './x-rechnung-conformance.service'; import { POSTGRES_TEST_IMAGE, testFileFolder } from '../../../../../__testing__'; import { BaseDataSource, DataSource, DataSourceOptions, MigrationEntity, Repository } from '../../../../../data-source'; import { XML } from '../../../../../document'; -import { BaseEntity } from '../../../../../entity'; +import { BaseEntity } from '../../../../../entity/base-entity.model'; import { Newable, OmitStrict } from '../../../../../types'; import { Ms } from '../../../../../utilities'; import { InvoicingOptions, Invoice } from '../../../models'; diff --git a/src/plugin/invoicing/services/invoice-number.service.test.ts b/src/plugin/invoicing/services/invoice-number.service.test.ts index 6555044..fac8702 100644 --- a/src/plugin/invoicing/services/invoice-number.service.test.ts +++ b/src/plugin/invoicing/services/invoice-number.service.test.ts @@ -7,7 +7,7 @@ import { PostgresConnectionCredentialsOptions } from 'typeorm/driver/postgres/Po import { InvoiceCalcService } from './invoice-calc.service'; import { BaseDataSource, DataSource, DataSourceOptions, MigrationEntity, Repository } from '../../../data-source'; -import { BaseEntity } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; import { Newable, OmitStrict } from '../../../types'; import { Invoice, InvoiceAddress, InvoicingOptions, NumberInvoices } from '../models'; import { InvoiceNumberService } from './invoice-number.service'; diff --git a/src/plugin/invoicing/services/invoice-pdf.service.test.ts b/src/plugin/invoicing/services/invoice-pdf.service.test.ts index c065a6a..e943f5a 100644 --- a/src/plugin/invoicing/services/invoice-pdf.service.test.ts +++ b/src/plugin/invoicing/services/invoice-pdf.service.test.ts @@ -12,7 +12,7 @@ import { InvoicePdfService } from './invoice-pdf.service'; import { POSTGRES_TEST_IMAGE, testFileFolder } from '../../../__testing__'; import { BaseDataSource, DataSource, DataSourceOptions, MigrationEntity, Repository } from '../../../data-source'; import { PdfDocument } from '../../../document'; -import { BaseEntity } from '../../../entity'; +import { BaseEntity } from '../../../entity/base-entity.model'; import { formatDate } from '../../../localization/formatting/format-date.function'; import { formatPercent } from '../../../localization/formatting/format-percent.function'; import { formatPrice } from '../../../localization/formatting/format-price.function'; diff --git a/src/routing/decorators/body.decorator.ts b/src/routing/decorators/body.decorator.ts index 1d81695..1f029b0 100644 --- a/src/routing/decorators/body.decorator.ts +++ b/src/routing/decorators/body.decorator.ts @@ -1,7 +1,9 @@ +import { PropertyMetadata, Relation } from '../../entity'; import { BasePropertyMetadata } from '../../entity/models/base-property-metadata.model'; +import { FileSize, fileSizeToBytes } from '../../entity/models/file-property-metadata.model'; import { MimeType } from '../../http'; import { Newable, OmitStrict } from '../../types'; -import { MetadataUtilities, Ms } from '../../utilities'; +import { BigNumber, MetadataUtilities, Ms } from '../../utilities'; /** * Base metadata shared by all possible http request body properties. @@ -14,7 +16,12 @@ type BaseBodyMetadata = OmitStrict>; +export type BodyMetadataInput = Partial> & { + /** + * The base maximum size of the body. + * + * This is IN ADDITION to any file properties on the request body. + */ + baseMaxSize?: FileSize +}; // eslint-disable-next-line jsdoc/require-returns /** @@ -66,8 +80,12 @@ export function Body(modelClass: Newable, options: BodyMetadataInput = description: undefined, type: MimeType.JSON, cleanupAfterMs: Ms.DAY, + maxSize: resolveMaxBodySize(modelClass, options.baseMaxSize), ...options }; + if ('baseMaxSize' in fullMetadata) { + delete fullMetadata.baseMaxSize; + } const ctor: Function = target.constructor; // eslint-disable-next-line unicorn/error-message const stack: string = new Error().stack ?? ''; @@ -75,4 +93,47 @@ export function Body(modelClass: Newable, options: BodyMetadataInput = const key: string = propertyKey?.toString() ?? ''; MetadataUtilities.setRouteBody(ctor, fullMetadata, key); }; +} + +// eslint-disable-next-line jsdoc/require-jsdoc +export function resolveMaxBodySize(modelClass: Newable, baseMaxSize: FileSize = '100kb'): BigNumber { + const bytes: BigNumber = fileSizeToBytes(baseMaxSize); + const properties: Record = MetadataUtilities.getModelProperties(modelClass); + return resolveMaxSize(bytes, properties); +} + +// eslint-disable-next-line jsdoc/require-jsdoc +function resolveMaxSize(bytes: BigNumber, properties: Record): BigNumber { + for (const key in properties) { + const property: PropertyMetadata = properties[key]; + switch (property.type) { + case 'file': { + bytes = bytes.plus(fileSizeToBytes(property.maxSize)); + break; + } + case 'array': { + if (property.items.type === 'file') { + bytes = bytes.plus(fileSizeToBytes(property.totalMaxSize)); + } + break; + } + case 'object': { + const objectProperties: Record = MetadataUtilities.getModelProperties(property.cls()); + bytes = bytes.plus(resolveMaxSize(bytes, objectProperties)); + break; + } + case Relation.ONE_TO_ONE: { throw new Error('Not implemented yet: Relation.ONE_TO_ONE case'); } + case Relation.ONE_TO_MANY: { throw new Error('Not implemented yet: Relation.ONE_TO_MANY case'); } + case Relation.MANY_TO_ONE: { throw new Error('Not implemented yet: Relation.MANY_TO_ONE case'); } + case Relation.MANY_TO_MANY: { throw new Error('Not implemented yet: Relation.MANY_TO_MANY case'); } + case 'string': + case 'number': + case 'boolean': + case 'date': + case 'unknown': { + break; + } + } + } + return bytes; } \ No newline at end of file diff --git a/src/routing/models/crud-controller.model.ts b/src/routing/models/crud-controller.model.ts index 04dd6a0..a88dd90 100644 --- a/src/routing/models/crud-controller.model.ts +++ b/src/routing/models/crud-controller.model.ts @@ -1,6 +1,6 @@ import { Repository } from '../../data-source'; import { inject, repositoryTokenFor } from '../../di'; -import { BaseEntity } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; import { HttpStatus } from '../../http'; import { PaginationResult, Response } from '../../open-api'; import { DeepPartial, Newable } from '../../types'; diff --git a/src/routing/param-metdata.helpers.ts b/src/routing/param-metdata.helpers.ts index e05b605..51d9143 100644 --- a/src/routing/param-metdata.helpers.ts +++ b/src/routing/param-metdata.helpers.ts @@ -122,6 +122,7 @@ export function createQueryParamMetadata(name: string, data: QueryParamMetadataI name, required: true, description: undefined, + totalMaxSize: '100kb', ...data, items: createArrayParamItemMetadata(data.items, name) }; @@ -197,6 +198,7 @@ export function createArrayParamItemMetadata( return { required: true, description: undefined, + totalMaxSize: '100kb', ...data, items: createArrayParamItemMetadata(data.items, fullPropertyKey) }; diff --git a/src/routing/resolve-route-params.function.ts b/src/routing/resolve-route-params.function.ts index 03308a9..4553968 100644 --- a/src/routing/resolve-route-params.function.ts +++ b/src/routing/resolve-route-params.function.ts @@ -36,8 +36,8 @@ export async function resolveRouteParams( const requestBody: BodyMetadata | undefined = MetadataUtilities.getRouteBody(controllerClass, controllerMethod); if (requestBody) { resolvedParamCount++; - params[requestBody.index] = await parser.parseRequestBody(req, requestBody); - validationService.validateRequestBody(params[requestBody.index], requestBody); + params[requestBody.index] = await parser.parseBody(req, requestBody); + validationService.validateBody(params[requestBody.index], requestBody); } // 3) Query decorators diff --git a/src/routing/route-configuration.model.ts b/src/routing/route-configuration.model.ts index 7f83b58..741b885 100644 --- a/src/routing/route-configuration.model.ts +++ b/src/routing/route-configuration.model.ts @@ -20,7 +20,7 @@ type QueryMetaObjectToParamsObject> = { +export type HeaderMetaObjectToParamsObject> = { [K in keyof HeaderMetaObject]: ParamMetadataToType }; @@ -46,7 +46,7 @@ type QueryMetaInputObjectToMetaObject> = { +export type HeaderMetaInputObjectToMetaObject> = { [K in keyof HeaderMetaInputObject]: MergeRequired< HeaderMetaInputObject[K], ParamMetadataInputToMeta diff --git a/src/routing/router.ts b/src/routing/router.ts index 477f9c6..1aae7d8 100644 --- a/src/routing/router.ts +++ b/src/routing/router.ts @@ -12,7 +12,7 @@ import { ZibriApplication } from '../application'; import { GlobalRegistry } from '../global'; import { LoggerInterface } from '../logging'; import { Newable } from '../types'; -import { BodyMetadata, BodyMetadataInput, HeaderParamMetadata, HeaderParamMetadataInput, PathParamMetadata, PathParamMetadataInput, QueryParamMetadata, QueryParamMetadataInput } from './decorators'; +import { BodyMetadata, BodyMetadataInput, HeaderParamMetadata, HeaderParamMetadataInput, PathParamMetadata, PathParamMetadataInput, QueryParamMetadata, QueryParamMetadataInput, resolveMaxBodySize } from './decorators'; import { OpenApiRouteConfiguration, RouteConfiguration, RouteConfigurationInput } from './route-configuration.model'; import { HttpMethod, HttpRequest, HttpResponse, KnownHeader, MimeType } from '../http'; import { OpenApiResponse } from '../open-api'; @@ -119,13 +119,13 @@ export class Router implements RouterInterface { description: undefined, type: MimeType.JSON, cleanupAfterMs: Ms.DAY, + maxSize: resolveMaxBodySize(input.bodyMetadata.modelClass, input.bodyMetadata.baseMaxSize), ...input.bodyMetadata } : undefined, pathParams, queryParams, headerParams - }; const handler: RequestHandler = this.routeToRequestHandler(route); await this.logger.debug(`- mounting ${route.httpMethod.toUpperCase()} ${route.route}`); @@ -216,8 +216,8 @@ export class Router implements RouterInterface { ) => { try { if (route.bodyMetadata) { - req.body = await this.parser.parseRequestBody(req, route.bodyMetadata); - this.validationService.validateRequestBody(req.body, route.bodyMetadata); + req.body = await this.parser.parseBody(req, route.bodyMetadata); + this.validationService.validateBody(req.body, route.bodyMetadata); } for (const key in route.pathParams) { (req.params[key] as unknown) = this.parser.parsePathParam(req, route.pathParams[key]); diff --git a/src/utilities/metadata.utilities.ts b/src/utilities/metadata.utilities.ts index b8736a3..aed46c1 100644 --- a/src/utilities/metadata.utilities.ts +++ b/src/utilities/metadata.utilities.ts @@ -5,7 +5,8 @@ import { Route, ControllerRouteConfiguration, PathParamMetadata, BodyMetadata, Q import { MetadataInjectionKeys } from './metadata-injection-keys.enum'; import { BelongsToMetadata, CurrentUserMetadata, HasRoleMetadata, IsLoggedInMetadata, IsNotLoggedInMetadata, Require2faMetadata, SkipAuthMetadata, SkipBelongsToMetadata, SkipHasRoleMetadata, SkipIsLoggedInMetadata, SkipIsNotLoggedInMetadata, SkipRequire2faMetadata } from '../auth'; import { BackupResourceInterface, BackupResourceMetadata } from '../backup'; -import { BaseEntity, EntityMetadata, PropertyMetadata } from '../entity'; +import { EntityMetadata, PropertyMetadata } from '../entity'; +import { BaseEntity } from '../entity/base-entity.model'; import { OpenApiResponse } from '../open-api'; import { Newable } from '../types'; import { CurrentWebsocketConnectionMetadata, WebsocketControllerData, WebsocketControllerRouteConfiguration } from '../websocket'; diff --git a/src/utilities/validate-entities-registered.function.ts b/src/utilities/validate-entities-registered.function.ts index 93b3561..d124457 100644 --- a/src/utilities/validate-entities-registered.function.ts +++ b/src/utilities/validate-entities-registered.function.ts @@ -1,6 +1,6 @@ import { BaseDataSource } from '../data-source'; import { inject } from '../di'; -import type { BaseEntity } from '../entity'; +import type { BaseEntity } from '../entity/base-entity.model'; import { MissingEntitiesError } from '../error-handling'; import { GlobalRegistry } from '../global'; import type { Newable } from '../types'; diff --git a/src/validation/functions/validate-file.function.ts b/src/validation/functions/validate-file.function.ts index 5395cb4..a6d46f2 100644 --- a/src/validation/functions/validate-file.function.ts +++ b/src/validation/functions/validate-file.function.ts @@ -1,6 +1,8 @@ -import { fileSizeToBytes, PropertyMetadata } from '../../entity'; +import { PropertyMetadata } from '../../entity'; +import { fileSizeToBytes } from '../../entity/models/file-property-metadata.model'; import { MimeType } from '../../http'; import { File } from '../../parsing'; +import { BigNumberUtilities } from '../../utilities'; import { MaxFileSizeValidationProblem, IsRequiredValidationProblem, TypeMismatchValidationProblem, ValidationProblem, MimeTypeMismatchValidationProblem } from '../validation-problem.model'; /** @@ -42,7 +44,7 @@ export function validateFile( return [new TypeMismatchValidationProblem(fullKey, 'file')]; } - if (property.size > fileSizeToBytes(metadata.maxSize)) { + if (BigNumberUtilities.new(property.size).isGreaterThan(fileSizeToBytes(metadata.maxSize))) { return [new MaxFileSizeValidationProblem(fullKey, metadata.maxSize)]; } if (metadata.allowedMimeTypes !== 'all' && !metadata.allowedMimeTypes.includes(property.mimetype as MimeType)) { diff --git a/src/validation/validation-problem.model.ts b/src/validation/validation-problem.model.ts index 1d6c363..d09ba1b 100644 --- a/src/validation/validation-problem.model.ts +++ b/src/validation/validation-problem.model.ts @@ -1,4 +1,6 @@ -import { BaseEntity, FileSize, ManyToManyPropertyMetadata, ManyToOnePropertyMetadata, OneToManyPropertyMetadata, OneToOnePropertyMetadata, Relation, RelationMetadata } from '../entity'; +import { ManyToManyPropertyMetadata, ManyToOnePropertyMetadata, OneToManyPropertyMetadata, OneToOnePropertyMetadata, Relation, RelationMetadata } from '../entity'; +import { BaseEntity } from '../entity/base-entity.model'; +import { FileSize } from '../entity/models/file-property-metadata.model'; import { MimeType } from '../http'; /** diff --git a/src/validation/validation-service.interface.ts b/src/validation/validation-service.interface.ts index 6be0b82..e8a697b 100644 --- a/src/validation/validation-service.interface.ts +++ b/src/validation/validation-service.interface.ts @@ -5,9 +5,9 @@ import { BodyMetadata, HeaderParamMetadata, PathParamMetadata, QueryParamMetadat */ export interface ValidationServiceInterface { /** - * Validate a request body. + * Validate a request/response body. */ - validateRequestBody: (body: unknown, meta: BodyMetadata) => void, + validateBody: (body: unknown, meta: BodyMetadata) => void, /** * Validate a header param. */ diff --git a/src/validation/validation.service.ts b/src/validation/validation.service.ts index c6aa32b..6641f4f 100644 --- a/src/validation/validation.service.ts +++ b/src/validation/validation.service.ts @@ -1,5 +1,6 @@ -import { BaseEntity, Property, PropertyMetadata, RelationMetadata } from '../entity'; +import { Property, PropertyMetadata, RelationMetadata } from '../entity'; +import { BaseEntity } from '../entity/base-entity.model'; import { ValidationError } from '../error-handling'; import { MimeType } from '../http'; import { FormData } from '../parsing'; @@ -131,7 +132,7 @@ export class ValidationService implements ValidationServiceInterface { } // eslint-disable-next-line jsdoc/require-jsdoc - validateRequestBody(body: unknown, meta: BodyMetadata): void { + validateBody(body: unknown, meta: BodyMetadata): void { // eslint-disable-next-line jsdoc/require-jsdoc class Temp implements OmitStrict, 'cleanup'> { // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/websocket/decorators/websocket-body.decorator.ts b/src/websocket/decorators/websocket-body.decorator.ts index d32d8f1..4fa38e6 100644 --- a/src/websocket/decorators/websocket-body.decorator.ts +++ b/src/websocket/decorators/websocket-body.decorator.ts @@ -1,12 +1,20 @@ +import { FileSize } from '../../entity/models/file-property-metadata.model'; import { MimeType } from '../../http'; -import { JsonBodyMetadata } from '../../routing'; +import { JsonBodyMetadata, resolveMaxBodySize } from '../../routing'; import { Newable, OmitStrict } from '../../types'; import { MetadataUtilities } from '../../utilities'; /** * Metadata Input for websocket request bodies. */ -export type WebsocketBodyMetadataInput = Partial>; +export type WebsocketBodyMetadataInput = Partial> & { + /** + * The base maximum size of the body. + * + * This is IN ADDITION to any file properties on the request body. + */ + baseMaxSize?: FileSize +}; // eslint-disable-next-line jsdoc/require-returns /** @@ -25,6 +33,7 @@ export function WebsocketBody( required: true, description: undefined, type: MimeType.JSON, + maxSize: resolveMaxBodySize(modelClass, options.baseMaxSize), ...options }; const ctor: Function = target.constructor; diff --git a/src/websocket/models/websocket-channel.model.ts b/src/websocket/models/websocket-channel.model.ts index 7080ee1..9948fcc 100644 --- a/src/websocket/models/websocket-channel.model.ts +++ b/src/websocket/models/websocket-channel.model.ts @@ -1,4 +1,5 @@ -import { BaseEntity, Entity, Property } from '../../entity'; +import { Entity, Property } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; /** * A websocket channel. diff --git a/src/websocket/models/websocket-message.model.ts b/src/websocket/models/websocket-message.model.ts index 62e29c7..1d6fdbd 100644 --- a/src/websocket/models/websocket-message.model.ts +++ b/src/websocket/models/websocket-message.model.ts @@ -1,7 +1,8 @@ import { type LooseWebsocketEvent } from './websocket-event.enum'; import { Repository } from '../../data-source'; import { inject, repositoryTokenFor } from '../../di'; -import { BaseEntity, Entity, OmitClass, Property } from '../../entity'; +import { Entity, OmitClass, Property } from '../../entity'; +import { BaseEntity } from '../../entity/base-entity.model'; import { HttpError } from '../../error-handling'; import { HttpStatus } from '../../http';