From 0fb0c6c2b57c604a6765f192960a8fa7c294ef11 Mon Sep 17 00:00:00 2001 From: "Fabien \"egg\" O'Carroll" Date: Mon, 22 Jan 2024 15:58:45 +0700 Subject: [PATCH] Added NestJS Playground no-issue This adds the barebones of a NestJS application wired up to the Admin API behind a feature flag, so that we can experiement with how to use Nest in the context of Ghost --- .../settings/advanced/labs/AlphaFeatures.tsx | 4 + ghost/core/core/boot.js | 27 +++ .../server/web/api/endpoints/admin/app.js | 10 + ghost/core/core/shared/labs.js | 1 + ghost/core/package.json | 1 + ghost/ghost/.eslintrc.js | 11 + ghost/ghost/README.md | 21 ++ ghost/ghost/package.json | 43 ++++ .../decorators/handle-event.decorator.ts | 40 ++++ .../decorators/permissions.decorator.ts | 8 + ghost/ghost/src/common/entity.base.ts | 129 ++++++++++ ghost/ghost/src/common/event.base.ts | 6 + ghost/ghost/src/common/helpers/date.helper.ts | 5 + .../src/common/libraries.defintitions.ts | 1 + ghost/ghost/src/common/types/actor.type.ts | 8 + .../src/core/example/example.entity.test.ts | 27 +++ .../ghost/src/core/example/example.entity.ts | 38 +++ ghost/ghost/src/core/example/example.event.ts | 18 ++ .../src/core/example/example.repository.ts | 13 + .../src/core/example/example.service.test.ts | 22 ++ .../ghost/src/core/example/example.service.ts | 25 ++ .../in-memory/example.repository.in-memory.ts | 37 +++ .../controllers/example.controller.test.ts | 19 ++ .../admin/controllers/example.controller.ts | 35 +++ ghost/ghost/src/index.ts | 37 +++ ghost/ghost/src/listeners/example.listener.ts | 24 ++ .../nestjs/filters/global-exception.filter.ts | 104 ++++++++ .../filters/not-found-fallthrough.filter.ts | 10 + .../guards/admin-api-authentication.guard.ts | 102 ++++++++ .../src/nestjs/guards/permissions.guard.ts | 26 ++ .../location-header.interceptor.ts | 71 ++++++ .../src/nestjs/modules/admin-api.module.ts | 17 ++ ghost/ghost/src/nestjs/modules/app.module.ts | 38 +++ ghost/ghost/test/.eslintrc.js | 7 + ghost/ghost/tsconfig.json | 11 + yarn.lock | 223 +++++++++++++++++- 36 files changed, 1209 insertions(+), 10 deletions(-) create mode 100644 ghost/ghost/.eslintrc.js create mode 100644 ghost/ghost/README.md create mode 100644 ghost/ghost/package.json create mode 100644 ghost/ghost/src/common/decorators/handle-event.decorator.ts create mode 100644 ghost/ghost/src/common/decorators/permissions.decorator.ts create mode 100644 ghost/ghost/src/common/entity.base.ts create mode 100644 ghost/ghost/src/common/event.base.ts create mode 100644 ghost/ghost/src/common/helpers/date.helper.ts create mode 100644 ghost/ghost/src/common/libraries.defintitions.ts create mode 100644 ghost/ghost/src/common/types/actor.type.ts create mode 100644 ghost/ghost/src/core/example/example.entity.test.ts create mode 100644 ghost/ghost/src/core/example/example.entity.ts create mode 100644 ghost/ghost/src/core/example/example.event.ts create mode 100644 ghost/ghost/src/core/example/example.repository.ts create mode 100644 ghost/ghost/src/core/example/example.service.test.ts create mode 100644 ghost/ghost/src/core/example/example.service.ts create mode 100644 ghost/ghost/src/db/in-memory/example.repository.in-memory.ts create mode 100644 ghost/ghost/src/http/admin/controllers/example.controller.test.ts create mode 100644 ghost/ghost/src/http/admin/controllers/example.controller.ts create mode 100644 ghost/ghost/src/index.ts create mode 100644 ghost/ghost/src/listeners/example.listener.ts create mode 100644 ghost/ghost/src/nestjs/filters/global-exception.filter.ts create mode 100644 ghost/ghost/src/nestjs/filters/not-found-fallthrough.filter.ts create mode 100644 ghost/ghost/src/nestjs/guards/admin-api-authentication.guard.ts create mode 100644 ghost/ghost/src/nestjs/guards/permissions.guard.ts create mode 100644 ghost/ghost/src/nestjs/interceptors/location-header.interceptor.ts create mode 100644 ghost/ghost/src/nestjs/modules/admin-api.module.ts create mode 100644 ghost/ghost/src/nestjs/modules/app.module.ts create mode 100644 ghost/ghost/test/.eslintrc.js create mode 100644 ghost/ghost/tsconfig.json diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx index 83094324d2..bcd4bbb384 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx @@ -55,6 +55,10 @@ const features = [{ title: 'New email addresses', description: 'For self hosters, forces the usage of the mail.from config as from address for all outgoing emails', flag: 'newEmailAddresses' +},{ + title: 'NestJS Playground', + description: 'Wires up the Ghost NestJS App to the Admin API', + flag: 'NestPlayground' }]; const AlphaFeatures: React.FC = () => { diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index 05820d9b62..7b5a1e151b 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -381,6 +381,32 @@ async function initServices() { debug('End: initServices'); } +/** + * Set up an dependencies that need to be injected into NestJS + */ +async function initNestDependencies() { + debug('Begin: initNestDependencies'); + const GhostNestApp = require('@tryghost/ghost'); + const providers = []; + providers.push({ + provide: 'logger', + useValue: require('@tryghost/logging') + }, { + provide: 'SessionService', + useValue: require('./server/services/auth/session').sessionService + }, { + provide: 'AdminAuthenticationService', + useValue: require('./server/services/auth/api-key').admin + }, { + provide: 'DomainEvents', + useValue: require('@tryghost/domain-events') + }); + for (const provider of providers) { + GhostNestApp.addProvider(provider); + } + debug('End: initNestDependencies'); +} + /** * Kick off recurring jobs and background services * These are things that happen on boot, but we don't need to wait for them to finish @@ -528,6 +554,7 @@ async function bootGhost({backend = true, frontend = true, server = true} = {}) } await initServices({config}); + await initNestDependencies(); debug('End: Load Ghost Services & Apps'); // Step 5 - Mount the full Ghost app onto the minimal root app & disable maintenance mode diff --git a/ghost/core/core/server/web/api/endpoints/admin/app.js b/ghost/core/core/server/web/api/endpoints/admin/app.js index 3ac02257f4..c35a029ad3 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/app.js +++ b/ghost/core/core/server/web/api/endpoints/admin/app.js @@ -4,11 +4,13 @@ const bodyParser = require('body-parser'); const errorHandler = require('@tryghost/mw-error-handler'); const versionMatch = require('@tryghost/mw-version-match'); +const labs = require('../../../../../shared/labs'); const shared = require('../../../shared'); const express = require('../../../../../shared/express'); const sentry = require('../../../../../shared/sentry'); const routes = require('./routes'); const APIVersionCompatibilityService = require('../../../../services/api-version-compatibility'); +const GhostNestApp = require('@tryghost/ghost'); module.exports = function setupApiApp() { debug('Admin API setup start'); @@ -33,6 +35,14 @@ module.exports = function setupApiApp() { // Routing apiApp.use(routes()); + apiApp.use(async (req, res, next) => { + if (!labs.isSet('NestPlayground')) { + return next(); + } + const app = await GhostNestApp.getApp(); + app.getHttpAdapter().getInstance()(req, res, next); + }); + // API error handling apiApp.use(errorHandler.resourceNotFound); apiApp.use(APIVersionCompatibilityService.errorHandler); diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 7d9d8a0559..883ef355ff 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -36,6 +36,7 @@ const BETA_FEATURES = [ ]; const ALPHA_FEATURES = [ + 'NestPlayground', 'urlCache', 'lexicalMultiplayer', 'websockets', diff --git a/ghost/core/package.json b/ghost/core/package.json index 7d07870f46..ee5550ce0f 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -89,6 +89,7 @@ "@tryghost/errors": "1.3.1", "@tryghost/express-dynamic-redirects": "0.0.0", "@tryghost/external-media-inliner": "0.0.0", + "@tryghost/ghost": "0.0.0", "@tryghost/helpers": "1.1.88", "@tryghost/html-to-plaintext": "0.0.0", "@tryghost/http-cache-utils": "0.1.11", diff --git a/ghost/ghost/.eslintrc.js b/ghost/ghost/.eslintrc.js new file mode 100644 index 0000000000..99420306c4 --- /dev/null +++ b/ghost/ghost/.eslintrc.js @@ -0,0 +1,11 @@ +module.exports = { + parser: '@typescript-eslint/parser', + plugins: ['ghost'], + extends: [ + 'plugin:ghost/ts' + ], + rules: { + // disable file naming rule in favor or dotted notation e.g. `snippets.service.ts` + 'ghost/filenames/match-exported-class': [0, null, true] + } +}; diff --git a/ghost/ghost/README.md b/ghost/ghost/README.md new file mode 100644 index 0000000000..65ecae5e02 --- /dev/null +++ b/ghost/ghost/README.md @@ -0,0 +1,21 @@ +# Ghost + + +## Usage + + +## Develop + +This is a monorepo package. + +Follow the instructions for the top-level repo. +1. `git clone` this repo & `cd` into it as usual +2. Run `yarn` to install top-level dependencies. + + + +## Test + +- `yarn lint` run just eslint +- `yarn test` run lint and tests + diff --git a/ghost/ghost/package.json b/ghost/ghost/package.json new file mode 100644 index 0000000000..e5e8534b5b --- /dev/null +++ b/ghost/ghost/package.json @@ -0,0 +1,43 @@ +{ + "name": "@tryghost/ghost", + "version": "0.0.0", + "repository": "https://github.com/TryGhost/Ghost/tree/main/packages/ghost", + "author": "Ghost Foundation", + "private": true, + "main": "build/index.js", + "types": "build/index.d.ts", + "scripts": { + "dev": "tsc --watch --preserveWatchOutput --sourceMap", + "build": "tsc", + "build:ts": "yarn build", + "test:unit": "NODE_ENV=testing c8 --src src --all --reporter text --reporter cobertura mocha -r ts-node/register/transpile-only './**/*.test.ts'", + "test": "yarn test:types && yarn test:unit", + "test:types": "tsc --noEmit", + "lint:code": "eslint src/ --ext .ts --cache", + "lint": "yarn lint:code" + }, + "files": [ + "build" + ], + "devDependencies": { + "@nestjs/testing": "10.2.8", + "@types/node": "^20.10.0", + "@types/sinon": "^17.0.3", + "c8": "8.0.1", + "mocha": "10.2.0", + "reflect-metadata": "0.1.13", + "sinon": "^17.0.1", + "ts-node": "10.9.1", + "typescript": "5.2.2" + }, + "dependencies": { + "@nestjs/common": "10.2.8", + "@nestjs/core": "10.2.8", + "@nestjs/platform-express": "10.2.8", + "@tryghost/errors": "^1.2.27", + "bson-objectid": "2.0.4", + "express": "^4.18.2", + "reflect-metadata": "^0.1.14", + "rxjs": "^7.8.1" + } +} diff --git a/ghost/ghost/src/common/decorators/handle-event.decorator.ts b/ghost/ghost/src/common/decorators/handle-event.decorator.ts new file mode 100644 index 0000000000..8d6c09b52b --- /dev/null +++ b/ghost/ghost/src/common/decorators/handle-event.decorator.ts @@ -0,0 +1,40 @@ +import {NestApplication} from '@nestjs/core'; + +interface IEvent { + data: T + timestamp: Date +} + +interface IDomainEvents { + subscribe(event: new (data: T, timestamp: Date) => IEvent, fn: (_event: IEvent) => void): void; + dispatch(event: IEvent): void +} + +type EventRegistrationSpec = { + Event: new (data: EventData, timestamp: Date) => IEvent, + target: new (...args: unknown[]) => Subscriber, + methodName: string +}; + +const events: EventRegistrationSpec[] = []; + +export function OnEvent(Event: new (data: T, timestamp: Date) => IEvent) { + return function (target: object, methodName: string) { + events.push({ + Event: Event as new (data: unknown, timestamp: Date) => IEvent, + target: target.constructor as new (...args: unknown[]) => unknown, + methodName + }); + }; +} + +export function registerEvents(app: NestApplication, DomainEvents: IDomainEvents) { + for (const eventSpec of events) { + DomainEvents.subscribe(eventSpec.Event, async function (event: IEvent) { + // We have to cast to `any` here because we don't know the type - but we do know that it should have the `methodName` method + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = await app.resolve(eventSpec.target) as any; + await service[eventSpec.methodName](event); + }); + } +} diff --git a/ghost/ghost/src/common/decorators/permissions.decorator.ts b/ghost/ghost/src/common/decorators/permissions.decorator.ts new file mode 100644 index 0000000000..5c02807782 --- /dev/null +++ b/ghost/ghost/src/common/decorators/permissions.decorator.ts @@ -0,0 +1,8 @@ +import {Reflector} from '@nestjs/core'; + +type UserRole = 'Contributor' | 'Author' | 'Editor' | 'Admin' | 'Owner'; +type APIKeyRole = 'Admin Integration' | 'Ghost Explore Integration' | 'Self-Serve Migration Integration' | 'DB Backup Integration' | 'Scheduler Integration'; + +export type Role = UserRole | APIKeyRole; + +export const Roles = Reflector.createDecorator(); diff --git a/ghost/ghost/src/common/entity.base.ts b/ghost/ghost/src/common/entity.base.ts new file mode 100644 index 0000000000..e8c297fcbf --- /dev/null +++ b/ghost/ghost/src/common/entity.base.ts @@ -0,0 +1,129 @@ +import {Actor} from './types/actor.type'; +import ObjectID from 'bson-objectid'; +import {now} from './helpers/date.helper'; +import {BaseEvent} from './event.base'; + +function equals(a: unknown, b: unknown) { + if (a === null || b === null) { + return a === b; + } + if (a === undefined || b === undefined) { + return a === b; + } + if (typeof a === 'object' && Reflect.has(a, 'equals')) { + const equalsFn = Reflect.get(a, 'equals'); + if (typeof equalsFn === 'function') { + return equalsFn.call(a, b); + } + } + return a === b; +} + +type BaseEntityUpdatedXData = { + updatedAt: Date; + updatedBy: Actor; +} | { + updatedAt: null; + updatedBy: null; +} + +type BaseEntityData = { + id: ObjectID; + deleted: boolean; + createdAt: Date; + createdBy: Actor; +} & BaseEntityUpdatedXData; + +type Optional = { + [K in keyof T]?: T[K] +}; + +export class Entity { + constructor(protected attr: Data & Optional, actor?: Actor) { + this.attr = attr; + if (!this.attr.id) { + this.attr.id = new ObjectID(); + } + if (!this.attr.createdAt) { + this.attr.createdAt = now(); + } + if (actor) { + this.actor = actor; + } + if (!this.attr.createdBy) { + if (this.actor) { + this.attr.createdBy = this.actor; + } else { + // TODO: This should maybe be system user? + this.attr.createdBy = { + id: ObjectID.createFromHexString('d34d01d0d34d01d0d34d01d0'), + type: 'user', + role: 'Owner' + }; + } + } + this.attr.deleted = false; + } + + private events: BaseEvent[] = []; + protected addEvent(event: BaseEvent) { + this.events.push(event); + } + static getEventsToDispatch(entity: Entity, fn: (events: BaseEvent[]) => void) { + const events = entity.events; + entity.events = []; + fn(events); + } + + private actor?: Actor | null; + setActor(actor: Actor) { + if (this.actor !== null) { + throw new Error(`Entity already owned by ${actor.id}`); + } + this.actor = actor; + } + + get id() { + return this.attr.id; + } + + get createdAt() { + return this.attr.createdAt; + } + + get createdBy() { + return this.attr.createdBy; + } + + get updatedAt() { + return this.attr.updatedAt; + } + + get updatedBy() { + return this.attr.updatedBy; + } + + get deleted() { + return this.attr.deleted; + } + + delete() { + this.attr.deleted = true; + } + + protected set(key: K, value: Data[K], actor?: Actor) { + if (equals(this.attr[key], value)) { + return; + } + (this.attr as Data)[key] = value; + if (actor) { + this.attr.updatedAt = now(); + this.attr.updatedBy = actor; + } else if (this.actor) { + this.attr.updatedAt = now(); + this.attr.updatedBy = this.actor; + } else { + // Maybe log a warning or smth? + } + } +} diff --git a/ghost/ghost/src/common/event.base.ts b/ghost/ghost/src/common/event.base.ts new file mode 100644 index 0000000000..68a7593d7d --- /dev/null +++ b/ghost/ghost/src/common/event.base.ts @@ -0,0 +1,6 @@ +export abstract class BaseEvent { + constructor( + public readonly data: Data, + public readonly timestamp: Date = new Date() + ) {} +} diff --git a/ghost/ghost/src/common/helpers/date.helper.ts b/ghost/ghost/src/common/helpers/date.helper.ts new file mode 100644 index 0000000000..5e579fc0f2 --- /dev/null +++ b/ghost/ghost/src/common/helpers/date.helper.ts @@ -0,0 +1,5 @@ +export function now(): Date { + const date = new Date(); + date.setMilliseconds(0); + return date; +} diff --git a/ghost/ghost/src/common/libraries.defintitions.ts b/ghost/ghost/src/common/libraries.defintitions.ts new file mode 100644 index 0000000000..afc8627392 --- /dev/null +++ b/ghost/ghost/src/common/libraries.defintitions.ts @@ -0,0 +1 @@ +declare module '@tryghost/errors'; diff --git a/ghost/ghost/src/common/types/actor.type.ts b/ghost/ghost/src/common/types/actor.type.ts new file mode 100644 index 0000000000..ec0e3ba4ec --- /dev/null +++ b/ghost/ghost/src/common/types/actor.type.ts @@ -0,0 +1,8 @@ +import ObjectID from 'bson-objectid'; +import {Role} from '../decorators/permissions.decorator'; + +export type Actor = { + id: ObjectID + role: Role + type: 'user' | 'api_key' +}; diff --git a/ghost/ghost/src/core/example/example.entity.test.ts b/ghost/ghost/src/core/example/example.entity.test.ts new file mode 100644 index 0000000000..68a29bbd9d --- /dev/null +++ b/ghost/ghost/src/core/example/example.entity.test.ts @@ -0,0 +1,27 @@ +import assert from 'assert'; +import {Greeting} from './example.entity'; + +describe('ExampleEntity (Greeting)', function () { + it('Can greet someone', function () { + const entity = new Greeting({ + greeting: 'Bonjour' + }); + const msg = entity.greet('Margot'); + assert.equal(msg, 'Bonjour, Margot.'); + }); + it('Has a custom greeting for the recipient "world"', function () { + const entity = new Greeting({ + greeting: 'Bonjour' + }); + const msg = entity.greet('world'); + assert.equal(msg, 'Hello, world!'); + }); + it('Can have its greeting updated', function () { + const entity = new Greeting({ + greeting: 'Bonjour' + }); + entity.greeting = 'Evening'; + const msg = entity.greet('Guvner'); + assert.equal(msg, 'Evening, Guvner.'); + }); +}); diff --git a/ghost/ghost/src/core/example/example.entity.ts b/ghost/ghost/src/core/example/example.entity.ts new file mode 100644 index 0000000000..382164437d --- /dev/null +++ b/ghost/ghost/src/core/example/example.entity.ts @@ -0,0 +1,38 @@ +/** + * Entity + * + * Represents a "Business Object" in the system + * + * - As much business logic as possible should be here + * - Its interface should describe what it is and what it can do/you can do to it. + * - Handles the creation of events related to the entity + */ +import {Entity} from '../../common/entity.base'; +import {ExampleEvent} from './example.event'; + +type GreetingData = { + greeting: string +}; + +export class Greeting extends Entity { + get greeting() { + return this.attr.greeting; + } + + set greeting(greeting: string) { + this.set('greeting', greeting); + } + + greet(recipient: string) { + let message; + if (recipient.trim() === 'world') { + message = 'Hello, world!'; + } else { + message = `${this.greeting}, ${recipient.trim()}.`; + } + this.addEvent(ExampleEvent.create({ + message + })); + return message; + } +} diff --git a/ghost/ghost/src/core/example/example.event.ts b/ghost/ghost/src/core/example/example.event.ts new file mode 100644 index 0000000000..5811f62d70 --- /dev/null +++ b/ghost/ghost/src/core/example/example.event.ts @@ -0,0 +1,18 @@ +/** + * Event + * + * Represents an "Business Event" in the system + * + * They are serialisable (only contain data) + */ +import {BaseEvent} from '../../common/event.base'; + +type ExampleEventData = { + message: string +}; + +export class ExampleEvent extends BaseEvent { + static create(data: ExampleEventData) { + return new ExampleEvent(data, new Date()); + } +} diff --git a/ghost/ghost/src/core/example/example.repository.ts b/ghost/ghost/src/core/example/example.repository.ts new file mode 100644 index 0000000000..eb46e648ef --- /dev/null +++ b/ghost/ghost/src/core/example/example.repository.ts @@ -0,0 +1,13 @@ +/** + * Repository + * + * These define how the service can retrieve and store entities to/from persistence + * + * They should generally be derived from a shared base interface + */ +import {Greeting} from './example.entity'; + +export interface ExampleRepository { + getOne(recipient: string): Promise + save(entity: Greeting): Promise +} diff --git a/ghost/ghost/src/core/example/example.service.test.ts b/ghost/ghost/src/core/example/example.service.test.ts new file mode 100644 index 0000000000..9be6ea4c87 --- /dev/null +++ b/ghost/ghost/src/core/example/example.service.test.ts @@ -0,0 +1,22 @@ +import Sinon from 'sinon'; +import {ExampleService} from './example.service'; +import {Greeting} from './example.entity'; +import assert from 'assert'; + +describe('ExampleService', function () { + it('Can greet a recipient and save the greeting', async function () { + const recipient = 'Mr Anderson'; + const entity = new Greeting({greeting: 'Testing'}); + const repository = { + getOne: Sinon.stub().resolves(entity), + save: Sinon.stub() + }; + const service = new ExampleService(repository); + + const result = await service.greet(recipient); + + assert.equal(result, entity.greet(recipient)); + + assert(repository.save.calledWithExactly(entity)); + }); +}); diff --git a/ghost/ghost/src/core/example/example.service.ts b/ghost/ghost/src/core/example/example.service.ts new file mode 100644 index 0000000000..21e3a77531 --- /dev/null +++ b/ghost/ghost/src/core/example/example.service.ts @@ -0,0 +1,25 @@ +/** + * Service + * + * These implement Use Cases of the system, they should use repositories, entities and other services to coordinate these Use Cases + * + * Business logic should only go in here if it does not fall in the domain of a single entity. + */ +import {Inject} from '@nestjs/common'; +import {ExampleRepository} from './example.repository'; + +export class ExampleService { + constructor( + @Inject('ExampleRepository') private readonly repository: ExampleRepository + ) {} + + async greet(recipient: string): Promise { + const greeting = await this.repository.getOne('Greetings'); + + const message = greeting.greet(recipient); + + await this.repository.save(greeting); + + return message; + } +} diff --git a/ghost/ghost/src/db/in-memory/example.repository.in-memory.ts b/ghost/ghost/src/db/in-memory/example.repository.in-memory.ts new file mode 100644 index 0000000000..5fb20c817a --- /dev/null +++ b/ghost/ghost/src/db/in-memory/example.repository.in-memory.ts @@ -0,0 +1,37 @@ +/** + * Repository Implementation + * + * Can be in-memory, knex based, bookshelf based, redis based, whatever... + * + * No business logic + * No modification of Entities - they just store what they're given, or fetch what they're asked for + * They should dispatch and clear the events of an entity when tehy successfully persist it. + */ +import {Inject} from '@nestjs/common'; +import {Greeting} from '../../core/example/example.entity'; +import {ExampleRepository} from '../../core/example/example.repository'; + +interface DomainEvents { + dispatch(event: unknown): void +} + +export class ExampleRepositoryInMemory implements ExampleRepository { + constructor( + @Inject('DomainEvents') private readonly events: DomainEvents + ) {} + + async getOne(greeting: string) { + const entity = new Greeting({ + greeting: greeting.trim() + }); + return entity; + } + + async save(entity: Greeting) { + Greeting.getEventsToDispatch(entity, (events) => { + for (const event of events) { + this.events.dispatch(event); + } + }); + } +} diff --git a/ghost/ghost/src/http/admin/controllers/example.controller.test.ts b/ghost/ghost/src/http/admin/controllers/example.controller.test.ts new file mode 100644 index 0000000000..6c0a2349a9 --- /dev/null +++ b/ghost/ghost/src/http/admin/controllers/example.controller.test.ts @@ -0,0 +1,19 @@ +import assert from 'assert'; +import {ExampleController} from './example.controller'; +import * as sinon from 'sinon'; +import {ExampleService} from '../../../core/example/example.service'; + +describe('ExampleController', function () { + describe('#read', function () { + it('returns the result of the greet method', async function () { + const service = Object.create(ExampleService.prototype); + service.greet = sinon.stub(); + + const controller = new ExampleController(service); + + const result = await controller.read('egg'); + + assert.equal(result, service.greet.returnValues[0]); + }); + }); +}); diff --git a/ghost/ghost/src/http/admin/controllers/example.controller.ts b/ghost/ghost/src/http/admin/controllers/example.controller.ts new file mode 100644 index 0000000000..a7c5540c46 --- /dev/null +++ b/ghost/ghost/src/http/admin/controllers/example.controller.ts @@ -0,0 +1,35 @@ +/** + * Controller + * + * These classes are responsible for wiring HTTP Requests to the Service layer. + * They do not contain business logic. + */ + +import { + Controller, + Get, + Param +} from '@nestjs/common'; +import {Roles} from '../../../common/decorators/permissions.decorator'; +import {ExampleService} from '../../../core/example/example.service'; + +@Controller('greetings') +export class ExampleController { + constructor(private readonly service: ExampleService) {} + + @Roles([ + 'Admin', + 'Author', + 'Contributor', + 'Editor', + 'Owner', + 'Admin Integration' + ]) + @Get(':recipient') + async read( + @Param('recipient') recipient: string + ): Promise { + const greeting = await this.service.greet(recipient); + return greeting; + } +} diff --git a/ghost/ghost/src/index.ts b/ghost/ghost/src/index.ts new file mode 100644 index 0000000000..b1cc39345a --- /dev/null +++ b/ghost/ghost/src/index.ts @@ -0,0 +1,37 @@ +import 'reflect-metadata'; +import {AppModule} from './nestjs/modules/app.module'; +import {NestApplication, NestFactory} from '@nestjs/core'; +import {registerEvents} from './common/decorators/handle-event.decorator'; +import {ClassProvider, ValueProvider} from '@nestjs/common'; + +let _app: NestApplication; + +export async function create() { + const app = await NestFactory.create(AppModule); + const DomainEvents = await app.resolve('DomainEvents'); + registerEvents(app as NestApplication, DomainEvents); + return app; +} + +export async function getApp() { + if (_app) { + return _app; + } + _app = await create(); + await _app.init(); + return _app; +} + +export async function resolve(token: string) { + const app = await getApp(); + return await app.resolve(token); +} + +export function addProvider(obj: ClassProvider | ValueProvider) { + AppModule.providers?.push(obj); + AppModule.exports?.push(obj.provide); +} + +export { + AppModule +}; diff --git a/ghost/ghost/src/listeners/example.listener.ts b/ghost/ghost/src/listeners/example.listener.ts new file mode 100644 index 0000000000..0f226903bf --- /dev/null +++ b/ghost/ghost/src/listeners/example.listener.ts @@ -0,0 +1,24 @@ +/** + * Listener + * + * Responsible for mapping events to perform actions on the service/provider layer. + * - "Like a Controller, but for Events not HTTP Requests" + */ +import {OnEvent} from '../common/decorators/handle-event.decorator'; +import {Inject} from '@nestjs/common'; +import {ExampleEvent} from '../core/example/example.event'; + +interface Logger { + info(message: string): void +} + +export class ExampleListener { + constructor( + @Inject('logger') private logger: Logger + ) {} + + @OnEvent(ExampleEvent) + async logEvents(event: ExampleEvent) { + this.logger.info(`Received an event with a message: ${event.data.message}`); + } +} diff --git a/ghost/ghost/src/nestjs/filters/global-exception.filter.ts b/ghost/ghost/src/nestjs/filters/global-exception.filter.ts new file mode 100644 index 0000000000..cbf36aee64 --- /dev/null +++ b/ghost/ghost/src/nestjs/filters/global-exception.filter.ts @@ -0,0 +1,104 @@ +import {ArgumentsHost, Catch, ExceptionFilter} from '@nestjs/common'; +import {Response} from 'express'; + +interface GhostError extends Error { + statusCode?: number; + context?: string; + errorType?: string; + errorDetails?: string; + property?: string; + help?: string; + code?: string; + id?: string; + ghostErrorCode?: string; +} + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + catch(error: GhostError, host: ArgumentsHost) { + const context = host.switchToHttp(); + const response = context.getResponse(); + + response.status(error.statusCode || 500); + response.json({ + errors: [ + { + message: error.message, + context: error.context || null, + type: error.errorType || null, + details: error.errorDetails || null, + property: error.property || null, + help: error.help || null, + code: error.code || null, + id: error.id || null, + ghostErrorCode: error.ghostErrorCode || null + } + ] + }); + } +} + +/* +function prepareError(err: unknown) { + if (Array.isArray(err)) { + err = err[0]; + } + + // If the error is already a GhostError, it has been handled and can be returned as-is + // For everything else, we do some custom handling here + if (errors.utils.isGhostError(err)) { + return err; + } + + if (!(err instanceof Error)) { + return new errors.InternalServerError({ + err: err, + message: tpl(messages.genericError), + context: err.message, + statusCode: err.statusCode, + code: 'UNEXPECTED_ERROR' + }); + } + + // Catch bookshelf empty errors and other 404s, and turn into a Ghost 404 + if ( + (err.statusCode && err.statusCode === 404) || + err.message === 'EmptyResponse' + ) { + return new errors.NotFoundError({ + err: err + }); + // Catch handlebars / express-hbs errors, and render them as 400, rather than 500 errors as the server isn't broken + } else if ( + isDependencyInStack('handlebars', err) || + isDependencyInStack('express-hbs', err) + ) { + // Temporary handling of theme errors from handlebars + // @TODO remove this when #10496 is solved properly + err = new errors.IncrrectUsageError({ + err: err, + message: err.message, + statusCode: err.statusCode + }); + // Catch database errors and turn them into 500 errors, but log some useful data to sentry + } else if (isDependencyInStack('mysql2', err)) { + // we don't want to return raw database errors to our users + err.sqlErrorCode = err.code; + err = new errors.InternalServerError({ + err: err, + message: tpl(messages.genericError), + statusCode: err.statusCode, + code: 'UNEXPECTED_ERROR' + }); + // For everything else, create a generic 500 error, with context set to the original error message + } else { + err = new errors.InternalServerError({ + err: err, + message: tpl(messages.genericError), + context: err.message, + statusCode: err.statusCode, + code: 'UNEXPECTED_ERROR' + }); + } +} +*/ diff --git a/ghost/ghost/src/nestjs/filters/not-found-fallthrough.filter.ts b/ghost/ghost/src/nestjs/filters/not-found-fallthrough.filter.ts new file mode 100644 index 0000000000..ddf1cf3bcc --- /dev/null +++ b/ghost/ghost/src/nestjs/filters/not-found-fallthrough.filter.ts @@ -0,0 +1,10 @@ +import {Catch, NotFoundException, ExceptionFilter, ArgumentsHost} from '@nestjs/common'; + +@Catch(NotFoundException) +export class NotFoundFallthroughExceptionFilter implements ExceptionFilter { + catch(exception: NotFoundException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const next = ctx.getNext(); + next(); + } +} diff --git a/ghost/ghost/src/nestjs/guards/admin-api-authentication.guard.ts b/ghost/ghost/src/nestjs/guards/admin-api-authentication.guard.ts new file mode 100644 index 0000000000..89b006a984 --- /dev/null +++ b/ghost/ghost/src/nestjs/guards/admin-api-authentication.guard.ts @@ -0,0 +1,102 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + Inject +} from '@nestjs/common'; +import {Request, Response} from 'express'; +import {Actor} from '../../common/types/actor.type'; +import ObjectID from 'bson-objectid'; + +// Here we extend the express Request interface with our new type +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + // eslint-disable-next-line no-shadow + export interface Request { + actor?: Actor + } + } +} + +interface SessionService { + // We use any because we've not got types for bookshelf models + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getUserForSession(req: Request, res: Response): Promise +} + +interface AuthenticationService { + // We use any because we've not got types for bookshelf models + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authenticateWithToken(url: string, token: string, ignoreMaxAge: boolean): Promise +} + +@Injectable() +export class AdminAPIAuthentication implements CanActivate { + constructor( + @Inject('SessionService') private sessionService: SessionService, + @Inject('AdminAuthenticationService') private authService: AuthenticationService + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + + const user = await this.sessionService.getUserForSession(request, response); + + if (user) { + await this.setUserActor(user, request); + return true; + } + + if (!request.headers || !request.headers.authorization) { + return false; + } + + const [scheme, token] = request.headers.authorization.split(' '); + + if (!/^Ghost$/i.test(scheme)) { + return false; + } + + const {apiKey, user: apiUser} = await this.authService.authenticateWithToken( + request.originalUrl, + token, + false + ); + + if (user) { + await this.setUserActor(apiUser, request); + return true; + } + + if (apiKey) { + await apiKey.related('role').fetch(); + const json = apiKey.toJSON(); + request.actor = { + id: ObjectID.createFromHexString(json.integration.id), + role: json.role.name, + type: 'api_key' + }; + + return true; + } + + return false; + } + + // This is `any` because again it represents a bookshelf model + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async setUserActor(user: any, request: Request) { + await user.related('roles').fetch(); + const json = user.toJSON(); + request.actor = { + // BS To work around Owner id === 1 + id: ObjectID.createFromHexString( + json.id === '1' ? 'DEAD01D0DEAD01D0DEAD01D0' : json.id + ), + role: json.roles[0].name, + type: 'user' + }; + } +} diff --git a/ghost/ghost/src/nestjs/guards/permissions.guard.ts b/ghost/ghost/src/nestjs/guards/permissions.guard.ts new file mode 100644 index 0000000000..a0c4d654e2 --- /dev/null +++ b/ghost/ghost/src/nestjs/guards/permissions.guard.ts @@ -0,0 +1,26 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + Inject +} from '@nestjs/common'; +import {Reflector} from '@nestjs/core'; +import {Roles} from '../../common/decorators/permissions.decorator'; +import {Request} from 'express'; + +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor(@Inject(Reflector) private reflector: Reflector) {} + + async canActivate(context: ExecutionContext): Promise { + const roles = this.reflector.get(Roles, context.getHandler()); + const request = context.switchToHttp().getRequest(); + + const role = request.actor?.role; + + if (role && roles.includes(role)) { + return true; + } + return false; + } +} diff --git a/ghost/ghost/src/nestjs/interceptors/location-header.interceptor.ts b/ghost/ghost/src/nestjs/interceptors/location-header.interceptor.ts new file mode 100644 index 0000000000..c3e7b87ba8 --- /dev/null +++ b/ghost/ghost/src/nestjs/interceptors/location-header.interceptor.ts @@ -0,0 +1,71 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler +} from '@nestjs/common'; +import {Observable} from 'rxjs'; +import {tap} from 'rxjs/operators'; +import {Request, Response} from 'express'; + +@Injectable() +export class LocationHeaderInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (context.getType() !== 'http') { + return next.handle(); + } + const [ + req, + res + ]: [Request, Response] = context.getArgs(); + + if (req.method !== 'POST') { + return next.handle(); + } + + function getLocationHeader(responseData: unknown) { + if (typeof responseData !== 'object' || responseData === null) { + return; + } + const keys = Object.keys(responseData); + if (keys.length !== 1) { + return; + } + + const data: unknown = Reflect.get(responseData, keys[0]); + + if (!Array.isArray(data)) { + return; + } + + if (data.length !== 1) { + return; + } + + const id = data[0].id; + + if (!id || typeof id !== 'string') { + return; + } + + const url = new URL('https://ghost.io'); + url.protocol = req.secure ? 'https:' : 'http:'; + // We use `any` here because we haven't yet extended the express Request object with the vhost plugin types + // eslint-disable-next-line @typescript-eslint/no-explicit-any + url.host = (req as any).vhost ? (req as any).vhost.host : req.get('host'); + url.pathname = req.path; + url.pathname += `${id}/`; + + return url; + } + + return next.handle().pipe( + tap((data) => { + const location = getLocationHeader(data); + if (location) { + res.set('location', location.href); + } + }) + ); + } +} diff --git a/ghost/ghost/src/nestjs/modules/admin-api.module.ts b/ghost/ghost/src/nestjs/modules/admin-api.module.ts new file mode 100644 index 0000000000..ce04e7517f --- /dev/null +++ b/ghost/ghost/src/nestjs/modules/admin-api.module.ts @@ -0,0 +1,17 @@ +import {Module} from '@nestjs/common'; +import {ExampleController} from '../../http/admin/controllers/example.controller'; +import {ExampleService} from '../../core/example/example.service'; +import {ExampleRepositoryInMemory} from '../../db/in-memory/example.repository.in-memory'; + +@Module({ + controllers: [ExampleController], + exports: [ExampleService], + providers: [ + ExampleService, + { + provide: 'ExampleRepository', + useClass: ExampleRepositoryInMemory + } + ] +}) +export class AdminAPIModule {} diff --git a/ghost/ghost/src/nestjs/modules/app.module.ts b/ghost/ghost/src/nestjs/modules/app.module.ts new file mode 100644 index 0000000000..db7e997e9a --- /dev/null +++ b/ghost/ghost/src/nestjs/modules/app.module.ts @@ -0,0 +1,38 @@ +import {DynamicModule} from '@nestjs/common'; +import {APP_FILTER, APP_GUARD, APP_INTERCEPTOR} from '@nestjs/core'; +import {AdminAPIModule} from './admin-api.module'; +import {NotFoundFallthroughExceptionFilter} from '../filters/not-found-fallthrough.filter'; +import {ExampleListener} from '../../listeners/example.listener'; +import {AdminAPIAuthentication} from '../guards/admin-api-authentication.guard'; +import {PermissionsGuard} from '../guards/permissions.guard'; +import {LocationHeaderInterceptor} from '../interceptors/location-header.interceptor'; +import {GlobalExceptionFilter} from '../filters/global-exception.filter'; + +class AppModuleClass {} + +export const AppModule: DynamicModule = { + global: true, + module: AppModuleClass, + imports: [AdminAPIModule], + exports: [], + controllers: [], + providers: [ + ExampleListener, + { + provide: APP_FILTER, + useClass: GlobalExceptionFilter + }, { + provide: APP_FILTER, + useClass: NotFoundFallthroughExceptionFilter + }, { + provide: APP_GUARD, + useClass: AdminAPIAuthentication + }, { + provide: APP_GUARD, + useClass: PermissionsGuard + }, { + provide: APP_INTERCEPTOR, + useClass: LocationHeaderInterceptor + } + ] +}; diff --git a/ghost/ghost/test/.eslintrc.js b/ghost/ghost/test/.eslintrc.js new file mode 100644 index 0000000000..6fe6dc1504 --- /dev/null +++ b/ghost/ghost/test/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + parser: '@typescript-eslint/parser', + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test' + ] +}; diff --git a/ghost/ghost/tsconfig.json b/ghost/ghost/tsconfig.json new file mode 100644 index 0000000000..cdd4604233 --- /dev/null +++ b/ghost/ghost/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "include": [ + "src/**/*" + ], + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "outDir": "build" + } +} diff --git a/yarn.lock b/yarn.lock index 3525dbb248..9291c67b7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3718,6 +3718,11 @@ tslib "^2.3.1" upath "^2.0.1" +"@lukeed/csprng@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@lukeed/csprng/-/csprng-1.1.0.tgz#1e3e4bd05c1cc7a0b2ddbd8a03f39f6e4b5e6cfe" + integrity sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA== + "@mdx-js/react@^2.1.5": version "2.3.0" resolved "https://registry.yarnpkg.com/@mdx-js/react/-/react-2.3.0.tgz#4208bd6d70f0d0831def28ef28c26149b03180b3" @@ -3768,6 +3773,45 @@ pump "^3.0.0" tar-fs "^2.1.1" +"@nestjs/common@10.2.8": + version "10.2.8" + resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.2.8.tgz#f8934e6353440d6e51c89c0cf1b0f9aef54e8729" + integrity sha512-rmpwcdvq2IWMmsUVP8rsdKub6uDWk7dwCYo0aif50JTwcvcxzaP3iKVFKoSgvp0RKYu8h15+/AEOfaInmPpl0Q== + dependencies: + uid "2.0.2" + iterare "1.2.1" + tslib "2.6.2" + +"@nestjs/core@10.2.8": + version "10.2.8" + resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-10.2.8.tgz#7b3abcf375113faffeef989a3a945c2494d390ec" + integrity sha512-9+MZ2s8ixfY9Bl/M9ofChiyYymcwdK9ZWNH4GDMF7Am7XRAQ1oqde6MYGG05rhQwiVXuTwaYLlXciJKfsrg5qg== + dependencies: + uid "2.0.2" + "@nuxtjs/opencollective" "0.3.2" + fast-safe-stringify "2.1.1" + iterare "1.2.1" + path-to-regexp "3.2.0" + tslib "2.6.2" + +"@nestjs/platform-express@10.2.8": + version "10.2.8" + resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-10.2.8.tgz#c5af1fe3afb6e9858fc5610fd11a247635187eff" + integrity sha512-WoSSVtwIRc5AdGMHWVzWZK4JZLT0f4o2xW8P9gQvcX+omL8W1kXCfY8GQYXNBG84XmBNYH8r0FtC8oMe/lH5NQ== + dependencies: + body-parser "1.20.2" + cors "2.8.5" + express "4.18.2" + multer "1.4.4-lts.1" + tslib "2.6.2" + +"@nestjs/testing@10.2.8": + version "10.2.8" + resolved "https://registry.yarnpkg.com/@nestjs/testing/-/testing-10.2.8.tgz#9bf0a05770b5afacf85aaf4abd99caa2284c3dd5" + integrity sha512-9Kj5IQhM67/nj/MT6Wi2OmWr5YQnCMptwKVFrX1TDaikpY12196v7frk0jVjdT7wms7rV07GZle9I2z0aSjqtQ== + dependencies: + tslib "2.6.2" + "@newrelic/aws-sdk@^7.1.0": version "7.1.0" resolved "https://registry.yarnpkg.com/@newrelic/aws-sdk/-/aws-sdk-7.1.0.tgz#7e934a44150e87dd88ab3b15690df03a20dfd718" @@ -3887,6 +3931,15 @@ nx "16.8.1" tslib "^2.3.0" +"@nuxtjs/opencollective@0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz#620ce1044f7ac77185e825e1936115bb38e2681c" + integrity sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA== + dependencies: + chalk "^4.1.0" + consola "^2.15.0" + node-fetch "^2.6.1" + "@nx/nx-darwin-arm64@16.8.1": version "16.8.1" resolved "https://registry.yarnpkg.com/@nx/nx-darwin-arm64/-/nx-darwin-arm64-16.8.1.tgz#fd85ed007d63d232700272cd07138ecac046525d" @@ -7234,7 +7287,7 @@ "@tryghost/mongo-knex@^0.9.0": version "0.9.0" - resolved "https://registry.npmjs.org/@tryghost/mongo-knex/-/mongo-knex-0.9.0.tgz#34463ceaa23b8e5e4c7ff859d9d5cc8f300ceade" + resolved "https://registry.yarnpkg.com/@tryghost/mongo-knex/-/mongo-knex-0.9.0.tgz#34463ceaa23b8e5e4c7ff859d9d5cc8f300ceade" integrity sha512-1dksBf+nVyfVRssFC3/Tn1KqMhKfLhsjCnxnLv8vW5ZxSw39U6Kyp97u4BWithx31M/g3Q8nfCVg8hIgYVyt7w== dependencies: debug "^4.3.3" @@ -7332,7 +7385,7 @@ got "13.0.0" lodash "^4.17.21" -"@tryghost/root-utils@0.3.24": +"@tryghost/root-utils@0.3.24", "@tryghost/root-utils@^0.3.24": version "0.3.24" resolved "https://registry.yarnpkg.com/@tryghost/root-utils/-/root-utils-0.3.24.tgz#91653fbadc882fb8510844f163a2231c87f30fab" integrity sha512-EzYM3dR/3xyvJHm37RumiIzeGEBRwnnQtQzswXpzn46Rooz7PA7NSjUbLZ8j2K3t0ee+CsPNuyzmzZl+Ih1P2g== @@ -7340,7 +7393,7 @@ caller "^1.0.1" find-root "^1.1.0" -"@tryghost/root-utils@^0.3.24", "@tryghost/root-utils@^0.3.25": +"@tryghost/root-utils@^0.3.25": version "0.3.25" resolved "https://registry.yarnpkg.com/@tryghost/root-utils/-/root-utils-0.3.25.tgz#d6e289004d2ee990f0baa337c007aba8eb6cc2c5" integrity sha512-UvqoDFo64rWEvZTqP7P4PfB7a4AuE8V3KpN/IbEIBbZw4wG7lnINn67r6EdOXQA4U7fB4lIw9Z82ZZwVT5MkPg== @@ -7884,6 +7937,13 @@ dependencies: undici-types "~5.26.4" +"@types/node@^20.10.0": + version "20.10.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.10.0.tgz#16ddf9c0a72b832ec4fcce35b8249cf149214617" + integrity sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ== + dependencies: + undici-types "~5.26.4" + "@types/node@^8.0.0": version "8.10.66" resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.66.tgz#dd035d409df322acc83dff62a602f12a5783bbb3" @@ -8035,6 +8095,13 @@ dependencies: "@types/sinonjs__fake-timers" "*" +"@types/sinon@^17.0.3": + version "17.0.3" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-17.0.3.tgz#9aa7e62f0a323b9ead177ed23a36ea757141a5fa" + integrity sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw== + dependencies: + "@types/sinonjs__fake-timers" "*" + "@types/sinonjs__fake-timers@*": version "8.1.2" resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz#bf2e02a3dbd4aecaf95942ecd99b7402e03fad5e" @@ -10436,6 +10503,24 @@ bn.js@^5.0.0, bn.js@^5.1.1: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== +body-parser@1.20.1: + version "1.20.1" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" + integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== + dependencies: + bytes "3.1.2" + content-type "~1.0.4" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.11.0" + raw-body "2.5.1" + type-is "~1.6.18" + unpipe "1.0.0" + body-parser@1.20.2, body-parser@^1.19.0: version "1.20.2" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" @@ -11486,7 +11571,7 @@ busboy@^0.2.11: dicer "0.2.5" readable-stream "1.1.x" -busboy@^1.6.0: +busboy@^1.0.0, busboy@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== @@ -12641,6 +12726,11 @@ connect@^3.6.6: parseurl "~1.3.3" utils-merge "1.0.1" +consola@^2.15.0: + version "2.15.3" + resolved "https://registry.yarnpkg.com/consola/-/consola-2.15.3.tgz#2e11f98d6a4be71ff72e0bdf07bd23e12cb61550" + integrity sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw== + console-browserify@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" @@ -16641,6 +16731,43 @@ express-unless@^2.1.3: resolved "https://registry.yarnpkg.com/express-unless/-/express-unless-2.1.3.tgz#f951c6cca52a24da3de32d42cfd4db57bc0f9a2e" integrity sha512-wj4tLMyCVYuIIKHGt0FhCtIViBcwzWejX0EjNxveAa6dG+0XBCQhMbx+PnkLkFCxLC69qoFrxds4pIyL88inaQ== +express@4.18.2: + version "4.18.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" + integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "1.20.1" + content-disposition "0.5.4" + content-type "~1.0.4" + cookie "0.5.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "2.0.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.2.0" + fresh "0.5.2" + http-errors "2.0.0" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "2.4.1" + parseurl "~1.3.3" + path-to-regexp "0.1.7" + proxy-addr "~2.0.7" + qs "6.11.0" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "0.18.0" + serve-static "1.15.0" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + express@4.18.3, express@^4.10.7, express@^4.17.1, express@^4.17.2, express@^4.17.3, express@^4.18.2: version "4.18.3" resolved "https://registry.yarnpkg.com/express/-/express-4.18.3.tgz#6870746f3ff904dee1819b82e4b51509afffb0d4" @@ -16821,7 +16948,7 @@ fast-ordered-set@^1.0.0, fast-ordered-set@^1.0.2: dependencies: blank-object "^1.0.1" -fast-safe-stringify@^2.1.1: +fast-safe-stringify@2.1.1, fast-safe-stringify@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== @@ -20005,6 +20132,11 @@ istextorbinary@^2.5.1: editions "^2.2.0" textextensions "^2.5.0" +iterare@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/iterare/-/iterare-1.2.1.tgz#139c400ff7363690e33abffa33cbba8920f00042" + integrity sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q== + iterate-iterator@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/iterate-iterator/-/iterate-iterator-1.0.2.tgz#551b804c9eaa15b847ea6a7cdc2f5bf1ec150f91" @@ -22909,6 +23041,19 @@ multer@1.4.4, multer@^1.4.4: type-is "^1.6.4" xtend "^4.0.0" +multer@1.4.4-lts.1: + version "1.4.4-lts.1" + resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.4-lts.1.tgz#24100f701a4611211cfae94ae16ea39bb314e04d" + integrity sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg== + dependencies: + append-field "^1.0.0" + busboy "^1.0.0" + concat-stream "^1.5.2" + mkdirp "^0.5.4" + object-assign "^4.1.1" + type-is "^1.6.4" + xtend "^4.0.0" + mustache@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" @@ -24315,6 +24460,11 @@ path-to-regexp@0.1.7: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== +path-to-regexp@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.2.0.tgz#fa7877ecbc495c601907562222453c43cc204a5f" + integrity sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA== + path-to-regexp@^1.0.0, path-to-regexp@^1.7.0: version "1.8.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" @@ -26081,6 +26231,16 @@ range-parser@~1.2.1: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== +raw-body@2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" + integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + raw-body@2.5.2: version "2.5.2" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" @@ -26475,6 +26635,11 @@ redis-parser@^3.0.0: dependencies: redis-errors "^1.0.0" +reflect-metadata@^0.1.14: + version "0.1.14" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.14.tgz#24cf721fe60677146bb77eeb0e1f9dece3d65859" + integrity sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A== + reframe.js@4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/reframe.js/-/reframe.js-4.0.2.tgz#07f5f8fb40e0eb6f2d783ce952030d3e14dca7b7" @@ -27730,6 +27895,18 @@ sinon@17.0.0: nise "^5.1.5" supports-color "^7.2.0" +sinon@^17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-17.0.1.tgz#26b8ef719261bf8df43f925924cccc96748e407a" + integrity sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g== + dependencies: + "@sinonjs/commons" "^3.0.0" + "@sinonjs/fake-timers" "^11.2.2" + "@sinonjs/samsam" "^8.0.0" + diff "^5.1.0" + nise "^5.1.5" + supports-color "^7.2.0" + sinon@^9.0.0: version "9.2.4" resolved "https://registry.yarnpkg.com/sinon/-/sinon-9.2.4.tgz#e55af4d3b174a4443a8762fa8421c2976683752b" @@ -29564,6 +29741,25 @@ ts-interface-checker@^0.1.9: resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== +ts-node@10.9.1: + version "10.9.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b" + integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + ts-node@10.9.2: version "10.9.2" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" @@ -29592,16 +29788,16 @@ tsconfig-paths@^4.1.2: minimist "^1.2.6" strip-bom "^3.0.0" +tslib@2.6.2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.5.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + tslib@^1.11.1, tslib@^1.13.0, tslib@^1.9.0: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.5.0: - version "2.6.2" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" - integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== - tsscmp@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" @@ -29764,6 +29960,13 @@ uid-safe@~2.1.5: dependencies: random-bytes "~1.0.0" +uid@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/uid/-/uid-2.0.2.tgz#4b5782abf0f2feeefc00fa88006b2b3b7af3e3b9" + integrity sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g== + dependencies: + "@lukeed/csprng" "^1.0.0" + unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"