diff --git a/.env.example b/.env.example index 97fe015f3..00dfd3782 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ APP_EVENT_DRIVER=sync APP_WORKER_DRIVER=queue JWT_SECRET= +JWT_EXPIRES_IN_MINUTES=60 DATABASE_DEFAULT_CONNECTION=default DATABASE_DEFAULT_PROVIDER= diff --git a/assets/banner_black.png b/assets/banner_black.png deleted file mode 100644 index cb5358d9d..000000000 Binary files a/assets/banner_black.png and /dev/null differ diff --git a/assets/banner_blank.png b/assets/banner_blank.png new file mode 100644 index 000000000..49ce06b77 Binary files /dev/null and b/assets/banner_blank.png differ diff --git a/changelist.md b/changelist.md new file mode 100644 index 000000000..628410e8a --- /dev/null +++ b/changelist.md @@ -0,0 +1,43 @@ +## Version 1.0.1 (Beta) + +### Security Enhancements +- Added security features to Express routes +- Implemented rate limiting +- Added configurable token expiration +- Introduced user scopes, resource scopes, and API token scopes +- Implemented permission groups, user groups, and roles + +### Authentication and Authorization +- Refactored security rules and middleware +- Updated authorization middleware to include scopes +- Improved handling of custom identifiers + +### Request Handling +- Refactored CurrentRequest into RequestContext +- Added IP address handling to RequestContext +- Moved RequestContext into an app container + +### Route Resources +- Added 'index' and 'all' filters to RouteResources +- Renamed 'name' to 'path' in IRouteResourceOptions +- Updated to allow for partial scopes + +### Command Handling +- Fixed argument processing in ListRoutesCommand +- Enabled registration of commands with configs in the same module + +### Code Refactoring and Optimization +- Consolidated security interfaces into a single file +- Removed debug console logs +- Fixed incorrect import paths +- Refactored Express domain files + +### Bug Fixes +- Resolved a potential "headers already sent" issue +- Fixed migration failures related to missing files +- Corrected custom identifier handling + +### Miscellaneous +- Updated Observer with awaitable methods +- Improved route logging to include security rules +- Various comment updates for improved clarity \ No newline at end of file diff --git a/package.json b/package.json index c56eacf16..bf2929cb4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "larascript-framework", - "version": "1.0.1", + "version": "1.1.1", "description": "A Node.js framework inspired by Laravel made with TypeScript", "main": "index.js", "scripts": { @@ -35,6 +35,7 @@ "sequelize": "^6.37.3", "sqlite3": "^5.1.7", "uuid": "^10.0.0", + "winston": "^3.15.0", "zod": "^3.23.8" }, "devDependencies": { diff --git a/readme.md b/readme.md index 7e265ef52..6e67db5fc 100644 --- a/readme.md +++ b/readme.md @@ -72,7 +72,17 @@ Follow these steps to quickly set up your project: This will install all the necessary dependencies for your project. -3. **Start Database Containers**: +3. **Add write permissions to logs directory** + + After installing dependencies, you need to add write permissions to the logs directory: + + ``` + chmod -R 755 /path/to/larascript/storage/logs + ``` + + This ensures that your application can write log files as needed. + +4. **Start Database Containers**: To set up your database environment, run: @@ -82,7 +92,7 @@ Follow these steps to quickly set up your project: This command will start the necessary database containers for your project. -4. **Run the setup command (optional)**: +5. **Run the setup command (optional)**: If you want to populate the .env file with configured settings, use: @@ -92,7 +102,7 @@ Follow these steps to quickly set up your project: This step is optional but can be helpful for quickly configuring your environment. -5. **Run database migrations**: +6. **Run database migrations**: To set up your database schema, run: @@ -102,7 +112,7 @@ Follow these steps to quickly set up your project: This command will apply all pending database migrations. -6. **Start developing**: +7. **Start developing**: To start your development server, use: diff --git a/src/app.ts b/src/app.ts index aaa78f98d..4640c91fe 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,6 +4,7 @@ import appConfig from '@src/config/app'; import CommandNotFoundException from '@src/core/domains/console/exceptions/CommandNotFoundException'; import CommandBootService from '@src/core/domains/console/service/CommandBootService'; import Kernel, { KernelOptions } from '@src/core/Kernel'; +import { App } from '@src/core/services/App'; (async () => { try { @@ -16,14 +17,14 @@ import Kernel, { KernelOptions } from '@src/core/Kernel'; */ await Kernel.boot(appConfig, options); - console.log('[App]: Started'); + App.container('logger').info('[App]: Started'); /** * Execute commands */ await cmdBoot.boot(args); - } + } catch (err) { // We can safetly ignore CommandNotFoundExceptions @@ -31,7 +32,7 @@ import Kernel, { KernelOptions } from '@src/core/Kernel'; return; } - console.error('[App]: Failed to start', err); + App.container('logger').error('[App]: Failed to start', err); throw err; } })(); \ No newline at end of file diff --git a/src/app/commands/ExampleCommand.ts b/src/app/commands/ExampleCommand.ts index bd66de7bd..286794b58 100644 --- a/src/app/commands/ExampleCommand.ts +++ b/src/app/commands/ExampleCommand.ts @@ -5,7 +5,7 @@ export default class ExampleCommand extends BaseCommand { signature: string = 'app:example'; async execute() { - console.log('Hello world!') + // Handle the logic } } \ No newline at end of file diff --git a/src/app/events/listeners/ExampleListener.ts b/src/app/events/listeners/ExampleListener.ts index 170aebee1..5f166aa43 100644 --- a/src/app/events/listeners/ExampleListener.ts +++ b/src/app/events/listeners/ExampleListener.ts @@ -2,8 +2,9 @@ import EventListener from "@src/core/domains/events/services/EventListener"; export class ExampleListener extends EventListener<{userId: string}> { + // eslint-disable-next-line no-unused-vars handle = async (payload: { userId: string}) => { - console.log('[ExampleListener]', payload.userId) + // Handle the logic } - + } \ No newline at end of file diff --git a/src/app/migrations/2024-09-06-create-api-token-table.ts b/src/app/migrations/2024-09-06-create-api-token-table.ts index c59b82dab..e5a7f546c 100644 --- a/src/app/migrations/2024-09-06-create-api-token-table.ts +++ b/src/app/migrations/2024-09-06-create-api-token-table.ts @@ -1,6 +1,6 @@ +import ApiToken from "@src/app/models/auth/ApiToken"; import BaseMigration from "@src/core/domains/migrations/base/BaseMigration"; import { DataTypes } from "sequelize"; -import ApiToken from "@src/app/models/auth/ApiToken"; export class CreateApiTokenMigration extends BaseMigration { @@ -17,6 +17,7 @@ export class CreateApiTokenMigration extends BaseMigration { await this.schema.createTable(this.table, { userId: DataTypes.STRING, token: DataTypes.STRING, + scopes: DataTypes.JSON, revokedAt: DataTypes.DATE }) } diff --git a/src/app/migrations/2024-09-06-create-user-table.ts b/src/app/migrations/2024-09-06-create-user-table.ts index a7b256ec1..eec4cbb79 100644 --- a/src/app/migrations/2024-09-06-create-user-table.ts +++ b/src/app/migrations/2024-09-06-create-user-table.ts @@ -1,6 +1,6 @@ +import User from "@src/app/models/auth/User"; import BaseMigration from "@src/core/domains/migrations/base/BaseMigration"; import { DataTypes } from "sequelize"; -import User from "@src/app/models/auth/User"; export class CreateUserModelMigration extends BaseMigration { @@ -22,6 +22,7 @@ export class CreateUserModelMigration extends BaseMigration { await this.schema.createTable(this.table, { email: DataTypes.STRING, hashedPassword: DataTypes.STRING, + groups: DataTypes.JSON, roles: DataTypes.JSON, firstName: stringNullable, lastName: stringNullable, diff --git a/src/app/models/auth/ApiToken.ts b/src/app/models/auth/ApiToken.ts index 85d3d6076..bc65f3a1d 100644 --- a/src/app/models/auth/ApiToken.ts +++ b/src/app/models/auth/ApiToken.ts @@ -1,7 +1,9 @@ import User from '@src/app/models/auth/User'; +import ApiTokenObserver from '@src/app/observers/ApiTokenObserver'; import Model from '@src/core/base/Model'; import IApiTokenModel, { IApiTokenData } from '@src/core/domains/auth/interfaces/IApitokenModel'; import IUserModel from '@src/core/domains/auth/interfaces/IUserModel'; +import Scopes from '@src/core/domains/auth/services/Scopes'; /** * ApiToken model @@ -20,9 +22,26 @@ class ApiToken extends Model implements IApiTokenModel { public fields: string[] = [ 'userId', 'token', + 'scopes', 'revokedAt' ] + public json: string[] = [ + 'scopes' + ] + + /** + * Construct an ApiToken model from the given data. + * + * @param {IApiTokenData} [data=null] The data to construct the model from. + * + * @constructor + */ + constructor(data: IApiTokenData | null = null) { + super(data) + this.observeWith(ApiTokenObserver) + } + /** * Disable createdAt and updatedAt timestamps */ @@ -39,6 +58,21 @@ class ApiToken extends Model implements IApiTokenModel { }) } + /** + * Checks if the given scope(s) are present in the scopes of this ApiToken + * @param scopes The scope(s) to check + * @returns True if all scopes are present, false otherwise + */ + public hasScope(scopes: string | string[], exactMatch: boolean = true): boolean { + const currentScopes = this.getAttribute('scopes') ?? []; + + if(exactMatch) { + return Scopes.exactMatch(currentScopes, scopes); + } + + return Scopes.partialMatch(currentScopes, scopes); + } + } export default ApiToken diff --git a/src/app/models/auth/User.ts b/src/app/models/auth/User.ts index f9a308de9..d0c056465 100644 --- a/src/app/models/auth/User.ts +++ b/src/app/models/auth/User.ts @@ -1,7 +1,23 @@ import ApiToken from "@src/app/models/auth/ApiToken"; import UserObserver from "@src/app/observers/UserObserver"; import Model from "@src/core/base/Model"; -import IUserModel, { IUserData } from "@src/core/domains/auth/interfaces/IUserModel"; +import IUserModel from "@src/core/domains/auth/interfaces/IUserModel"; +import IModelAttributes from "@src/core/interfaces/IModelData"; + +/** + * User structure + */ +export interface IUserData extends IModelAttributes { + email: string; + password?: string; + hashedPassword: string; + roles: string[]; + groups: string[]; + firstName?: string; + lastName?: string; + createdAt?: Date; + updatedAt?: Date; +} /** * User model @@ -31,7 +47,8 @@ export default class User extends Model implements IUserModel { guarded: string[] = [ 'hashedPassword', 'password', - 'roles' + 'roles', + 'groups', ]; /** @@ -56,9 +73,44 @@ export default class User extends Model implements IUserModel { * These fields will be returned as JSON when the model is serialized. */ json = [ + 'groups', 'roles' ] + /** + * Checks if the user has the given role + * + * @param role The role to check + * @returns True if the user has the role, false otherwise + */ + hasRole(roles: string | string[]): boolean { + roles = typeof roles === 'string' ? [roles] : roles; + const userRoles = this.getAttribute('roles') ?? []; + + for(const role of roles) { + if(!userRoles.includes(role)) return false; + } + + return true; + } + + /** + * Checks if the user has the given role + * + * @param role The role to check + * @returns True if the user has the role, false otherwise + */ + hasGroup(groups: string | string[]): boolean { + groups = typeof groups === 'string' ? [groups] : groups; + const userGroups = this.getAttribute('groups') ?? []; + + for(const group of groups) { + if(!userGroups.includes(group)) return false; + } + + return true; + } + /** * @returns The tokens associated with this user * diff --git a/src/app/observers/ApiTokenObserver.ts b/src/app/observers/ApiTokenObserver.ts new file mode 100644 index 000000000..1c281a333 --- /dev/null +++ b/src/app/observers/ApiTokenObserver.ts @@ -0,0 +1,53 @@ +import UserRepository from "@src/app/repositories/auth/UserRepository"; +import { IApiTokenData } from "@src/core/domains/auth/interfaces/IApitokenModel"; +import IUserModel from "@src/core/domains/auth/interfaces/IUserModel"; +import Observer from "@src/core/domains/observer/services/Observer"; +import { App } from "@src/core/services/App"; + +interface IApiTokenObserverData extends IApiTokenData { + +} + +export default class ApiTokenObserver extends Observer { + + protected readonly userRepository = new UserRepository(); + + /** + * Called when a data object is being created. + * @param data The model data being created. + * @returns The processed model data. + */ + async creating(data: IApiTokenObserverData): Promise { + data = await this.addGroupScopes(data) + return data + } + + /** + * Adds scopes from groups the user is a member of to the scopes of the ApiToken being created. + * @param data The ApiToken data being created. + * @returns The ApiToken data with the added scopes. + */ + + async addGroupScopes(data: IApiTokenObserverData): Promise { + const user = await this.userRepository.findById(data.userId) as IUserModel; + + if(!user) { + return data + } + + const userGroups = user.getAttribute('groups') ?? []; + + for(const userGroup of userGroups) { + const group = App.container('auth').config.permissions.groups.find(g => g.name === userGroup); + const scopes = group?.scopes ?? []; + + data.scopes = [ + ...data.scopes, + ...scopes + ] + } + + return data + } + +} \ No newline at end of file diff --git a/src/app/observers/UserObserver.ts b/src/app/observers/UserObserver.ts index 83483f778..daa6ce8c2 100644 --- a/src/app/observers/UserObserver.ts +++ b/src/app/observers/UserObserver.ts @@ -1,6 +1,7 @@ -import { IUserData } from "@src/core/domains/auth/interfaces/IUserModel"; +import { IUserData } from "@src/app/models/auth/User"; import hashPassword from "@src/core/domains/auth/utils/hashPassword"; import Observer from "@src/core/domains/observer/services/Observer"; +import { App } from "@src/core/services/App"; /** * Observer for the User model. @@ -15,8 +16,32 @@ export default class UserObserver extends Observer { * @param data The User data being created. * @returns The processed User data. */ - creating(data: IUserData): IUserData { + async creating(data: IUserData): Promise { data = this.onPasswordChange(data) + data = await this.updateRoles(data) + return data + } + + /** + * Updates the roles of the user based on the groups they belong to. + * Retrieves the roles associated with each group the user belongs to from the permissions configuration. + * @param data The User data being created/updated. + * @returns The processed User data with the updated roles. + */ + async updateRoles(data: IUserData): Promise { + let updatedRoles: string[] = []; + + for(const group of data.groups) { + const relatedRoles = App.container('auth').config.permissions.groups.find(g => g.name === group)?.roles ?? []; + + updatedRoles = [ + ...updatedRoles, + ...relatedRoles + ] + } + + data.roles = updatedRoles + return data } diff --git a/src/config/auth.ts b/src/config/auth.ts index b1e6c4648..c32d3f93e 100644 --- a/src/config/auth.ts +++ b/src/config/auth.ts @@ -8,6 +8,25 @@ import { IAuthConfig } from '@src/core/domains/auth/interfaces/IAuthConfig'; import AuthService from '@src/core/domains/auth/services/AuthService'; import parseBooleanFromString from '@src/core/util/parseBooleanFromString'; +/** + * Available groups + */ +export const GROUPS = { + User: 'group_user', + Admin: 'group_admin', +} as const + +/** + * Available roles + */ +export const ROLES = { + USER: 'role_user', + ADMIN: 'role_admin' +} as const + +/** + * Auth configuration + */ const config: IAuthConfig = { service: { authService: AuthService @@ -30,6 +49,11 @@ const config: IAuthConfig = { */ jwtSecret: process.env.JWT_SECRET as string ?? '', + /** + * JWT expiration time in minutes + */ + expiresInMinutes: process.env.JWT_EXPIRES_IN_MINUTES ? parseInt(process.env.JWT_EXPIRES_IN_MINUTES) : 60, + /** * Enable or disable auth routes */ @@ -39,6 +63,52 @@ const config: IAuthConfig = { * Enable or disable create a new user endpoint */ enableAuthRoutesAllowCreate: parseBooleanFromString(process.env.ENABLE_AUTH_ROUTES_ALLOW_CREATE, 'true'), + + /** + * Permissions configuration + * - user.defaultGroup - The group will be the default group for new user accounts + * - groups - The list of groups + * - groups.roles will auto populate the roles on creation + * - groups.scopes will be automatically added to new ApiTokenModels + * + * + * You can use ModelScopes to generate scopes for models. These scopes will be + * added to any routes created with RouteResource. For example: + * + * Example: + * { + * name: GROUPS.User, + * roles: [ROLES.USER], + * scopes: [ + * ...ModelScopes.getScopes(ExampleModel, ['read', 'write']) + * ] + * }, + */ + permissions: { + + /** + * The default user group + */ + user: { + defaultGroup: GROUPS.User, + }, + + /** + * The list of groups + */ + groups: [ + { + name: GROUPS.User, + roles: [ROLES.USER], + scopes: [] + }, + { + name: GROUPS.Admin, + roles: [ROLES.USER, ROLES.ADMIN], + scopes: [] + } + ] + } } export default config; \ No newline at end of file diff --git a/src/config/http.ts b/src/config/http.ts index d1695677b..49ba1656c 100644 --- a/src/config/http.ts +++ b/src/config/http.ts @@ -1,13 +1,23 @@ -import express from 'express'; import IExpressConfig from '@src/core/domains/express/interfaces/IExpressConfig'; import parseBooleanFromString from '@src/core/util/parseBooleanFromString'; import bodyParser from 'body-parser'; +import express from 'express'; const config: IExpressConfig = { + + /** + * Enable Express + */ enabled: parseBooleanFromString(process.env.ENABLE_EXPRESS, 'true'), + /** + * HTTP port + */ port: parseInt(process.env.APP_PORT ?? '5000'), + /** + * Global middlewares + */ globalMiddlewares: [ express.json(), bodyParser.urlencoded({ extended: true }), diff --git a/src/core/Kernel.ts b/src/core/Kernel.ts index 1b17df422..f44bbf24c 100644 --- a/src/core/Kernel.ts +++ b/src/core/Kernel.ts @@ -43,6 +43,8 @@ export default class Kernel extends Singleton const { appConfig } = kernel; + App.getInstance().env = appConfig.environment; + for (const provider of appConfig.providers) { if(withoutProviders.includes(provider.constructor.name)) { continue; @@ -60,7 +62,7 @@ export default class Kernel extends Singleton kernel.preparedProviders.push(provider.constructor.name); } - App.getInstance().env = appConfig.environment; + Kernel.getInstance().readyProviders = [...kernel.preparedProviders]; } diff --git a/src/core/actions/health.ts b/src/core/actions/health.ts index 306b6308a..23f1e3af1 100644 --- a/src/core/actions/health.ts +++ b/src/core/actions/health.ts @@ -33,7 +33,6 @@ export default async (req: Request, res: Response) => { } } catch (error) { - console.error(error) // If there is an error, send the error response responseError(req, res, error as Error) return; diff --git a/src/core/base/Model.ts b/src/core/base/Model.ts index 0221b7fa2..ffa7dca48 100644 --- a/src/core/base/Model.ts +++ b/src/core/base/Model.ts @@ -3,20 +3,24 @@ import { IBelongsToOptions } from '@src/core/domains/database/interfaces/relatio import { IHasManyOptions } from '@src/core/domains/database/interfaces/relationships/IHasMany'; import { IObserver } from '@src/core/domains/observer/interfaces/IObserver'; import { WithObserver } from '@src/core/domains/observer/services/WithObserver'; +import UnexpectedAttributeError from '@src/core/exceptions/UnexpectedAttributeError'; import { ICtor } from '@src/core/interfaces/ICtor'; import { GetDataOptions, IModel } from '@src/core/interfaces/IModel'; -import IModelData from '@src/core/interfaces/IModelData'; +import IModelAttributes from '@src/core/interfaces/IModelData'; import { App } from '@src/core/services/App'; import Str from '@src/core/util/str/Str'; + /** * Abstract base class for database models. * Extends WithObserver to provide observation capabilities. * Implements IModel interface for consistent model behavior. * - * @template Data Type extending IModelData, representing the structure of the model's data. + * @template Attributes Type extending IModelData, representing the structure of the model's data. */ -export default abstract class Model extends WithObserver implements IModel { +export default abstract class Model extends WithObserver implements IModel { + + public name!: string; /** * The name of the database connection to use. @@ -34,7 +38,13 @@ export default abstract class Model extends WithObserve * The actual data of the model. * Can be null if the model hasn't been populated. */ - public data: Data | null; + public attributes: Attributes | null = null; + + /** + * The original data of the model. + * Can be null if the model hasn't been populated. + */ + public original: Attributes | null = null; /** * The name of the MongoDB collection associated with this model. @@ -81,12 +91,14 @@ export default abstract class Model extends WithObserve /** * Constructs a new instance of the Model class. * - * @param {Data | null} data - Initial data to populate the model. + * @param {Attributes | null} data - Initial data to populate the model. */ - constructor(data: Data | null) { + constructor(data: Attributes | null) { super(); - this.data = data; + this.name = this.constructor.name; this.setDefaultTable(); + this.attributes = { ...data } as Attributes; + this.original = { ...data } as Attributes; } /** @@ -97,7 +109,14 @@ export default abstract class Model extends WithObserve if (this.table) { return; } - this.table = Str.plural(Str.startLowerCase(this.constructor.name)); + this.table = this.constructor.name; + + if (this.table.endsWith('Model')) { + this.table = this.table.slice(0, -5); + } + + this.table = Str.plural(Str.startLowerCase(this.table)) + } /** @@ -115,7 +134,27 @@ export default abstract class Model extends WithObserve * @returns {string | undefined} The primary key value or undefined if not set. */ getId(): string | undefined { - return this.data?.[this.primaryKey]; + return this.attributes?.[this.primaryKey] as string | undefined; + } + + /** + * Sets or retrieves the value of a specific attribute from the model's data. + * If called with a single argument, returns the value of the attribute. + * If called with two arguments, sets the value of the attribute. + * If the value is not set, returns null. + * + * @template K Type of the attribute key. + * @param {K} key - The key of the attribute to retrieve or set. + * @param {any} [value] - The value to set for the attribute. + * @returns {Attributes[K] | null | undefined} The value of the attribute or null if not found, or undefined if setting. + */ + attr(key: K, value?: unknown): Attributes[K] | null | undefined { + if (value === undefined) { + return this.getAttribute(key) as Attributes[K] ?? null; + } + + this.setAttribute(key, value); + return undefined; } /** @@ -123,10 +162,62 @@ export default abstract class Model extends WithObserve * * @template K Type of the attribute key. * @param {K} key - The key of the attribute to retrieve. - * @returns {Data[K] | null} The value of the attribute or null if not found. + * @returns {Attributes[K] | null} The value of the attribute or null if not found. + */ + getAttribute(key: K): Attributes[K] | null { + return this.attributes?.[key] ?? null; + } + + /** + * Retrieves the original value of a specific attribute from the model's original data. + * + * @template K Type of the attribute key. + * @param {K} key - The key of the attribute to retrieve. + * @returns {Attributes[K] | null} The original value of the attribute or null if not found. + */ + getOriginal(key: K): Attributes[K] | null { + return this.original?.[key] ?? null; + } + + /** + * Checks if the model is dirty. + * + * A model is considered dirty if any of its attributes have changed since the last time the model was saved. + * + * @returns {boolean} True if the model is dirty, false otherwise. + */ + isDirty(): boolean { + if(!this.original) { + return false; + } + return Object.keys(this.getDirty() ?? {}).length > 0; + } + + /** + * Gets the dirty attributes. + * @returns */ - getAttribute(key: K): Data[K] | null { - return this.data?.[key] ?? null; + getDirty(): Record | null { + + const dirty = {} as Record; + + Object.entries(this.attributes as object).forEach(([key, value]) => { + + try { + if (typeof value === 'object' && JSON.stringify(value) !== JSON.stringify(this.original?.[key])) { + dirty[key as keyof Attributes] = value; + return; + } + } + // eslint-disable-next-line no-unused-vars + catch (e) { } + + if (value !== this.original?.[key]) { + dirty[key as keyof Attributes] = value; + } + }); + + return dirty; } /** @@ -137,19 +228,24 @@ export default abstract class Model extends WithObserve * @param {any} value - The value to set for the attribute. * @throws {Error} If the attribute is not in the allowed fields or if a date field is set with a non-Date value. */ - setAttribute(key: K, value: any): void { + setAttribute(key: K, value?: unknown): void { if (!this.fields.includes(key as string)) { - throw new Error(`Attribute ${key as string} not found in model ${this.constructor.name}`); + throw new UnexpectedAttributeError(`Unexpected attribute '${key as string}'`); } if (this.dates.includes(key as string) && !(value instanceof Date)) { - throw new Error(`Attribute '${key as string}' is a date and can only be set with a Date object in model ${this.table}`); + throw new UnexpectedAttributeError(`Unexpected attribute value. Expected attribute '${key as string}' value to be of type Date`); + } + if (this.attributes === null) { + this.attributes = {} as Attributes; } - if (this.data) { - this.data[key] = value; + if (this.attributes) { + this.attributes[key] = value as Attributes[K]; } if (Object.keys(this.observeProperties).includes(key as string)) { - this.data = this.observeDataCustom(this.observeProperties[key as string] as keyof IObserver, this.data); + this.observeDataCustom(this.observeProperties[key as string] as keyof IObserver, this.attributes).then((data) => { + this.attributes = data; + }) } } @@ -169,9 +265,9 @@ export default abstract class Model extends WithObserve /** * Fills the model with the provided data. * - * @param {Partial} data - The data to fill the model with. + * @param {Partial} data - The data to fill the model with. */ - fill(data: Partial): void { + fill(data: Partial): void { Object.entries(data) // eslint-disable-next-line no-unused-vars .filter(([_key, value]) => value !== undefined) @@ -184,15 +280,15 @@ export default abstract class Model extends WithObserve * Retrieves the data from the model. * * @param {GetDataOptions} [options={ excludeGuarded: true }] - Options for data retrieval. - * @returns {Data | null} The model's data, potentially excluding guarded fields. + * @returns {Attributes | null} The model's data, potentially excluding guarded fields. */ - getData(options: GetDataOptions = { excludeGuarded: true }): Data | null { - let data = this.data; + getData(options: GetDataOptions = { excludeGuarded: true }): Attributes | null { + let data = this.attributes; if (data && options.excludeGuarded) { data = Object.fromEntries( Object.entries(data).filter(([key]) => !this.guarded.includes(key)) - ) as Data; + ) as Attributes; } return data; @@ -201,16 +297,17 @@ export default abstract class Model extends WithObserve /** * Refreshes the model's data from the database. * - * @returns {Promise} The refreshed data or null if the model has no ID. + * @returns {Promise} The refreshed data or null if the model has no ID. */ - async refresh(): Promise { + async refresh(): Promise { const id = this.getId(); if (!id) return null; - this.data = await this.getDocumentManager().findById(id); + this.attributes = await this.getDocumentManager().findById(id); + this.original = { ...this.attributes } as Attributes - return this.data; + return this.attributes; } /** @@ -219,7 +316,7 @@ export default abstract class Model extends WithObserve * @returns {Promise} */ async update(): Promise { - if (!this.getId() || !this.data) return; + if (!this.getId() || !this.attributes) return; await this.getDocumentManager().updateOne(this.prepareDocument()); } @@ -232,7 +329,7 @@ export default abstract class Model extends WithObserve * @returns {T} The prepared document. */ prepareDocument(): T { - return this.getDocumentManager().prepareDocument({ ...this.data }, { + return this.getDocumentManager().prepareDocument({ ...this.attributes }, { jsonStringify: this.json }) as T; } @@ -244,23 +341,24 @@ export default abstract class Model extends WithObserve * @returns {Promise} */ async save(): Promise { - if (this.data && !this.getId()) { - this.data = this.observeData('creating', this.data); + if (this.attributes && !this.getId()) { + this.attributes = await this.observeData('creating', this.attributes); this.setTimestamp('createdAt'); this.setTimestamp('updatedAt'); - this.data = await this.getDocumentManager().insertOne(this.prepareDocument()); - await this.refresh(); + this.attributes = await this.getDocumentManager().insertOne(this.prepareDocument()); + this.attributes = await this.refresh(); - this.data = this.observeData('created', this.data); + this.attributes = await this.observeData('created', this.attributes); return; } - this.data = this.observeData('updating', this.data); + this.attributes = await this.observeData('updating', this.attributes); this.setTimestamp('updatedAt'); await this.update(); - await this.refresh(); - this.data = this.observeData('updated', this.data); + this.attributes = await this.refresh(); + this.attributes = await this.observeData('updated', this.attributes); + this.original = { ...this.attributes } } /** @@ -269,11 +367,12 @@ export default abstract class Model extends WithObserve * @returns {Promise} */ async delete(): Promise { - if (!this.data) return; - this.data = this.observeData('deleting', this.data); - await this.getDocumentManager().deleteOne(this.data); - this.data = null; - this.observeData('deleted', this.data); + if (!this.attributes) return; + this.attributes = await this.observeData('deleting', this.attributes); + await this.getDocumentManager().deleteOne(this.attributes); + this.attributes = null; + this.original = null; + await this.observeData('deleted', this.attributes); } /** @@ -287,11 +386,11 @@ export default abstract class Model extends WithObserve async belongsTo(foreignModel: ICtor, options: Omit): Promise { const documentManager = App.container('db').documentManager(this.connection); - if (!this.data) { + if (!this.attributes) { return null; } - const result = await documentManager.belongsTo(this.data, { + const result = await documentManager.belongsTo(this.attributes, { ...options, foreignTable: (new foreignModel()).table }); @@ -314,11 +413,11 @@ export default abstract class Model extends WithObserve public async hasMany(foreignModel: ICtor, options: Omit): Promise { const documentManager = App.container('db').documentManager(this.connection); - if (!this.data) { + if (!this.attributes) { return []; } - const results = await documentManager.hasMany(this.data, { + const results = await documentManager.hasMany(this.attributes, { ...options, foreignTable: (new foreignModel()).table }); diff --git a/src/core/base/Provider.ts b/src/core/base/Provider.ts index 0043a73f2..d2ea88320 100644 --- a/src/core/base/Provider.ts +++ b/src/core/base/Provider.ts @@ -1,4 +1,5 @@ import { IProvider } from "@src/core/interfaces/IProvider"; +import { App } from "@src/core/services/App"; /** * Base class for providers @@ -45,12 +46,7 @@ export default abstract class BaseProvider implements IProvider { * @param {...any[]} args - Additional arguments to log */ protected log(message: string, ...args: any[]): void { - const str = `[Provider] ${message}`; - if(args.length > 0) { - console.log(str, ...args); - return; - } - console.log(`[Provider] ${message}`); + App.container('logger').info(message, ...args); } /** diff --git a/src/core/domains/auth/actions/create.ts b/src/core/domains/auth/actions/create.ts index beb8b3919..1898836c4 100644 --- a/src/core/domains/auth/actions/create.ts +++ b/src/core/domains/auth/actions/create.ts @@ -1,6 +1,5 @@ -import Roles from '@src/core/domains/auth/enums/RolesEnum'; +import { IUserData } from '@src/app/models/auth/User'; import UserFactory from '@src/core/domains/auth/factory/userFactory'; -import { IUserData } from '@src/core/domains/auth/interfaces/IUserModel'; import hashPassword from '@src/core/domains/auth/utils/hashPassword'; import responseError from '@src/core/domains/express/requests/responseError'; import ValidationError from '@src/core/exceptions/ValidationError'; @@ -33,7 +32,10 @@ export default async (req: Request, res: Response): Promise => { email, password, hashedPassword: hashPassword(password ?? ''), - roles: [Roles.USER], + groups: [ + App.container('auth').config.permissions.user.defaultGroup + ], + roles: [], firstName, lastName }); @@ -61,6 +63,7 @@ export default async (req: Request, res: Response): Promise => { // Handle other errors if (error instanceof Error) { responseError(req, res, error); + return; } } diff --git a/src/core/domains/auth/actions/getUser.ts b/src/core/domains/auth/actions/getUser.ts index 706221717..e645cf88b 100644 --- a/src/core/domains/auth/actions/getUser.ts +++ b/src/core/domains/auth/actions/getUser.ts @@ -18,6 +18,7 @@ export default async (req: IAuthorizedRequest, res: Response) => { // If there is an error, send the error response if (error instanceof Error) { responseError(req, res, error); + return; } } }; diff --git a/src/core/domains/auth/actions/login.ts b/src/core/domains/auth/actions/login.ts index 99062bdd9..c7a2ca2d8 100644 --- a/src/core/domains/auth/actions/login.ts +++ b/src/core/domains/auth/actions/login.ts @@ -38,6 +38,7 @@ export default async (req: Request, res: Response): Promise => { // Handle other errors if (error instanceof Error) { responseError(req, res, error) + return; } } } diff --git a/src/core/domains/auth/actions/revoke.ts b/src/core/domains/auth/actions/revoke.ts index 01a641e71..b5cbf93df 100644 --- a/src/core/domains/auth/actions/revoke.ts +++ b/src/core/domains/auth/actions/revoke.ts @@ -26,6 +26,7 @@ export default async (req: IAuthorizedRequest, res: Response) => { // Handle any errors if (error instanceof Error) { responseError(req, res, error); + return; } } }; diff --git a/src/core/domains/auth/actions/update.ts b/src/core/domains/auth/actions/update.ts index 317655b76..1880ed77f 100644 --- a/src/core/domains/auth/actions/update.ts +++ b/src/core/domains/auth/actions/update.ts @@ -1,5 +1,4 @@ -import User from '@src/app/models/auth/User'; -import { IUserData } from '@src/core/domains/auth/interfaces/IUserModel'; +import User, { IUserData } from '@src/app/models/auth/User'; import hashPassword from '@src/core/domains/auth/utils/hashPassword'; import responseError from '@src/core/domains/express/requests/responseError'; import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; @@ -38,6 +37,7 @@ export default async (req: BaseRequest, res: Response) => { // If there is an error, send the error response if(error instanceof Error) { responseError(req, res, error) + return; } } } diff --git a/src/core/domains/auth/actions/user.ts b/src/core/domains/auth/actions/user.ts index 7c014d094..c41b8d391 100644 --- a/src/core/domains/auth/actions/user.ts +++ b/src/core/domains/auth/actions/user.ts @@ -18,6 +18,7 @@ export default (req: IAuthorizedRequest, res: Response) => { // Handle any errors if (error instanceof Error) { responseError(req, res, error); + return; } } }; diff --git a/src/core/domains/auth/commands/GenerateJWTSecret.ts b/src/core/domains/auth/commands/GenerateJWTSecret.ts index 6e1cdc6e9..477fddec8 100644 --- a/src/core/domains/auth/commands/GenerateJWTSecret.ts +++ b/src/core/domains/auth/commands/GenerateJWTSecret.ts @@ -1,8 +1,7 @@ +import BaseCommand from "@src/core/domains/console/base/BaseCommand"; import { IEnvService } from "@src/core/interfaces/IEnvService"; import EnvService from "@src/core/services/EnvService"; -import BaseCommand from "../../console/base/BaseCommand"; - class GenerateJwtSecret extends BaseCommand { signature = 'auth:generate-jwt-secret'; diff --git a/src/core/domains/auth/exceptions/ForbiddenResourceError.ts b/src/core/domains/auth/exceptions/ForbiddenResourceError.ts new file mode 100644 index 000000000..749d52745 --- /dev/null +++ b/src/core/domains/auth/exceptions/ForbiddenResourceError.ts @@ -0,0 +1,8 @@ +export default class ForbiddenResourceError extends Error { + + constructor(message: string = 'You do not have permission to access this resource') { + super(message); + this.name = 'ForbiddenResourceError'; + } + +} \ No newline at end of file diff --git a/src/core/domains/auth/exceptions/RateLimitedExceededError.ts b/src/core/domains/auth/exceptions/RateLimitedExceededError.ts new file mode 100644 index 000000000..d08b1c0ef --- /dev/null +++ b/src/core/domains/auth/exceptions/RateLimitedExceededError.ts @@ -0,0 +1,8 @@ +export default class RateLimitedExceededError extends Error { + + constructor(message: string = 'Too many requests. Try again later.') { + super(message); + this.name = 'RateLimitedExceededError'; + } + +} \ No newline at end of file diff --git a/src/core/domains/auth/factory/apiTokenFactory.ts b/src/core/domains/auth/factory/apiTokenFactory.ts index 62c67130f..3f4bcfb6e 100644 --- a/src/core/domains/auth/factory/apiTokenFactory.ts +++ b/src/core/domains/auth/factory/apiTokenFactory.ts @@ -22,10 +22,11 @@ class ApiTokenFactory extends Factory { * @param {IUserModel} user * @returns {IApiTokenModel} */ - createFromUser(user: IUserModel): IApiTokenModel { + createFromUser(user: IUserModel, scopes: string[] = []): IApiTokenModel { return new this.modelCtor({ - userId: user.data?.id, + userId: user.attributes?.id, token: tokenFactory(), + scopes: scopes, revokedAt: null, }) } diff --git a/src/core/domains/auth/factory/userFactory.ts b/src/core/domains/auth/factory/userFactory.ts index e91fed6b4..b9e084756 100644 --- a/src/core/domains/auth/factory/userFactory.ts +++ b/src/core/domains/auth/factory/userFactory.ts @@ -1,6 +1,5 @@ -import User from '@src/app/models/auth/User'; +import User, { IUserData } from '@src/app/models/auth/User'; import Factory from '@src/core/base/Factory'; -import { IUserData } from '@src/core/domains/auth/interfaces/IUserModel'; /** * Factory for creating User models. diff --git a/src/core/domains/auth/interfaces/IApitokenModel.ts b/src/core/domains/auth/interfaces/IApitokenModel.ts index 35acb054f..c957ecbe5 100644 --- a/src/core/domains/auth/interfaces/IApitokenModel.ts +++ b/src/core/domains/auth/interfaces/IApitokenModel.ts @@ -1,12 +1,15 @@ +/* eslint-disable no-unused-vars */ import { IModel } from "@src/core/interfaces/IModel"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; -export interface IApiTokenData extends IModelData { +export interface IApiTokenData extends IModelAttributes { userId: string; - token: string + token: string; + scopes: string[]; revokedAt: Date | null; } export default interface IApiTokenModel extends IModel { user(): Promise; + hasScope(scopes: string | string[], exactMatch?: boolean): boolean; } \ No newline at end of file diff --git a/src/core/domains/auth/interfaces/IAuthConfig.ts b/src/core/domains/auth/interfaces/IAuthConfig.ts index f0298e1b8..a5c9b143d 100644 --- a/src/core/domains/auth/interfaces/IAuthConfig.ts +++ b/src/core/domains/auth/interfaces/IAuthConfig.ts @@ -1,6 +1,7 @@ import IApiTokenModel from "@src/core/domains/auth/interfaces/IApitokenModel"; import IApiTokenRepository from "@src/core/domains/auth/interfaces/IApiTokenRepository"; import { IAuthService } from "@src/core/domains/auth/interfaces/IAuthService"; +import { IPermissionsConfig } from "@src/core/domains/auth/interfaces/IPermissionsConfig"; import IUserModel from "@src/core/domains/auth/interfaces/IUserModel"; import IUserRepository from "@src/core/domains/auth/interfaces/IUserRepository"; import { IInterfaceCtor } from "@src/core/domains/validator/interfaces/IValidator"; @@ -25,6 +26,8 @@ export interface IAuthConfig { updateUser: IInterfaceCtor; }; jwtSecret: string, + expiresInMinutes: number; enableAuthRoutes: boolean; enableAuthRoutesAllowCreate: boolean; + permissions: IPermissionsConfig; } \ No newline at end of file diff --git a/src/core/domains/auth/interfaces/IAuthService.ts b/src/core/domains/auth/interfaces/IAuthService.ts index 694d4392f..939c24a24 100644 --- a/src/core/domains/auth/interfaces/IAuthService.ts +++ b/src/core/domains/auth/interfaces/IAuthService.ts @@ -1,6 +1,7 @@ /* eslint-disable no-unused-vars */ import IApiTokenModel from "@src/core/domains/auth/interfaces/IApitokenModel"; import IApiTokenRepository from "@src/core/domains/auth/interfaces/IApiTokenRepository"; +import { IAuthConfig } from "@src/core/domains/auth/interfaces/IAuthConfig"; import IUserModel from "@src/core/domains/auth/interfaces/IUserModel"; import IUserRepository from "@src/core/domains/auth/interfaces/IUserRepository"; import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; @@ -22,7 +23,7 @@ export interface IAuthService extends IService { * @type {any} * @memberof IAuthService */ - config: any; + config: IAuthConfig; /** * The user repository @@ -56,7 +57,7 @@ export interface IAuthService extends IService { * @returns {Promise} The JWT token * @memberof IAuthService */ - createJwtFromUser: (user: IUserModel) => Promise; + createJwtFromUser: (user: IUserModel, scopes?: string[]) => Promise; /** * Creates a new ApiToken model from the User @@ -65,7 +66,7 @@ export interface IAuthService extends IService { * @returns {Promise} The new ApiToken model * @memberof IAuthService */ - createApiTokenFromUser: (user: IUserModel) => Promise; + createApiTokenFromUser: (user: IUserModel, scopes?: string[]) => Promise; /** * Revokes a token. @@ -84,7 +85,7 @@ export interface IAuthService extends IService { * @returns {Promise} The JWT token * @memberof IAuthService */ - attemptCredentials: (email: string, password: string) => Promise; + attemptCredentials: (email: string, password: string, scopes?: string[]) => Promise; /** * Generates a JWT. diff --git a/src/core/domains/auth/interfaces/IPermissionsConfig.ts b/src/core/domains/auth/interfaces/IPermissionsConfig.ts new file mode 100644 index 000000000..cbc7af21c --- /dev/null +++ b/src/core/domains/auth/interfaces/IPermissionsConfig.ts @@ -0,0 +1,14 @@ +export interface IPermissionGroup { + name: string; + scopes?: string[]; + roles?: string[]; +} + +export interface IPermissionUser { + defaultGroup: string; +} + +export interface IPermissionsConfig { + user: IPermissionUser; + groups: IPermissionGroup[] +} \ No newline at end of file diff --git a/src/core/domains/auth/interfaces/IRequestIdentifiable.ts b/src/core/domains/auth/interfaces/IRequestIdentifiable.ts new file mode 100644 index 000000000..b959f395b --- /dev/null +++ b/src/core/domains/auth/interfaces/IRequestIdentifiable.ts @@ -0,0 +1,5 @@ +import { Request } from 'express'; + +export default interface IRequestIdentifiable extends Request { + id?: string; +} \ No newline at end of file diff --git a/src/core/domains/auth/interfaces/IScope.ts b/src/core/domains/auth/interfaces/IScope.ts new file mode 100644 index 000000000..a1fe0a73b --- /dev/null +++ b/src/core/domains/auth/interfaces/IScope.ts @@ -0,0 +1 @@ +export type Scope = 'read' | 'write' | 'create' | 'delete' | 'all'; \ No newline at end of file diff --git a/src/core/domains/auth/interfaces/IUserModel.ts b/src/core/domains/auth/interfaces/IUserModel.ts index a2dfa055a..96c3c23b9 100644 --- a/src/core/domains/auth/interfaces/IUserModel.ts +++ b/src/core/domains/auth/interfaces/IUserModel.ts @@ -1,18 +1,9 @@ /* eslint-disable no-unused-vars */ +import { IUserData } from "@src/app/models/auth/User"; import { IModel } from "@src/core/interfaces/IModel"; -import IModelData from "@src/core/interfaces/IModelData"; - -export interface IUserData extends IModelData { - email: string - password?: string; - hashedPassword: string - roles: string[], - firstName?: string; - lastName?: string; - createdAt?: Date, - updatedAt?: Date, -} export default interface IUserModel extends IModel { tokens(...args: any[]): Promise; + hasRole(...args: any[]): any; + hasGroup(...args: any[]): any; } \ No newline at end of file diff --git a/src/core/domains/auth/providers/AuthProvider.ts b/src/core/domains/auth/providers/AuthProvider.ts index d4390b142..a355ce295 100644 --- a/src/core/domains/auth/providers/AuthProvider.ts +++ b/src/core/domains/auth/providers/AuthProvider.ts @@ -1,10 +1,9 @@ import authConfig from "@src/config/auth"; import BaseProvider from "@src/core/base/Provider"; +import GenerateJwtSecret from "@src/core/domains/auth/commands/GenerateJWTSecret"; import { IAuthConfig } from "@src/core/domains/auth/interfaces/IAuthConfig"; import { App } from "@src/core/services/App"; -import GenerateJwtSecret from "../commands/GenerateJWTSecret"; - export default class AuthProvider extends BaseProvider { /** diff --git a/src/core/domains/auth/routes/auth.ts b/src/core/domains/auth/routes/auth.ts index fbda9c686..4549c77e5 100644 --- a/src/core/domains/auth/routes/auth.ts +++ b/src/core/domains/auth/routes/auth.ts @@ -6,7 +6,7 @@ import user from "@src/core/domains/auth/actions/user"; import authConsts from "@src/core/domains/auth/consts/authConsts"; import { IAuthConfig } from "@src/core/domains/auth/interfaces/IAuthConfig"; import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; -import { authorize } from "@src/core/domains/express/middleware/authorize"; +import { authorizeMiddleware } from "@src/core/domains/express/middleware/authorizeMiddleware"; import Route from "@src/core/domains/express/routing/Route"; import RouteGroup from "@src/core/domains/express/routing/RouteGroup"; @@ -31,7 +31,7 @@ export const routes = (config: IAuthConfig): IRoute[] => { method: 'patch', path: '/auth/user', action: update, - middlewares: [authorize()], + middlewares: [authorizeMiddleware()], validator: config.validators.updateUser, validateBeforeAction: true }), @@ -40,14 +40,14 @@ export const routes = (config: IAuthConfig): IRoute[] => { method: 'get', path: '/auth/user', action: user, - middlewares: [authorize()] + middlewares: [authorizeMiddleware()] }), Route({ name: authConsts.routes.authRevoke, method: 'post', path: '/auth/revoke', action: revoke, - middlewares: [authorize()] + middlewares: [authorizeMiddleware()] }) ]) } diff --git a/src/core/domains/auth/services/AuthRequest.ts b/src/core/domains/auth/services/AuthRequest.ts new file mode 100644 index 000000000..0c7959cc0 --- /dev/null +++ b/src/core/domains/auth/services/AuthRequest.ts @@ -0,0 +1,37 @@ +import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { App } from "@src/core/services/App"; + +class AuthRequest { + + /** + * Attempts to authorize a request with a Bearer token. + * + * If successful, attaches the user and apiToken to the request. Sets the user in the App. + * + * @param req The request to authorize + * @returns The authorized request + * @throws UnauthorizedError if the token is invalid + */ + public static async attemptAuthorizeRequest(req: BaseRequest): Promise { + const authorization = (req.headers.authorization ?? '').replace('Bearer ', ''); + + const apiToken = await App.container('auth').attemptAuthenticateToken(authorization) + + const user = await apiToken?.user() + + if(!user || !apiToken) { + throw new UnauthorizedError(); + } + + req.user = user; + req.apiToken = apiToken + + App.container('requestContext').setByRequest(req, 'userId', user?.getId()) + + return req; + } + +} + +export default AuthRequest \ No newline at end of file diff --git a/src/core/domains/auth/services/AuthService.ts b/src/core/domains/auth/services/AuthService.ts index 821c26195..c7b416ab4 100644 --- a/src/core/domains/auth/services/AuthService.ts +++ b/src/core/domains/auth/services/AuthService.ts @@ -7,7 +7,6 @@ import IApiTokenModel from '@src/core/domains/auth/interfaces/IApitokenModel'; import IApiTokenRepository from '@src/core/domains/auth/interfaces/IApiTokenRepository'; import { IAuthConfig } from '@src/core/domains/auth/interfaces/IAuthConfig'; import { IAuthService } from '@src/core/domains/auth/interfaces/IAuthService'; -import { IJSonWebToken } from '@src/core/domains/auth/interfaces/IJSonWebToken'; import IUserModel from '@src/core/domains/auth/interfaces/IUserModel'; import IUserRepository from '@src/core/domains/auth/interfaces/IUserRepository'; import authRoutes from '@src/core/domains/auth/routes/auth'; @@ -15,6 +14,7 @@ import comparePassword from '@src/core/domains/auth/utils/comparePassword'; import createJwt from '@src/core/domains/auth/utils/createJwt'; import decodeJwt from '@src/core/domains/auth/utils/decodeJwt'; import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; +import { JsonWebTokenError } from 'jsonwebtoken'; export default class AuthService extends Service implements IAuthService { @@ -27,7 +27,7 @@ export default class AuthService extends Service implements IAuthSe * Repository for accessing user data */ public userRepository: IUserRepository; - + /** * Repository for accessing api tokens */ @@ -48,7 +48,7 @@ export default class AuthService extends Service implements IAuthSe * Validate jwt secret */ private validateJwtSecret() { - if(!this.config.jwtSecret || this.config.jwtSecret === '') { + if (!this.config.jwtSecret || this.config.jwtSecret === '') { throw new InvalidJWTSecret(); } } @@ -58,19 +58,19 @@ export default class AuthService extends Service implements IAuthSe * @param user * @returns */ - public async createApiTokenFromUser(user: IUserModel): Promise { - const apiToken = new ApiTokenFactory().createFromUser(user) + public async createApiTokenFromUser(user: IUserModel, scopes: string[] = []): Promise { + const apiToken = new ApiTokenFactory().createFromUser(user, scopes) await apiToken.save(); return apiToken } - + /** * Creates a JWT from a user model * @param user * @returns */ - async createJwtFromUser(user: IUserModel): Promise { - const apiToken = await this.createApiTokenFromUser(user); + async createJwtFromUser(user: IUserModel, scopes: string[] = []): Promise { + const apiToken = await this.createApiTokenFromUser(user, scopes); return this.jwt(apiToken) } @@ -80,11 +80,11 @@ export default class AuthService extends Service implements IAuthSe * @returns */ jwt(apiToken: IApiTokenModel): string { - if(!apiToken?.data?.userId) { + if (!apiToken?.attributes?.userId) { throw new Error('Invalid token'); } - const payload = JWTTokenFactory.create(apiToken.data?.userId?.toString(), apiToken.data?.token); - return createJwt(this.config.jwtSecret, payload, '1d'); + const payload = JWTTokenFactory.create(apiToken.attributes?.userId?.toString(), apiToken.attributes?.token); + return createJwt(this.config.jwtSecret, payload, `${this.config.expiresInMinutes}m`); } /** @@ -93,7 +93,7 @@ export default class AuthService extends Service implements IAuthSe * @returns */ async revokeToken(apiToken: IApiTokenModel): Promise { - if(apiToken?.data?.revokedAt) { + if (apiToken?.attributes?.revokedAt) { return; } @@ -107,21 +107,30 @@ export default class AuthService extends Service implements IAuthSe * @returns */ async attemptAuthenticateToken(token: string): Promise { - const decoded = decodeJwt(this.config.jwtSecret, token) as IJSonWebToken; + try { + const decoded = decodeJwt(this.config.jwtSecret, token); - const apiToken = await this.apiTokenRepository.findOneActiveToken(decoded.token) + const apiToken = await this.apiTokenRepository.findOneActiveToken(decoded.token) - if(!apiToken) { - throw new UnauthorizedError() - } + if (!apiToken) { + throw new UnauthorizedError() + } - const user = await this.userRepository.findById(decoded.uid) + const user = await this.userRepository.findById(decoded.uid) - if(!user) { - throw new UnauthorizedError() + if (!user) { + throw new UnauthorizedError() + } + + return apiToken + } + catch (err) { + if(err instanceof JsonWebTokenError) { + throw new UnauthorizedError() + } } - return apiToken + return null } /** @@ -130,18 +139,18 @@ export default class AuthService extends Service implements IAuthSe * @param password * @returns */ - async attemptCredentials(email: string, password: string): Promise { + async attemptCredentials(email: string, password: string, scopes: string[] = []): Promise { const user = await this.userRepository.findOneByEmail(email) as IUserModel; - if(!user?.data?.id) { + if (!user?.attributes?.id) { throw new UnauthorizedError() } - if(user?.data?.hashedPassword && !comparePassword(password, user.data?.hashedPassword)) { + if (user?.attributes?.hashedPassword && !comparePassword(password, user.attributes?.hashedPassword)) { throw new UnauthorizedError() } - return this.createJwtFromUser(user) + return this.createJwtFromUser(user, scopes) } /** @@ -150,13 +159,13 @@ export default class AuthService extends Service implements IAuthSe * @returns an array of IRoute objects, or null if auth routes are disabled */ getAuthRoutes(): IRoute[] | null { - if(!this.config.enableAuthRoutes) { + if (!this.config.enableAuthRoutes) { return null } const routes = authRoutes(this.config); - if(!this.config.enableAuthRoutesAllowCreate) { + if (!this.config.enableAuthRoutesAllowCreate) { return routes.filter((route) => route.name !== 'authCreate'); } diff --git a/src/core/domains/auth/services/ModelScopes.ts b/src/core/domains/auth/services/ModelScopes.ts new file mode 100644 index 000000000..22686c4d4 --- /dev/null +++ b/src/core/domains/auth/services/ModelScopes.ts @@ -0,0 +1,36 @@ +import { Scope } from "@src/core/domains/auth/interfaces/IScope"; +import { ModelConstructor } from "@src/core/interfaces/IModel"; + +class ModelScopes { + + /** + * Generates a list of scopes, given a model name and some scope types. + * + * Available Scopes + * - all - All scopes + * - read - Read scopes + * - write - Write scopes + * - delete - Delete scopes + * - create - Create scopes + * + * @param model The model as a constructor + * @param scopes The scope type(s) to generate scopes for. If a string, it will be an array of only that type. + * @returns A list of scopes in the format of 'modelName:scopeType' + * + * Example: + * + * const scopes = ModelScopes.getScopes(BlogModel, ['write', 'read']) + * + * // Output + * [ + * 'BlogModel:write', + * 'BlogModel:read' + * ] + */ + public static getScopes(model: ModelConstructor, scopes: Scope[] = ['all'], additionalScopes: string[] = []): string[] { + return [...scopes.map((scope) => `${(model.name)}:${scope}`), ...additionalScopes]; + } + +} + +export default ModelScopes \ No newline at end of file diff --git a/src/core/domains/auth/services/Scopes.ts b/src/core/domains/auth/services/Scopes.ts new file mode 100644 index 000000000..d6d724230 --- /dev/null +++ b/src/core/domains/auth/services/Scopes.ts @@ -0,0 +1,57 @@ +class Scopes { + + /** + * Returns an object of default scopes that can be used in the system. + * @returns An object with the following properties: + * - READ: 'read' + * - WRITE: 'write' + * - DELETE: 'delete' + * - CREATE: 'create' + * - ALL: 'all' + */ + public static getDefaultScopes() { + return { + READ: 'read', + WRITE: 'write', + DELETE: 'delete', + CREATE: 'create', + ALL: 'all' + } as const; + } + + /** + * Checks if the given scopes match exactly with the scopes in the scopesMatch array. + * @param scopesMatch The array of scopes to check against + * @param scopesSearch The scopes to search for in the scopesMatch array + * @returns True if all scopes in scopesSearch are present in scopesMatch, false otherwise + */ + public static exactMatch(scopesMatch: string[] | string, scopesSearch: string[] | string): boolean { + scopesMatch = typeof scopesMatch === 'string' ? [scopesMatch] : scopesMatch; + scopesSearch = typeof scopesSearch === 'string' ? [scopesSearch] : scopesSearch; + + for(const scopeSearch of scopesSearch) { + if(!scopesMatch.includes(scopeSearch)) return false; + } + + return true; + } + + /** + * Checks if any of the given scopes match with the scopes in the scopesMatch array. + * @param scopesMatch The array of scopes to check against + * @param scopesSearch The scopes to search for in the scopesMatch array + * @returns True if any scopes in scopesSearch are present in scopesMatch, false otherwise + */ + public static partialMatch(scopesMatch: string[] | string, scopesSearch: string[] | string): boolean { + scopesMatch = typeof scopesMatch === 'string' ? [scopesMatch] : scopesMatch; + scopesSearch = typeof scopesSearch === 'string' ? [scopesSearch] : scopesSearch; + + for(const scopeSearch of scopesSearch) { + if(scopesMatch.includes(scopeSearch)) return true; + } + + return false; + } + +} +export default Scopes \ No newline at end of file diff --git a/src/core/domains/console/commands/ListRoutesCommand.ts b/src/core/domains/console/commands/ListRoutesCommand.ts index 826c42c25..655362c0a 100644 --- a/src/core/domains/console/commands/ListRoutesCommand.ts +++ b/src/core/domains/console/commands/ListRoutesCommand.ts @@ -9,12 +9,11 @@ export default class ListRoutesCommand extends BaseCommand { public keepProcessAlive = false; - /** * Execute the command */ async execute() { - const showDetails = (this.getArguementByKey('details')?.value ?? false) !== false; + const showDetails = this.parsedArgumenets.find(arg => ['--details', '--d', '--detailed'].includes(arg.value)); const expressService = App.container('express') this.input.clearScreen(); @@ -27,8 +26,10 @@ export default class ListRoutesCommand extends BaseCommand { this.input.writeLine(` Name: ${route.name}`); this.input.writeLine(` Method: ${route.method}`); this.input.writeLine(` Action: ${route.action.name}`); - this.input.writeLine(` Middleware: ${route.middlewares?.map(m => m.name).join(', ')}`); - this.input.writeLine(` Validators: ${route.validator?.name ?? 'None'}`); + this.input.writeLine(` Middleware: [${route.middlewares?.map(m => m.name).join(', ') ?? ''}]`); + this.input.writeLine(` Validators: [${route.validator?.name ?? ''}]`); + this.input.writeLine(` Scopes: [${route.scopes?.join(', ') ?? ''}]`); + this.input.writeLine(` Security: [${route.security?.map(s => s.id).join(', ')}]`); this.input.writeLine(); return; } diff --git a/src/core/domains/console/commands/WorkerCommand.ts b/src/core/domains/console/commands/WorkerCommand.ts index 992832b23..febf1b1d7 100644 --- a/src/core/domains/console/commands/WorkerCommand.ts +++ b/src/core/domains/console/commands/WorkerCommand.ts @@ -25,7 +25,7 @@ export default class WorkerCommand extends BaseCommand { const worker = Worker.getInstance() worker.setDriver(driver) - console.log('Running worker...', worker.options) + App.container('logger').console('Running worker...', worker.options) await worker.work(); @@ -35,7 +35,7 @@ export default class WorkerCommand extends BaseCommand { setInterval(async () => { await worker.work() - console.log('Running worker again in ' + worker.options.runAfterSeconds.toString() + ' seconds') + App.container('logger').console('Running worker again in ' + worker.options.runAfterSeconds.toString() + ' seconds') }, worker.options.runAfterSeconds * 1000) } diff --git a/src/core/domains/console/interfaces/ICommandRegister.ts b/src/core/domains/console/interfaces/ICommandRegister.ts index ffb45890a..d90506503 100644 --- a/src/core/domains/console/interfaces/ICommandRegister.ts +++ b/src/core/domains/console/interfaces/ICommandRegister.ts @@ -11,14 +11,16 @@ export interface ICommandRegister { /** * Registers a new command. * @param cmdCtor The command to register. + * @param config The configuration for the commands. */ - register: (cmdCtor: ICommandConstructor) => void; + register: (cmdCtor: ICommandConstructor, config?: object) => void; /** * Registers multiple commands. * @param cmds The commands to register. + * @param config The configuration for the commands. */ - registerAll: (cmds: Array) => void; + registerAll: (cmds: Array, config?: object) => void; /** * Adds configuration for commands. diff --git a/src/core/domains/console/service/CommandRegister.ts b/src/core/domains/console/service/CommandRegister.ts index caf68eb52..628188f3e 100644 --- a/src/core/domains/console/service/CommandRegister.ts +++ b/src/core/domains/console/service/CommandRegister.ts @@ -19,17 +19,23 @@ export default class CommandRegister extends Singleton implements ICommandRegist /** * Register multiple commands * @param cmds + * @param config The configuration for the commands. */ - registerAll(cmds: Array) { + registerAll(cmds: Array, config?: object) { cmds.forEach(cmdCtor => this.register(cmdCtor)) + + if(config) { + const signatures = cmds.map(cmdCtor => (new cmdCtor).signature); + this.addCommandConfig(signatures, config); + } } /** * Register a new command - * @param key * @param cmdCtor + * @param config The configuration for the commands. */ - register(cmdCtor: ICommandConstructor) { + register(cmdCtor: ICommandConstructor, config?: object) { const signature = (new cmdCtor).signature if(this.commands.has(signature)) { @@ -37,6 +43,10 @@ export default class CommandRegister extends Singleton implements ICommandRegist } this.commands.set(signature, cmdCtor); + + if(config) { + this.addCommandConfig([signature], config); + } } /** diff --git a/src/core/domains/database/base/BaseDocumentManager.ts b/src/core/domains/database/base/BaseDocumentManager.ts index 9ddda0f00..68f4a66c1 100644 --- a/src/core/domains/database/base/BaseDocumentManager.ts +++ b/src/core/domains/database/base/BaseDocumentManager.ts @@ -10,6 +10,7 @@ import { IHasManyOptions } from "@src/core/domains/database/interfaces/relations import BelongsTo from "@src/core/domains/database/relationships/BelongsTo"; import HasMany from "@src/core/domains/database/relationships/HasMany"; import DocumentValidator from "@src/core/domains/database/validator/DocumentValidator"; +import { App } from "@src/core/services/App"; /** * Abstract base class for document management operations @@ -131,7 +132,7 @@ abstract class BaseDocumentManager[] + +/** + * Options for select query + */ +export type SelectOptions = { + + /** + * Filter for query + */ + filter?: object; + + /** + * Allow partial search + */ + allowPartialSearch?: boolean + + /** + * Use fuzzy search + */ + useFuzzySearch?: boolean + +} + +class MongoDbQueryBuilder { + + /** + * Build select query + * @param options Select options + * @returns Query filter object + */ + select({ filter = {}, allowPartialSearch = false, useFuzzySearch = false }: SelectOptions): object { + + for(const key in filter) { + const value = filter[key] + + if(typeof value !== 'string') { + continue; + } + + if(allowPartialSearch && value.startsWith('%') || value.endsWith('%')) { + + if(useFuzzySearch) { + filter[key] = { $text: { $search: value } } + continue; + } + + const pattern = this.buildSelectRegexPattern(value) + filter[key] = { $regex: pattern } + } + } + + return filter + } + + /** + * Builds the regex pattern for partial searches + * @param value + * @returns + */ + buildSelectRegexPattern = (value: string): string => { + const valueWithoutPercentSign = this.stripPercentSigns(value) + let regex = valueWithoutPercentSign + + if(value.startsWith('%')) { + regex = '.*' + regex; + } + + if(value.endsWith('%')) { + regex = regex + '.*' + } + + return regex + } + + /** + * Strips the percent signs from the start and end of a string + * @param value The string to strip + * @returns The stripped string + */ + stripPercentSigns(value: string): string { + if(value.startsWith('%')) { + return value.substring(1, value.length - 1) + } + + if(value.endsWith('%')) { + return value.substring(0, value.length - 1) + } + + return value + } + +} + +/** + * Default export + */ +export default MongoDbQueryBuilder diff --git a/src/core/domains/database/builder/PostgresQueryBuilder.ts b/src/core/domains/database/builder/PostgresQueryBuilder.ts index 1f44a4268..0420321be 100644 --- a/src/core/domains/database/builder/PostgresQueryBuilder.ts +++ b/src/core/domains/database/builder/PostgresQueryBuilder.ts @@ -21,7 +21,12 @@ export type SelectOptions = { /** * Filter for query */ - filter?: object + filter?: object; + + /** + * Allow partial search + */ + allowPartialSearch?: boolean /** * Order by @@ -32,6 +37,11 @@ export type SelectOptions = { * Limit */ limit?: number + + /** + * Skip + */ + skip?: number } /** @@ -44,21 +54,25 @@ class PostgresQueryBuilder { * @param options Select options * @returns Query string */ - select({ fields, tableName, filter = {}, order = [], limit = undefined }: SelectOptions): string { + select({ fields, tableName, filter = {}, order = [], limit = undefined, skip = undefined, allowPartialSearch = false }: SelectOptions): string { let queryStr = `SELECT ${this.selectColumnsClause(fields)} FROM "${tableName}"`; if(Object.keys(filter ?? {}).length > 0) { - queryStr += ` WHERE ${this.whereClause(filter)}`; + queryStr += ` WHERE ${this.whereClause(filter, { allowPartialSearch })}` ; } if(order.length > 0) { queryStr += ` ORDER BY ${this.orderByClause(order)}` } - if(limit) { + if(limit && !skip) { queryStr += ` LIMIT ${limit}` } + if(skip && limit) { + queryStr += ` OFFSET ${skip} LIMIT ${limit}` + } + return queryStr; } @@ -90,13 +104,17 @@ class PostgresQueryBuilder { * @param filter Filter * @returns Where clause */ - whereClause(filter: object = {}): string { + whereClause(filter: object = {}, { allowPartialSearch = false } = {}): string { return Object.keys(filter).map((key) => { const value = filter[key]; if(value === null) { return `"${key}" IS NULL`; } + + if(allowPartialSearch && typeof value === 'string' && (value.startsWith('%') || value.endsWith('%'))) { + return `"${key}" LIKE :${key}` + } return `"${key}" = :${key}` }).join(' AND '); diff --git a/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts b/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts index 022df75a6..29365154f 100644 --- a/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts +++ b/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts @@ -1,6 +1,8 @@ import BaseDocumentManager from "@src/core/domains/database/base/BaseDocumentManager"; +import MongoDbQueryBuilder from "@src/core/domains/database/builder/MongoDbQueryBuilder"; import InvalidObjectId from "@src/core/domains/database/exceptions/InvalidObjectId"; import { FindOptions, IDatabaseDocument, OrderOptions } from "@src/core/domains/database/interfaces/IDocumentManager"; +import { IPrepareOptions } from "@src/core/domains/database/interfaces/IPrepareOptions"; import { IBelongsToOptions } from "@src/core/domains/database/interfaces/relationships/IBelongsTo"; import MongoDB from "@src/core/domains/database/providers-db/MongoDB"; import MongoDBBelongsTo from "@src/core/domains/database/relationships/mongodb/MongoDBBelongsTo"; @@ -10,11 +12,26 @@ class MongoDbDocumentManager extends BaseDocumentManager({ filter = {} }: { filter?: object }): Promise { + async findOne({ filter = {}, allowPartialSearch = false, useFuzzySearch = false }: Pick): Promise { return this.captureError(async() => { + + filter = this.builder.select({ filter, allowPartialSearch, useFuzzySearch }) + let document = await this.driver.getDb().collection(this.getTable()).findOne(filter) as T | null; if (document) { @@ -117,16 +137,22 @@ class MongoDbDocumentManager extends BaseDocumentManager({ filter, order }: FindOptions): Promise { + + async findMany({ filter, order, limit, skip, allowPartialSearch = false, useFuzzySearch = false }: FindOptions): Promise { return this.captureError(async() => { + + filter = this.builder.select({ filter, allowPartialSearch, useFuzzySearch }) + const documents = await this.driver .getDb() .collection(this.getTable()) .find(filter as object, { sort: order ? this.convertOrderToSort(order ?? []) : undefined, + limit, + skip }) .toArray(); diff --git a/src/core/domains/database/interfaces/IDocumentManager.ts b/src/core/domains/database/interfaces/IDocumentManager.ts index 55eef796e..b27710f96 100644 --- a/src/core/domains/database/interfaces/IDocumentManager.ts +++ b/src/core/domains/database/interfaces/IDocumentManager.ts @@ -10,7 +10,7 @@ export interface IDatabaseDocument { } export type OrderOptions = Record[]; -export type FindOptions = { filter?: object, order?: OrderOptions } +export type FindOptions = { filter?: object, order?: OrderOptions, limit?: number, skip?: number, allowPartialSearch?: boolean, useFuzzySearch?: boolean }; /** * Provides methods for interacting with a database table. diff --git a/src/core/domains/database/interfaces/relationships/IBelongsTo.ts b/src/core/domains/database/interfaces/relationships/IBelongsTo.ts index 75dc05475..a785c9551 100644 --- a/src/core/domains/database/interfaces/relationships/IBelongsTo.ts +++ b/src/core/domains/database/interfaces/relationships/IBelongsTo.ts @@ -1,12 +1,12 @@ /* eslint-disable no-unused-vars */ import { IDatabaseDocument } from "@src/core/domains/database/interfaces/IDocumentManager"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; export type IBelongsToCtor = new () => IBelongsTo; export interface IBelongsToOptions { - localKey: keyof IModelData; - foreignKey: keyof IModelData; + localKey: keyof IModelAttributes; + foreignKey: keyof IModelAttributes; foreignTable: string; filters?: object; } @@ -16,5 +16,5 @@ export interface IBelongsTo { connection: string, document: IDatabaseDocument, options: IBelongsToOptions - ): Promise; + ): Promise; } \ No newline at end of file diff --git a/src/core/domains/database/interfaces/relationships/IHasMany.ts b/src/core/domains/database/interfaces/relationships/IHasMany.ts index 9584c428a..75efa502f 100644 --- a/src/core/domains/database/interfaces/relationships/IHasMany.ts +++ b/src/core/domains/database/interfaces/relationships/IHasMany.ts @@ -1,12 +1,12 @@ /* eslint-disable no-unused-vars */ import { IDatabaseDocument } from "@src/core/domains/database/interfaces/IDocumentManager"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; export type IHasManyCtor = new () => IHasMany; export interface IHasManyOptions { - localKey: keyof IModelData; - foreignKey: keyof IModelData; + localKey: keyof IModelAttributes; + foreignKey: keyof IModelAttributes; foreignTable: string; filters?: object; } @@ -16,5 +16,5 @@ export interface IHasMany { connection: string, document: IDatabaseDocument, options: IHasManyOptions - ): Promise; + ): Promise; } \ No newline at end of file diff --git a/src/core/domains/database/providers-db/Postgres.ts b/src/core/domains/database/providers-db/Postgres.ts index 0d98e80f4..24aea79dc 100644 --- a/src/core/domains/database/providers-db/Postgres.ts +++ b/src/core/domains/database/providers-db/Postgres.ts @@ -1,4 +1,5 @@ +import { EnvironmentProduction } from '@src/core/consts/Environment'; import PostgresDocumentManager from '@src/core/domains/database/documentManagers/PostgresDocumentManager'; import InvalidSequelize from '@src/core/domains/database/exceptions/InvalidSequelize'; import { IDatabaseGenericConnectionConfig } from '@src/core/domains/database/interfaces/IDatabaseGenericConnectionConfig'; @@ -6,6 +7,7 @@ import { IDatabaseProvider } from '@src/core/domains/database/interfaces/IDataba import { IDatabaseSchema } from '@src/core/domains/database/interfaces/IDatabaseSchema'; import { IDocumentManager } from '@src/core/domains/database/interfaces/IDocumentManager'; import PostgresSchema from '@src/core/domains/database/schema/PostgresSchema'; +import { App } from '@src/core/services/App'; import pg from 'pg'; import { QueryInterface, Sequelize } from 'sequelize'; import { Options, Options as SequelizeOptions } from 'sequelize/types/sequelize'; @@ -48,6 +50,7 @@ export default class Postgres implements IDatabaseProvider { */ async connect(): Promise { this.sequelize = new Sequelize(this.config.uri, { + logging: App.env() !== EnvironmentProduction, ...this.config.options, ...this.overrideConfig }) diff --git a/src/core/domains/database/relationships/BelongsTo.ts b/src/core/domains/database/relationships/BelongsTo.ts index be0e66166..73e23a97a 100644 --- a/src/core/domains/database/relationships/BelongsTo.ts +++ b/src/core/domains/database/relationships/BelongsTo.ts @@ -1,6 +1,6 @@ import { IDatabaseDocument } from "@src/core/domains/database/interfaces/IDocumentManager"; import { IBelongsTo, IBelongsToOptions } from "@src/core/domains/database/interfaces/relationships/IBelongsTo"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; import { App } from "@src/core/services/App"; /** @@ -18,7 +18,7 @@ export default class BelongsTo implements IBelongsTo { * @param options - The relationship options. * @returns The related document or null if not found. */ - public async handle(connection: string, document: IDatabaseDocument, options: IBelongsToOptions): Promise { + public async handle(connection: string, document: IDatabaseDocument, options: IBelongsToOptions): Promise { /** * Get the local key and foreign key from the options. diff --git a/src/core/domains/database/relationships/HasMany.ts b/src/core/domains/database/relationships/HasMany.ts index d4d5dee79..c3c8b6dcb 100644 --- a/src/core/domains/database/relationships/HasMany.ts +++ b/src/core/domains/database/relationships/HasMany.ts @@ -1,6 +1,6 @@ import { IDatabaseDocument } from "@src/core/domains/database/interfaces/IDocumentManager"; import { IHasMany, IHasManyOptions } from "@src/core/domains/database/interfaces/relationships/IHasMany"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; import { App } from "@src/core/services/App"; /** @@ -17,9 +17,9 @@ export default class HasMany implements IHasMany { * @param {string} connection - The connection name. * @param {IDatabaseDocument} document - The source document. * @param {IHasManyOptions} options - The relationship options. - * @returns {Promise} The related documents. + * @returns {Promise} The related documents. */ - public async handle(connection: string, document: IDatabaseDocument, options: IHasManyOptions): Promise { + public async handle(connection: string, document: IDatabaseDocument, options: IHasManyOptions): Promise { // Get the local key, foreign key, foreign table, and filters from the options. const { localKey, diff --git a/src/core/domains/database/relationships/mongodb/MongoDBHasMany.ts b/src/core/domains/database/relationships/mongodb/MongoDBHasMany.ts index 6c5108cde..1e0fb7372 100644 --- a/src/core/domains/database/relationships/mongodb/MongoDBHasMany.ts +++ b/src/core/domains/database/relationships/mongodb/MongoDBHasMany.ts @@ -1,6 +1,6 @@ import { IDatabaseDocument } from "@src/core/domains/database/interfaces/IDocumentManager"; import { IHasMany, IHasManyOptions } from "@src/core/domains/database/interfaces/relationships/IHasMany"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; import { App } from "@src/core/services/App"; import { ObjectId } from "mongodb"; @@ -18,9 +18,9 @@ export default class HasMany implements IHasMany { * @param {string} connection - The connection name. * @param {IDatabaseDocument} document - The source document. * @param {IHasManyOptions} options - The relationship options. - * @returns {Promise} The related documents. + * @returns {Promise} The related documents. */ - public async handle(connection: string, document: IDatabaseDocument, options: IHasManyOptions): Promise { + public async handle(connection: string, document: IDatabaseDocument, options: IHasManyOptions): Promise { const { foreignTable, diff --git a/src/core/domains/events/models/FailedWorkerModel.ts b/src/core/domains/events/models/FailedWorkerModel.ts index 4986de818..99e3ef778 100644 --- a/src/core/domains/events/models/FailedWorkerModel.ts +++ b/src/core/domains/events/models/FailedWorkerModel.ts @@ -1,13 +1,13 @@ import Model from "@src/core/base/Model"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; /** * Represents a failed worker model. * * @interface FailedWorkerModelData - * @extends IModelData + * @extends IModelAttributes */ -export interface FailedWorkerModelData extends IModelData { +export interface FailedWorkerModelData extends IModelAttributes { /** * The name of the event that failed. diff --git a/src/core/domains/events/models/WorkerModel.ts b/src/core/domains/events/models/WorkerModel.ts index 1bc3c159a..bb966ab6e 100644 --- a/src/core/domains/events/models/WorkerModel.ts +++ b/src/core/domains/events/models/WorkerModel.ts @@ -1,7 +1,7 @@ import Model from "@src/core/base/Model"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; -export interface WorkerModelData extends IModelData { +export interface WorkerModelData extends IModelAttributes { queueName: string; eventName: string; payload: any; diff --git a/src/core/domains/events/services/EventDispatcher.ts b/src/core/domains/events/services/EventDispatcher.ts index 96971d65f..4e548c3d4 100644 --- a/src/core/domains/events/services/EventDispatcher.ts +++ b/src/core/domains/events/services/EventDispatcher.ts @@ -7,13 +7,13 @@ import { App } from "@src/core/services/App"; export default class EventDispatcher extends Singleton implements IEventDispatcher { - + /** * Handle the dispatched event * @param event */ public async dispatch(event: IEvent) { - console.log(`[EventDispatcher:dispatch] Event '${event.name}' with driver '${event.driver}'`) + App.container('logger').info(`[EventDispatcher:dispatch] Event '${event.name}' with driver '${event.driver}'`) const driverOptions = this.getDriverOptionsFromEvent(event) const driverCtor = driverOptions.driver @@ -36,5 +36,5 @@ export default class EventDispatcher extends Singleton implements IEventDispatch return driver } - + } \ No newline at end of file diff --git a/src/core/domains/events/services/Worker.ts b/src/core/domains/events/services/Worker.ts index 017244a0e..0d792ae8c 100644 --- a/src/core/domains/events/services/Worker.ts +++ b/src/core/domains/events/services/Worker.ts @@ -33,7 +33,7 @@ export default class Worker extends Singleton { */ setDriver(driver: string) { this.options = this.getOptions(driver) - this.log(`Driver set to '${driver}'`,) + this.logToConsole(`Driver set to '${driver}'`,) } /** @@ -57,8 +57,8 @@ export default class Worker extends Singleton { // Fetch the current list of queued results const workerResults: WorkerModel[] = await worker.getWorkerResults(this.options.queueName) - this.log('collection: ' + new this.options.workerModelCtor().table) - this.log(`${workerResults.length} queued items with queue name '${this.options.queueName}'`) + this.logToConsole('collection: ' + new this.options.workerModelCtor().table) + this.logToConsole(`${workerResults.length} queued items with queue name '${this.options.queueName}'`) for(const workerModel of workerResults) { // We set the model here to pass it to the failedWorkerModel method, @@ -66,12 +66,12 @@ export default class Worker extends Singleton { model = workerModel try { - console.log('Worker processing model', model.getId()?.toString()) + App.container('logger').console('Worker processing model', model.getId()?.toString()) await worker.processWorkerModel(model) } catch (err) { if(!(err instanceof Error)) { - console.error(err) + App.container('logger').error(err) return; } @@ -124,7 +124,7 @@ export default class Worker extends Singleton { // Delete record as it was a success await model.delete(); - this.log(`Processed: ${eventName}`) + this.logToConsole(`Processed: ${eventName}`) } /** @@ -141,7 +141,7 @@ export default class Worker extends Singleton { const currentAttempt = (model.getAttribute('attempt') ?? 0) const nextCurrentAttempt = currentAttempt + 1 - this.log(`Failed ${model.getAttribute('eventName')} attempts ${currentAttempt + 1} out of ${retries}, ID: ${model.getId()?.toString()}`) + this.logToConsole(`Failed ${model.getAttribute('eventName')} attempts ${currentAttempt + 1} out of ${retries}, ID: ${model.getId()?.toString()}`) // If reached max, move to failed collection if(nextCurrentAttempt >= retries) { @@ -160,7 +160,7 @@ export default class Worker extends Singleton { * @param err */ async moveFailedWorkerModel(model: WorkerModel, err: Error) { - this.log('Moved to failed') + this.logToConsole('Moved to failed') const failedWorkerModel = (new FailedWorkerModelFactory).create( this.options.failedCollection, @@ -183,8 +183,8 @@ export default class Worker extends Singleton { * Logs a message to the console * @param message The message to log */ - protected log(message: string) { - console.log('[Worker]: ', message) + protected logToConsole(message: string) { + App.container('logger').console('[Worker]: ', message) } } diff --git a/src/core/domains/express/actions/resourceAction.ts b/src/core/domains/express/actions/baseAction.ts similarity index 79% rename from src/core/domains/express/actions/resourceAction.ts rename to src/core/domains/express/actions/baseAction.ts index c6e18f9dc..c63f463f2 100644 --- a/src/core/domains/express/actions/resourceAction.ts +++ b/src/core/domains/express/actions/baseAction.ts @@ -11,10 +11,6 @@ import { Response } from 'express'; * @param {IAction} action The action function that will be called with the BaseRequest, Response, and options. * @return {(req: BaseRequest, res: Response) => Promise} A new action function that calls the given action with the given options. */ -const ResourceAction = (options: IRouteResourceOptions, action: IAction) => { - return (req: BaseRequest, res: Response) => { - return action(req, res, options) - } -} +const baseAction = (options: IRouteResourceOptions, action: IAction) => (req: BaseRequest, res: Response) => action(req, res, options) -export default ResourceAction \ No newline at end of file +export default baseAction \ No newline at end of file diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceAll.ts similarity index 57% rename from src/core/domains/express/actions/resourceIndex.ts rename to src/core/domains/express/actions/resourceAll.ts index 7e5e64085..36f5d99e3 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceAll.ts @@ -1,7 +1,7 @@ -import Repository from '@src/core/base/Repository'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; +import ResourceAllService from '@src/core/domains/express/services/Resources/ResourceAllService'; +import ResourceErrorService from '@src/core/domains/express/services/Resources/ResourceErrorService'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; -import { IModel } from '@src/core/interfaces/IModel'; import { Response } from 'express'; /** @@ -13,11 +13,11 @@ import { Response } from 'express'; * @returns {Promise} */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { - - const repository = new Repository(options.resource); - - let results = await repository.findMany(); - results = results.map(result => result.getData({ excludeGuarded : true }) as IModel); - - res.send(results) + try { + const resourceAllService = new ResourceAllService(); + await resourceAllService.handler(req, res, options); + } + catch (err) { + ResourceErrorService.handleError(req, res, err) + } } \ No newline at end of file diff --git a/src/core/domains/express/actions/resourceCreate.ts b/src/core/domains/express/actions/resourceCreate.ts index 680ecde31..22549134a 100644 --- a/src/core/domains/express/actions/resourceCreate.ts +++ b/src/core/domains/express/actions/resourceCreate.ts @@ -1,8 +1,10 @@ import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; -import responseError from '@src/core/domains/express/requests/responseError'; +import ResourceCreateService from '@src/core/domains/express/services/Resources/ResourceCreateService'; +import ResourceErrorService from '@src/core/domains/express/services/Resources/ResourceErrorService'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { Response } from 'express'; + /** * Creates a new instance of the model * @@ -13,16 +15,10 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { - const modelInstance = new options.resource(req.body); - await modelInstance.save(); - - res.status(201).send(modelInstance.getData({ excludeGuarded: true }) as IRouteResourceOptions['resource']) + const resourceCreateService = new ResourceCreateService(); + await resourceCreateService.handler(req, res, options); } catch (err) { - if (err instanceof Error) { - responseError(req, res, err) - } - - res.status(500).send({ error: 'Something went wrong' }) + ResourceErrorService.handleError(req, res, err) } } \ No newline at end of file diff --git a/src/core/domains/express/actions/resourceDelete.ts b/src/core/domains/express/actions/resourceDelete.ts index 1d88165da..f90a47f66 100644 --- a/src/core/domains/express/actions/resourceDelete.ts +++ b/src/core/domains/express/actions/resourceDelete.ts @@ -1,8 +1,7 @@ -import Repository from '@src/core/base/Repository'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; -import responseError from '@src/core/domains/express/requests/responseError'; +import ResourceDeleteService from '@src/core/domains/express/services/Resources/ResourceDeleteService'; +import ResourceErrorService from '@src/core/domains/express/services/Resources/ResourceErrorService'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; -import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { Response } from 'express'; /** @@ -15,26 +14,10 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { - const repository = new Repository(options.resource); - - const result = await repository.findById(req.params?.id); - - if (!result) { - throw new ModelNotFound('Resource not found'); - } - - await result.delete(); - - res.send({ success: true }) + const resourceDeleteService = new ResourceDeleteService(); + await resourceDeleteService.handler(req, res, options); } catch (err) { - if(err instanceof ModelNotFound) { - responseError(req, res, err, 404) - } - if (err instanceof Error) { - responseError(req, res, err) - } - - res.status(500).send({ error: 'Something went wrong' }) + ResourceErrorService.handleError(req, res, err) } } \ No newline at end of file diff --git a/src/core/domains/express/actions/resourceShow.ts b/src/core/domains/express/actions/resourceShow.ts index 5a0b4f2c7..3b4a8abd2 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -1,9 +1,7 @@ -import Repository from '@src/core/base/Repository'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; -import responseError from '@src/core/domains/express/requests/responseError'; +import ResourceErrorService from '@src/core/domains/express/services/Resources/ResourceErrorService'; +import ResourceShowService from '@src/core/domains/express/services/Resources/ResourceShowService'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; -import ModelNotFound from '@src/core/exceptions/ModelNotFound'; -import { IModel } from '@src/core/interfaces/IModel'; import { Response } from 'express'; /** @@ -16,24 +14,10 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { - const repository = new Repository(options.resource); - - const result = await repository.findById(req.params?.id); - - if (!result) { - throw new ModelNotFound('Resource not found'); - } - - res.send(result?.getData({ excludeGuarded: true }) as IModel); + const resourceShowService = new ResourceShowService() + await resourceShowService.handler(req, res, options) } catch (err) { - if(err instanceof ModelNotFound) { - responseError(req, res, err, 404) - } - if (err instanceof Error) { - responseError(req, res, err) - } - - res.status(500).send({ error: 'Something went wrong' }) + ResourceErrorService.handleError(req, res, err) } } \ No newline at end of file diff --git a/src/core/domains/express/actions/resourceUpdate.ts b/src/core/domains/express/actions/resourceUpdate.ts index db0578c37..14b3bc050 100644 --- a/src/core/domains/express/actions/resourceUpdate.ts +++ b/src/core/domains/express/actions/resourceUpdate.ts @@ -1,9 +1,7 @@ -import Repository from '@src/core/base/Repository'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; -import responseError from '@src/core/domains/express/requests/responseError'; +import ResourceErrorService from '@src/core/domains/express/services/Resources/ResourceErrorService'; +import ResourceUpdateService from '@src/core/domains/express/services/Resources/ResourceUpdateService'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; -import ModelNotFound from '@src/core/exceptions/ModelNotFound'; -import { IModel } from '@src/core/interfaces/IModel'; import { Response } from 'express'; /** @@ -16,27 +14,10 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { - const repository = new Repository(options.resource); - - const result = await repository.findById(req.params?.id); - - if (!result) { - throw new ModelNotFound('Resource not found'); - } - - result.fill(req.body); - await result.save(); - - res.send(result?.getData({ excludeGuarded: true }) as IModel); + const resourceUpdateService = new ResourceUpdateService(); + await resourceUpdateService.handler(req, res, options); } catch (err) { - if(err instanceof ModelNotFound) { - responseError(req, res, err, 404) - } - if (err instanceof Error) { - responseError(req, res, err) - } - - res.status(500).send({ error: 'Something went wrong' }) + ResourceErrorService.handleError(req, res, err) } } \ No newline at end of file diff --git a/src/core/domains/express/exceptions/MissingSecurityError.ts b/src/core/domains/express/exceptions/MissingSecurityError.ts new file mode 100644 index 000000000..8ae527ff4 --- /dev/null +++ b/src/core/domains/express/exceptions/MissingSecurityError.ts @@ -0,0 +1,8 @@ +export default class MissingSecurityError extends Error { + + constructor(message: string = 'Missing security for this route') { + super(message); + this.name = 'MissingSecurityError'; + } + +} \ No newline at end of file diff --git a/src/core/domains/express/interfaces/ICurrentRequest.ts b/src/core/domains/express/interfaces/ICurrentRequest.ts new file mode 100644 index 000000000..4c728e27a --- /dev/null +++ b/src/core/domains/express/interfaces/ICurrentRequest.ts @@ -0,0 +1,20 @@ +/* eslint-disable no-unused-vars */ +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; + +export interface IRequestContextData extends Map> {} + +export type IPDatesArrayTTL = { value: T; ttlSeconds: number | null }; + +export type IPContextData = Map>>; + +export interface IRequestContext { + setByRequest(req: BaseRequest, key: string, value: T): this; + getByRequest(req: BaseRequest, key?: string): T | undefined; + setByIpAddress(req: BaseRequest, key: string, value: T, ttlSeconds?: number): this; + getByIpAddress(req: BaseRequest, key?: string): T | undefined; + endRequestContext(req: BaseRequest): void; + getRequestContext(): IRequestContextData; + setRequestContext(context: IRequestContextData): this; + getIpContext(): IPContextData; + setIpContext(context: IPContextData): this; +} \ No newline at end of file diff --git a/src/core/domains/express/interfaces/ICurrentRequestCleanUpConfig.ts b/src/core/domains/express/interfaces/ICurrentRequestCleanUpConfig.ts new file mode 100644 index 000000000..e907c9437 --- /dev/null +++ b/src/core/domains/express/interfaces/ICurrentRequestCleanUpConfig.ts @@ -0,0 +1,3 @@ +export interface ICurrentRequestCleanUpConfig { + delayInSeconds: number +} \ No newline at end of file diff --git a/src/core/domains/express/interfaces/IExpressConfig.ts b/src/core/domains/express/interfaces/IExpressConfig.ts index 267815caf..62bf85445 100644 --- a/src/core/domains/express/interfaces/IExpressConfig.ts +++ b/src/core/domains/express/interfaces/IExpressConfig.ts @@ -4,4 +4,5 @@ export default interface IExpressConfig { enabled: boolean; port: number; globalMiddlewares?: express.RequestHandler[]; + currentRequestCleanupDelay?: number; } \ No newline at end of file diff --git a/src/core/domains/express/interfaces/IResourceService.ts b/src/core/domains/express/interfaces/IResourceService.ts new file mode 100644 index 000000000..3bd52c8dd --- /dev/null +++ b/src/core/domains/express/interfaces/IResourceService.ts @@ -0,0 +1,16 @@ +/* eslint-disable no-unused-vars */ +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { Response } from "express"; + +export type IPartialRouteResourceOptions = Omit + +export interface IResourceService { + handler(req: BaseRequest, res: Response, options: IPartialRouteResourceOptions): Promise +} + +export interface IPageOptions { + page: number; + pageSize?: number; + skip?: number; +} \ No newline at end of file diff --git a/src/core/domains/express/interfaces/IRoute.ts b/src/core/domains/express/interfaces/IRoute.ts index 9391eb82d..401285b7b 100644 --- a/src/core/domains/express/interfaces/IRoute.ts +++ b/src/core/domains/express/interfaces/IRoute.ts @@ -1,4 +1,5 @@ import { IRouteAction } from '@src/core/domains/express/interfaces/IRouteAction'; +import { IIdentifiableSecurityCallback } from '@src/core/domains/express/interfaces/ISecurity'; import { ValidatorCtor } from '@src/core/domains/validator/types/ValidatorCtor'; import { Middleware } from '@src/core/interfaces/Middleware.t'; @@ -7,7 +8,12 @@ export interface IRoute { path: string; method: 'get' | 'post' | 'put' | 'patch' | 'delete'; action: IRouteAction; + resourceType?: string; + scopes?: string[]; + scopesPartial?: string[]; + enableScopes?: boolean; middlewares?: Middleware[]; validator?: ValidatorCtor; validateBeforeAction?: boolean; + security?: IIdentifiableSecurityCallback[]; } \ No newline at end of file diff --git a/src/core/domains/express/interfaces/IRouteResourceOptions.ts b/src/core/domains/express/interfaces/IRouteResourceOptions.ts index 3d450a82a..fafbf07c3 100644 --- a/src/core/domains/express/interfaces/IRouteResourceOptions.ts +++ b/src/core/domains/express/interfaces/IRouteResourceOptions.ts @@ -1,14 +1,31 @@ import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; -import { IModel, ModelConstructor } from "@src/core/interfaces/IModel"; +import { IIdentifiableSecurityCallback } from "@src/core/domains/express/interfaces/ISecurity"; import { ValidatorCtor } from "@src/core/domains/validator/types/ValidatorCtor"; +import { IModel, ModelConstructor } from "@src/core/interfaces/IModel"; + -export type ResourceType = 'index' | 'create' | 'update' | 'show' | 'delete'; +export type ResourceType = 'all' | 'create' | 'update' | 'show' | 'destroy'; + +export type SearchOptions = { + fields: string[]; + useFuzzySearch?: boolean; // Only applies to MongoDB provider +} export interface IRouteResourceOptions extends Pick { + path: string; + resource: ModelConstructor; except?: ResourceType[]; only?: ResourceType[]; - resource: ModelConstructor; - name: string; createValidator?: ValidatorCtor; updateValidator?: ValidatorCtor; + security?: IIdentifiableSecurityCallback[]; + scopes?: string[]; + enableScopes?: boolean; + showFilters?: object; + allFilters?: object; + paginate?: { + pageSize: number; + allowPageSizeOverride?: boolean; + }, + searching?: SearchOptions } \ No newline at end of file diff --git a/src/core/domains/express/interfaces/ISecurity.ts b/src/core/domains/express/interfaces/ISecurity.ts new file mode 100644 index 000000000..62fd46b45 --- /dev/null +++ b/src/core/domains/express/interfaces/ISecurity.ts @@ -0,0 +1,44 @@ +import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; +import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; +import { NextFunction, Request, Response } from 'express'; + +/** + * Authorize Security props + */ +export interface ISecurityAuthorizeProps { + throwExceptionOnUnauthorized?: boolean +} + +/** + * The callback function + */ +// eslint-disable-next-line no-unused-vars +export type SecurityCallback = (req: BaseRequest, ...args: any[]) => boolean; + +/** + * An interface for defining security callbacks with an identifier. + * + * id - The identifier for the security callback. + * also - The security rule to include in the callback. + * when - The condition for when the security check should be executed. Defaults to 'always'. + * never - The condition for when the security check should never be executed. + * callback - The security callback function. + */ +export type IIdentifiableSecurityCallback = { + id: string; + also?: string | null; + when: string[] | null; + never: string[] | null; + arguements?: Record; + callback: SecurityCallback; +} + +// eslint-disable-next-line no-unused-vars +export type ISecurityMiddleware = ({ route }: { route: IRoute }) => (req: BaseRequest, res: Response, next: NextFunction) => Promise; + +/** + * Security request to be included in BaseRequest + */ +export default interface ISecurityRequest extends Request { + security?: IIdentifiableSecurityCallback[] +} \ No newline at end of file diff --git a/src/core/domains/express/middleware/authorize.ts b/src/core/domains/express/middleware/authorize.ts deleted file mode 100644 index b7b396624..000000000 --- a/src/core/domains/express/middleware/authorize.ts +++ /dev/null @@ -1,46 +0,0 @@ -import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; -import responseError from '@src/core/domains/express/requests/responseError'; -import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; -import { App } from '@src/core/services/App'; -import { NextFunction, Response } from 'express'; - -/** - * Authorize middleware - * - * This middleware will check the authorization header for a valid JWT token. - * If the token is valid, it will set the user and apiToken properties on the request. - * If the token is invalid, it will throw a UnauthorizedError. - * - * @param {BaseRequest} req - The request object - * @param {Response} res - The response object - * @param {NextFunction} next - The next function - * @returns {Promise} - */ -export const authorize = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { - try { - const authorization = (req.headers.authorization ?? '').replace('Bearer ', ''); - - const apiToken = await App.container('auth').attemptAuthenticateToken(authorization) - - const user = await apiToken?.user() - - if(!user || !apiToken) { - throw new UnauthorizedError(); - } - - req.user = user; - req.apiToken = apiToken - - next(); - } - catch (error) { - if(error instanceof UnauthorizedError) { - responseError(req, res, error, 401) - return; - } - - if(error instanceof Error) { - responseError(req, res, error) - } - } -}; \ No newline at end of file diff --git a/src/core/domains/express/middleware/authorizeMiddleware.ts b/src/core/domains/express/middleware/authorizeMiddleware.ts new file mode 100644 index 000000000..f6ce1609b --- /dev/null +++ b/src/core/domains/express/middleware/authorizeMiddleware.ts @@ -0,0 +1,74 @@ +import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; +import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import AuthRequest from '@src/core/domains/auth/services/AuthRequest'; +import responseError from '@src/core/domains/express/requests/responseError'; +import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; +import { NextFunction, Response } from 'express'; + +/** + * Validates that the scopes in the api token match the required scopes for the request. + * If the scopes do not match, it will throw a ForbiddenResourceError. + * If no api token is found, it will throw a UnauthorizedError. + * @param scopes The scopes required for the request + * @param req The request object + * @param res The response object + */ +// eslint-disable-next-line no-unused-vars +const validateScopes = (scopes: string[], req: BaseRequest, res: Response): void | null => { + if(scopes.length === 0) { + return; + } + + const apiToken = req.apiToken; + + if(!apiToken) { + throw new UnauthorizedError(); + } + + if(!apiToken.hasScope(scopes)) { + throw new ForbiddenResourceError(); + } +} + +/** + * Authorize middleware + * + * This middleware will check the authorization header for a valid JWT token. + * If the token is valid, it will set the user and apiToken properties on the request. + * If the token is invalid, it will throw a UnauthorizedError. + * + * @param {BaseRequest} req - The request object + * @param {Response} res - The response object + * @param {NextFunction} next - The next function + * @returns {Promise} + */ +export const authorizeMiddleware = (scopes: string[] = []) => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { + try { + + // Authorize the request + // Parses the authorization header + // If successful, attaches the user and apiToken to the request + // and sets the user in the App + await AuthRequest.attemptAuthorizeRequest(req); + + // Validate the scopes if the authorization was successful + validateScopes(scopes, req, res) + + next(); + } + catch (error) { + if(error instanceof UnauthorizedError) { + responseError(req, res, error, 401) + return; + } + + if(error instanceof ForbiddenResourceError) { + responseError(req, res, error, 403) + } + + if(error instanceof Error) { + responseError(req, res, error) + return; + } + } +}; \ No newline at end of file diff --git a/src/core/domains/express/middleware/basicLoggerMiddleware.ts b/src/core/domains/express/middleware/basicLoggerMiddleware.ts new file mode 100644 index 000000000..a3ce78f5b --- /dev/null +++ b/src/core/domains/express/middleware/basicLoggerMiddleware.ts @@ -0,0 +1,9 @@ +import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; +import { App } from '@src/core/services/App'; +import { NextFunction, Response } from 'express'; + + +export const basicLoggerMiddleware = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { + App.container('logger').info('New request: ', `${req.method} ${req.url}`, 'Headers: ', req.headers); + next(); +}; diff --git a/src/core/domains/express/middleware/endRequestContextMiddleware.ts b/src/core/domains/express/middleware/endRequestContextMiddleware.ts new file mode 100644 index 000000000..9e807628c --- /dev/null +++ b/src/core/domains/express/middleware/endRequestContextMiddleware.ts @@ -0,0 +1,18 @@ +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { App } from "@src/core/services/App"; +import { NextFunction, Response } from "express"; + + + +/** + * Middleware that ends the current request context and removes all associated values. + */ +const endRequestContextMiddleware = () => (req: BaseRequest, res: Response, next: NextFunction) => { + res.once('finish', () => { + App.container('requestContext').endRequestContext(req) + }) + + next() +} + +export default endRequestContextMiddleware \ No newline at end of file diff --git a/src/core/domains/express/middleware/logger.ts b/src/core/domains/express/middleware/logger.ts deleted file mode 100644 index 1620a55c7..000000000 --- a/src/core/domains/express/middleware/logger.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; -import { NextFunction, Response } from 'express'; - -export const logger = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { - console.log('Request', `${req.method} ${req.url}`); - next(); -}; diff --git a/src/core/domains/express/middleware/requestContextLoggerMiddleware.ts b/src/core/domains/express/middleware/requestContextLoggerMiddleware.ts new file mode 100644 index 000000000..e13c8de4d --- /dev/null +++ b/src/core/domains/express/middleware/requestContextLoggerMiddleware.ts @@ -0,0 +1,24 @@ +import { EnvironmentDevelopment } from "@src/core/consts/Environment"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { App } from "@src/core/services/App"; +import { NextFunction, Response } from "express"; + +/** + * Middleware to log the request context + */ +const requestContextLoggerMiddleware = () => (req: BaseRequest, res: Response, next: NextFunction) => { + + if(App.env() !== EnvironmentDevelopment) { + next() + return; + } + + res.once('finish', () => { + App.container('logger').info('requestContext: ', App.container('requestContext').getRequestContext()) + App.container('logger').info('ipContext: ', App.container('requestContext').getIpContext()) + }) + + next() +} + +export default requestContextLoggerMiddleware \ No newline at end of file diff --git a/src/core/domains/express/middleware/requestIdMiddleware.ts b/src/core/domains/express/middleware/requestIdMiddleware.ts new file mode 100644 index 000000000..f73fe89ad --- /dev/null +++ b/src/core/domains/express/middleware/requestIdMiddleware.ts @@ -0,0 +1,39 @@ +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { generateUuidV4 } from "@src/core/util/uuid/generateUuidV4"; +import { NextFunction, Response } from "express"; + +type Props = { + // eslint-disable-next-line no-unused-vars + generator: (...args: any[]) => string; + setHeader: boolean; + headerName: string; +} + +const defaultProps: Props = { + generator: generateUuidV4, + setHeader: true, + headerName: 'X-Request-Id' +} + +/** + * Sets a request id on the request object and sets the response header if desired + * + * @param {Props} props - Options to configure the request id middleware + * @param {string} [props.generator=generateUuidV4] - Function to generate a request id + * @param {boolean} [props.setHeader=true] - If true, sets the response header with the request id + * @param {string} [props.headerName='X-Request-Id'] - Name of the response header to set + * @returns {import("express").RequestHandler} - The middleware function + */ +const requestIdMiddleware = ({ generator, setHeader, headerName }: Props = defaultProps) => (req: BaseRequest, res: Response, next: NextFunction) => { + const oldValue = req.get(headerName) + const id = oldValue ?? generator() + + if(setHeader) { + res.set(headerName, id) + } + + req.id = id + next() +} + +export default requestIdMiddleware \ No newline at end of file diff --git a/src/core/domains/express/middleware/securityMiddleware.ts b/src/core/domains/express/middleware/securityMiddleware.ts new file mode 100644 index 000000000..1bb5ba809 --- /dev/null +++ b/src/core/domains/express/middleware/securityMiddleware.ts @@ -0,0 +1,180 @@ +import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; +import RateLimitedExceededError from '@src/core/domains/auth/exceptions/RateLimitedExceededError'; +import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import AuthRequest from '@src/core/domains/auth/services/AuthRequest'; +import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; +import { ISecurityMiddleware } from '@src/core/domains/express/interfaces/ISecurity'; +import responseError from '@src/core/domains/express/requests/responseError'; +import { ALWAYS } from '@src/core/domains/express/services/Security'; +import SecurityReader from '@src/core/domains/express/services/SecurityReader'; +import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; +import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; +import { NextFunction, Response } from 'express'; + +const bindSecurityToRequest = (route: IRoute, req: BaseRequest) => { + req.security = route.security ?? []; +} + + +/** + * Applies the authorization security check on the request. + */ +const applyAuthorizeSecurity = async (route: IRoute, req: BaseRequest, res: Response): Promise => { + + const conditions = [ALWAYS] + + if (route.resourceType) { + conditions.push(route.resourceType) + } + + // Check if the authorize security has been defined for this route + const authorizeSecurity = SecurityReader.findFromRequest(req, SecurityIdentifiers.AUTHORIZED, conditions); + + if (authorizeSecurity) { + try { + // Authorize the request + req = await AuthRequest.attemptAuthorizeRequest(req); + + // Validate the authentication + if (!authorizeSecurity.callback(req)) { + responseError(req, res, new UnauthorizedError(), 401); + return null; + } + } + catch (err) { + + // Conditionally throw error + if (err instanceof UnauthorizedError && authorizeSecurity.arguements?.throwExceptionOnUnauthorized) { + throw err; + } + + // Continue processing + } + } +} + +/** + * Checks if the hasRole security has been defined and validates it. + * If the hasRole security is defined and the validation fails, it will send a 403 response with a ForbiddenResourceError. + */ +const applyHasRoleSecurity = (req: BaseRequest, res: Response): void | null => { + // Check if the hasRole security has been defined and validate + const securityHasRole = SecurityReader.findFromRequest(req, SecurityIdentifiers.HAS_ROLE); + + if (securityHasRole && !securityHasRole.callback(req)) { + responseError(req, res, new ForbiddenResourceError(), 403) + return null; + } + +} + +/** + * Checks if the hasRole security has been defined and validates it. + * If the hasRole security is defined and the validation fails, it will send a 403 response with a ForbiddenResourceError. + */ +const applyHasScopeSecurity = (req: BaseRequest, res: Response): void | null => { + + // Check if the hasRole security has been defined and validate + const securityHasScope = SecurityReader.findFromRequest(req, SecurityIdentifiers.HAS_SCOPE); + + if (securityHasScope && !securityHasScope.callback(req)) { + responseError(req, res, new ForbiddenResourceError(), 403) + return null; + } + +} + +/** + * Checks if the rate limited security has been defined and validates it. + * If the rate limited security is defined and the validation fails, it will send a 429 response with a RateLimitedExceededError. + */ +const applyRateLimitSecurity = async (req: BaseRequest, res: Response): Promise => { + + // Find the rate limited security + const securityRateLimit = SecurityReader.findFromRequest(req, SecurityIdentifiers.RATE_LIMITED); + + if (securityRateLimit && !securityRateLimit.callback(req)) { + responseError(req, res, new RateLimitedExceededError(), 429) + return null; + } +} + +/** + * Security middleware for Express routes. + * + * This middleware check for defined security rules defined on the route. + * - Authorized (allow continue of processing, this is particular useful for RouteResource actions) + * - Authorized throw exceptions (Returns a 401 immediately if authorization fails) + * - Checks rate limits + * - Check authorized scopes + * - Check authorized roles + * + * @param {IRoute} route - The Express route + * @return {(req: BaseRequest, res: Response, next: NextFunction) => Promise} + */ +export const securityMiddleware: ISecurityMiddleware = ({ route }) => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { + try { + + /** + * Adds security rules to the Express Request + * This is used below to find the defined security rules + */ + bindSecurityToRequest(route, req); + + + /** + * Check if the rate limit has been exceeded + */ + if(await applyRateLimitSecurity(req, res) === null) { + return; + } + + /** + * Authorizes the user + * Depending on option 'throwExceptionOnUnauthorized', can allow continue processing on failed auth + */ + if (await applyAuthorizeSecurity(route, req, res) === null) { + return; + } + + /** + * Check if the authorized user passes the has role security + */ + if (applyHasRoleSecurity(req, res) === null) { + return; + } + + /** + * Check if the authorized user passes the has scope security + */ + if (applyHasScopeSecurity(req, res) === null) { + return; + } + + /** + * All security checks have passed + */ + next(); + } + catch (error) { + if (error instanceof UnauthorizedError) { + responseError(req, res, error, 401) + return; + } + + if (error instanceof RateLimitedExceededError) { + responseError(req, res, error, 429) + return; + } + + if (error instanceof ForbiddenResourceError) { + responseError(req, res, error, 403) + return; + } + + if (error instanceof Error) { + responseError(req, res, error) + return; + } + } +}; \ No newline at end of file diff --git a/src/core/domains/express/providers/ExpressProvider.ts b/src/core/domains/express/providers/ExpressProvider.ts index f230e77a3..9323a342b 100644 --- a/src/core/domains/express/providers/ExpressProvider.ts +++ b/src/core/domains/express/providers/ExpressProvider.ts @@ -1,8 +1,11 @@ import httpConfig from '@src/config/http'; import BaseProvider from "@src/core/base/Provider"; import IExpressConfig from "@src/core/domains/express/interfaces/IExpressConfig"; -import { App } from "@src/core/services/App"; import ExpressService from '@src/core/domains/express/services/ExpressService'; +import RequestContext from '@src/core/domains/express/services/RequestContext'; +import RequestContextCleaner from '@src/core/domains/express/services/RequestContextCleaner'; +import { App } from "@src/core/services/App"; + export default class ExpressProvider extends BaseProvider { @@ -27,6 +30,12 @@ export default class ExpressProvider extends BaseProvider { // Register the Express service in the container // This will be available in any provider or service as App.container('express') App.setContainer('express', new ExpressService(this.config)); + + // Register the RequestContext service in the container + // This will be available in any provider or service as App.container('requestContext') + // The RequestContext class can be used to store data over a request's life cycle + // Additionally, data can be stored which can be linked to the requests IP Address with a TTL + App.setContainer('requestContext', new RequestContext()); } /** @@ -58,6 +67,14 @@ export default class ExpressProvider extends BaseProvider { */ await express.listen(); + /** + * Start the RequestContextCleaner + */ + RequestContextCleaner.boot({ + delayInSeconds: this.config.currentRequestCleanupDelay ?? 30 + }) + + // Log that Express is successfully listening this.log('Express successfully listening on port ' + express.getConfig()?.port); } diff --git a/src/core/domains/express/requests/responseError.ts b/src/core/domains/express/requests/responseError.ts index dd0822886..ae7fc41c4 100644 --- a/src/core/domains/express/requests/responseError.ts +++ b/src/core/domains/express/requests/responseError.ts @@ -17,6 +17,7 @@ export default (req: Request , res: Response, err: Error, code: number = 500) => return; } - console.error(err) + App.container('logger').error(err) + res.status(code).send({ error: `${err.message}` }) } \ No newline at end of file diff --git a/src/core/domains/express/routing/RouteResource.ts b/src/core/domains/express/routing/RouteResource.ts index 0ca860571..f3edf1961 100644 --- a/src/core/domains/express/routing/RouteResource.ts +++ b/src/core/domains/express/routing/RouteResource.ts @@ -1,7 +1,8 @@ -import resourceAction from "@src/core/domains/express/actions/resourceAction"; +import ModelScopes from "@src/core/domains/auth/services/ModelScopes"; +import baseAction from "@src/core/domains/express/actions/baseAction"; +import resourceAll from "@src/core/domains/express/actions/resourceAll"; import resourceCreate from "@src/core/domains/express/actions/resourceCreate"; import resourceDelete from "@src/core/domains/express/actions/resourceDelete"; -import resourceIndex from "@src/core/domains/express/actions/resourceIndex"; import resourceShow from "@src/core/domains/express/actions/resourceShow"; import resourceUpdate from "@src/core/domains/express/actions/resourceUpdate"; import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; @@ -10,6 +11,17 @@ import Route from "@src/core/domains/express/routing/Route"; import RouteGroup from "@src/core/domains/express/routing/RouteGroup"; import routeGroupUtil from "@src/core/domains/express/utils/routeGroupUtil"; +/** + * Resource types that can be utilized when adding Security to a route + */ +export const RouteResourceTypes = { + ALL: 'all', + SHOW: 'show', + CREATE: 'create', + UPDATE: 'update', + DESTROY: 'destroy' +} as const + /** * Returns a group of routes for a given resource * - name.index - GET - /name @@ -24,48 +36,88 @@ import routeGroupUtil from "@src/core/domains/express/utils/routeGroupUtil"; * @param options.except - An array of resource types to exclude from the routes * @param options.createValidator - A validator to use for the create route * @param options.updateValidator - A validator to use for the update route + * @param options.enableScopes - Enable scopes security for these routes * @returns A group of routes that can be used to handle requests for the resource */ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { - const name = options.name.startsWith('/') ? options.name.slice(1) : options.name + const path = options.path.startsWith('/') ? options.path.slice(1) : options.path + + const { + resource, + scopes = [], + enableScopes, + } = options; + /** + * Define all the routes for the resource + */ const routes = RouteGroup([ - // Get all resources + // Get all resources Route({ - name: `${name}.index`, + name: `${path}.all`, + resourceType: RouteResourceTypes.ALL, + scopes, + scopesPartial: ModelScopes.getScopes(resource, ['read', 'all']), + enableScopes, method: 'get', - path: `/${name}`, - action: resourceAction(options, resourceIndex) + path: `/${path}`, + action: baseAction(options, resourceAll), + middlewares: options.middlewares, + security: options.security }), // Get resource by id Route({ - name: `${name}.show`, + name: `${path}.show`, + resourceType: RouteResourceTypes.SHOW, + scopes, + scopesPartial: ModelScopes.getScopes(resource, ['read', 'all']), + enableScopes, method: 'get', - path: `/${name}/:id`, - action: resourceAction(options, resourceShow) + path: `/${path}/:id`, + action: baseAction(options, resourceShow), + middlewares: options.middlewares, + security: options.security }), // Update resource by id Route({ - name: `${name}.update`, + name: `${path}.update`, + resourceType: RouteResourceTypes.UPDATE, + scopes, + scopesPartial: ModelScopes.getScopes(resource, ['write', 'all']), + enableScopes, method: 'put', - path: `/${name}/:id`, - action: resourceAction(options, resourceUpdate), - validator: options.updateValidator + path: `/${path}/:id`, + action: baseAction(options, resourceUpdate), + validator: options.updateValidator, + middlewares: options.middlewares, + security: options.security }), // Delete resource by id Route({ - name: `${name}.destroy`, + name: `${path}.destroy`, + resourceType: RouteResourceTypes.DESTROY, + scopes, + scopesPartial: ModelScopes.getScopes(resource, ['delete', 'all']), + enableScopes, method: 'delete', - path: `/${name}/:id`, - action: resourceAction(options, resourceDelete) + path: `/${path}/:id`, + action: baseAction(options, resourceDelete), + middlewares: options.middlewares, + security: options.security }), // Create resource Route({ - name: `${name}.create`, + name: `${path}.create`, + resourceType: RouteResourceTypes.CREATE, + scopes, + scopesPartial: ModelScopes.getScopes(resource, ['create', 'all']), + enableScopes, method: 'post', - path: `/${name}`, - action: resourceAction(options, resourceCreate), - validator: options.createValidator + path: `/${path}`, + action: baseAction(options, resourceCreate), + validator: options.createValidator, + middlewares: options.middlewares, + security: options.security }) ]) diff --git a/src/core/domains/express/routing/RouteResourceScope.ts b/src/core/domains/express/routing/RouteResourceScope.ts new file mode 100644 index 000000000..471f79915 --- /dev/null +++ b/src/core/domains/express/routing/RouteResourceScope.ts @@ -0,0 +1,51 @@ +import { Scope } from "@src/core/domains/auth/interfaces/IScope"; +import ModelScopes from "@src/core/domains/auth/services/ModelScopes"; +import { ModelConstructor } from "@src/core/interfaces/IModel"; + + +export const defaultRouteResourceScopes: Scope[] = ['all']; + +export type GetScopesOptions = { + resource: ModelConstructor, + scopes?: Scope[] | Scope +} + +class RouteResourceScope { + + /** + * Generates a list of scopes, given a resource name and some scope types. + * @param resource The model as a constructor + * @param types The scope type(s) to generate scopes for. If a string, it will be an array of only that type. + * @param additionalScopes Additional scopes to append to the output + * @returns A list of scopes in the format of 'resourceName:scopeType' + * + * Example: + * + * const scopes = RouteResourceScope.getScopes(BlogModel, ['write', 'read'], ['otherScope']) + * + * // Output + * [ + * 'BlogModel:write', + * 'BlogModel:read', + * 'otherScope' + * ] + */ + public static getScopes(options: GetScopesOptions): string[] { + const resource = options.resource + let scopes = options.scopes ?? ModelScopes.getScopes(resource, ['all']); + + // Shape the scopes to an array + scopes = typeof scopes === 'string' ? [scopes] : scopes; + + // Generate scopes from the resource + const resourceScopes = ModelScopes.getScopes(resource, scopes as Scope[]); + + return [ + ...defaultRouteResourceScopes, + ...resourceScopes + ]; + } + +} + +export default RouteResourceScope \ No newline at end of file diff --git a/src/core/domains/express/rules/authorizedSecurity.ts b/src/core/domains/express/rules/authorizedSecurity.ts new file mode 100644 index 000000000..3272cdb93 --- /dev/null +++ b/src/core/domains/express/rules/authorizedSecurity.ts @@ -0,0 +1,20 @@ + +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { App } from "@src/core/services/App"; + + +/** + * Checks if the request is authorized, i.e. if the user is logged in. + * + * @param req - The Express Request object + * @returns True if the user is logged in, false otherwise + */ +const authorizedSecurity = (req: BaseRequest): boolean => { + if(App.container('requestContext').getByRequest(req, 'userId')) { + return true; + } + + return false; +} + +export default authorizedSecurity \ No newline at end of file diff --git a/src/core/domains/express/rules/hasRoleSecurity.ts b/src/core/domains/express/rules/hasRoleSecurity.ts new file mode 100644 index 000000000..ec1959b3a --- /dev/null +++ b/src/core/domains/express/rules/hasRoleSecurity.ts @@ -0,0 +1,20 @@ +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; + +/** + * Checks if the currently logged in user has the given role(s). + * + * @param {BaseRequest} req - The Express Request object + * @param {string | string[]} roles - The role(s) to check + * @returns {boolean} True if the user has the role, false otherwise + */ +const hasRoleSecurity = (req: BaseRequest, roles: string | string[]): boolean => { + const user = req.user; + + if(!user) { + return false; + } + + return user?.hasRole(roles) ?? false +} + +export default hasRoleSecurity \ No newline at end of file diff --git a/src/core/domains/express/rules/hasScopeSecurity.ts b/src/core/domains/express/rules/hasScopeSecurity.ts new file mode 100644 index 000000000..0700b3e21 --- /dev/null +++ b/src/core/domains/express/rules/hasScopeSecurity.ts @@ -0,0 +1,29 @@ +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; + +/** + * Checks if the given scope(s) are present in the scopes of the current request's API token. + * If no API token is found, it will return false. + * @param req The request object + * @param scopesExactMatch The scope(s) to check - must be an exact match - ignores empty scopes + * @param scopesPartialMatch The scope(s) to check - must be a partial match - ignores empty scopes + * @returns True if all scopes are present, false otherwise + */ +const hasScopeSecurity = (req: BaseRequest, scopesExactMatch: string | string[], scopesPartialMatch: string | string[]): boolean => { + const apiToken = req.apiToken; + + if(!apiToken) { + return false; + } + + if(scopesPartialMatch.length && !apiToken.hasScope(scopesPartialMatch, false)) { + return false + } + + if(scopesExactMatch.length && !apiToken.hasScope(scopesExactMatch, true)) { + return false + } + + return true; +} + +export default hasScopeSecurity \ No newline at end of file diff --git a/src/core/domains/express/rules/rateLimitedSecurity.ts b/src/core/domains/express/rules/rateLimitedSecurity.ts new file mode 100644 index 000000000..bfd711bd8 --- /dev/null +++ b/src/core/domains/express/rules/rateLimitedSecurity.ts @@ -0,0 +1,115 @@ +import RateLimitedExceededError from "@src/core/domains/auth/exceptions/RateLimitedExceededError"; +import { IPDatesArrayTTL } from "@src/core/domains/express/interfaces/ICurrentRequest"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { App } from "@src/core/services/App"; +import { Request } from "express"; + +/** + * Adds a new date to the rate limited context. + * + * @param ipContextIdentifier - The rate limited context id. + * @param req - The express request object. + * @param ttlSeconds - The ttl in seconds of the context. + */ +const addDate = (ipContextIdentifier: string, req: Request, ttlSeconds: number) => { + const context = getContext(ipContextIdentifier, req); + const dates = context.value + + App.container('requestContext').setByIpAddress(req, ipContextIdentifier, [ + ...dates, + new Date() + ], ttlSeconds) +} + +/** + * Removes the last date from the rate limited context. + * + * @param ipContextIdentifier - The rate limited context id. + * @param req - The express request object. + */ +const removeLastDate = (ipContextIdentifier: string, req: Request) => { + const context = getContext(ipContextIdentifier, req); + const dates = context.value; + const ttlSeconds = context.ttlSeconds ?? undefined; + const newDates = [...dates]; + newDates.pop(); + + App.container('requestContext').setByIpAddress(req, ipContextIdentifier, newDates, ttlSeconds) +} + + +/** + * Gets the current rate limited context for the given id and request. + * + * Returns an object with a "value" property containing an array of Date objects and a "ttlSeconds" property containing the TTL in seconds. + * Example: { value: [Date, Date], ttlSeconds: 60 } + * + * @param id - The rate limited context id. + * @param req - The express request object. + * @returns The current rate limited context value with the given id, or an empty array if none exists. + */ +const getContext = (id: string, req: Request): IPDatesArrayTTL => { + return App.container('requestContext').getByIpAddress>(req, id) || { value: [], ttlSeconds: null }; +} + +/** + * Finds the number of dates in the given array that are within the given start and end date range. + * + * @param start The start date of the range. + * @param end The end date of the range. + * @param dates The array of dates to search through. + * @returns The number of dates in the array that fall within the given range. + */ +const findDatesWithinTimeRange = (start: Date, end: Date, dates: Date[]): number => { + return dates.filter((date) => { + return date >= start && date <= end; + }).length; +} + +/** + * Checks if the current request has exceeded the given rate limit per minute. + * + * @param req The request to check. + * @param limitPerMinute The maximum number of requests the user can make per minute. + * @returns true if the request has not exceeded the rate limit, false otherwise. + * @throws RateLimitedExceededError if the rate limit has been exceeded. + */ +const rateLimitedSecurity = (req: BaseRequest, limit: number, perMinuteAmount: number = 1): boolean => { + + // Get pathname from request + const url = new URL(req.url, `http${req.secure ? 's' : ''}://${req.headers.host}`); + + // The id for the rate limited context + const ipContextIdentifier = `rateLimited:${req.method}:${url.pathname}` + + // Update the context with a new date + addDate(ipContextIdentifier, req, perMinuteAmount * 60); + + // Get the current date + const now = new Date(); + + // Get date in the past + const dateInPast = new Date(); + dateInPast.setMinutes(dateInPast.getMinutes() - perMinuteAmount); + + // Get an array of dates that represents that hit log + const datesArray = getContext(ipContextIdentifier, req).value; + + // Filter down the array of dates that match our specified time from past to now + const attemptCount = findDatesWithinTimeRange(dateInPast, now, datesArray); + + // If the number of requests is greater than the limit, throw an error + if(attemptCount > limit) { + + // Undo the last added date, we won't consider this failed request as part of the limit + removeLastDate(ipContextIdentifier, req); + + // Throw the error + throw new RateLimitedExceededError() + } + + // Limits not exceeded + return true; +} + +export default rateLimitedSecurity \ No newline at end of file diff --git a/src/core/domains/express/rules/resourceOwnerSecurity.ts b/src/core/domains/express/rules/resourceOwnerSecurity.ts new file mode 100644 index 000000000..87555b246 --- /dev/null +++ b/src/core/domains/express/rules/resourceOwnerSecurity.ts @@ -0,0 +1,26 @@ +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { IModel } from "@src/core/interfaces/IModel"; + +/** + * Checks if the currently logged in user is the owner of the given resource. + * + * @param req - The request object + * @param resource - The resource object + * @param attribute - The attribute name that should contain the user id + * @returns True if the user is the resource owner, false otherwise + */ +const resourceOwnerSecurity = (req: BaseRequest, resource: IModel, attribute: string): boolean => { + const user = req.user; + + if(!user) { + return false; + } + + if(typeof resource.getAttribute !== 'function') { + throw new Error('Resource is not an instance of IModel'); + } + + return resource.getAttribute(attribute) === user?.getId() +} + +export default resourceOwnerSecurity \ No newline at end of file diff --git a/src/core/domains/express/services/ExpressService.ts b/src/core/domains/express/services/ExpressService.ts index 8975966d4..cf40544c4 100644 --- a/src/core/domains/express/services/ExpressService.ts +++ b/src/core/domains/express/services/ExpressService.ts @@ -2,6 +2,10 @@ import Service from '@src/core/base/Service'; import IExpressConfig from '@src/core/domains/express/interfaces/IExpressConfig'; import IExpressService from '@src/core/domains/express/interfaces/IExpressService'; import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; +import endRequestContextMiddleware from '@src/core/domains/express/middleware/endRequestContextMiddleware'; +import requestIdMiddleware from '@src/core/domains/express/middleware/requestIdMiddleware'; +import { securityMiddleware } from '@src/core/domains/express/middleware/securityMiddleware'; +import SecurityRules, { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { Middleware } from '@src/core/interfaces/Middleware.t'; import { App } from '@src/core/services/App'; import express from 'express'; @@ -18,7 +22,7 @@ export default class ExpressService extends Service implements I private app: express.Express private registedRoutes: IRoute[] = []; - + /** * Config defined in @src/config/http/express.ts * @param config @@ -36,6 +40,17 @@ export default class ExpressService extends Service implements I if (!this.config) { throw new Error('Config not provided'); } + + // Adds an identifier to the request object + // This id is used in the requestContext service to store information over a request life cycle + this.app.use(requestIdMiddleware()) + + // End the request context + // This will be called when the request is finished + // Deletes the request context and associated values + this.app.use(endRequestContextMiddleware()) + + // Apply global middlewares for (const middleware of this.config?.globalMiddlewares ?? []) { this.app.use(middleware); } @@ -45,9 +60,9 @@ export default class ExpressService extends Service implements I * Starts listening for connections on the port specified in the config. * If no port is specified, the service will not start listening. */ - public async listen(): Promise { - const port = this.config?.port - + public async listen(): Promise { + const port = this.config?.port + return new Promise(resolve => { this.app.listen(port, () => resolve()) }) @@ -68,11 +83,22 @@ export default class ExpressService extends Service implements I * @param route */ public bindSingleRoute(route: IRoute): void { - const middlewares = this.addValidatorMiddleware(route); + const userDefinedMiddlewares = route.middlewares ?? []; + + // Add security and validator middlewares + const middlewares: Middleware[] = [ + ...userDefinedMiddlewares, + ...this.addValidatorMiddleware(route), + ...this.addSecurityMiddleware(route), + ]; + + // Add route handlers const handlers = [...middlewares, route?.action] - console.log(`[Express] binding route ${route.method.toUpperCase()}: '${route.path}' as '${route.name}'`) + // Log route + this.logRoute(route) + // Bind route switch (route.method) { case 'get': this.app.get(route.path, handlers); @@ -91,7 +117,7 @@ export default class ExpressService extends Service implements I break; default: throw new Error(`Unsupported method ${route.method} for path ${route.path}`); - } + } this.registedRoutes.push(route) } @@ -102,8 +128,11 @@ export default class ExpressService extends Service implements I * @returns middlewares with added validator middleware */ public addValidatorMiddleware(route: IRoute): Middleware[] { - const middlewares = [...route?.middlewares ?? []]; + const middlewares: Middleware[] = []; + /** + * Add validator middleware + */ if (route?.validator) { const validatorMiddleware = App.container('validate').middleware() const validator = new route.validator(); @@ -117,6 +146,50 @@ export default class ExpressService extends Service implements I return middlewares; } + /** + * Adds security middleware to the route. If the route has enableScopes + * and scopes is present, it adds the HAS_SCOPE security rule to the route. + * Then it adds the security middleware to the route's middleware array. + * @param route The route to add the middleware to + * @returns The route's middleware array with the security middleware added + */ + public addSecurityMiddleware(route: IRoute): Middleware[] { + const middlewares: Middleware[] = []; + + /** + * Enabling Scopes Security + * - If enableScopes has not been defined in the route, check if it has been defined in the security rules + * - If yes, set enableScopes to true + */ + const hasEnableScopesSecurity = route.security?.find(security => security.id === SecurityIdentifiers.ENABLE_SCOPES); + const enableScopes = route.enableScopes ?? typeof hasEnableScopesSecurity !== 'undefined'; + + if (enableScopes) { + route.enableScopes = true + } + + /** + * Check if scopes is present, add related security rule + */ + if (route?.enableScopes && (route?.scopes?.length || route?.scopesPartial?.length)) { + route.security = [ + ...(route.security ?? []), + SecurityRules[SecurityIdentifiers.HAS_SCOPE](route.scopes, route.scopesPartial) + ] + } + + /** + * Add security middleware + */ + if (route?.security) { + middlewares.push( + securityMiddleware({ route }) + ) + } + + return middlewares; + } + /** * Returns the Express instance. */ @@ -140,4 +213,46 @@ export default class ExpressService extends Service implements I return this.registedRoutes } + /** + * Logs a route binding to the console. + * @param route - IRoute instance + */ + private logRoute(route: IRoute): void { + const indent = ' '; + let str = `[Express] binding route ${route.method.toUpperCase()}: '${route.path}' as '${route.name}'`; + + if (route.scopes?.length || route.scopesPartial?.length) { + str += `\r\n${indent}SECURITY:`; + + if (route.scopes?.length) { + str += indent + `with exact scopes: [${(route.scopes ?? []).join(', ')}]` + } + + if (route.scopesPartial?.length) { + str += indent + `with partial scopes: [${route.scopesPartial.join(', ')}]` + } + + if (route?.enableScopes) { + str += indent + '(scopes enabled)' + } + else { + str += indent + '(scopes disabled)' + } + } + + for(const security of (route?.security ?? [])) { + str += `\r\n${indent}SECURITY:${indent}${security.id}` + + if(Array.isArray(security.when)) { + str += indent + `with when: [${security.when.join(', ')}]` + } + + if(Array.isArray(security.never)) { + str += indent + `with never: [${security.never.join(', ')}]` + } + } + + App.container('logger').info(str) + } + } diff --git a/src/core/domains/express/services/Paginate.ts b/src/core/domains/express/services/Paginate.ts new file mode 100644 index 000000000..8bf3ae7d4 --- /dev/null +++ b/src/core/domains/express/services/Paginate.ts @@ -0,0 +1,52 @@ +import Singleton from "@src/core/base/Singleton"; +import { Request } from "express"; + +export type ParseRequestOptions = { + allowPageSizeOverride?: boolean +} + +class Paginate extends Singleton { + + protected page: number | undefined = undefined + + protected pageSize: number | undefined = undefined; + + /** + * Parses the request object to extract the page and pageSize from the query string + * + * @param {Request} req - The Express Request object + * @returns {this} - The Paginate class itself to enable chaining + */ + parseRequest(req: Request, options: ParseRequestOptions = { allowPageSizeOverride: true }): this { + if(req.query?.page) { + this.page = parseInt(req.query?.page as string); + } + + if(options.allowPageSizeOverride && req.query?.pageSize) { + this.pageSize = parseInt(req.query?.pageSize as string); + } + + return this + } + + /** + * Gets the page number, defaulting to 1 if undefined. + * @param {number} defaultValue - The default value if this.page is undefined. + * @returns {number} - The page number. + */ + getPage(defaultValue: number = 1): number { + return this.page ?? defaultValue + } + + /** + * Gets the page size, defaulting to the defaultValue if undefined. + * @param {number} [defaultValue=undefined] - The default value if this.pageSize is undefined. + * @returns {number | undefined} - The page size. + */ + getPageSize(defaultValue?: number): number | undefined { + return this.pageSize ?? defaultValue + } + +} + +export default Paginate \ No newline at end of file diff --git a/src/core/domains/express/services/QueryFilters.ts b/src/core/domains/express/services/QueryFilters.ts new file mode 100644 index 000000000..ae9bd2f0b --- /dev/null +++ b/src/core/domains/express/services/QueryFilters.ts @@ -0,0 +1,56 @@ +import Singleton from "@src/core/base/Singleton"; +import { SearchOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { App } from "@src/core/services/App"; +import { Request } from "express"; + +class QueryFilters extends Singleton { + + protected filters: object | undefined = undefined + + + /** + * Parses the request object to extract the filters from the query string + * + * @param {Request} req - The Express Request object + * @returns {this} - The QueryFilters class itself to enable chaining + */ + parseRequest(req: Request, options: SearchOptions = {} as SearchOptions): this { + try { + const { fields = [] } = options; + const decodedQuery = decodeURIComponent(req.query?.filters as string ?? ''); + const filtersParsed: object = JSON.parse(decodedQuery ?? '{}'); + let filters: object = {}; + + fields.forEach((field: string) => { + if (field in filtersParsed) { + filters = { + ...filters, + [field]: filtersParsed[field] + } + } + }) + + this.filters = filters + } + + catch (err) { + App.container('logger').error(err) + } + + return this; + } + + + /** + * Returns the parsed filters from the request query string. + * If no filters were found, returns the defaultValue. + * @param defaultValue - The default value to return if no filters were found. + * @returns The parsed filters or the defaultValue. + */ + getFilters(defaultValue: object | undefined = undefined): object | undefined { + return this.filters ?? defaultValue + } + +} + +export default QueryFilters \ No newline at end of file diff --git a/src/core/domains/express/services/RequestContext.ts b/src/core/domains/express/services/RequestContext.ts new file mode 100644 index 000000000..7445d52ab --- /dev/null +++ b/src/core/domains/express/services/RequestContext.ts @@ -0,0 +1,173 @@ +import Singleton from "@src/core/base/Singleton"; +import { IPContextData, IRequestContext, IRequestContextData } from "@src/core/domains/express/interfaces/ICurrentRequest"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import getIpAddress from "@src/core/domains/express/utils/getIpAddress"; + + +/** + * Current request service + * + * - Stores the current request context + * - Store the current IP context + */ +class RequestContext extends Singleton implements IRequestContext { + + /** + * Request context + * + * Example of how the values object looks like: + * { + * '': { + * 'key': unknown, + * 'key2': unknown + * } + * } + */ + protected requestContext: IRequestContextData = new Map(); + + /** + * IP context + * + * Example of how the values object looks like: + * { + * '127.0.0.1': { + * 'key': unknown, + * 'key2': unknown + * } + * } + */ + protected ipContext: IPContextData = new Map(); + + /** + * Sets a value in the current request context + * + * @param {BaseRequest} req - The Express Request object + * @param {string} key - The key of the value to set + * @param {unknown} value - The value associated with the key + * @returns {typeof RequestContext} - The CurrentRequest class itself to enable chaining + */ + public setByRequest(req: BaseRequest, key: string, value: T): this { + const requestId = req.id as string; + + if(!this.requestContext.has(requestId)) { + this.requestContext.set(requestId, new Map()); + } + + this.requestContext.get(requestId)!.set(key, value); + + return this; + } + + /** + * Gets a value from the current request context + * + * @param {BaseRequest} req - The Express Request object + * @param {string} key - The key of the value to retrieve + * @returns {T | undefined} - The value associated with the key, or undefined if not found + */ + public getByRequest(req: BaseRequest, key?: string): T | undefined { + const requestId = req.id as string; + + if (!key) { + return this.requestContext.get(requestId) as T ?? undefined; + } + + return this.requestContext.get(requestId)?.get(key) as T ?? undefined + } + + /** + * Sets a value in the current request context by the request's IP address. + * + * If the ttlSeconds is not provided, the value will be stored indefinitely (only in memory). + * + * @param {BaseRequest} req - The Express Request object + * @param {string} key - The key of the value to set + * @param {unknown} value - The value associated with the key + * @returns {typeof RequestContext} - The CurrentRequest class itself to enable chaining + */ + public setByIpAddress(req: BaseRequest, key: string, value: T, ttlSeconds?: number): this { + const ip = getIpAddress(req); + + if(!this.ipContext.has(ip)) { + this.ipContext.set(ip, new Map()); + } + + this.ipContext.get(ip)!.set(key, { + value, + ttlSeconds: ttlSeconds ?? null + }) + + return this; + } + + /** + * Gets a value from the current request context by the request's IP address + * + * @param {BaseRequest} req - The Express Request object + * @param {string} [key] - The key of the value to retrieve + * @returns {T | undefined} - The value associated with the key, or undefined if not found + */ + public getByIpAddress(req: BaseRequest, key?: string): T | undefined { + const ip = getIpAddress(req); + + if (!key) { + return this.ipContext.get(ip) as T ?? undefined; + } + + return this.ipContext.get(ip)?.get(key) as T ?? undefined + } + + /** + * Ends the current request context and removes all associated values + * + * @param {BaseRequest} req - The Express Request object + * @returns {void} + */ + public endRequestContext(req: BaseRequest) { + const requestId = req.id as string; + this.requestContext.delete(requestId); + } + + /** + * Returns the current request context data + * + * @returns {Record} - The current request context data + */ + public getRequestContext(): IRequestContextData { + return this.requestContext + } + + /** + * Sets the current request context data + * + * @param {Record>} context - The current request context data + * @returns {this} - The CurrentRequest class itself to enable chaining + */ + public setRequestContext(context: IRequestContextData): this { + this.requestContext = context; + return this; + } + + /** + * Returns the current ip context data + * + * @returns {IPContextData} - The current ip context data + */ + public getIpContext(): IPContextData { + return this.ipContext + } + + /** + * Sets the current ip context data + * + * @param {IPContextData} context - The current ip context data + * @returns {this} - The CurrentRequest class itself to enable chaining + */ + public setIpContext(context: IPContextData): this { + this.ipContext = context; + return this; + } + +} + +export default RequestContext \ No newline at end of file diff --git a/src/core/domains/express/services/RequestContextCleaner.ts b/src/core/domains/express/services/RequestContextCleaner.ts new file mode 100644 index 000000000..fe255a744 --- /dev/null +++ b/src/core/domains/express/services/RequestContextCleaner.ts @@ -0,0 +1,93 @@ +import Singleton from "@src/core/base/Singleton"; +import { IPContextData, IPDatesArrayTTL } from "@src/core/domains/express/interfaces/ICurrentRequest"; +import { ICurrentRequestCleanUpConfig } from "@src/core/domains/express/interfaces/ICurrentRequestCleanUpConfig"; +import { App } from "@src/core/services/App"; + +/** + * A class that handles cleaning up expired items from the current IP context. + */ +class RequestContextCleaner extends Singleton { + + /** + * Starts the cleanup process. This will run an interval every N seconds specified in the config. + * If no delay is specified, it will default to 60 seconds. + * + * @param {ICurrentRequestCleanUpConfig} config The configuration for the cleanup process. + */ + public static boot(config: ICurrentRequestCleanUpConfig) { + const instance = this.getInstance(); + + const delayInSeconds = config.delayInSeconds ?? 60; + + setInterval(() => { + instance.scan(); + }, delayInSeconds * 1000); + } + + /** + * Scans the current IP context and removes expired items from it. + * This is done by checking the TTL of each item in the context and + * removing the ones that have expired. If the context is empty after + * removing expired items, it is removed from the store as well. + */ + scan() { + // Get the entire current IP context + let context = App.container('requestContext').getIpContext() as IPContextData; + + // Loop through the context and handle each IP + for(const [ip, ipContext] of context.entries()) { + context = this.handleIpContext(ip, ipContext, context); + } + + // Set the updated IP context + App.container('requestContext').setIpContext(context); + } + + /** + * Handles a single IP context by removing expired items from it. + * This is done by checking the TTL of each item in the context and + * removing the ones that have expired. If the context is empty after + * removing expired items, it is removed from the store as well. + * + * @param {string} ip - The IP address of the context to handle. + * @param {Map>} ipContext - The IP context to handle. + * @param {IPContextData} context - The current IP context. + * @returns {IPContextData} - The updated IP context. + */ + protected handleIpContext(ip: string, ipContext: Map>, context: IPContextData): IPContextData { + const now = new Date(); + + // Loop through the IP context and remove expired items + for(const [key, item] of ipContext.entries()) { + + // If the TTL is not a number, skip this item + if(typeof item.ttlSeconds !== 'number') continue; + + // Check if the dates are in the past, remove them if true. + for(const date of item.value) { + + const expiresAt = new Date(date); + expiresAt.setSeconds(expiresAt.getSeconds() + item.ttlSeconds); + + // Remove expired items + if(now > expiresAt) { + ipContext.delete(key); + } + + // Update size + context.set(ip, ipContext) + } + + // If the context is empty, remove it from the store + if(context.size === 0) { + context.delete(ip) + } + } + + // Return the updated IP context + return context + } + +} + +export default RequestContextCleaner \ No newline at end of file diff --git a/src/core/domains/express/services/Resources/BaseResourceService.ts b/src/core/domains/express/services/Resources/BaseResourceService.ts new file mode 100644 index 000000000..3d357658b --- /dev/null +++ b/src/core/domains/express/services/Resources/BaseResourceService.ts @@ -0,0 +1,105 @@ +import { IPartialRouteResourceOptions, IResourceService } from "@src/core/domains/express/interfaces/IResourceService"; +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { IIdentifiableSecurityCallback } from "@src/core/domains/express/interfaces/ISecurity"; +import { RouteResourceTypes } from "@src/core/domains/express/routing/RouteResource"; +import { ALWAYS } from "@src/core/domains/express/services/Security"; +import SecurityReader from "@src/core/domains/express/services/SecurityReader"; +import { SecurityIdentifiers } from "@src/core/domains/express/services/SecurityRules"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { IModel } from "@src/core/interfaces/IModel"; +import { Response } from "express"; + +abstract class BaseResourceService implements IResourceService { + + /** + * The route resource type (RouteResourceTypes) + */ + abstract routeResourceType: string; + + // eslint-disable-next-line no-unused-vars + abstract handler(req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise; + + /** + * Checks if the request is authorized to perform the action and if the resource owner security is set + * + * @param {BaseRequest} req - The request object + * @param {IRouteResourceOptions} options - The options object + * @returns {boolean} - Whether the request is authorized and resource owner security is set + */ + validateResourceOwner(req: BaseRequest, options: IRouteResourceOptions): boolean { + const resourceOwnerSecurity = this.getResourceOwnerSecurity(options); + + if(this.validateAuthorization(req, options) && resourceOwnerSecurity ) { + return true; + } + + return false; + } + + /** + * Checks if the request is authorized to perform the action and if the resource owner security is set on the given resource instance + * + * @param {BaseRequest} req - The request object + * @param {IRouteResourceOptions} options - The options object + * @param {IModel} resourceInstance - The resource instance + * @returns {boolean} - Whether the request is authorized and resource owner security is set on the given resource instance + */ + validateResourceOwnerCallback(req: BaseRequest, options: IRouteResourceOptions, resourceInstance: IModel): boolean { + const resourceOwnerSecurity = this.getResourceOwnerSecurity(options); + + if(this.validateAuthorization(req, options) && resourceOwnerSecurity?.callback(req, resourceInstance)) { + return true; + } + + return false; + } + + /** + * Checks if the request is authorized to perform the action + * + * @param {BaseRequest} req - The request object + * @param {IRouteResourceOptions} options - The options object + * @returns {boolean} - Whether the request is authorized + */ + validateAuthorization(req: BaseRequest, options: IRouteResourceOptions): boolean { + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.ALL, ALWAYS]); + + if(authorizationSecurity && !authorizationSecurity.callback(req)) { + return false; + } + + return true; + } + + /** + * Finds the resource owner security from the given options + * @param {IRouteResourceOptions} options - The options object + * @returns {IIdentifiableSecurityCallback | undefined} - The found resource owner security or undefined if not found + */ + getResourceOwnerSecurity(options: IPartialRouteResourceOptions): IIdentifiableSecurityCallback | undefined { + return SecurityReader.findFromRouteResourceOptions(options as IRouteResourceOptions, SecurityIdentifiers.RESOURCE_OWNER, [this.routeResourceType]); + } + + + /** + * Returns a new object with the same key-value pairs as the given object, but + * with an additional key-value pair for each key, where the key is wrapped in + * percent signs (e.g. "foo" becomes "%foo%"). This is useful for building + * filters in MongoDB queries. + * @param {object} filters - The object to transform + * @returns {object} - The transformed object + */ + filtersWithPercentSigns(filters: object): object { + return { + ...filters, + ...Object.keys(filters).reduce((acc, curr) => { + const value = filters[curr]; + acc[curr] = `%${value}%`; + return acc; + }, {}) + } + } + +} + +export default BaseResourceService \ No newline at end of file diff --git a/src/core/domains/express/services/Resources/ResourceAllService.ts b/src/core/domains/express/services/Resources/ResourceAllService.ts new file mode 100644 index 000000000..7352bc614 --- /dev/null +++ b/src/core/domains/express/services/Resources/ResourceAllService.ts @@ -0,0 +1,124 @@ +import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; +import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import { IPageOptions } from "@src/core/domains/express/interfaces/IResourceService"; +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { RouteResourceTypes } from "@src/core/domains/express/routing/RouteResource"; +import Paginate from "@src/core/domains/express/services/Paginate"; +import QueryFilters from "@src/core/domains/express/services/QueryFilters"; +import BaseResourceService from "@src/core/domains/express/services/Resources/BaseResourceService"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import stripGuardedResourceProperties from "@src/core/domains/express/utils/stripGuardedResourceProperties"; +import { IModel } from "@src/core/interfaces/IModel"; +import { App } from "@src/core/services/App"; +import { Response } from "express"; + + +class ResourceAllService extends BaseResourceService { + + routeResourceType: string = RouteResourceTypes.ALL + + /** + * Handles the resource all action + * - Validates that the request is authorized + * - If the resource owner security is enabled, adds the owner's id to the filters + * - Fetches the results using the filters and page options + * - Maps the results to models + * - Strips the guarded properties from the results + * - Sends the results back to the client + * @param req The request object + * @param res The response object + * @param options The resource options + */ + async handler(req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise { + + // Check if the authorization security applies to this route and it is valid + if(!this.validateAuthorization(req, options)) { + throw new UnauthorizedError() + } + + // Build the page options, filters + const pageOptions = this.buildPageOptions(req, options); + let filters = this.buildFilters(req, options); + + // Check if the resource owner security applies to this route and it is valid + // If it is valid, we add the owner's id to the filters + if(this.validateResourceOwner(req, options)) { + const resourceOwnerSecurity = this.getResourceOwnerSecurity(options) + const propertyKey = resourceOwnerSecurity?.arguements?.key as string; + const userId = App.container('requestContext').getByRequest(req, 'userId'); + + if(!userId) { + throw new ForbiddenResourceError() + } + + if(typeof propertyKey !== 'string') { + throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); + } + + filters = { + ...filters, + [propertyKey]: userId + } + } + + // Fetch the results + const results = await this.fetchResults(options, filters, pageOptions) + const resultsAsModels = results.map((result) => new options.resource(result)); + + // Send the results + res.send(stripGuardedResourceProperties(resultsAsModels)) + } + + /** + * Fetches the results from the database + * + * @param {object} filters - The filters to use when fetching the results + * @param {IPageOptions} pageOptions - The page options to use when fetching the results + * @returns {Promise} - A promise that resolves to the fetched results as an array of models + */ + async fetchResults(options: IRouteResourceOptions, filters: object, pageOptions: IPageOptions): Promise { + const tableName = (new options.resource).table; + const documentManager = App.container('db').documentManager().table(tableName); + + return await documentManager.findMany({ + filter: filters, + limit: pageOptions.pageSize, + skip: pageOptions.skip, + useFuzzySearch: options.searching?.useFuzzySearch, + }) + } + + /** + * Builds the filters object + * + * @param {IRouteResourceOptions} options - The options object + * @returns {object} - The filters object + */ + buildFilters(req: BaseRequest, options: IRouteResourceOptions): object { + const baseFilters = options.allFilters ?? {}; + + return this.filtersWithPercentSigns({ + ...baseFilters, + ...(new QueryFilters).parseRequest(req, options?.searching).getFilters() + }) + } + + /** + * Builds the page options + * + * @param {BaseRequest} req - The request object + * @param {IRouteResourceOptions} options - The options object + * @returns {IPageOptions} - An object containing the page number, page size, and skip + */ + buildPageOptions(req: BaseRequest, options: IRouteResourceOptions): IPageOptions { + const paginate = new Paginate().parseRequest(req, options.paginate); + const page = paginate.getPage(1); + const pageSize = paginate.getPageSize() ?? options?.paginate?.pageSize; + const skip = pageSize ? (page - 1) * pageSize : undefined; + + return { skip, page, pageSize }; + } + +} + +export default ResourceAllService; \ No newline at end of file diff --git a/src/core/domains/express/services/Resources/ResourceCreateService.ts b/src/core/domains/express/services/Resources/ResourceCreateService.ts new file mode 100644 index 000000000..378a05d84 --- /dev/null +++ b/src/core/domains/express/services/Resources/ResourceCreateService.ts @@ -0,0 +1,64 @@ +import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; +import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { RouteResourceTypes } from "@src/core/domains/express/routing/RouteResource"; +import BaseResourceService from "@src/core/domains/express/services/Resources/BaseResourceService"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import stripGuardedResourceProperties from "@src/core/domains/express/utils/stripGuardedResourceProperties"; +import { App } from "@src/core/services/App"; +import { Response } from "express"; + + +class ResourceCreateService extends BaseResourceService { + + routeResourceType: string = RouteResourceTypes.CREATE + + /** + * Handles the resource create action + * - Validates that the request is authorized + * - If the resource owner security is enabled, adds the owner's id to the model properties + * - Creates a new model instance with the request body + * - Saves the model instance + * - Strips the guarded properties from the model instance + * - Sends the model instance back to the client + * @param req The request object + * @param res The response object + * @param options The resource options + */ + async handler(req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise { + + // Check if the authorization security applies to this route and it is valid + if(!this.validateAuthorization(req, options)) { + throw new UnauthorizedError() + } + + // Build the page options, filters + const modalInstance = new options.resource(req.body); + + // Check if the resource owner security applies to this route and it is valid + // If it is valid, we add the owner's id to the filters + if(this.validateResourceOwner(req, options)) { + const resourceOwnerSecurity = this.getResourceOwnerSecurity(options) + const propertyKey = resourceOwnerSecurity?.arguements?.key as string; + const userId = App.container('requestContext').getByRequest(req, 'userId'); + + if(!userId) { + throw new ForbiddenResourceError() + } + + if(typeof propertyKey !== 'string') { + throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); + } + + modalInstance.setAttribute(propertyKey, userId) + } + + await modalInstance.save(); + + // Send the results + res.status(201).send(stripGuardedResourceProperties(modalInstance)) + } + +} + +export default ResourceCreateService; \ No newline at end of file diff --git a/src/core/domains/express/services/Resources/ResourceDeleteService.ts b/src/core/domains/express/services/Resources/ResourceDeleteService.ts new file mode 100644 index 000000000..0041d1add --- /dev/null +++ b/src/core/domains/express/services/Resources/ResourceDeleteService.ts @@ -0,0 +1,56 @@ +import Repository from "@src/core/base/Repository"; +import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; +import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { RouteResourceTypes } from "@src/core/domains/express/routing/RouteResource"; +import BaseResourceService from "@src/core/domains/express/services/Resources/BaseResourceService"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import ModelNotFound from "@src/core/exceptions/ModelNotFound"; +import { Response } from "express"; + + +class ResourceDeleteService extends BaseResourceService { + + routeResourceType: string = RouteResourceTypes.DESTROY + + /** + * Handles the resource delete action + * - Validates that the request is authorized + * - Checks if the resource owner security applies to this route and it is valid + * - Deletes the resource + * - Sends the results back to the client + * @param {BaseRequest} req - The request object + * @param {Response} res - The response object + * @param {IRouteResourceOptions} options - The options object + * @returns {Promise} + */ + async handler(req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise { + + // Check if the authorization security applies to this route and it is valid + if(!this.validateAuthorization(req, options)) { + throw new UnauthorizedError() + } + + const repository = new Repository(options.resource) + + const result = await repository.findById(req.params?.id) + + if(!result) { + throw new ModelNotFound() + } + + // Check if the resource owner security applies to this route and it is valid + if(!this.validateResourceOwnerCallback(req, options, result)) { + throw new ForbiddenResourceError() + } + + // Delete the resource item + await result.delete() + + // Send the results + res.send({ success: true }) + } + +} + +export default ResourceDeleteService; \ No newline at end of file diff --git a/src/core/domains/express/services/Resources/ResourceErrorService.ts b/src/core/domains/express/services/Resources/ResourceErrorService.ts new file mode 100644 index 000000000..e8fd7cab0 --- /dev/null +++ b/src/core/domains/express/services/Resources/ResourceErrorService.ts @@ -0,0 +1,49 @@ +import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; +import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import responseError from "@src/core/domains/express/requests/responseError"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import ModelNotFound from "@src/core/exceptions/ModelNotFound"; +import { Response } from "express"; + +class ResourceErrorService { + + /** + * Handles an error by sending an appropriate error response to the client. + * + * If the error is a ModelNotFound, it will be sent as a 404. + * If the error is a ForbiddenResourceError, it will be sent as a 403. + * If the error is an UnauthorizedError, it will be sent as a 401. + * If the error is an Error, it will be sent as a 500. + * If the error is anything else, it will be sent as a 500. + * + * @param req The Express Request object + * @param res The Express Response object + * @param err The error to handle + */ + public static handleError(req: BaseRequest, res: Response, err: unknown):void { + if(err instanceof ModelNotFound) { + responseError(req, res, err, 404) + return; + } + + if(err instanceof ForbiddenResourceError) { + responseError(req, res, err, 403) + return; + } + + if(err instanceof UnauthorizedError) { + responseError(req, res, err, 401) + return; + } + + if (err instanceof Error) { + responseError(req, res, err) + return; + } + + res.status(500).send({ error: 'Something went wrong.' }) + } + +} + +export default ResourceErrorService \ No newline at end of file diff --git a/src/core/domains/express/services/Resources/ResourceShowService.ts b/src/core/domains/express/services/Resources/ResourceShowService.ts new file mode 100644 index 000000000..e132e59a5 --- /dev/null +++ b/src/core/domains/express/services/Resources/ResourceShowService.ts @@ -0,0 +1,93 @@ +import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; +import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { RouteResourceTypes } from "@src/core/domains/express/routing/RouteResource"; +import BaseResourceService from "@src/core/domains/express/services/Resources/BaseResourceService"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import stripGuardedResourceProperties from "@src/core/domains/express/utils/stripGuardedResourceProperties"; +import ModelNotFound from "@src/core/exceptions/ModelNotFound"; +import { IModel } from "@src/core/interfaces/IModel"; +import { App } from "@src/core/services/App"; +import { Response } from "express"; + + + +class ResourceShowService extends BaseResourceService { + + routeResourceType: string = RouteResourceTypes.SHOW + + /** + * Handles the resource show action + * - Validates that the request is authorized + * - If the resource owner security is enabled, adds the owner's id to the filters + * - Fetches the result using the filters + * - Maps the result to a model + * - Strips the guarded properties from the result + * - Sends the result back to the client + * @param req The request object + * @param res The response object + * @param options The resource options + */ + async handler(req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise { + + // Check if the authorization security applies to this route and it is valid + if(!this.validateAuthorization(req, options)) { + throw new UnauthorizedError() + } + + // Build the filters + let filters: object = {} + + // Check if the resource owner security applies to this route and it is valid + // If it is valid, we add the owner's id to the filters + if(this.validateResourceOwner(req, options)) { + const resourceOwnerSecurity = this.getResourceOwnerSecurity(options) + const propertyKey = resourceOwnerSecurity?.arguements?.key as string; + const userId = App.container('requestContext').getByRequest(req, 'userId'); + + if(!userId) { + throw new ForbiddenResourceError() + } + + if(typeof propertyKey !== 'string') { + throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); + } + + filters = { + ...filters, + [propertyKey]: userId + } + } + + // Fetch the results + const result = await this.fetchRecord(options, filters) + + if (!result) { + throw new ModelNotFound(); + } + + const resultAsModel = new options.resource(result) + + // Send the results + res.send(stripGuardedResourceProperties(resultAsModel)) + } + + /** + * Fetches the results from the database + * + * @param {object} filters - The filters to use when fetching the results + * @param {IPageOptions} pageOptions - The page options to use when fetching the results + * @returns {Promise} - A promise that resolves to the fetched results as an array of models + */ + async fetchRecord(options: IRouteResourceOptions, filters: object): Promise { + const tableName = (new options.resource).table; + const documentManager = App.container('db').documentManager().table(tableName); + + return await documentManager.findOne({ + filter: filters, + }) + } + +} + +export default ResourceShowService; \ No newline at end of file diff --git a/src/core/domains/express/services/Resources/ResourceUpdateService.ts b/src/core/domains/express/services/Resources/ResourceUpdateService.ts new file mode 100644 index 000000000..4aafc25bc --- /dev/null +++ b/src/core/domains/express/services/Resources/ResourceUpdateService.ts @@ -0,0 +1,57 @@ +import Repository from "@src/core/base/Repository"; +import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; +import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { RouteResourceTypes } from "@src/core/domains/express/routing/RouteResource"; +import BaseResourceService from "@src/core/domains/express/services/Resources/BaseResourceService"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import stripGuardedResourceProperties from "@src/core/domains/express/utils/stripGuardedResourceProperties"; +import ModelNotFound from "@src/core/exceptions/ModelNotFound"; +import { Response } from "express"; + + +class ResourceUpdateService extends BaseResourceService { + + routeResourceType: string = RouteResourceTypes.UPDATE + + /** + * Handles the resource delete action + * - Validates that the request is authorized + * - Checks if the resource owner security applies to this route and it is valid + * - Deletes the resource + * - Sends the results back to the client + * @param {BaseRequest} req - The request object + * @param {Response} res - The response object + * @param {IRouteResourceOptions} options - The options object + * @returns {Promise} + */ + async handler(req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise { + + // Check if the authorization security applies to this route and it is valid + if(!this.validateAuthorization(req, options)) { + throw new UnauthorizedError() + } + + const repository = new Repository(options.resource) + + const result = await repository.findById(req.params?.id) + + if (!result) { + throw new ModelNotFound(); + } + + // Check if the resource owner security applies to this route and it is valid + if(!this.validateResourceOwnerCallback(req, options, result)) { + throw new ForbiddenResourceError() + } + + result.fill(req.body); + await result.save(); + + // Send the results + res.send(stripGuardedResourceProperties(result)) + } + +} + +export default ResourceUpdateService; \ No newline at end of file diff --git a/src/core/domains/express/services/Security.ts b/src/core/domains/express/services/Security.ts new file mode 100644 index 000000000..191a7853b --- /dev/null +++ b/src/core/domains/express/services/Security.ts @@ -0,0 +1,177 @@ +import Singleton from "@src/core/base/Singleton"; +import { IIdentifiableSecurityCallback, SecurityCallback } from "@src/core/domains/express/interfaces/ISecurity"; +import SecurityRules, { SecurityIdentifiers } from "@src/core/domains/express/services/SecurityRules"; + +/** + * The default condition for when the security check should be executed. + */ +export const ALWAYS = 'always'; + +/** + * Security class with static methods for basic defining security callbacks. + */ +class Security extends Singleton { + + /** + * The condition for when the security check should be executed. + */ + public when: string[] | null = null; + + /** + * The condition for when the security check should never be executed. + */ + public never: string[] | null = null; + + /** + * Sets the condition for when the security check should be executed. + * + * @param condition - The condition value. If the value is 'always', the security check is always executed. + * @returns The Security class instance for chaining. + */ + public static when(condition: string | string[]): typeof Security { + condition = typeof condition === 'string' ? [condition] : condition; + this.getInstance().when = condition; + return this; + } + + /** + * Sets the condition for when the security check should never be executed. + * + * @param condition - The condition value(s) to set. If the value is 'always', the security check is never executed. + * @returns The Security class instance for chaining. + */ + public static never(condition: string | string[]): typeof Security { + condition = typeof condition === 'string' ? [condition] : condition; + this.getInstance().never = condition; + return this; + } + + /** + * Gets and then resets the condition for when the security check should be executed to always. + * @returns The when condition + */ + public getWhenAndReset(): string[] | null { + const when = this.when; + this.when = null; + return when; + } + + /** + * Gets and then resets the condition for when the security check should never be executed. + * @returns The when condition + */ + public getNeverAndReset(): string[] | null { + const never = this.never; + this.never = null; + return never; + } + + /** + * Checks if the currently logged in user is the owner of the given resource. + * + * Usage with RouteResource: + * - CREATE - Adds the attribute to the resource model + * - UPDATE - Checks the authroized user is the owner of the resource + * - DELETE - Checks the authroized user is the owner of the resource + * - SHOW - Only shows the resource if the authroized user is the owner of the resource + * - INDEX - Filters the resources by the authroized user + * + * Example usage within an Action/Controller: + * + * // Instance of a model (resource) + * const result = await repository.findById(id) + * + * // Check if resourceOwner is applicable + * const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, ['SomeConditionValue']); + * + * // The callback checks the attribute on the resource model matches the authorized user + * if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(req, result)) { + * responseError(req, res, new ForbiddenResourceError(), 403) + * return; + * } + * + * @param attribute - The key of the resource attribute that should contain the user id. + * @returns A security callback that can be used in the security definition. + */ + public static resourceOwner(attribute: string = 'userId'): IIdentifiableSecurityCallback { + return SecurityRules[SecurityIdentifiers.RESOURCE_OWNER](attribute); + } + + /** + * Enable scope security checks. + * + * This will include scope security checks for all route resources. + * + * @returns A security callback that can be used in the security definition. + */ + public static enableScopes(): IIdentifiableSecurityCallback { + return SecurityRules[SecurityIdentifiers.ENABLE_SCOPES](); + } + + /** + * Checks if the request is authorized, i.e. if the user is logged in. + * + * Authorization failure does not throw any exceptions, this method allows the middleware to pass regarldess of authentication failure. + * This will allow the user to have full control over the unathenticated flow. + * + * Example usage within an Action/Controller: + * const authorizationSecurity = SecurityReader.findFromRequest(req, SecurityIdentifiers.AUTHORIZATION, [ALWAYS]); + * + * if(authorizationSecurity && !authorizationSecurity.callback(req)) { + * responseError(req, res, new UnauthorizedError(), 401) + * return; + * } + * + * // Continue processing + * + * @returns A security callback that can be used in the security definition. + */ + public static authorized(): IIdentifiableSecurityCallback { + return SecurityRules[SecurityIdentifiers.AUTHORIZED](); + } + + /** + * Same as `authorization` but throws an exception if the user is not authenticated. + * This method is useful if you want to handle authentication failure in a centralized way. + * + * @returns A security callback that can be used in the security definition. + */ + public static authorizationThrowsException(): IIdentifiableSecurityCallback { + return SecurityRules[SecurityIdentifiers.AUTHORIZED_THROW_EXCEPTION](); + } + + /** + * Checks if the currently logged in user has the given role. + * @param role The role to check. + * @returns A callback function to be used in the security definition. + */ + public static hasRole(roles: string | string[]): IIdentifiableSecurityCallback { + return SecurityRules[SecurityIdentifiers.HAS_ROLE](roles); + } + + /** + * Creates a security callback to check if the currently IP address has not exceeded a given rate limit. + * + * @param limit - The maximum number of requests the user can make per minute.* + * @returns A callback function to be used in the security definition. + */ + public static rateLimited(limit: number, perMinuteAmount: number = 1): IIdentifiableSecurityCallback { + return SecurityRules[SecurityIdentifiers.RATE_LIMITED](limit, perMinuteAmount); + } + + /** + * Creates a custom security callback. + * + * @param identifier - The identifier for the security callback. + * @param callback - The callback to be executed to check the security. + * @param rest - The arguments for the security callback. + * @returns A callback function to be used in the security definition. + */ + + public static custom(identifier: string, callback: SecurityCallback, ...rest: any[]): IIdentifiableSecurityCallback { + return SecurityRules[SecurityIdentifiers.CUSTOM](identifier, callback, ...rest); + } + +} + +export default Security \ No newline at end of file diff --git a/src/core/domains/express/services/SecurityReader.ts b/src/core/domains/express/services/SecurityReader.ts new file mode 100644 index 000000000..e53599cee --- /dev/null +++ b/src/core/domains/express/services/SecurityReader.ts @@ -0,0 +1,116 @@ +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { IIdentifiableSecurityCallback } from "@src/core/domains/express/interfaces/ISecurity"; +import { ALWAYS } from "@src/core/domains/express/services/Security"; +import SecurityRules from "@src/core/domains/express/services/SecurityRules"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; + +class SecurityReader { + + /** + * Finds a security callback in the security callbacks of the given route resource options. + * + * @param options - The route resource options containing the security callbacks. + * @param id - The id of the security callback to find. + * @param when - The optional when condition. If specified, the security callback will only be found if it matches this condition. + * @returns The found security callback, or undefined if not found. + */ + public static findFromRouteResourceOptions(options: IRouteResourceOptions, id: string, when?: string[] | null): IIdentifiableSecurityCallback | undefined { + return this.find(options.security ?? [], id, when); + } + + /** + * Finds a security callback from the security callbacks associated with the given request. + * + * @param req - The request object containing the security callbacks. + * @param id - The id of the security callback to find. + * @param when - The optional when condition. If specified, the security callback will only be found if it matches this condition. + * @returns The found security callback, or undefined if not found. + */ + public static findFromRequest(req: BaseRequest, id: string, when?: string[] | null): IIdentifiableSecurityCallback | undefined { + return this.find(req.security ?? [], id, when); + } + + /** + * Finds a security callback in the given array of security callbacks. + * + * @param security - The array of security callbacks to search. + * @param options - The route resource options containing the security callbacks. + * @param id - The id of the security callback to find. + * @param when - The when condition to match. If not provided, the method will return the first match. + * @returns The security callback if found, or undefined if not found. + */ + public static find(security: IIdentifiableSecurityCallback[], id: string, when?: string[] | null): IIdentifiableSecurityCallback | undefined { + let result: IIdentifiableSecurityCallback | undefined = undefined; + + when = when ?? null; + when = when && typeof when === 'string' ? [when] : when; + + // Checks if the condition should never be passable + const conditionNeverPassable = (conditions: string[] | null, never: string[] | null = null) => { + if(!never) return false; + + for(const neverCondition of never) { + if(conditions?.includes(neverCondition)) return true; + } + + return false; + } + + // Checks if the condition should be passable + const conditionPassable = (condition: string[] | null) => { + if(!condition) { + return true; + } + + condition = typeof condition === 'string' ? [condition] : condition; + + if(when?.includes(ALWAYS)) return true; + + for(const conditionString of condition) { + if(when?.includes(conditionString)) { + return true; + } + } + + return false; + } + + + /** + * Find by 'id' + */ + result = security?.find(security => { + const matchesIdentifier = security.id === id + + return matchesIdentifier && + conditionNeverPassable(when, security.never) === false && + conditionPassable(security.when); + }); + + /** + * Includes security rule defined in optional 'also' property + * + * Example: hasScope rule requires authorized rule + */ + if(!result) { + + // We need to find the unrelated security rule that has the ID in 'also' + const unrelatedSecurityRule = security?.find(security => { + return security.also === id && + conditionNeverPassable(when, security.never) === false && + conditionPassable(security.when); + }); + + // The 'unrelatedSecurityRule' contains the 'also' property. + // We can use it to fetch the desired security rule. + if(unrelatedSecurityRule) { + return SecurityRules[unrelatedSecurityRule.also as string](); + } + } + + return result + } + +} + +export default SecurityReader \ No newline at end of file diff --git a/src/core/domains/express/services/SecurityRules.ts b/src/core/domains/express/services/SecurityRules.ts new file mode 100644 index 000000000..1a6f509f1 --- /dev/null +++ b/src/core/domains/express/services/SecurityRules.ts @@ -0,0 +1,148 @@ +import { IIdentifiableSecurityCallback, SecurityCallback } from "@src/core/domains/express/interfaces/ISecurity" +import authorizedSecurity from "@src/core/domains/express/rules/authorizedSecurity" +import hasRoleSecurity from "@src/core/domains/express/rules/hasRoleSecurity" +import hasScopeSecurity from "@src/core/domains/express/rules/hasScopeSecurity" +import rateLimitedSecurity from "@src/core/domains/express/rules/rateLimitedSecurity" +import resourceOwnerSecurity from "@src/core/domains/express/rules/resourceOwnerSecurity" +import Security from "@src/core/domains/express/services/Security" +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t" +import { IModel } from "@src/core/interfaces/IModel" + +/** + * Security rules + */ +export interface ISecurityRules { + // eslint-disable-next-line no-unused-vars + [key: string]: (...args: any[]) => IIdentifiableSecurityCallback +} + +/** + * The list of security identifiers. + */ +export const SecurityIdentifiers = { + AUTHORIZED: 'authorized', + AUTHORIZED_THROW_EXCEPTION: 'authorizedThrowException', + RESOURCE_OWNER: 'resourceOwner', + HAS_ROLE: 'hasRole', + HAS_SCOPE: 'hasScope', + RATE_LIMITED: 'rateLimited', + ENABLE_SCOPES: 'enableScopes', + CUSTOM: 'custom' +} as const; + +const SecurityRules: ISecurityRules = { + + /** + * Checks if the request is authorized, i.e. if the user is logged in. + * Does not throw exceptions on unauthorized requests. + * @returns + */ + [SecurityIdentifiers.AUTHORIZED]: () => ({ + id: SecurityIdentifiers.AUTHORIZED, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + arguements: { + throwExceptionOnUnauthorized: true + }, + callback: (req: BaseRequest) => authorizedSecurity(req) + }), + + /** + * Checks if the request is authorized, i.e. if the user is logged in. + * Throws an exception on unauthorized requests. + * @returns + */ + [SecurityIdentifiers.AUTHORIZED_THROW_EXCEPTION]: () => ({ + id: SecurityIdentifiers.AUTHORIZED_THROW_EXCEPTION, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + arguements: { + throwExceptionOnUnauthorized: true + }, + callback: (req: BaseRequest) => authorizedSecurity(req) + }), + + /** + * Checks if the currently logged in user is the owner of the given resource. + * @param attribute + * @returns + */ + [SecurityIdentifiers.RESOURCE_OWNER]: (attribute: string = 'userId') => ({ + id: SecurityIdentifiers.RESOURCE_OWNER, + also: SecurityIdentifiers.AUTHORIZED, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + arguements: { key: attribute }, + callback: (req: BaseRequest, resource: IModel) => resourceOwnerSecurity(req, resource, attribute) + }), + + + /** + * Checks if the currently logged in user has the given role. + * @param roles + * @returns + */ + [SecurityIdentifiers.HAS_ROLE]: (roles: string | string[]) => ({ + id: SecurityIdentifiers.HAS_ROLE, + also: SecurityIdentifiers.AUTHORIZED, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + callback: (req: BaseRequest) => hasRoleSecurity(req, roles) + }), + + /** + * Enable scopes on the resource + * @returns + */ + [SecurityIdentifiers.ENABLE_SCOPES]: () => ({ + id: SecurityIdentifiers.ENABLE_SCOPES, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + // eslint-disable-next-line no-unused-vars + callback: (_req: BaseRequest, _resource: IModel) => true, + }), + + /** + * Checks if the currently logged in user has the given scope(s). + * @param scopesExactMatch + * @returns + */ + [SecurityIdentifiers.HAS_SCOPE]: (scopesExactMatch: string | string[] = [], scopesPartialMatch: string | string[] = []) => ({ + id: SecurityIdentifiers.HAS_SCOPE, + also: SecurityIdentifiers.AUTHORIZED, + arguements: { scopesExactMatch, scopesPartialMatch }, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + callback: (req: BaseRequest) => hasScopeSecurity(req, scopesExactMatch, scopesPartialMatch) + }), + + /** + * Rate limited security + * @param limit + * @returns + */ + [SecurityIdentifiers.RATE_LIMITED]: (limit: number, perMinuteAmount: number) => ({ + id: SecurityIdentifiers.RATE_LIMITED, + never: Security.getInstance().getNeverAndReset(), + when: Security.getInstance().getWhenAndReset(), + callback: (req: BaseRequest) => rateLimitedSecurity(req, limit, perMinuteAmount), + }), + + /** + * Custom security rule + * @param callback + * @param rest + * @returns + */ + // eslint-disable-next-line no-unused-vars + [SecurityIdentifiers.CUSTOM]: (identifier: string, callback: SecurityCallback, ...rest: any[]) => ({ + id: identifier, + never: Security.getInstance().getNeverAndReset(), + when: Security.getInstance().getWhenAndReset(), + callback: (req: BaseRequest, ...rest: any[]) => { + return callback(req, ...rest) + } + }) +} as const + +export default SecurityRules \ No newline at end of file diff --git a/src/core/domains/express/types/BaseRequest.t.ts b/src/core/domains/express/types/BaseRequest.t.ts index f353c28b4..92b3bf5e6 100644 --- a/src/core/domains/express/types/BaseRequest.t.ts +++ b/src/core/domains/express/types/BaseRequest.t.ts @@ -1,8 +1,10 @@ import IAuthorizedRequest from "@src/core/domains/auth/interfaces/IAuthorizedRequest"; +import IRequestIdentifiable from "@src/core/domains/auth/interfaces/IRequestIdentifiable"; +import ISecurityRequest from "@src/core/domains/express/interfaces/ISecurity"; import IValidatorRequest from "@src/core/domains/express/interfaces/IValidatorRequest"; import { Request } from "express"; /** * Extends the express Request object with auth and validator properties. */ -export type BaseRequest = Request & IAuthorizedRequest & IValidatorRequest; \ No newline at end of file +export type BaseRequest = Request & IAuthorizedRequest & IValidatorRequest & ISecurityRequest & IRequestIdentifiable; \ No newline at end of file diff --git a/src/core/domains/express/utils/getIpAddress.ts b/src/core/domains/express/utils/getIpAddress.ts new file mode 100644 index 000000000..8bd700387 --- /dev/null +++ b/src/core/domains/express/utils/getIpAddress.ts @@ -0,0 +1,7 @@ +import { Request } from "express"; + +const getIpAddress = (req: Request): string => { + return req.socket.remoteAddress as string +} + +export default getIpAddress \ No newline at end of file diff --git a/src/core/domains/express/utils/stripGuardedResourceProperties.ts b/src/core/domains/express/utils/stripGuardedResourceProperties.ts new file mode 100644 index 000000000..1efa44ee1 --- /dev/null +++ b/src/core/domains/express/utils/stripGuardedResourceProperties.ts @@ -0,0 +1,12 @@ +import { IModel } from "@src/core/interfaces/IModel"; +import IModelAttributes from "@src/core/interfaces/IModelData"; + +const stripGuardedResourceProperties = (results: IModel[] | IModel) => { + if(!Array.isArray(results)) { + return results.getData({ excludeGuarded: true }) + } + + return results.map(result => result.getData({ excludeGuarded: true })); +} + +export default stripGuardedResourceProperties \ No newline at end of file diff --git a/src/core/domains/logger/interfaces/ILoggerService.ts b/src/core/domains/logger/interfaces/ILoggerService.ts new file mode 100644 index 000000000..5ccb2ff88 --- /dev/null +++ b/src/core/domains/logger/interfaces/ILoggerService.ts @@ -0,0 +1,14 @@ +/* eslint-disable no-unused-vars */ +import winston from "winston"; + +export interface ILoggerService +{ + getLogger(): winston.Logger; + + info(...args: any[]): void; + warn(...args: any[]): void; + error(...args: any[]): void; + debug(...args: any[]): void; + verbose(...args: any[]): void; + console(...args: any[]): void; +} \ No newline at end of file diff --git a/src/core/domains/logger/providers/LoggerProvider.ts b/src/core/domains/logger/providers/LoggerProvider.ts new file mode 100644 index 000000000..dfb656805 --- /dev/null +++ b/src/core/domains/logger/providers/LoggerProvider.ts @@ -0,0 +1,22 @@ +import BaseProvider from "@src/core/base/Provider"; +import LoggerService from "@src/core/domains/logger/services/LoggerService"; +import { App } from "@src/core/services/App"; + +class LoggerProvider extends BaseProvider { + + async register(): Promise { + + const loggerService = new LoggerService(); + + // We will boot the logger here to provide it early for other providers + loggerService.boot(); + + App.setContainer('logger', loggerService); + + } + + async boot(): Promise {} + +} + +export default LoggerProvider \ No newline at end of file diff --git a/src/core/domains/logger/services/LoggerService.ts b/src/core/domains/logger/services/LoggerService.ts new file mode 100644 index 000000000..84c448854 --- /dev/null +++ b/src/core/domains/logger/services/LoggerService.ts @@ -0,0 +1,122 @@ +import { ILoggerService } from "@src/core/domains/logger/interfaces/ILoggerService"; +import path from "path"; +import winston, { format } from "winston"; + +class LoggerService implements ILoggerService { + + /** + * Winston logger instance + */ + protected logger!: winston.Logger + + /** + * Bootstraps the winston logger instance. + * @returns {Promise} + */ + boot() { + if(this.logger) { + return; + } + + const formatPrintf = (info: winston.Logform.TransformableInfo) => { + return `${info.timestamp} ${info.level}: ${info.message}`+(info.splat!==undefined?`${info.splat}`:" ") + } + + const logger = winston.createLogger({ + level:'info', + format: winston.format.combine( + format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + format.printf(formatPrintf) + ), + transports: [ + new winston.transports.Console({ format: winston.format.printf(formatPrintf) }), + new winston.transports.File({ filename: path.resolve('@src/../', 'storage/logs/larascript.log') }) + ] + }) + + this.logger = logger + } + + /** + * Returns the underlying winston logger instance. + * @returns {winston.Logger} + */ + getLogger() { + return this.logger + } + + /** + * Logs the given arguments to the console with the 'error' log level. + * @param {...any[]} args The arguments to log to the console. + */ + error(...args: any[]) { + this.logger.error([...args]) + } + + /** + * Logs the given arguments to the console with the 'help' log level. + * @param {...any[]} args The arguments to log to the console. + */ + help(...args: any[]) { + this.logger.help([...args]) + } + + /** + * Logs the given arguments to the console with the 'data' log level. + * @param {...any[]} args The arguments to log to the console. + */ + data(...args: any[]) { + this.logger.data([...args]) + } + + /** + * Logs the given arguments to the console with the 'info' log level. + * @param {...any[]} args The arguments to log to the console. + */ + info(...args: any[]) { + this.logger.info([...args]) + } + + /** + * Logs the given arguments to the console with the 'warn' log level. + * @param {...any[]} args The arguments to log to the console. + */ + warn(...args: any[]) { + this.logger.warn([...args]) + } + + /** + * Logs the given arguments to the console with the 'debug' log level. + * @param {...any[]} args The arguments to log to the console. + */ + debug(...args: any[]) { + this.logger.debug([...args]) + } + + /** + * Logs the given arguments to the console with the 'verbose' log level. + * @param {...any[]} args The arguments to log to the console. + */ + verbose(...args: any[]) { + this.logger.verbose([...args]) + } + + /** + * Outputs the given arguments directly to the console using the console transport. + * @param {...any[]} args The arguments to output to the console. + */ + console(...args: any[]) { + const logger = winston.createLogger({ + level:'info', + format: winston.format.json(), + transports: [ + new winston.transports.Console({ format: winston.format.simple() }) + ] + }) + + logger.info([...args]) + } + +} + +export default LoggerService \ No newline at end of file diff --git a/src/core/domains/make/base/BaseMakeFileCommand.ts b/src/core/domains/make/base/BaseMakeFileCommand.ts index 2684f8dc5..88947b5b1 100644 --- a/src/core/domains/make/base/BaseMakeFileCommand.ts +++ b/src/core/domains/make/base/BaseMakeFileCommand.ts @@ -4,6 +4,7 @@ import { IMakeFileArguments } from "@src/core/domains/make/interfaces/IMakeFileA import { IMakeOptions } from "@src/core/domains/make/interfaces/IMakeOptions"; import ArgumentObserver from "@src/core/domains/make/observers/ArgumentObserver"; import MakeFileService from "@src/core/domains/make/services/MakeFileService"; +import { App } from "@src/core/services/App"; import Str from "@src/core/util/str/Str"; const DefaultOptions: Partial = { @@ -66,11 +67,13 @@ export default class BaseMakeFileCommand extends BaseCommand { } // Set a default collection, if required - this.makeFileArguments = this.argumentObserver.onCustom('setDefaultCollection', this.makeFileArguments, this.options); + this.argumentObserver.onCustom('setDefaultCollection', this.makeFileArguments, this.options).then(data => this.makeFileArguments = data); + // Set name the name (lower or upper depending on options) - this.makeFileArguments = this.argumentObserver.onCustom('setName', this.makeFileArguments, this.options); + this.argumentObserver.onCustom('setName', this.makeFileArguments, this.options).then(data => this.makeFileArguments = data); + // Ensure the file ends with the specified value - this.makeFileArguments = this.argumentObserver.onCustom('setEndsWith', this.makeFileArguments, this.options); + this.argumentObserver.onCustom('setEndsWith', this.makeFileArguments, this.options).then(data => this.makeFileArguments = data); this.setOverwriteArg('name', this.makeFileArguments.name); @@ -106,7 +109,7 @@ export default class BaseMakeFileCommand extends BaseCommand { // Write the new file this.makeFileService.writeContent(template); - console.log(`Created ${this.options.makeType}: ` + this.makeFileService.getTargetDirFullPath()); + App.container('logger').info(`Created ${this.options.makeType}: ` + this.makeFileService.getTargetDirFullPath()); } /** diff --git a/src/core/domains/make/providers/MakeProvider.ts b/src/core/domains/make/providers/MakeProvider.ts index 1c074c7b4..b130041f3 100644 --- a/src/core/domains/make/providers/MakeProvider.ts +++ b/src/core/domains/make/providers/MakeProvider.ts @@ -18,7 +18,7 @@ import { App } from "@src/core/services/App"; export default class MakeProvider extends BaseProvider { async register(): Promise { - console.log('[Provider] Registering MakeProvider') + this.log('Registering MakeProvider') App.container('console').register().registerAll([ MakeCmdCommand, diff --git a/src/core/domains/make/templates/Action.ts.template b/src/core/domains/make/templates/Action.ts.template index 39004e93d..aa39e09cb 100644 --- a/src/core/domains/make/templates/Action.ts.template +++ b/src/core/domains/make/templates/Action.ts.template @@ -16,6 +16,7 @@ export const #name# = (req: BaseRequest, res: Response) => { catch (error) { if(error instanceof Error) { responseError(req, res, error) + return; } } } \ No newline at end of file diff --git a/src/core/domains/make/templates/Listener.ts.template b/src/core/domains/make/templates/Listener.ts.template index 2f8d81525..3bc95d8e7 100644 --- a/src/core/domains/make/templates/Listener.ts.template +++ b/src/core/domains/make/templates/Listener.ts.template @@ -16,7 +16,7 @@ export class #name# extends EventListener { */ handle = async (payload: I#name#Data) => { - console.log('[TestListener]', payload); + // Handle the logic } diff --git a/src/core/domains/make/templates/Middleware.ts.template b/src/core/domains/make/templates/Middleware.ts.template index 6141a8bb5..d516affbc 100644 --- a/src/core/domains/make/templates/Middleware.ts.template +++ b/src/core/domains/make/templates/Middleware.ts.template @@ -23,6 +23,7 @@ export const #name# = () => async (req: BaseRequest, res: Response, next: NextFu catch (error) { if(error instanceof Error) { responseError(req, res, error) + return; } } }; \ No newline at end of file diff --git a/src/core/domains/make/templates/Migration.ts.template b/src/core/domains/make/templates/Migration.ts.template index 84a034f09..24a13ddf7 100644 --- a/src/core/domains/make/templates/Migration.ts.template +++ b/src/core/domains/make/templates/Migration.ts.template @@ -1,7 +1,8 @@ import BaseMigration from "@src/core/domains/migrations/base/BaseMigration"; +import { DataTypes } from "sequelize"; + +export class #name#Migration extends BaseMigration { -export class #name#Migration extends BaseMigration -{ // Specify the database provider if this migration should run on a particular database. // Uncomment and set to 'mongodb', 'postgres', or another supported provider. // If left commented out, the migration will run only on the default provider. @@ -12,14 +13,12 @@ export class #name#Migration extends BaseMigration * * @return {Promise} */ - async up(): Promise - { + async up(): Promise { // Example: - // await this.schema.createTable('users', (table) => { - // table.increments('id').primary(); - // table.string('username').unique().notNullable(); - // table.string('email').unique().notNullable(); - // table.timestamps(true, true); + // await this.schema.createTable('users', { + // userId: DataTypes.STRING, + // createdAt: DataTypes.DATE, + // updatedAt: DataTypes.DATE // }); } @@ -28,8 +27,7 @@ export class #name#Migration extends BaseMigration * * @return {Promise} */ - async down(): Promise - { + async down(): Promise { // Example: // await this.schema.dropTable('users'); } diff --git a/src/core/domains/make/templates/Model.ts.template b/src/core/domains/make/templates/Model.ts.template index c80008b73..40c652a29 100644 --- a/src/core/domains/make/templates/Model.ts.template +++ b/src/core/domains/make/templates/Model.ts.template @@ -9,7 +9,7 @@ import IModelData from '@src/core/interfaces/IModelData'; * bar: number; * baz: boolean; */ -interface I#name#Data extends IModelData { +export interface I#name#Data extends IModelData { } diff --git a/src/core/domains/make/templates/Observer.ts.template b/src/core/domains/make/templates/Observer.ts.template index 281693c9c..19166703d 100644 --- a/src/core/domains/make/templates/Observer.ts.template +++ b/src/core/domains/make/templates/Observer.ts.template @@ -11,7 +11,7 @@ export default class #name# extends Observer { * @param data The model data being created. * @returns The processed model data. */ - created(data: I#name#Data): I#name#Data { + async created(data: I#name#Data): Promise { return data } @@ -20,7 +20,7 @@ export default class #name# extends Observer { * @param data The model data being created. * @returns The processed model data. */ - creating(data: I#name#Data): I#name#Data { + async creating(data: I#name#Data): Promise { return data } @@ -29,7 +29,7 @@ export default class #name# extends Observer { * @param data The model data being updated. * @returns The processed model data. */ - updating(data: I#name#Data): I#name#Data { + async updating(data: I#name#Data): Promise { return data } @@ -38,7 +38,7 @@ export default class #name# extends Observer { * @param data The model data that has been updated. * @returns The processed model data. */ - updated(data: I#name#Data): I#name#Data { + async updated(data: I#name#Data): Promise { return data } @@ -47,7 +47,7 @@ export default class #name# extends Observer { * @param data The model data being deleted. * @returns The processed model data. */ - deleting(data: I#name#Data): I#name#Data { + async deleting(data: I#name#Data): Promise { return data } @@ -56,7 +56,7 @@ export default class #name# extends Observer { * @param data The model data that has been deleted. * @returns The processed model data. */ - deleted(data: I#name#Data): I#name#Data { + async deleted(data: I#name#Data): Promise { return data } diff --git a/src/core/domains/migrations/factory/MigrationFactory.ts b/src/core/domains/migrations/factory/MigrationFactory.ts index 70eed3da1..503071c42 100644 --- a/src/core/domains/migrations/factory/MigrationFactory.ts +++ b/src/core/domains/migrations/factory/MigrationFactory.ts @@ -1,4 +1,6 @@ import MigrationModel from "@src/core/domains/migrations/models/MigrationModel"; +import { IModel, ModelConstructor } from "@src/core/interfaces/IModel"; + type Props = { name: string; @@ -14,13 +16,13 @@ class MigrationFactory { * @param param0 * @returns */ - create({ name, batch, checksum, appliedAt }: Props): MigrationModel { - return new MigrationModel({ + create({ name, batch, checksum, appliedAt }: Props, modelCtor: ModelConstructor = MigrationModel): IModel { + return new modelCtor({ name, batch, checksum, appliedAt - }) + }); } } diff --git a/src/core/domains/migrations/interfaces/IMigrationConfig.ts b/src/core/domains/migrations/interfaces/IMigrationConfig.ts index e3a591cd4..03ad3831e 100644 --- a/src/core/domains/migrations/interfaces/IMigrationConfig.ts +++ b/src/core/domains/migrations/interfaces/IMigrationConfig.ts @@ -1,5 +1,8 @@ +import { ModelConstructor } from "@src/core/interfaces/IModel"; + export interface IMigrationConfig { appMigrationsDir?: string; keepProcessAlive?: boolean; + modelCtor?: ModelConstructor; } \ No newline at end of file diff --git a/src/core/domains/migrations/models/MigrationModel.ts b/src/core/domains/migrations/models/MigrationModel.ts index 80ee9142a..a710bba65 100644 --- a/src/core/domains/migrations/models/MigrationModel.ts +++ b/src/core/domains/migrations/models/MigrationModel.ts @@ -1,10 +1,10 @@ import Model from "@src/core/base/Model"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; /** * Represents a migration stored in the database. */ -export interface MigrationModelData extends IModelData { +export interface MigrationModelData extends IModelAttributes { /** * The name of the migration. @@ -32,10 +32,10 @@ export interface MigrationModelData extends IModelData { */ class MigrationModel extends Model { - /** - * The name of the table in the database. - */ - table = 'migrations'; + constructor(data: MigrationModelData | null, tableName = 'migrations') { + super(data); + this.table = tableName + } /** * The fields that are dates. diff --git a/src/core/domains/migrations/providers/MigrationProvider.ts b/src/core/domains/migrations/providers/MigrationProvider.ts index 07259292c..f4a3eb449 100644 --- a/src/core/domains/migrations/providers/MigrationProvider.ts +++ b/src/core/domains/migrations/providers/MigrationProvider.ts @@ -26,11 +26,6 @@ class MigrationProvider extends BaseProvider { App.container('console').register().registerAll([ MigrateUpCommand, MigrateDownCommand - ]) - - App.container('console').register().addCommandConfig([ - (new MigrateUpCommand).signature, - (new MigrateDownCommand).signature ], this.config) } diff --git a/src/core/domains/migrations/repository/MigrationRepository.ts b/src/core/domains/migrations/repository/MigrationRepository.ts deleted file mode 100644 index 6e57dbc0e..000000000 --- a/src/core/domains/migrations/repository/MigrationRepository.ts +++ /dev/null @@ -1,12 +0,0 @@ -import Repository from "@src/core/base/Repository"; -import MigrationModel from "@src/core/domains/migrations/models/MigrationModel"; - -class MigrationRepository extends Repository { - - constructor() { - super(MigrationModel) - } - -} - -export default MigrationRepository \ No newline at end of file diff --git a/src/core/domains/migrations/schema/createMongoDBSchema.ts b/src/core/domains/migrations/schema/createMongoDBSchema.ts index 244c834b7..dedfea3ec 100644 --- a/src/core/domains/migrations/schema/createMongoDBSchema.ts +++ b/src/core/domains/migrations/schema/createMongoDBSchema.ts @@ -6,14 +6,14 @@ import { App } from "@src/core/services/App"; * * @returns {Promise} */ -const createMongoDBSchema = async () => { +const createMongoDBSchema = async (tableName: string = 'migrations') => { const db = App.container('db').provider().getDb(); - if ((await db.listCollections().toArray()).map(c => c.name).includes('migrations')) { + if ((await db.listCollections().toArray()).map(c => c.name).includes(tableName)) { return; } - await db.createCollection('migrations'); + await db.createCollection(tableName); } export default createMongoDBSchema \ No newline at end of file diff --git a/src/core/domains/migrations/schema/createPostgresSchema.ts b/src/core/domains/migrations/schema/createPostgresSchema.ts index 3cf01f2c5..19de73338 100644 --- a/src/core/domains/migrations/schema/createPostgresSchema.ts +++ b/src/core/domains/migrations/schema/createPostgresSchema.ts @@ -7,15 +7,15 @@ import { DataTypes } from "sequelize"; * * @returns {Promise} */ -const createPostgresSchema = async () => { +const createPostgresSchema = async (tableName: string = 'migrations') => { const sequelize = App.container('db').provider().getSequelize(); const queryInterface = sequelize.getQueryInterface(); - if ((await queryInterface.showAllTables())?.includes('migrations')) { + if ((await queryInterface.showAllTables())?.includes(tableName)) { return; } - await queryInterface.createTable('migrations', { + await queryInterface.createTable(tableName, { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, diff --git a/src/core/domains/migrations/services/MigrationFilesService.ts b/src/core/domains/migrations/services/MigrationFilesService.ts index 41d6dad44..84d62bd2e 100644 --- a/src/core/domains/migrations/services/MigrationFilesService.ts +++ b/src/core/domains/migrations/services/MigrationFilesService.ts @@ -1,4 +1,5 @@ import { IMigration } from '@src/core/domains/migrations/interfaces/IMigration'; +import FileNotFoundError from '@src/core/exceptions/FileNotFoundError'; import checksumFile from '@src/core/util/checksum'; import Str from '@src/core/util/str/Str'; import fs from 'fs'; @@ -43,7 +44,7 @@ class MigrationFileService { const absolutePath = path.resolve(this.appMigrationsDir, fileName); if(!fs.existsSync(absolutePath)) { - throw new Error(`File ${absolutePath} does not exist`); + throw new FileNotFoundError(`File ${absolutePath} does not exist`); } const importedModule = await import(absolutePath); diff --git a/src/core/domains/migrations/services/MigrationService.ts b/src/core/domains/migrations/services/MigrationService.ts index c786ad089..afc8e96a0 100644 --- a/src/core/domains/migrations/services/MigrationService.ts +++ b/src/core/domains/migrations/services/MigrationService.ts @@ -1,11 +1,15 @@ +import Repository from "@src/core/base/Repository"; import MigrationFactory from "@src/core/domains/migrations/factory/MigrationFactory"; import { IMigration } from "@src/core/domains/migrations/interfaces/IMigration"; import { IMigrationConfig } from "@src/core/domains/migrations/interfaces/IMigrationConfig"; import { IMigrationService, IMigrationServiceOptions } from "@src/core/domains/migrations/interfaces/IMigrationService"; -import MigrationRepository from "@src/core/domains/migrations/repository/MigrationRepository"; +import MigrationModel from "@src/core/domains/migrations/models/MigrationModel"; import createMongoDBSchema from "@src/core/domains/migrations/schema/createMongoDBSchema"; import createPostgresSchema from "@src/core/domains/migrations/schema/createPostgresSchema"; import MigrationFileService from "@src/core/domains/migrations/services/MigrationFilesService"; +import FileNotFoundError from "@src/core/exceptions/FileNotFoundError"; +import { ModelConstructor } from "@src/core/interfaces/IModel"; +import { IRepository } from "@src/core/interfaces/IRepository"; import { App } from "@src/core/services/App"; interface MigrationDetail { @@ -20,20 +24,23 @@ interface MigrationDetail { */ class MigrationService implements IMigrationService { - private fileService!: MigrationFileService; + private readonly fileService!: MigrationFileService; - private repository!: MigrationRepository; + private readonly repository!: IRepository; protected config!: IMigrationConfig; + protected modelCtor!: ModelConstructor; + constructor(config: IMigrationConfig = {}) { this.config = config; this.fileService = new MigrationFileService(config.appMigrationsDir); - this.repository = new MigrationRepository(); + this.modelCtor = config.modelCtor ?? MigrationModel; + this.repository = new Repository(this.modelCtor); } async boot() { - // Create the migrations schema + // Create the migrations schema await this.createSchema(); } @@ -45,19 +52,26 @@ class MigrationService implements IMigrationService { const result: MigrationDetail[] = []; const migrationFileNames = this.fileService.getMigrationFileNames(); - - for(const fileName of migrationFileNames) { - const migration = await this.fileService.getImportMigrationClass(fileName); - if(filterByFileName && fileName !== filterByFileName) { - continue; - } + for (const fileName of migrationFileNames) { + try { + const migration = await this.fileService.getImportMigrationClass(fileName); - if(group && migration.group !== group) { - continue; - } + if (filterByFileName && fileName !== filterByFileName) { + continue; + } - result.push({fileName, migration}); + if (group && migration.group !== group) { + continue; + } + + result.push({ fileName, migration }); + } + catch (err) { + if (err instanceof FileNotFoundError) { + continue; + } + } } return result; @@ -77,7 +91,7 @@ class MigrationService implements IMigrationService { const aDate = this.fileService.parseDate(a.fileName); const bDate = this.fileService.parseDate(b.fileName); - if(!aDate || !bDate) { + if (!aDate || !bDate) { return 0; } @@ -87,14 +101,14 @@ class MigrationService implements IMigrationService { // Get the current batch count const newBatchCount = (await this.getCurrentBatchCount()) + 1; - if(!migrationsDetails.length) { - console.log('[Migration] No migrations to run'); + if (!migrationsDetails.length) { + App.container('logger').info('[Migration] No migrations to run'); } // Run the migrations for every file for (const migrationDetail of migrationsDetails) { - console.log('[Migration] up -> ' + migrationDetail.fileName); - + App.container('logger').info('[Migration] up -> ' + migrationDetail.fileName); + await this.handleFileUp(migrationDetail, newBatchCount); } } @@ -104,7 +118,7 @@ class MigrationService implements IMigrationService { */ async down({ batch }: Pick): Promise { // Get the current batch count - let batchCount = typeof batch !== 'undefined' ? batch : await this.getCurrentBatchCount(); + let batchCount = typeof batch !== 'undefined' ? batch : await this.getCurrentBatchCount(); batchCount = isNaN(batchCount) ? 1 : batchCount; // Get the migration results @@ -117,28 +131,35 @@ class MigrationService implements IMigrationService { const aDate = a.getAttribute('appliedAt') as Date; const bDate = b.getAttribute('appliedAt') as Date; - if(!aDate || !bDate) { + if (!aDate || !bDate) { return 0; } return aDate.getTime() - bDate.getTime(); }); - if(!results.length) { - console.log('[Migration] No migrations to run'); + if (!results.length) { + App.container('logger').info('[Migration] No migrations to run'); } // Run the migrations - for(const result of results) { - const fileName = result.getAttribute('name') as string; - const migration = await this.fileService.getImportMigrationClass(fileName); + for (const result of results) { + try { + const fileName = result.getAttribute('name') as string; + const migration = await this.fileService.getImportMigrationClass(fileName); - // Run the down method - console.log(`[Migration] down -> ${fileName}`); - await migration.down(); + // Run the down method + App.container('logger').info(`[Migration] down -> ${fileName}`); + await migration.down(); - // Delete the migration document - await result.delete(); + // Delete the migration document + await result.delete(); + } + catch (err) { + if (err instanceof FileNotFoundError) { + continue; + } + } } } @@ -159,16 +180,16 @@ class MigrationService implements IMigrationService { }); if (migrationDocument) { - console.log(`[Migration] ${fileName} already applied`); + App.container('logger').info(`[Migration] ${fileName} already applied`); return; } - if(!migration.shouldUp()) { - console.log(`[Migration] Skipping (Provider mismatch) -> ${fileName}`); + if (!migration.shouldUp()) { + App.container('logger').info(`[Migration] Skipping (Provider mismatch) -> ${fileName}`); return; } - console.log(`[Migration] up -> ${fileName}`); + App.container('logger').info(`[Migration] up -> ${fileName}`); await migration.up(); const model = (new MigrationFactory).create({ @@ -176,7 +197,7 @@ class MigrationService implements IMigrationService { batch: newBatchCount, checksum: fileChecksum, appliedAt: new Date(), - }) + }, this.modelCtor) await model.save(); } @@ -203,7 +224,7 @@ class MigrationService implements IMigrationService { * @returns */ protected async getMigrationResults(filters?: object) { - return await (new MigrationRepository).findMany({ + return await this.repository.findMany({ ...(filters ?? {}) }) } @@ -214,26 +235,27 @@ class MigrationService implements IMigrationService { */ protected async createSchema(): Promise { try { + const tableName = (new this.modelCtor).table /** * Handle MongoDB driver */ if (App.container('db').isProvider('mongodb')) { - await createMongoDBSchema(); + await createMongoDBSchema(tableName); } /** * Handle Postgres driver */ - if(App.container('db').isProvider('postgres')) { - await createPostgresSchema(); + if (App.container('db').isProvider('postgres')) { + await createPostgresSchema(tableName); } } catch (err) { - console.log('[Migration] createSchema', err) + App.container('logger').info('[Migration] createSchema', err) if (err instanceof Error) { - console.error(err) + App.container('logger').error(err) } } } diff --git a/src/core/domains/observer/interfaces/IObserver.ts b/src/core/domains/observer/interfaces/IObserver.ts index 481783490..3d6a7ef49 100644 --- a/src/core/domains/observer/interfaces/IObserver.ts +++ b/src/core/domains/observer/interfaces/IObserver.ts @@ -2,14 +2,14 @@ export type IObserverEvent = keyof IObserver; export interface IObserver { - creating(data: ReturnType): ReturnType; - created(data: ReturnType): ReturnType; - updating(data: ReturnType): ReturnType; - updated(data: ReturnType): ReturnType; - saving(data: ReturnType): ReturnType; - saved(data: ReturnType): ReturnType; - deleting(data: ReturnType): ReturnType; - deleted(data: ReturnType): ReturnType; - on(name: IObserverEvent, data: ReturnType): ReturnType; - onCustom(customName: string, data: ReturnType): ReturnType; + creating(data: ReturnType): Promise; + created(data: ReturnType): Promise; + updating(data: ReturnType): Promise; + updated(data: ReturnType): Promise; + saving(data: ReturnType): Promise; + saved(data: ReturnType): Promise; + deleting(data: ReturnType): Promise; + deleted(data: ReturnType): Promise; + on(name: IObserverEvent, data: ReturnType): Promise; + onCustom(customName: string, data: ReturnType): Promise; } \ No newline at end of file diff --git a/src/core/domains/observer/interfaces/IWithObserve.ts b/src/core/domains/observer/interfaces/IWithObserve.ts index e08fcf2ef..f934a7814 100644 --- a/src/core/domains/observer/interfaces/IWithObserve.ts +++ b/src/core/domains/observer/interfaces/IWithObserve.ts @@ -22,12 +22,12 @@ export default interface IWithObserve; /** * Call an observer event method * [usage] * [class extends IWithObserve].observer.onCustom('someCustomMethod', data) */ - observeDataCustom?(customName: keyof Observer, data: any): ReturnType; + observeDataCustom?(customName: keyof Observer, data: any): Promise; } \ No newline at end of file diff --git a/src/core/domains/observer/services/Observer.ts b/src/core/domains/observer/services/Observer.ts index c6bf73c7d..8ba588f0f 100644 --- a/src/core/domains/observer/services/Observer.ts +++ b/src/core/domains/observer/services/Observer.ts @@ -24,7 +24,7 @@ export default abstract class Observer implements IObserver { return data; } @@ -34,7 +34,7 @@ export default abstract class Observer implements IObserver { return data; } @@ -44,7 +44,7 @@ export default abstract class Observer implements IObserver { return data; } @@ -54,7 +54,7 @@ export default abstract class Observer implements IObserver { return data; } @@ -64,7 +64,7 @@ export default abstract class Observer implements IObserver { return data; } @@ -74,7 +74,7 @@ export default abstract class Observer implements IObserver { return data; } @@ -84,7 +84,7 @@ export default abstract class Observer implements IObserver { return data; } @@ -94,7 +94,7 @@ export default abstract class Observer implements IObserver { return data; } @@ -111,11 +111,11 @@ export default abstract class Observer implements IObserver { if (this[name] && typeof this[name] === 'function') { // Call the method associated with the event name // eslint-disable-next-line no-unused-vars - return (this[name] as (data: ReturnType, ...args: any[]) => ReturnType)(data); + return await (this[name] as (data: ReturnType, ...args: any[]) => ReturnType)(data); } // If no method is found or it's not a function, return the original data return data; @@ -133,7 +133,7 @@ export default abstract class Observer implements IObserver { // Attempt to find a method on this instance with the given custom name // eslint-disable-next-line no-unused-vars const method = this[customName as keyof this] as ((data: ReturnType, ...args: any[]) => ReturnType) | undefined; diff --git a/src/core/domains/observer/services/WithObserver.ts b/src/core/domains/observer/services/WithObserver.ts index 284294a59..a6019190b 100644 --- a/src/core/domains/observer/services/WithObserver.ts +++ b/src/core/domains/observer/services/WithObserver.ts @@ -37,11 +37,11 @@ export abstract class WithObserver implements IWithObserve { if(!this.observer) { return data } - return this.observer.on(name, data) + return await this.observer.on(name, data) } /** @@ -54,7 +54,7 @@ export abstract class WithObserver implements IWithObserve { if(!this.observer) { return data } diff --git a/src/core/domains/setup/utils/defaultCredentials.ts b/src/core/domains/setup/utils/defaultCredentials.ts index b25b9d6fa..01cb96882 100644 --- a/src/core/domains/setup/utils/defaultCredentials.ts +++ b/src/core/domains/setup/utils/defaultCredentials.ts @@ -1,3 +1,4 @@ +import { App } from "@src/core/services/App" import fs from "fs" import path from "path" @@ -17,7 +18,7 @@ const extractDefaultMongoDBCredentials = () => { } } catch (err) { - console.error(err) + App.container('logger').error(err) } return null; @@ -40,7 +41,7 @@ const extractDefaultPostgresCredentials = () => { } } catch (err) { - console.error(err) + App.container('logger').error(err) } return null; diff --git a/src/core/domains/validator/base/BaseValidator.ts b/src/core/domains/validator/base/BaseValidator.ts index 550c964a0..ed662aeb3 100644 --- a/src/core/domains/validator/base/BaseValidator.ts +++ b/src/core/domains/validator/base/BaseValidator.ts @@ -107,6 +107,14 @@ abstract class BaseValidator

