Files
directus/api/src/extensions.ts
Rijk van Zanten ca3e7f521f Upgrade previous "Extensions" system to new "Errors" model (#18797)
* Typecheck across packages that are built with esbuild

* Boilerplate new Errors package

* No need, tsup checks with --dts

* Switch to tsup

* Setup dev script

* Add readme

* More boilerplaty things

* Finish createError function

* Install @directus/random

* Downgrade node types

* Add utility function to check if an error is a DirectusError

* Use new is-error check

* Install errors package

* Add failed validation common error

* Export common errors

* Move joi convertion to utils

* Export failed validation

* Use new failed validation error in validate-batch

* Enhance typing output of createError

* Remove outdir (handled by tsup now)

* Replace Exception with Error

* Replace exception in test

* Remove exceptions from app

* Remove exceptions from app

* Remove failed validation exception from users service

* Remove old failed validation exception from shared

* Remove exceptions package in favor of errors

* Uninstall exceptions

* Replace baseexception check

* Migrate content too large error

* Critical detail

* Replace ForbiddenException

* WIP remove exceptions

* Add ForbiddenError to errors

* HitRateLimitError

* Move validation related error/helper to new validation package

* Add index

* Add docs

* Install random

* Convert TokenExpired

* Convert user-suspended

* Convert invalid-credentials

* Move UnsupportedMediaType

* Replace wrong imports for forbidden

* Convert invalid-ip

* Move invalid provider

* Move InvalidOtp

* Convert InvalidToken

* Move MethodNotAllowed

* Convert range not satisfiable

* Move unexpect response

* Move UnprocessableContent

* Move IllegalAssetTransformation

* Move RouteNotFound

* Finalize not found

* Various db errors

* Move value too long

* Move not null

* Move record-not-unique

* Move value out of range

* Finish db errors

* Service unavailable

* GQL errors

* Update packages/validation/src/errors/failed-validation.ts

Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>

* Update packages/validation/src/errors/failed-validation.ts

Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>

* InvalidQuery

* Add test for invalid query message constructor

* Invalid Payload

* Finalize exceptions move

* Improve type of isDirectusError

* Various fixes

* Fix build in api

* Update websocket exceptions use

* Allow optional reason for invalid config

* Update errors usage in utils

* Remove unused package from errors

* Update lockfile

* Update api/src/auth/drivers/ldap.ts

Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>

* Update packages/validation/src/utils/joi-to-error-extensions.ts

Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>

* Put error codes in shared enum

* Replace instanceof checks in api

* Fix tests I think

* Tweak override names

* Fix linter warnings

* Set snapshots

* Start fixing BB tests

* Fix blackbox tests

* Add changeset

* Update changeset

* Update extension docs to use new createError abstraction

* 🙄

* Fix graphql validation error name

* 🥳

* use ErrorCode.Forbidden

* fix blackbox auth login test

* Add license files

* Rename preMutationException to preMutationError

* Remove unused ms dep & sort package.json

* Remove periods from error messages for consistency

Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>

* Add optional code check

* Use updated error code checker

* Rename InvalidConfigError to InvalidProviderConfigError

---------

Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch>
Co-authored-by: ian <licitdev@gmail.com>
2023-06-27 17:22:26 -04:00

675 lines
18 KiB
TypeScript

import {
APP_EXTENSION_TYPES,
APP_SHARED_DEPS,
HYBRID_EXTENSION_TYPES,
JAVASCRIPT_FILE_EXTS,
NESTED_EXTENSION_TYPES,
} from '@directus/constants';
import type {
ActionHandler,
ApiExtension,
BundleExtension,
EmbedHandler,
EndpointConfig,
Extension,
ExtensionInfo,
ExtensionType,
FilterHandler,
HookConfig,
HybridExtension,
InitHandler,
NestedExtensionType,
OperationApiConfig,
ScheduleHandler,
} from '@directus/types';
import { isIn, isTypeIn, pluralize } from '@directus/utils';
import {
ensureExtensionDirs,
generateExtensionsEntrypoint,
getLocalExtensions,
getPackageExtensions,
pathToRelativeUrl,
resolvePackage,
resolvePackageExtensions,
} from '@directus/utils/node';
import aliasDefault from '@rollup/plugin-alias';
import nodeResolveDefault from '@rollup/plugin-node-resolve';
import virtualDefault from '@rollup/plugin-virtual';
import chokidar, { FSWatcher } from 'chokidar';
import express, { Router } from 'express';
import { clone, escapeRegExp } from 'lodash-es';
import { readdir } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import path from 'path';
import { rollup } from 'rollup';
import getDatabase from './database/index.js';
import emitter, { Emitter } from './emitter.js';
import env from './env.js';
import { getFlowManager } from './flows.js';
import logger from './logger.js';
import * as services from './services/index.js';
import type { EventHandler } from './types/index.js';
import getModuleDefault from './utils/get-module-default.js';
import { getSchema } from './utils/get-schema.js';
import { JobQueue } from './utils/job-queue.js';
import { scheduleSynchronizedJob, validateCron } from './utils/schedule.js';
import { Url } from './utils/url.js';
// Workaround for https://github.com/rollup/plugins/issues/1329
const virtual = virtualDefault as unknown as typeof virtualDefault.default;
const alias = aliasDefault as unknown as typeof aliasDefault.default;
const nodeResolve = nodeResolveDefault as unknown as typeof nodeResolveDefault.default;
const require = createRequire(import.meta.url);
const __dirname = dirname(fileURLToPath(import.meta.url));
let extensionManager: ExtensionManager | undefined;
export function getExtensionManager(): ExtensionManager {
if (extensionManager) {
return extensionManager;
}
extensionManager = new ExtensionManager();
return extensionManager;
}
type BundleConfig = {
endpoints: { name: string; config: EndpointConfig }[];
hooks: { name: string; config: HookConfig }[];
operations: { name: string; config: OperationApiConfig }[];
};
type AppExtensions = string | null;
type ApiExtensions = { path: string }[];
type Options = {
schedule: boolean;
watch: boolean;
};
const defaultOptions: Options = {
schedule: true,
watch: env['EXTENSIONS_AUTO_RELOAD'] && env['NODE_ENV'] !== 'development',
};
class ExtensionManager {
private isLoaded = false;
private options: Options;
private extensions: Extension[] = [];
private appExtensions: AppExtensions = null;
private appExtensionChunks: Map<string, string>;
private apiExtensions: ApiExtensions = [];
private apiEmitter: Emitter;
private hookEvents: EventHandler[] = [];
private endpointRouter: Router;
private hookEmbedsHead: string[] = [];
private hookEmbedsBody: string[] = [];
private reloadQueue: JobQueue;
private watcher: FSWatcher | null = null;
constructor() {
this.options = defaultOptions;
this.apiEmitter = new Emitter();
this.endpointRouter = Router();
this.reloadQueue = new JobQueue();
this.appExtensionChunks = new Map();
}
public async initialize(options: Partial<Options> = {}): Promise<void> {
this.options = {
...defaultOptions,
...options,
};
const wasWatcherInitialized = this.watcher !== null;
if (this.options.watch && !wasWatcherInitialized) {
this.initializeWatcher();
} else if (!this.options.watch && wasWatcherInitialized) {
await this.closeWatcher();
}
if (!this.isLoaded) {
await this.load();
const loadedExtensions = this.getExtensionsList();
if (loadedExtensions.length > 0) {
logger.info(`Loaded extensions: ${loadedExtensions.map((ext) => ext.name).join(', ')}`);
}
}
if (this.options.watch && !wasWatcherInitialized) {
this.updateWatchedExtensions(this.extensions);
}
}
public reload(): void {
this.reloadQueue.enqueue(async () => {
if (this.isLoaded) {
logger.info('Reloading extensions');
const prevExtensions = clone(this.extensions);
await this.unload();
await this.load();
const added = this.extensions.filter(
(extension) => !prevExtensions.some((prevExtension) => extension.path === prevExtension.path)
);
const removed = prevExtensions.filter(
(prevExtension) => !this.extensions.some((extension) => prevExtension.path === extension.path)
);
this.updateWatchedExtensions(added, removed);
const addedExtensions = added.map((extension) => extension.name);
const removedExtensions = removed.map((extension) => extension.name);
if (addedExtensions.length > 0) {
logger.info(`Added extensions: ${addedExtensions.join(', ')}`);
}
if (removedExtensions.length > 0) {
logger.info(`Removed extensions: ${removedExtensions.join(', ')}`);
}
} else {
logger.warn('Extensions have to be loaded before they can be reloaded');
}
});
}
public getExtensionsList(type?: ExtensionType) {
if (type === undefined) {
return this.extensions.map(mapInfo);
} else {
return this.extensions.map(mapInfo).filter((extension) => extension.type === type);
}
function mapInfo(extension: Extension): ExtensionInfo {
const extensionInfo: ExtensionInfo = {
name: extension.name,
type: extension.type,
local: extension.local,
entries: [],
};
if (extension.host) extensionInfo.host = extension.host;
if (extension.version) extensionInfo.version = extension.version;
if (extension.type === 'bundle') {
const bundleExtensionInfo: Omit<BundleExtension, 'entrypoint' | 'path'> = {
name: extensionInfo.name,
type: 'bundle',
local: extensionInfo.local,
entries: extension.entries.map((entry) => ({
name: entry.name,
type: entry.type,
})) as { name: ExtensionInfo['name']; type: NestedExtensionType }[],
};
return bundleExtensionInfo;
} else {
return extensionInfo;
}
}
}
public getExtension(name: string): Extension | undefined {
return this.extensions.find((extension) => extension.name === name);
}
public getAppExtensions(): string | null {
return this.appExtensions;
}
public getAppExtensionChunk(name: string): string | null {
return this.appExtensionChunks.get(name) ?? null;
}
public getEndpointRouter(): Router {
return this.endpointRouter;
}
public getEmbeds() {
return {
head: wrapEmbeds('Custom Embed Head', this.hookEmbedsHead),
body: wrapEmbeds('Custom Embed Body', this.hookEmbedsBody),
};
function wrapEmbeds(label: string, content: string[]): string {
if (content.length === 0) return '';
return `<!-- Start ${label} -->\n${content.join('\n')}\n<!-- End ${label} -->`;
}
}
private async load(): Promise<void> {
try {
await ensureExtensionDirs(env['EXTENSIONS_PATH'], NESTED_EXTENSION_TYPES);
this.extensions = await this.getExtensions();
} catch (err: any) {
logger.warn(`Couldn't load extensions`);
logger.warn(err);
}
await this.registerHooks();
await this.registerEndpoints();
await this.registerOperations();
await this.registerBundles();
if (env['SERVE_APP']) {
this.appExtensions = await this.generateExtensionBundle();
}
this.isLoaded = true;
}
private async unload(): Promise<void> {
await this.unregisterApiExtensions();
this.apiEmitter.offAll();
if (env['SERVE_APP']) {
this.appExtensions = null;
}
this.isLoaded = false;
}
private initializeWatcher(): void {
logger.info('Watching extensions for changes...');
const extensionDirUrl = pathToRelativeUrl(env['EXTENSIONS_PATH']);
const localExtensionUrls = NESTED_EXTENSION_TYPES.flatMap((type) => {
const typeDir = path.posix.join(extensionDirUrl, pluralize(type));
if (isIn(type, HYBRID_EXTENSION_TYPES)) {
return [
path.posix.join(typeDir, '*', `app.{${JAVASCRIPT_FILE_EXTS.join()}}`),
path.posix.join(typeDir, '*', `api.{${JAVASCRIPT_FILE_EXTS.join()}}`),
];
} else {
return path.posix.join(typeDir, '*', `index.{${JAVASCRIPT_FILE_EXTS.join()}}`);
}
});
this.watcher = chokidar.watch(
[path.resolve('package.json'), path.posix.join(extensionDirUrl, '*', 'package.json'), ...localExtensionUrls],
{
ignoreInitial: true,
}
);
this.watcher
.on('add', () => this.reload())
.on('change', () => this.reload())
.on('unlink', () => this.reload());
}
private async closeWatcher(): Promise<void> {
if (this.watcher) {
await this.watcher.close();
this.watcher = null;
}
}
private updateWatchedExtensions(added: Extension[], removed: Extension[] = []): void {
if (this.watcher) {
const toPackageExtensionPaths = (extensions: Extension[]) =>
extensions
.filter((extension) => !extension.local || extension.type === 'bundle')
.flatMap((extension) =>
isTypeIn(extension, HYBRID_EXTENSION_TYPES) || extension.type === 'bundle'
? [
path.resolve(extension.path, extension.entrypoint.app),
path.resolve(extension.path, extension.entrypoint.api),
]
: path.resolve(extension.path, extension.entrypoint)
);
const addedPackageExtensionPaths = toPackageExtensionPaths(added);
const removedPackageExtensionPaths = toPackageExtensionPaths(removed);
this.watcher.add(addedPackageExtensionPaths);
this.watcher.unwatch(removedPackageExtensionPaths);
}
}
private async getExtensions(): Promise<Extension[]> {
const packageExtensions = await getPackageExtensions(env['PACKAGE_FILE_LOCATION']);
const localPackageExtensions = await resolvePackageExtensions(env['EXTENSIONS_PATH']);
const localExtensions = await getLocalExtensions(env['EXTENSIONS_PATH']);
return [...packageExtensions, ...localPackageExtensions, ...localExtensions].filter(
(extension) => env['SERVE_APP'] || APP_EXTENSION_TYPES.includes(extension.type as any) === false
);
}
private async generateExtensionBundle(): Promise<string | null> {
const sharedDepsMapping = await this.getSharedDepsMapping(APP_SHARED_DEPS);
const internalImports = Object.entries(sharedDepsMapping).map(([name, path]) => ({
find: name,
replacement: path,
}));
const entrypoint = generateExtensionsEntrypoint(this.extensions);
try {
const bundle = await rollup({
input: 'entry',
external: Object.values(sharedDepsMapping),
makeAbsoluteExternalsRelative: false,
plugins: [virtual({ entry: entrypoint }), alias({ entries: internalImports }), nodeResolve({ browser: true })],
});
const { output } = await bundle.generate({ format: 'es', compact: true });
for (const out of output) {
if (out.type === 'chunk') {
this.appExtensionChunks.set(out.fileName, out.code);
}
}
await bundle.close();
return output[0].code;
} catch (error: any) {
logger.warn(`Couldn't bundle App extensions`);
logger.warn(error);
}
return null;
}
private async getSharedDepsMapping(deps: string[]): Promise<Record<string, string>> {
const appDir = await readdir(path.join(resolvePackage('@directus/app', __dirname), 'dist', 'assets'));
const depsMapping: Record<string, string> = {};
for (const dep of deps) {
const depRegex = new RegExp(`${escapeRegExp(dep.replace(/\//g, '_'))}\\.[0-9a-f]{8}\\.entry\\.js`);
const depName = appDir.find((file) => depRegex.test(file));
if (depName) {
const depUrl = new Url(env['PUBLIC_URL']).addPath('admin', 'assets', depName);
depsMapping[dep] = depUrl.toString({ rootRelative: true });
} else {
logger.warn(`Couldn't find shared extension dependency "${dep}"`);
}
}
return depsMapping;
}
private async registerHooks(): Promise<void> {
const hooks = this.extensions.filter((extension): extension is ApiExtension => extension.type === 'hook');
for (const hook of hooks) {
try {
const hookPath = path.resolve(hook.path, hook.entrypoint);
const hookInstance: HookConfig | { default: HookConfig } = await import(
`./${pathToRelativeUrl(hookPath, __dirname)}?t=${Date.now()}`
);
const config = getModuleDefault(hookInstance);
this.registerHook(config, hook.name);
this.apiExtensions.push({ path: hookPath });
} catch (error: any) {
logger.warn(`Couldn't register hook "${hook.name}"`);
logger.warn(error);
}
}
}
private async registerEndpoints(): Promise<void> {
const endpoints = this.extensions.filter((extension): extension is ApiExtension => extension.type === 'endpoint');
for (const endpoint of endpoints) {
try {
const endpointPath = path.resolve(endpoint.path, endpoint.entrypoint);
const endpointInstance: EndpointConfig | { default: EndpointConfig } = await import(
`./${pathToRelativeUrl(endpointPath, __dirname)}?t=${Date.now()}`
);
const config = getModuleDefault(endpointInstance);
this.registerEndpoint(config, endpoint.name);
this.apiExtensions.push({ path: endpointPath });
} catch (error: any) {
logger.warn(`Couldn't register endpoint "${endpoint.name}"`);
logger.warn(error);
}
}
}
private async registerOperations(): Promise<void> {
const internalOperations = await readdir(path.join(__dirname, 'operations'));
for (const operation of internalOperations) {
const operationInstance: OperationApiConfig | { default: OperationApiConfig } = await import(
`./operations/${operation}/index.js`
);
const config = getModuleDefault(operationInstance);
this.registerOperation(config);
}
const operations = this.extensions.filter(
(extension): extension is HybridExtension => extension.type === 'operation'
);
for (const operation of operations) {
try {
const operationPath = path.resolve(operation.path, operation.entrypoint.api!);
const operationInstance: OperationApiConfig | { default: OperationApiConfig } = await import(
`./${pathToRelativeUrl(operationPath, __dirname)}?t=${Date.now()}`
);
const config = getModuleDefault(operationInstance);
this.registerOperation(config);
this.apiExtensions.push({ path: operationPath });
} catch (error: any) {
logger.warn(`Couldn't register operation "${operation.name}"`);
logger.warn(error);
}
}
}
private async registerBundles(): Promise<void> {
const bundles = this.extensions.filter((extension): extension is BundleExtension => extension.type === 'bundle');
for (const bundle of bundles) {
try {
const bundlePath = path.resolve(bundle.path, bundle.entrypoint.api);
const bundleInstances: BundleConfig | { default: BundleConfig } = await import(
`./${pathToRelativeUrl(bundlePath, __dirname)}?t=${Date.now()}`
);
const configs = getModuleDefault(bundleInstances);
for (const { config, name } of configs.hooks) {
this.registerHook(config, name);
}
for (const { config, name } of configs.endpoints) {
this.registerEndpoint(config, name);
}
for (const { config } of configs.operations) {
this.registerOperation(config);
}
this.apiExtensions.push({ path: bundlePath });
} catch (error: any) {
logger.warn(`Couldn't register bundle "${bundle.name}"`);
logger.warn(error);
}
}
}
private registerHook(register: HookConfig, name: string): void {
let scheduleIndex = 0;
const registerFunctions = {
filter: (event: string, handler: FilterHandler) => {
emitter.onFilter(event, handler);
this.hookEvents.push({
type: 'filter',
name: event,
handler,
});
},
action: (event: string, handler: ActionHandler) => {
emitter.onAction(event, handler);
this.hookEvents.push({
type: 'action',
name: event,
handler,
});
},
init: (event: string, handler: InitHandler) => {
emitter.onInit(event, handler);
this.hookEvents.push({
type: 'init',
name: event,
handler,
});
},
schedule: (cron: string, handler: ScheduleHandler) => {
if (validateCron(cron)) {
const job = scheduleSynchronizedJob(`${name}:${scheduleIndex}`, cron, async () => {
if (this.options.schedule) {
try {
await handler();
} catch (error: any) {
logger.error(error);
}
}
});
scheduleIndex++;
this.hookEvents.push({
type: 'schedule',
job,
});
} else {
logger.warn(`Couldn't register cron hook. Provided cron is invalid: ${cron}`);
}
},
embed: (position: 'head' | 'body', code: string | EmbedHandler) => {
const content = typeof code === 'function' ? code() : code;
if (content.trim().length === 0) {
logger.warn(`Couldn't register embed hook. Provided code is empty!`);
return;
}
if (position === 'head') {
this.hookEmbedsHead.push(content);
}
if (position === 'body') {
this.hookEmbedsBody.push(content);
}
},
};
register(registerFunctions, {
services,
env,
database: getDatabase(),
emitter: this.apiEmitter,
logger,
getSchema,
});
}
private registerEndpoint(config: EndpointConfig, name: string): void {
const register = typeof config === 'function' ? config : config.handler;
const routeName = typeof config === 'function' ? name : config.id;
const scopedRouter = express.Router();
this.endpointRouter.use(`/${routeName}`, scopedRouter);
register(scopedRouter, {
services,
env,
database: getDatabase(),
emitter: this.apiEmitter,
logger,
getSchema,
});
}
private registerOperation(config: OperationApiConfig): void {
const flowManager = getFlowManager();
flowManager.addOperation(config.id, config.handler);
}
private async unregisterApiExtensions(): Promise<void> {
for (const event of this.hookEvents) {
switch (event.type) {
case 'filter':
emitter.offFilter(event.name, event.handler);
break;
case 'action':
emitter.offAction(event.name, event.handler);
break;
case 'init':
emitter.offInit(event.name, event.handler);
break;
case 'schedule':
await event.job.stop();
break;
}
}
this.hookEvents = [];
this.endpointRouter.stack = [];
const flowManager = getFlowManager();
flowManager.clearOperations();
for (const apiExtension of this.apiExtensions) {
delete require.cache[require.resolve(apiExtension.path)];
}
this.apiExtensions = [];
}
}