im } } + /** + * Gets the Joi validation options + * @returns The Joi validation options + */ + getJoiOptions(): Joi.ValidationOptions { + return {} + } + } export default BaseValidator \ No newline at end of file diff --git a/src/core/domains/validator/interfaces/IValidator.ts b/src/core/domains/validator/interfaces/IValidator.ts index a1b649096..7a68c2b91 100644 --- a/src/core/domains/validator/interfaces/IValidator.ts +++ b/src/core/domains/validator/interfaces/IValidator.ts @@ -40,6 +40,12 @@ interface IValidator * @returns The validator instance. */ setErrorMessage(customMessages: Record): IValidator; + + /** + * Gets the Joi validation options + * @returns The Joi validation options + */ + getJoiOptions(): ValidationOptions; } export default IValidator diff --git a/src/core/domains/validator/middleware/validateMiddleware.ts b/src/core/domains/validator/middleware/validateMiddleware.ts index 69997722c..659505c1a 100644 --- a/src/core/domains/validator/middleware/validateMiddleware.ts +++ b/src/core/domains/validator/middleware/validateMiddleware.ts @@ -18,7 +18,13 @@ export const validateMiddleware = ({validator, validateBeforeAction}: ValidatorM req.validator = validator; if(validateBeforeAction) { - const result = await validator.validate(req.body); + const result = await validator.validate( + req.body, + { + stripUnknown: true, + ...validator.getJoiOptions() + } + ); if(!result.success) { res.send({ @@ -34,6 +40,7 @@ export const validateMiddleware = ({validator, validateBeforeAction}: ValidatorM catch (error) { if(error instanceof Error) { responseError(req, res, error) + return; } } }; \ No newline at end of file diff --git a/src/core/exceptions/FileNotFoundError.ts b/src/core/exceptions/FileNotFoundError.ts new file mode 100644 index 000000000..ca761a770 --- /dev/null +++ b/src/core/exceptions/FileNotFoundError.ts @@ -0,0 +1,8 @@ +export default class FileNotFoundError extends Error { + + constructor(message: string = 'File not found') { + super(message); + this.name = 'FileNotFoundError'; + } + +} \ No newline at end of file diff --git a/src/core/exceptions/UnexpectedAttributeError.ts b/src/core/exceptions/UnexpectedAttributeError.ts new file mode 100644 index 000000000..a46776227 --- /dev/null +++ b/src/core/exceptions/UnexpectedAttributeError.ts @@ -0,0 +1,8 @@ +export default class UnexpectedAttributeError extends Error { + + constructor(message: string = 'Unexpected attribute') { + super(message); + this.name = 'UnexpectedAttributeError'; + } + +} \ No newline at end of file diff --git a/src/core/interfaces/ICoreContainers.ts b/src/core/interfaces/ICoreContainers.ts index b8dabc32c..140c65179 100644 --- a/src/core/interfaces/ICoreContainers.ts +++ b/src/core/interfaces/ICoreContainers.ts @@ -2,7 +2,9 @@ import { IAuthService } from '@src/core/domains/auth/interfaces/IAuthService'; import ICommandService from '@src/core/domains/console/interfaces/ICommandService'; import { IDatabaseService } from '@src/core/domains/database/interfaces/IDatabaseService'; import { IEventService } from '@src/core/domains/events/interfaces/IEventService'; +import { IRequestContext } from '@src/core/domains/express/interfaces/ICurrentRequest'; import IExpressService from '@src/core/domains/express/interfaces/IExpressService'; +import { ILoggerService } from '@src/core/domains/logger/interfaces/ILoggerService'; import IValidatorService from '@src/core/domains/validator/interfaces/IValidatorService'; import readline from 'node:readline'; @@ -33,6 +35,12 @@ export interface ICoreContainers { */ express: IExpressService; + /** + * Request Context service + * Provided by '@src/core/domains/express/providers/ExpressProvider' + */ + requestContext: IRequestContext; + /** * Console service * Provided by '@src/core/domains/console/providers/ConsoleProvider' @@ -50,4 +58,10 @@ export interface ICoreContainers { * Provided by '@src/core/domains/validator/providers/ValidatorProvider' */ validate: IValidatorService; + + /** + * Logger service + * Provided by '@src/core/domains/logger/providers/LoggerProvider' + */ + logger: ILoggerService; } diff --git a/src/core/interfaces/IModel.ts b/src/core/interfaces/IModel.ts index 671370270..21b2d16e5 100644 --- a/src/core/interfaces/IModel.ts +++ b/src/core/interfaces/IModel.ts @@ -4,7 +4,7 @@ import { IBelongsToOptions } from "@src/core/domains/database/interfaces/relatio import { IHasManyOptions } from "@src/core/domains/database/interfaces/relationships/IHasMany"; import IWithObserve from "@src/core/domains/observer/interfaces/IWithObserve"; import { ICtor } from "@src/core/interfaces/ICtor"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; export type GetDataOptions = {excludeGuarded: boolean} @@ -12,7 +12,6 @@ export type ModelConstructor = new (...args: any[]) = export type ModelInstance> = InstanceType - /** * @interface IModel * @description Abstract base class for database models. @@ -41,13 +40,13 @@ export type ModelInstance> = InstanceType extends IWithObserve { +export interface IModel extends IWithObserve { connection: string; primaryKey: string; table: string; fields: string[]; guarded: string[]; - data: Data | null; + attributes: Attributes | null; dates: string[]; timestamps: boolean; json: string[]; @@ -55,12 +54,16 @@ export interface IModel extends IWithObser prepareDocument(): T; getDocumentManager(): IDocumentManager; getId(): string | undefined; - setAttribute(key: keyof Data, value: any): void; - getAttribute(key: keyof Data): any; + attr(key: K, value?: unknown): Attributes[K] | null | undefined + setAttribute(key: keyof Attributes, value: any): void; + getAttribute(key: keyof Attributes): any; + getOriginal(key: K): Attributes[K] | null + isDirty(): boolean; + getDirty(): Record | null setTimestamp(dateTimeField: string, value: Date): void; - fill(data: Partial): void; - getData(options: GetDataOptions): Data | null; - refresh(): Promise; + fill(data: Partial): void; + getData(options: GetDataOptions): Attributes | null; + refresh(): Promise; update(): Promise; save(): Promise; delete(): Promise; diff --git a/src/core/interfaces/IModelData.ts b/src/core/interfaces/IModelData.ts index 3423d3abc..09df8fe6f 100644 --- a/src/core/interfaces/IModelData.ts +++ b/src/core/interfaces/IModelData.ts @@ -6,9 +6,9 @@ * @property {Date} [updatedAt] - The date and time the model was updated. * @property {any} [key] - Any other property that is not explicitly defined. */ -export default interface IModelData { +export default interface IModelAttributes { id?: string; createdAt?: Date; updatedAt?: Date; - [key: string]: any; + [key: string]: unknown; } diff --git a/src/core/providers/CoreProviders.ts b/src/core/providers/CoreProviders.ts index 56926e1ea..269636de3 100644 --- a/src/core/providers/CoreProviders.ts +++ b/src/core/providers/CoreProviders.ts @@ -3,6 +3,7 @@ import ConsoleProvider from "@src/core/domains/console/providers/ConsoleProvider import DatabaseProvider from "@src/core/domains/database/providers/DatabaseProvider"; import EventProvider from "@src/core/domains/events/providers/EventProvider"; import ExpressProvider from "@src/core/domains/express/providers/ExpressProvider"; +import LoggerProvider from "@src/core/domains/logger/providers/LoggerProvider"; import MakeProvider from "@src/core/domains/make/providers/MakeProvider"; import MigrationProvider from "@src/core/domains/migrations/providers/MigrationProvider"; import SetupProvider from "@src/core/domains/setup/providers/SetupProvider"; @@ -18,6 +19,13 @@ import { IProvider } from "@src/core/interfaces/IProvider"; */ const CoreProviders: IProvider[] = [ + /** + * Logger provider + * + * Provides logging services by utilising winston + */ + new LoggerProvider(), + /** * Console provider * @@ -80,6 +88,8 @@ const CoreProviders: IProvider[] = [ * Provides setup commands and helpers */ new SetupProvider(), + + ]; export default CoreProviders \ No newline at end of file diff --git a/src/core/services/App.ts b/src/core/services/App.ts index 8b771f9d2..65ddd755c 100644 --- a/src/core/services/App.ts +++ b/src/core/services/App.ts @@ -18,6 +18,28 @@ export class App extends Singleton { */ public env!: string; + /** + * Global values + */ + protected values: Record = {}; + + /** + * Sets a value + * @param key The key of the value + * @param value The value to set + */ + public static setValue(key: string, value: unknown): void { + this.getInstance().values[key] = value; + } + + /** + * Gets a value + * @param key The key of the value to get + */ + public static getValue(key: string): T | undefined { + return this.getInstance().values[key] as T; + } + /** * Sets a container * @param name The name of the container diff --git a/src/core/services/PackageJsonService.ts b/src/core/services/PackageJsonService.ts index 87fbb8b42..1f31c266d 100644 --- a/src/core/services/PackageJsonService.ts +++ b/src/core/services/PackageJsonService.ts @@ -1,4 +1,5 @@ import { IPackageJson, IPackageJsonService } from "@src/core/interfaces/IPackageJsonService"; +import { App } from "@src/core/services/App"; import { exec } from "child_process"; import fs from "fs"; import path from "path"; @@ -27,7 +28,7 @@ export default class PackageJsonService implements IPackageJsonService { */ async installPackage(name: string) { const cmd = `yarn add ${name}` - console.log('Running command: ', cmd) + App.container('logger').info('Running command: ', cmd) await execPromise(cmd); } @@ -45,7 +46,7 @@ export default class PackageJsonService implements IPackageJsonService { } const cmd = `yarn remove ${name}` - console.log('Running command: ', cmd) + App.container('logger').info('Running command: ', cmd) await execPromise(cmd); } diff --git a/src/tests/auth/auth.test.ts b/src/tests/auth/auth.test.ts index f192abc51..494e36571 100644 --- a/src/tests/auth/auth.test.ts +++ b/src/tests/auth/auth.test.ts @@ -11,6 +11,7 @@ import DatabaseProvider from '@src/core/domains/database/providers/DatabaseProvi import Kernel from '@src/core/Kernel'; import { App } from '@src/core/services/App'; import testAppConfig from '@src/tests/config/testConfig'; +import TestConsoleProvider from '@src/tests/providers/TestConsoleProvider'; describe('attempt to run app with normal appConfig', () => { @@ -25,6 +26,8 @@ describe('attempt to run app with normal appConfig', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, + new TestConsoleProvider(), new DatabaseProvider(), new AuthProvider() ] @@ -37,6 +40,7 @@ describe('attempt to run app with normal appConfig', () => { email, hashedPassword, roles: [], + groups: [], firstName: 'Tony', lastName: 'Stark' }) @@ -75,7 +79,7 @@ describe('attempt to run app with normal appConfig', () => { }); if(!result.success) { - console.error(result.joi.error); + App.container('logger').error(result.joi.error); } expect(result.success).toBeTruthy(); @@ -91,7 +95,7 @@ describe('attempt to run app with normal appConfig', () => { }); if(!result.success) { - console.error(result.joi.error); + App.container('logger').error(result.joi.error); } expect(result.success).toBeTruthy(); @@ -125,7 +129,7 @@ describe('attempt to run app with normal appConfig', () => { apiToken && await App.container('auth').revokeToken(apiToken); await apiToken?.refresh(); - expect(apiToken?.data?.revokedAt).toBeTruthy(); + expect(apiToken?.attributes?.revokedAt).toBeTruthy(); }) }) \ No newline at end of file diff --git a/src/tests/config/testConfig.ts b/src/tests/config/testConfig.ts index b51c462bd..0402cdbc7 100644 --- a/src/tests/config/testConfig.ts +++ b/src/tests/config/testConfig.ts @@ -1,4 +1,5 @@ import { EnvironmentTesting } from '@src/core/consts/Environment'; +import LoggerProvider from '@src/core/domains/logger/providers/LoggerProvider'; import IAppConfig from '@src/core/interfaces/IAppConfig'; require('dotenv').config(); @@ -6,7 +7,9 @@ require('dotenv').config(); const testAppConfig: IAppConfig = { environment: EnvironmentTesting, - providers: [] + providers: [ + new LoggerProvider() + ] }; export default testAppConfig; diff --git a/src/tests/database/dbConnection.test.ts b/src/tests/database/dbConnection.test.ts index aa3ec7e99..d0512bafb 100644 --- a/src/tests/database/dbConnection.test.ts +++ b/src/tests/database/dbConnection.test.ts @@ -14,6 +14,7 @@ describe('attempt to connect to MongoDB database', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new DatabaseProvider() ] }, {}) diff --git a/src/tests/database/dbPartialSearch.test.ts b/src/tests/database/dbPartialSearch.test.ts new file mode 100644 index 000000000..c6318688a --- /dev/null +++ b/src/tests/database/dbPartialSearch.test.ts @@ -0,0 +1,97 @@ +/* eslint-disable no-undef */ +import { describe, expect, test } from '@jest/globals'; +import Kernel from '@src/core/Kernel'; +import { IDocumentManager } from '@src/core/domains/database/interfaces/IDocumentManager'; +import { App } from '@src/core/services/App'; +import testAppConfig from '@src/tests/config/testConfig'; +import { getTestConnectionNames } from '@src/tests/config/testDatabaseConfig'; +import TestDatabaseProvider from '@src/tests/providers/TestDatabaseProvider'; +import { DataTypes } from 'sequelize'; + +const connections = getTestConnectionNames() + +const createTable = async (connectionName: string) => { + const schema = App.container('db').schema(connectionName) + + schema.createTable('tests', { + name: DataTypes.STRING, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE + }) +} + +const dropTable = async (connectionName: string) => { + const schema = App.container('db').schema(connectionName) + + if(await schema.tableExists('tests')) { + await schema.dropTable('tests'); + } +} + + +describe('test partial search', () => { + + beforeAll(async () => { + await Kernel.boot({ + ...testAppConfig, + providers: [ + ...testAppConfig.providers, + new TestDatabaseProvider() + ] + }, {}) + + + for(const connectionName of connections) { + await dropTable(connectionName) + await createTable(connectionName) + } + }) + + test('test', async () => { + + for(const connectionName of connections) { + const documentManager = App.container('db').documentManager(connectionName).table('tests') as IDocumentManager + + App.container('logger').info('connectionName', connectionName) + + const recordOneData = { + name: 'Test One', + createdAt: new Date(), + updatedAt: new Date() + } + const recordTwoData = { + name: 'Test Two', + createdAt: new Date(), + updatedAt: new Date() + } + + await documentManager.insertOne(recordOneData) + await documentManager.insertOne(recordTwoData) + + const recordOne = await documentManager.findOne({ filter: { name: 'Test One'} }) + const recordTwo = await documentManager.findOne({ filter: { name: 'Test Two'} }) + + App.container('logger').info('Created two records', recordOne, recordTwo) + + expect(recordOne.id).toBeTruthy() + expect(recordTwo.id).toBeTruthy() + + const recordBothPartial = await documentManager.findMany({ filter: { name: '%Test%' }, allowPartialSearch: true }) + expect(recordBothPartial.length).toEqual(2) + + App.container('logger').info('recordBothPartial', recordBothPartial) + + const recordOnePartial = await documentManager.findOne({ filter: { name: '%One' }, allowPartialSearch: true }) + expect(recordOnePartial?.id === recordOne.id).toBeTruthy() + + App.container('logger').info('recordOnePartial', recordOnePartial) + + const recordTwoPartial = await documentManager.findOne({ filter: { name: '%Two' }, allowPartialSearch: true }) + expect(recordTwoPartial?.id === recordTwo.id).toBeTruthy() + + App.container('logger').info('recordTwoPartial', recordTwoPartial) + } + + + }) +}); \ No newline at end of file diff --git a/src/tests/database/dbProviders.test.ts b/src/tests/database/dbProviders.test.ts index 22aec099d..5cbe2d790 100644 --- a/src/tests/database/dbProviders.test.ts +++ b/src/tests/database/dbProviders.test.ts @@ -45,6 +45,7 @@ describe('Combined DocumentManager Interface Test', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestDatabaseProvider() ] }, {}); @@ -63,13 +64,13 @@ describe('Combined DocumentManager Interface Test', () => { test('All DocumentManager operations', async () => { for (const connectionName of connections) { - console.log('[Connection]', connectionName); + App.container('logger').info('[Connection]', connectionName); const documentManager = App.container('db').documentManager(connectionName).table(tableName); await documentManager.truncate(); // Test insertOne and findById - console.log('--- Testing insertOne and findById ---'); + App.container('logger').info('--- Testing insertOne and findById ---'); await documentManager.truncate() const data = createDocument(); const insertedDoc = await documentManager.insertOne(data); @@ -84,7 +85,7 @@ describe('Combined DocumentManager Interface Test', () => { expect(nonExistentDoc).toBeNull(); // Test findOne - console.log('--- Testing findOne ---'); + App.container('logger').info('--- Testing findOne ---'); await documentManager.truncate() const findOneData = createDocument() await documentManager.insertOne(findOneData) @@ -97,7 +98,7 @@ describe('Combined DocumentManager Interface Test', () => { expect(nonExistentOneDoc).toBeNull(); // Test insertMany and findMany - console.log('--- Testing insertMany and findMany ---'); + App.container('logger').info('--- Testing insertMany and findMany ---'); await documentManager.truncate() const data1 = createDocument(); const data2 = createDocument(); @@ -113,7 +114,7 @@ describe('Combined DocumentManager Interface Test', () => { expect(noResults.length).toBe(0); // Test updateOne - console.log('--- Testing updateOne ---'); + App.container('logger').info('--- Testing updateOne ---'); await documentManager.truncate() const updateOneData = createDocument() const updateOneInsertedDocument = await documentManager.insertOne(updateOneData); @@ -124,7 +125,7 @@ describe('Combined DocumentManager Interface Test', () => { expect(updatedDoc?.age).toEqual(updateOneData.age); // Test updateMany - console.log('--- Testing updateMany ---'); + App.container('logger').info('--- Testing updateMany ---'); await documentManager.truncate() await documentManager.insertMany([createDocument(), createDocument(), createDocument()]); const allDocs = await documentManager.findMany({}); @@ -135,7 +136,7 @@ describe('Combined DocumentManager Interface Test', () => { expect(updatedDocs.length).toBeGreaterThanOrEqual(3); // Test belongsTo - console.log('--- Testing belongsTo ---'); + App.container('logger').info('--- Testing belongsTo ---'); const parentDoc = await documentManager.insertOne({ name: 'Parent', age: 50 @@ -156,7 +157,7 @@ describe('Combined DocumentManager Interface Test', () => { expect(relatedChildDoc?.age).toEqual(childDoc.age); // Test deleteOne - console.log('--- Testing deleteOne ---'); + App.container('logger').info('--- Testing deleteOne ---'); await documentManager.truncate() const docToDelete = await documentManager.insertOne(createDocument()); await documentManager.deleteOne(docToDelete); @@ -167,7 +168,7 @@ describe('Combined DocumentManager Interface Test', () => { expect(deletedDoc).toBeNull(); // Test deleteMany - console.log('--- Testing deleteMany ---'); + App.container('logger').info('--- Testing deleteMany ---'); await documentManager.truncate() await documentManager.insertMany([createDocument(), createDocument(), createDocument()]); const docsBeforeDelete = await documentManager.findMany({}); @@ -177,7 +178,7 @@ describe('Combined DocumentManager Interface Test', () => { expect(remainingDocs.length).toBe(0); // Test truncate - console.log('--- Testing truncate ---'); + App.container('logger').info('--- Testing truncate ---'); await documentManager.insertMany([createDocument(), createDocument()]); await documentManager.truncate(); await documentManager.findMany({}); diff --git a/src/tests/database/dbSchema.test.ts b/src/tests/database/dbSchema.test.ts index 7184ff62c..8c0a31f2a 100644 --- a/src/tests/database/dbSchema.test.ts +++ b/src/tests/database/dbSchema.test.ts @@ -36,6 +36,7 @@ describe('Combined DocumentManager Interface Test', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestDatabaseProvider() ] }, {}); diff --git a/src/tests/endTests.test.ts b/src/tests/endTests.test.ts index a446da09e..3dbf423cb 100644 --- a/src/tests/endTests.test.ts +++ b/src/tests/endTests.test.ts @@ -4,6 +4,7 @@ import Kernel from '@src/core/Kernel'; import { App } from '@src/core/services/App'; import testAppConfig from '@src/tests/config/testConfig'; import { getTestConnectionNames } from '@src/tests/config/testDatabaseConfig'; +import TestMigrationModel from '@src/tests/migration/models/TestMigrationModel'; import { TestAuthorModel } from '@src/tests/models/models/TestAuthor'; import TestModel from '@src/tests/models/models/TestModel'; import { TestMovieModel } from '@src/tests/models/models/TestMovie'; @@ -16,6 +17,7 @@ describe('clean up tables', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestDatabaseProvider() ] }, {}) @@ -28,6 +30,7 @@ describe('clean up tables', () => { (new TestAuthorModel(null)).table, (new TestWorkerModel(null)).table, (new TestModel(null)).table, + (new TestMigrationModel(null)).table ].filter((value, index, self) => self.indexOf(value) === index); for (const connectionName of getTestConnectionNames()) { diff --git a/src/tests/events/eventQueue.test.ts b/src/tests/events/eventQueue.test.ts index d8b578f5c..65b792a34 100644 --- a/src/tests/events/eventQueue.test.ts +++ b/src/tests/events/eventQueue.test.ts @@ -46,6 +46,7 @@ describe('mock event service', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestDatabaseProvider(), new TestConsoleProvider(), new TestEventProvider() @@ -75,7 +76,7 @@ describe('mock event service', () => { expect(movie?.getAttribute('name')).toBe(movieName); await movie?.delete(); - expect(movie?.data).toBeNull(); + expect(movie?.attributes).toBeNull(); }); diff --git a/src/tests/events/eventSync.test.ts b/src/tests/events/eventSync.test.ts index e8b1e2445..83836c2d6 100644 --- a/src/tests/events/eventSync.test.ts +++ b/src/tests/events/eventSync.test.ts @@ -2,6 +2,7 @@ import { describe } from '@jest/globals'; import Kernel from '@src/core/Kernel'; import { App } from '@src/core/services/App'; +import testAppConfig from '@src/tests/config/testConfig'; import TestSubscriber from '@src/tests/events/subscribers/TestSyncSubscriber'; import TestConsoleProvider from '@src/tests/providers/TestConsoleProvider'; import TestEventProvider from '@src/tests/providers/TestEventProvider'; @@ -13,12 +14,12 @@ describe('mock event service', () => { */ beforeAll(async () => { await Kernel.boot({ - environment: 'testing', + ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestConsoleProvider(), new TestEventProvider() - ], - commands: [] + ] }, {}) }) diff --git a/src/tests/events/listeners/TestListener.ts b/src/tests/events/listeners/TestListener.ts index f2d3b5165..44694bda9 100644 --- a/src/tests/events/listeners/TestListener.ts +++ b/src/tests/events/listeners/TestListener.ts @@ -1,9 +1,10 @@ import EventListener from "@src/core/domains/events/services/EventListener"; +import { App } from "@src/core/services/App"; export class TestListener extends EventListener { handle = async (payload: any) => { - console.log('[TestListener]', payload) + App.container('logger').info('[TestListener]', payload) } } \ No newline at end of file diff --git a/src/tests/events/listeners/TestQueueListener.ts b/src/tests/events/listeners/TestQueueListener.ts index f4de063b5..719d278c1 100644 --- a/src/tests/events/listeners/TestQueueListener.ts +++ b/src/tests/events/listeners/TestQueueListener.ts @@ -1,10 +1,11 @@ import EventListener from "@src/core/domains/events/services/EventListener"; +import { App } from "@src/core/services/App"; import { TestMovieModel } from "@src/tests/models/models/TestMovie"; export class TestQueueListener extends EventListener<{name: string}> { handle = async (payload: {name: string}) => { - console.log('[TestQueueListener]', { name: payload }) + App.container('logger').info('[TestQueueListener]', { name: payload }) const movie = new TestMovieModel({ name: payload.name diff --git a/src/tests/make/make.test.ts b/src/tests/make/make.test.ts index c7f52a07f..2bba589fe 100644 --- a/src/tests/make/make.test.ts +++ b/src/tests/make/make.test.ts @@ -15,6 +15,7 @@ describe(`testing make commands (total ${makeTypes.length})`, () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestConsoleProvider() ] }, {}) diff --git a/src/tests/migration/migration.test.ts b/src/tests/migration/migration.test.ts index fe7ea1f42..f4b713690 100644 --- a/src/tests/migration/migration.test.ts +++ b/src/tests/migration/migration.test.ts @@ -16,6 +16,7 @@ describe('test migrations', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestConsoleProvider(), new TestDatabaseProvider(), new TestMigrationProvider(), diff --git a/src/tests/migration/migrations/TestMigration.ts b/src/tests/migration/migrations/CreateTestTableMigration.ts similarity index 100% rename from src/tests/migration/migrations/TestMigration.ts rename to src/tests/migration/migrations/CreateTestTableMigration.ts diff --git a/src/tests/migration/models/TestMigrationModel.ts b/src/tests/migration/models/TestMigrationModel.ts new file mode 100644 index 000000000..47881043f --- /dev/null +++ b/src/tests/migration/models/TestMigrationModel.ts @@ -0,0 +1,17 @@ +import MigrationModel, { MigrationModelData } from "@src/core/domains/migrations/models/MigrationModel"; + +/** + * Model for test migrations stored in the database. + */ +class TestMigrationModel extends MigrationModel { + + constructor(data: MigrationModelData | null) { + super(data, 'testMigrations'); + } + +} + +/** + * The default migration model. + */ +export default TestMigrationModel diff --git a/src/tests/migration/providers/TestMigrationProvider.ts b/src/tests/migration/providers/TestMigrationProvider.ts index 7c2c59595..0606ecb24 100644 --- a/src/tests/migration/providers/TestMigrationProvider.ts +++ b/src/tests/migration/providers/TestMigrationProvider.ts @@ -3,6 +3,7 @@ import MigrateUpCommand from "@src/core/domains/migrations/commands/MigrateUpCom import { IMigrationConfig } from "@src/core/domains/migrations/interfaces/IMigrationConfig"; import MigrationProvider from "@src/core/domains/migrations/providers/MigrationProvider"; import { App } from "@src/core/services/App"; +import TestMigrationModel from "@src/tests/migration/models/TestMigrationModel"; class TestMigrationProvider extends MigrationProvider { @@ -17,6 +18,7 @@ class TestMigrationProvider extends MigrationProvider { const config: IMigrationConfig = { keepProcessAlive: true, appMigrationsDir: '@src/../src/tests/migration/migrations', + modelCtor: TestMigrationModel } App.container('console').register().addCommandConfig([ diff --git a/src/tests/models/modelAttr.test.ts b/src/tests/models/modelAttr.test.ts new file mode 100644 index 000000000..ac38e35f7 --- /dev/null +++ b/src/tests/models/modelAttr.test.ts @@ -0,0 +1,33 @@ +/* eslint-disable no-undef */ +import { describe, expect, test } from '@jest/globals'; +import Kernel from '@src/core/Kernel'; +import testAppConfig from '@src/tests/config/testConfig'; +import TestModel from '@src/tests/models/models/TestModel'; +import TestDatabaseProvider from '@src/tests/providers/TestDatabaseProvider'; + +describe('test model attr', () => { + + beforeAll(async () => { + await Kernel.boot({ + ...testAppConfig, + providers: [ + ...testAppConfig.providers, + new TestDatabaseProvider() + ] + }, {}) + }) + + test('attr', async () => { + const model = new TestModel({ + name: 'John' + }); + expect(model.attr('name')).toEqual('John'); + + model.attr('name', 'Jane'); + expect(model.attr('name')).toEqual('Jane'); + + const modelNoProperties = new TestModel(null); + modelNoProperties.attr('name', 'John') + expect(modelNoProperties.attr('name')).toEqual('John'); + }) +}); \ No newline at end of file diff --git a/src/tests/models/modelBelongsTo.test.ts b/src/tests/models/modelBelongsTo.test.ts index e9736d028..15c323b05 100644 --- a/src/tests/models/modelBelongsTo.test.ts +++ b/src/tests/models/modelBelongsTo.test.ts @@ -11,7 +11,6 @@ import { DataTypes } from 'sequelize'; const tableName = 'tests'; const connections = getTestConnectionNames() -console.log('modelBelongsTo', connections) const createTable = async (connectionName: string) => { const schema = App.container('db').schema(connectionName); @@ -43,6 +42,7 @@ describe('test belongsTo by fetching an author from a movie', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestDatabaseProvider() ] }, {}) @@ -54,7 +54,7 @@ describe('test belongsTo by fetching an author from a movie', () => { test('belongsTo', async () => { for(const connectionName of connections) { - console.log('[Connection]', connectionName) + App.container('logger').info('[Connection]', connectionName) await dropTable(connectionName) await createTable(connectionName) diff --git a/src/tests/models/modelCrud.test.ts b/src/tests/models/modelCrud.test.ts index b63e16838..59bfb95e1 100644 --- a/src/tests/models/modelCrud.test.ts +++ b/src/tests/models/modelCrud.test.ts @@ -36,6 +36,7 @@ describe('test model crud', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestDatabaseProvider() ] }, {}) @@ -50,7 +51,7 @@ describe('test model crud', () => { test('CRUD', async () => { for(const connectionName of connections) { - console.log('[Connection]', connectionName) + App.container('logger').info('[Connection]', connectionName) App.container('db').setDefaultConnectionName(connectionName); const documentManager = App.container('db').documentManager(connectionName).table('tests'); diff --git a/src/tests/models/modelDirty.test.ts b/src/tests/models/modelDirty.test.ts new file mode 100644 index 000000000..2e8e3eba7 --- /dev/null +++ b/src/tests/models/modelDirty.test.ts @@ -0,0 +1,148 @@ +/* eslint-disable no-undef */ +import { describe, expect, test } from '@jest/globals'; +import Repository from '@src/core/base/Repository'; +import { IModel } from '@src/core/interfaces/IModel'; +import Kernel from '@src/core/Kernel'; +import { App } from '@src/core/services/App'; +import testAppConfig from '@src/tests/config/testConfig'; +import { getTestConnectionNames } from '@src/tests/config/testDatabaseConfig'; +import TestDirtyModel from '@src/tests/models/models/TestDirtyModel'; +import TestDatabaseProvider from '@src/tests/providers/TestDatabaseProvider'; +import { DataTypes } from 'sequelize'; + +const connections = getTestConnectionNames() + +const createTable = async (connectionName: string) => { + const schema = App.container('db').schema(connectionName) + + schema.createTable('tests', { + name: DataTypes.STRING, + object: DataTypes.JSON, + array: DataTypes.JSON, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE + }) +} + +const dropTable = async (connectionName: string) => { + const schema = App.container('db').schema(connectionName) + + if(await schema.tableExists('tests')) { + await schema.dropTable('tests'); + } +} + +const truncate = async (connectionName: string) => { + await App.container('db').documentManager(connectionName).table('tests').truncate() +} + +describe('test dirty', () => { + + /** + * Boot the MongoDB provider + */ + beforeAll(async () => { + await Kernel.boot({ + ...testAppConfig, + providers: [ + ...testAppConfig.providers, + new TestDatabaseProvider() + ] + }, {}) + + for(const connection of connections) { + await dropTable(connection) + await createTable(connection) + } + }) + + test('dirty', async () => { + for(const connectionName of connections) { + App.container('logger').info('[Connection]', connectionName) + App.container('db').setDefaultConnectionName(connectionName); + + await truncate(connectionName); + + + /** + * Create author model + */ + const modelOne = new TestDirtyModel({ + name: 'John', + array: ['a', 'b'], + object: { + a: 1, + b: 1 + } + }) + expect(modelOne.isDirty()).toBeFalsy(); + + modelOne.attr('name', 'Jane') + expect(modelOne.isDirty()).toBeTruthy(); + expect(Object.keys(modelOne.getDirty() ?? {}).includes('name')).toBeTruthy() + expect(modelOne.getOriginal('name') === 'John') + + modelOne.attr('array', ['a', 'b', 'c']) + expect(modelOne.isDirty()).toBeTruthy(); + expect(Object.keys(modelOne.getDirty() ?? {}).includes('array')).toBeTruthy() + expect((modelOne.getOriginal('array') as string[])?.length).toEqual(2) + + modelOne.attr('object', { + a: 2, + b: 2 + }) + expect(modelOne.isDirty()).toBeTruthy(); + expect(Object.keys(modelOne.getDirty() ?? {}).includes('object')).toBeTruthy() + expect((modelOne.getOriginal('object') as {a: number, b: number})?.a).toEqual(1) + expect((modelOne.getOriginal('object') as {a: number, b: number})?.b).toEqual(1) + + await modelOne.save(); + expect(modelOne.isDirty()).toBeFalsy(); + + modelOne.attr('name', 'Bob') + expect(modelOne.isDirty()).toBeTruthy() + expect(Object.keys(modelOne.getDirty() ?? {}).includes('name')).toBeTruthy() + expect(modelOne.getOriginal('name') === 'Jane') + + await modelOne.delete(); + expect(modelOne.isDirty()).toBeFalsy(); + + await (new TestDirtyModel({ + name: 'John', + array: ['a', 'b'], + object: { + a: 1, + b: 1 + } + })).save() + const repository = new Repository(TestDirtyModel); + + const modelTwo = await repository.findOne({name: 'John'}) as IModel + expect(modelTwo).toBeTruthy() + expect(modelTwo).toBeInstanceOf(TestDirtyModel); + expect(modelTwo.isDirty()).toBeFalsy(); + + modelTwo.attr('name', 'Jane') + expect(modelTwo.isDirty()).toBeTruthy(); + expect(Object.keys(modelTwo.getDirty() ?? {}).includes('name')).toBeTruthy() + expect(modelTwo.getOriginal('name') === 'John') + + modelTwo.attr('array', ['a', 'b', 'c']) + expect(modelTwo.isDirty()).toBeTruthy(); + expect(Object.keys(modelTwo.getDirty() ?? {}).includes('array')).toBeTruthy() + expect((modelTwo.getAttribute('array') as string[])?.length).toEqual(3) + expect((modelTwo.getOriginal('array') as string[])?.length).toEqual(2) + + modelTwo.attr('object', { + a: 2, + b: 2 + }) + expect(modelTwo.isDirty()).toBeTruthy(); + expect(Object.keys(modelTwo.getDirty() ?? {}).includes('object')).toBeTruthy() + expect((modelTwo.getAttribute('object') as {a: number, b: number})?.a).toEqual(2) + expect((modelTwo.getAttribute('object') as {a: number, b: number})?.b).toEqual(2) + expect((modelTwo.getOriginal('object') as {a: number, b: number})?.a).toEqual(1) + expect((modelTwo.getOriginal('object') as {a: number, b: number})?.b).toEqual(1) + } + }) +}); \ No newline at end of file diff --git a/src/tests/models/modelHasMany.test.ts b/src/tests/models/modelHasMany.test.ts index 0a3b16391..d6275999d 100644 --- a/src/tests/models/modelHasMany.test.ts +++ b/src/tests/models/modelHasMany.test.ts @@ -44,6 +44,7 @@ describe('test hasMany', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestDatabaseProvider() ] }, {}) @@ -56,7 +57,7 @@ describe('test hasMany', () => { test('hasMany', async () => { for(const connectionName of connections) { - console.log('[Connection]', connectionName) + App.container('logger').info('[Connection]', connectionName) App.container('db').setDefaultConnectionName(connectionName); await truncate(connectionName); @@ -69,7 +70,7 @@ describe('test hasMany', () => { }) await authorModel.save(); expect(typeof authorModel.getId() === 'string').toBe(true) - expect(authorModel.data?.name).toEqual('John'); + expect(authorModel.attributes?.name).toEqual('John'); /** * Create movie model one and two @@ -81,8 +82,8 @@ describe('test hasMany', () => { }) await movieModelOne.save(); expect(typeof movieModelOne.getId() === 'string').toBe(true); - expect(movieModelOne.data?.name).toEqual('Movie One'); - expect(movieModelOne.data?.yearReleased).toEqual(1970); + expect(movieModelOne.attributes?.name).toEqual('Movie One'); + expect(movieModelOne.attributes?.yearReleased).toEqual(1970); const movieModelTwo = new TestMovieModel({ authorId: authorModel.getId()?.toString() as string, @@ -91,23 +92,23 @@ describe('test hasMany', () => { }) await movieModelTwo.save(); expect(typeof movieModelTwo.getId() === 'string').toBe(true); - expect(movieModelTwo.data?.name).toEqual('Movie Two'); - expect(movieModelTwo.data?.yearReleased).toEqual(1980); + expect(movieModelTwo.attributes?.name).toEqual('Movie Two'); + expect(movieModelTwo.attributes?.yearReleased).toEqual(1980); /** * Get related movies from author */ const relatedMovies = await authorModel.movies(); expect(relatedMovies.length).toEqual(2); - expect(relatedMovies.find((m) => m.data?.name === movieModelOne.data?.name)).toBeTruthy() - expect(relatedMovies.find((m) => m.data?.name === movieModelTwo.data?.name)).toBeTruthy() + expect(relatedMovies.find((m) => m.attributes?.name === movieModelOne.attributes?.name)).toBeTruthy() + expect(relatedMovies.find((m) => m.attributes?.name === movieModelTwo.attributes?.name)).toBeTruthy() /** * Get related movies from author from year 1970 */ const relatedMoviesWithFilters = await authorModel.moviesFromYear(1970); expect(relatedMoviesWithFilters.length).toEqual(1); - expect(relatedMovies.find((m) => m.data?.name === movieModelOne.data?.name)).toBeTruthy() + expect(relatedMovies.find((m) => m.attributes?.name === movieModelOne.attributes?.name)).toBeTruthy() } }) }); \ No newline at end of file diff --git a/src/tests/models/models/TestAuthor.ts b/src/tests/models/models/TestAuthor.ts index 309ae22de..3300df061 100644 --- a/src/tests/models/models/TestAuthor.ts +++ b/src/tests/models/models/TestAuthor.ts @@ -1,8 +1,8 @@ import Model from "@src/core/base/Model"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; import { TestMovieModel } from "@src/tests/models/models/TestMovie"; -export interface TestAuthorModelData extends IModelData { +export interface TestAuthorModelData extends IModelAttributes { name: string } export class TestAuthorModel extends Model { diff --git a/src/tests/models/models/TestDirtyModel.ts b/src/tests/models/models/TestDirtyModel.ts new file mode 100644 index 000000000..564ba8b7b --- /dev/null +++ b/src/tests/models/models/TestDirtyModel.ts @@ -0,0 +1,26 @@ +import Model from "@src/core/base/Model"; +import IModelAttributes from "@src/core/interfaces/IModelData"; + +interface TestDirtyModelAttributes extends IModelAttributes { + name: string, + array: string[], + object: object +} + +class TestDirtyModel extends Model { + + public table: string = 'tests'; + + public fields: string[] = [ + 'name', + 'array', + 'object', + 'createdAt', + 'updatedAt' + ] + + public json: string[] = ['array', 'object'] + +} + +export default TestDirtyModel \ No newline at end of file diff --git a/src/tests/models/models/TestModel.ts b/src/tests/models/models/TestModel.ts index a2bae1901..c657bd673 100644 --- a/src/tests/models/models/TestModel.ts +++ b/src/tests/models/models/TestModel.ts @@ -1,6 +1,7 @@ import Model from "@src/core/base/Model"; +import IModelAttributes from "@src/core/interfaces/IModelData"; -type TestModelData = { +interface TestModelData extends IModelAttributes { name: string } diff --git a/src/tests/models/models/TestMovie.ts b/src/tests/models/models/TestMovie.ts index 7ce61b0b4..561760353 100644 --- a/src/tests/models/models/TestMovie.ts +++ b/src/tests/models/models/TestMovie.ts @@ -1,8 +1,8 @@ import Model from "@src/core/base/Model"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; import { TestAuthorModel } from "@src/tests/models/models/TestAuthor"; -export interface TestMovieModelData extends IModelData { +export interface TestMovieModelData extends IModelAttributes { authorId?: string; name?: string; yearReleased?: number; diff --git a/src/tests/validator/validator.test.ts b/src/tests/validator/validator.test.ts index c689932cd..a4f105658 100644 --- a/src/tests/validator/validator.test.ts +++ b/src/tests/validator/validator.test.ts @@ -13,6 +13,7 @@ describe('test validation', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new ValidationProvider() ] }, {}) @@ -46,6 +47,6 @@ describe('test validation', () => { expect(result.success).toBeFalsy(); expect(result.joi.error).toBeTruthy(); - console.log('failed validation', result.joi.error) + App.container('logger').warn('failed validation', result.joi.error) }) }); \ No newline at end of file diff --git a/src/tinker.ts b/src/tinker.ts index 19ae28a39..5680915ea 100644 --- a/src/tinker.ts +++ b/src/tinker.ts @@ -3,6 +3,7 @@ import 'tsconfig-paths/register'; import appConfig from '@src/config/app'; import Kernel from "@src/core/Kernel"; +import { App } from '@src/core/services/App'; (async () => { @@ -17,5 +18,5 @@ import Kernel from "@src/core/Kernel"; // Add your tinkers below - + App.container('logger').info('Tinkers are ready!') })(); \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 42c86d7d8..1e5dfdb37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -296,6 +296,11 @@ resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== +"@colors/colors@1.6.0", "@colors/colors@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.6.0.tgz#ec6cd237440700bc23ca23087f513c75508958b0" + integrity sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA== + "@cspotcode/source-map-support@^0.8.0": version "0.8.1" resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" @@ -303,6 +308,15 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@dabh/diagnostics@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.3.tgz#7f7e97ee9a725dffc7808d93668cc984e1dc477a" + integrity sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA== + dependencies: + colorspace "1.1.x" + enabled "2.0.x" + kuler "^2.0.0" + "@discoveryjs/json-ext@^0.5.0": version "0.5.7" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" @@ -982,6 +996,11 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== +"@types/triple-beam@^1.3.2": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c" + integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw== + "@types/validator@^13.7.17": version "13.12.1" resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.12.1.tgz#8835d22f7e25b261e624d02a42fe4ade2c689a3c" @@ -1243,6 +1262,13 @@ abbrev@1: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -1658,6 +1684,14 @@ buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + bytes@3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" @@ -1822,7 +1856,7 @@ collect-v8-coverage@^1.0.0: resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz#c0b29bcd33bcd0779a1344c2136051e6afd3d9e9" integrity sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q== -color-convert@^1.9.0: +color-convert@^1.9.0, color-convert@^1.9.3: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== @@ -1841,21 +1875,45 @@ color-name@1.1.3: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== -color-name@~1.1.4: +color-name@^1.0.0, color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color-string@^1.6.0: + version "1.9.1" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" + integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + color-support@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== +color@^3.1.3: + version "3.2.1" + resolved "https://registry.yarnpkg.com/color/-/color-3.2.1.tgz#3544dc198caf4490c3ecc9a790b54fe9ff45e164" + integrity sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA== + dependencies: + color-convert "^1.9.3" + color-string "^1.6.0" + colorette@^2.0.14: version "2.0.20" resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== +colorspace@1.1.x: + version "1.1.4" + resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.4.tgz#8d442d1186152f60453bf8070cd66eb364e59243" + integrity sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w== + dependencies: + color "^3.1.3" + text-hex "1.0.x" + commander@^10.0.1: version "10.0.1" resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" @@ -2124,6 +2182,11 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +enabled@2.0.x: + version "2.0.0" + resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" + integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -2458,7 +2521,12 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== -events@^3.2.0: +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + +events@^3.2.0, events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== @@ -2581,6 +2649,11 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" +fecha@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.3.tgz#4d9ccdbc61e8629b259fdca67e65891448d569fd" + integrity sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw== + file-entry-cache@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" @@ -2654,6 +2727,11 @@ flatted@^3.2.9: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a" integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw== +fn.name@1.x.x: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" + integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -2973,7 +3051,7 @@ iconv-lite@^0.6.2: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -ieee754@^1.1.13: +ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -3082,6 +3160,11 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== +is-arrayish@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + is-bigint@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" @@ -3811,6 +3894,11 @@ kleur@^3.0.3: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== +kuler@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" + integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== + leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" @@ -3898,6 +3986,18 @@ lodash@^4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +logform@^2.6.0, logform@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/logform/-/logform-2.6.1.tgz#71403a7d8cae04b2b734147963236205db9b3df0" + integrity sha512-CdaO738xRapbKIMVn2m4F6KTj4j7ooJ8POVnebSgKo3KBz5axNXRAL7ZdRjIV6NOr2Uf4vjtRkxrFETOioCqSA== + dependencies: + "@colors/colors" "1.6.0" + "@types/triple-beam" "^1.3.2" + fecha "^4.2.0" + ms "^2.1.1" + safe-stable-stringify "^2.3.1" + triple-beam "^1.3.0" + lru-cache@^11.0.0: version "11.0.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.0.1.tgz#3a732fbfedb82c5ba7bca6564ad3f42afcb6e147" @@ -4339,6 +4439,13 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" +one-time@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45" + integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g== + dependencies: + fn.name "1.x.x" + onetime@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" @@ -4640,6 +4747,11 @@ pretty-format@^29.0.0, pretty-format@^29.7.0: ansi-styles "^5.0.0" react-is "^18.0.0" +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" @@ -4750,6 +4862,17 @@ readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-stream@^4.5.2: + version "4.5.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.5.2.tgz#9e7fc4c45099baeed934bff6eb97ba6cf2729e09" + integrity sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g== + dependencies: + abort-controller "^3.0.0" + buffer "^6.0.3" + events "^3.3.0" + process "^0.11.10" + string_decoder "^1.3.0" + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -4868,6 +4991,11 @@ safe-regex-test@^1.0.3: es-errors "^1.3.0" is-regex "^1.1.4" +safe-stable-stringify@^2.3.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" + integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== + "safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" @@ -5040,6 +5168,13 @@ simple-get@^4.0.0: once "^1.3.1" simple-concat "^1.0.0" +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg== + dependencies: + is-arrayish "^0.3.1" + simple-update-notifier@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb" @@ -5146,6 +5281,11 @@ ssri@^8.0.0, ssri@^8.0.1: dependencies: minipass "^3.1.1" +stack-trace@0.0.x: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" + integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== + stack-utils@^2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" @@ -5221,7 +5361,7 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -string_decoder@^1.1.1: +string_decoder@^1.1.1, string_decoder@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== @@ -5368,6 +5508,11 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +text-hex@1.0.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" + integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== + text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -5412,6 +5557,11 @@ tr46@^4.1.1: dependencies: punycode "^2.3.0" +triple-beam@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.4.1.tgz#6fde70271dc6e5d73ca0c3b24e2d92afb7441984" + integrity sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg== + ts-api-utils@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1" @@ -5811,6 +5961,32 @@ wildcard@^2.0.0: resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67" integrity sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ== +winston-transport@^4.7.0: + version "4.8.0" + resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.8.0.tgz#a15080deaeb80338455ac52c863418c74fcf38ea" + integrity sha512-qxSTKswC6llEMZKgCQdaWgDuMJQnhuvF5f2Nk3SNXc4byfQ+voo2mX1Px9dkNOuR8p0KAjfPG29PuYUSIb+vSA== + dependencies: + logform "^2.6.1" + readable-stream "^4.5.2" + triple-beam "^1.3.0" + +winston@^3.15.0: + version "3.15.0" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.15.0.tgz#4df7b70be091bc1a38a4f45b969fa79589b73ff5" + integrity sha512-RhruH2Cj0bV0WgNL+lOfoUBI4DVfdUNjVnJGVovWZmrcKtrFTTRzgXYK2O9cymSGjrERCtaAeHwMNnUWXlwZow== + dependencies: + "@colors/colors" "^1.6.0" + "@dabh/diagnostics" "^2.0.2" + async "^3.2.3" + is-stream "^2.0.0" + logform "^2.6.0" + one-time "^1.0.0" + readable-stream "^3.4.0" + safe-stable-stringify "^2.3.1" + stack-trace "0.0.x" + triple-beam "^1.3.0" + winston-transport "^4.7.0" + wkx@^0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/wkx/-/wkx-0.5.0.tgz#c6c37019acf40e517cc6b94657a25a3d4aa33e8c"