Merge pull request #404 from directus/custom-extensions-api

Custom extensions api
This commit is contained in:
Rijk van Zanten
2020-09-22 16:12:15 -04:00
committed by GitHub
60 changed files with 555 additions and 223 deletions

View File

@@ -37,26 +37,31 @@ import webhooksRouter from './controllers/webhooks';
import notFoundHandler from './controllers/not-found';
import sanitizeQuery from './middleware/sanitize-query';
import WebhooksService from './services/webhooks';
import { WebhooksService } from './services/webhooks';
import { InvalidPayloadException } from './exceptions';
import { registerExtensions } from './extensions';
import emitter from './emitter';
validateEnv(['KEY', 'SECRET']);
const app = express();
const customRouter = express.Router();
app.disable('x-powered-by');
app.set('trust proxy', true);
app.use(expressLogger({ logger }));
app.use((req, res, next) => {
bodyParser.json()(req, res, err => {
if (err) {
bodyParser.json()(req, res, (err) => {
if (err) {
return next(new InvalidPayloadException(err.message));
}
}
return next();
});
return next();
});
});
app.use(bodyParser.json());
@@ -111,6 +116,7 @@ app.use('/settings', settingsRouter, respond);
app.use('/users', usersRouter, respond);
app.use('/utils', utilsRouter, respond);
app.use('/webhooks', webhooksRouter, respond);
app.use('/custom', customRouter);
app.use(notFoundHandler);
app.use(errorHandler);
@@ -118,6 +124,11 @@ app.use(errorHandler);
const webhooksService = new WebhooksService();
webhooksService.register();
// Register custom hooks / endpoints
registerExtensions(customRouter);
track('serverStarted');
emitter.emitAsync('server.started').catch((err) => logger.warn(err));
export default app;

View File

@@ -27,18 +27,19 @@ function getKevyInstance() {
}
}
function getConfig(
store: 'memory' | 'redis' | 'memcache' = 'memory'
): Options<any> {
const config: Options<any> = { namespace: env.CACHE_NAMESPACE, ttl: ms(env.CACHE_TTL as string) };
function getConfig(store: 'memory' | 'redis' | 'memcache' = 'memory'): Options<any> {
const config: Options<any> = {
namespace: env.CACHE_NAMESPACE,
ttl: ms(env.CACHE_TTL as string),
};
if (store === 'redis') {
const Redis = require('ioredis');
const KeyvRedis = require('@keyv/redis');
config.store = new KeyvRedis(new Redis(
env.CACHE_REDIS || getConfigFromEnv('CACHE_REDIS_')
));
config.store = new KeyvRedis(
new Redis(env.CACHE_REDIS || getConfigFromEnv('CACHE_REDIS_'))
);
}
if (store === 'memcache') {

View File

@@ -5,7 +5,7 @@ export default async function migrate(direction: 'latest' | 'up' | 'down') {
try {
await run(database, direction);
} catch(err) {
} catch (err) {
console.log(err);
process.exit(1);
} finally {

View File

@@ -19,9 +19,18 @@ program.command('init').description('Create a new Directus Project').action(init
const dbCommand = program.command('database');
dbCommand.command('install').description('Install the database').action(dbInstall);
dbCommand.command('migrate:latest').description('Upgrade the database').action(() => dbMigrate('latest'));
dbCommand.command('migrate:up').description('Upgrade the database').action(() => dbMigrate('up'));
dbCommand.command('migrate:down').description('Downgrade the database').action(() => dbMigrate('down'));
dbCommand
.command('migrate:latest')
.description('Upgrade the database')
.action(() => dbMigrate('latest'));
dbCommand
.command('migrate:up')
.description('Upgrade the database')
.action(() => dbMigrate('up'));
dbCommand
.command('migrate:down')
.description('Downgrade the database')
.action(() => dbMigrate('down'));
const usersCommand = program.command('users');
usersCommand
@@ -34,7 +43,7 @@ usersCommand
const rolesCommand = program.command('roles');
rolesCommand
.command('create')
.command('create')
.storeOptionsAsProperties(false)
.passCommandToAction(false)
.description('Create a new role')

View File

@@ -1,7 +1,6 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import ActivityService from '../services/activity';
import MetaService from '../services/meta';
import { ActivityService, MetaService } from '../services';
import { Action } from '../types';
import { ForbiddenException } from '../exceptions';
import useCollection from '../middleware/use-collection';
@@ -25,7 +24,7 @@ router.get(
};
return next();
}),
})
);
router.get(
@@ -39,7 +38,7 @@ router.get(
};
return next();
}),
})
);
router.post(
@@ -70,7 +69,7 @@ router.post(
}
return next();
}),
})
);
router.patch(
@@ -94,7 +93,7 @@ router.patch(
}
return next();
}),
})
);
router.delete(
@@ -104,7 +103,7 @@ router.delete(
await service.delete(req.params.pk);
return next();
}),
})
);
export default router;

View File

@@ -3,12 +3,11 @@ import asyncHandler from 'express-async-handler';
import database from '../database';
import { SYSTEM_ASSET_ALLOW_LIST, ASSET_TRANSFORM_QUERY_KEYS } from '../constants';
import { InvalidQueryException, ForbiddenException } from '../exceptions';
import AssetsService from '../services/assets';
import validate from 'uuid-validate';
import { pick } from 'lodash';
import { Transformation } from '../types/assets';
import storage from '../storage';
import PayloadService from '../services/payload';
import { PayloadService, AssetsService } from '../services';
import useCollection from '../middleware/use-collection';
const router = Router();

View File

@@ -2,7 +2,6 @@ import { Router } from 'express';
import session from 'express-session';
import asyncHandler from 'express-async-handler';
import Joi from 'joi';
import AuthenticationService from '../services/authentication';
import grant from 'grant';
import getGrantConfig from '../utils/get-grant-config';
import getEmailFromProfile from '../utils/get-email-from-profile';
@@ -10,7 +9,7 @@ import { InvalidPayloadException } from '../exceptions/invalid-payload';
import ms from 'ms';
import cookieParser from 'cookie-parser';
import env from '../env';
import UsersService from '../services/users';
import { UsersService, AuthenticationService } from '../services';
const router = Router();

View File

@@ -1,7 +1,6 @@
import { Router } from 'express';
import asyncHandler from 'express-async-handler';
import CollectionsService from '../services/collections'
import MetaService from '../services/meta';
import { CollectionsService, MetaService } from '../services';
import { ForbiddenException } from '../exceptions';
const router = Router();

View File

@@ -1,7 +1,7 @@
import express, { Router } from 'express';
import asyncHandler from 'express-async-handler';
import { RouteNotFoundException } from '../exceptions';
import ExtensionsService from '../services/extensions';
import { listExtensions } from '../extensions';
import env from '../env';
const router = Router();
@@ -12,14 +12,13 @@ router.use(express.static(extensionsPath));
router.get(
'/:type',
asyncHandler(async (req, res, next) => {
const service = new ExtensionsService();
const typeAllowList = ['interfaces', 'layouts', 'displays', 'modules'];
if (typeAllowList.includes(req.params.type) === false) {
throw new RouteNotFoundException(req.path);
}
const extensions = await service.listExtensions(req.params.type);
const extensions = await listExtensions(req.params.type);
res.locals.payload = {
data: extensions,

View File

@@ -1,12 +1,11 @@
import { Router } from 'express';
import asyncHandler from 'express-async-handler';
import FieldsService from '../services/fields';
import { FieldsService } from '../services/fields';
import validateCollection from '../middleware/collection-exists';
import { schemaInspector } from '../database';
import { InvalidPayloadException, ForbiddenException } from '../exceptions';
import Joi from 'joi';
import { Field } from '../types/field';
import { types } from '../types';
import { types, Field } from '../types';
import useCollection from '../middleware/use-collection';
const router = Router();

View File

@@ -1,8 +1,7 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import Busboy from 'busboy';
import FilesService from '../services/files';
import MetaService from '../services/meta';
import { MetaService, FilesService } from '../services';
import { File, PrimaryKey } from '../types';
import formatTitle from '@directus/format-title';
import env from '../env';
@@ -113,7 +112,9 @@ router.post(
try {
const record = await service.readByKey(keys as any, req.sanitizedQuery);
res.locals.payload = { data: res.locals.savedFiles.length === 1 ? record[0] : record || null };
res.locals.payload = {
data: res.locals.savedFiles.length === 1 ? record[0] : record || null,
};
} catch (error) {
if (error instanceof ForbiddenException) {
return next();
@@ -128,7 +129,7 @@ router.post(
const importSchema = Joi.object({
url: Joi.string().required(),
data: Joi.object()
data: Joi.object(),
});
router.post(

View File

@@ -1,7 +1,6 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import FoldersService from '../services/folders';
import MetaService from '../services/meta';
import { FoldersService, MetaService } from '../services';
import { ForbiddenException } from '../exceptions';
import useCollection from '../middleware/use-collection';

View File

@@ -1,8 +1,7 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import ItemsService from '../services/items';
import collectionExists from '../middleware/collection-exists';
import MetaService from '../services/meta';
import { ItemsService, MetaService } from '../services';
import { RouteNotFoundException, ForbiddenException } from '../exceptions';
const router = express.Router();
@@ -30,7 +29,7 @@ router.post(
}
return next();
}),
})
);
router.get(
@@ -51,7 +50,7 @@ router.get(
data: records || null,
};
return next();
}),
})
);
router.get(
@@ -70,7 +69,7 @@ router.get(
data: result || null,
};
return next();
}),
})
);
router.patch(
@@ -101,7 +100,7 @@ router.patch(
}
return next();
}),
})
);
router.patch(
@@ -129,7 +128,7 @@ router.patch(
}
return next();
}),
})
);
router.delete(
@@ -140,7 +139,7 @@ router.delete(
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
await service.delete(pk as any);
return next();
}),
})
);
export default router;

View File

@@ -1,7 +1,6 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import PermissionsService from '../services/permissions';
import MetaService from '../services/meta';
import { PermissionsService, MetaService } from '../services';
import { clone } from 'lodash';
import { InvalidCredentialsException, ForbiddenException } from '../exceptions';
import useCollection from '../middleware/use-collection';

View File

@@ -1,7 +1,6 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import PresetsService from '../services/presets';
import MetaService from '../services/meta';
import { PresetsService, MetaService } from '../services';
import { ForbiddenException } from '../exceptions';
import useCollection from '../middleware/use-collection';

View File

@@ -1,7 +1,6 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import RelationsService from '../services/relations';
import MetaService from '../services/meta';
import { RelationsService, MetaService } from '../services';
import { ForbiddenException } from '../exceptions';
import useCollection from '../middleware/use-collection';

View File

@@ -1,7 +1,6 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import RevisionsService from '../services/revisions';
import MetaService from '../services/meta';
import { RevisionsService, MetaService } from '../services';
import useCollection from '../middleware/use-collection';
const router = express.Router();

View File

@@ -1,7 +1,6 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import RolesService from '../services/roles';
import MetaService from '../services/meta';
import { RolesService, MetaService } from '../services';
import { ForbiddenException } from '../exceptions';
import useCollection from '../middleware/use-collection';

View File

@@ -1,5 +1,5 @@
import { Router } from 'express';
import ServerService from '../services/server';
import { ServerService } from '../services';
const router = Router();

View File

@@ -1,6 +1,6 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import SettingsService from '../services/settings';
import { SettingsService } from '../services';
import { ForbiddenException } from '../exceptions';
import useCollection from '../middleware/use-collection';

View File

@@ -1,10 +1,12 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import Joi from 'joi';
import { InvalidPayloadException, InvalidCredentialsException, ForbiddenException } from '../exceptions';
import UsersService from '../services/users';
import MetaService from '../services/meta';
import AuthService from '../services/authentication';
import {
InvalidPayloadException,
InvalidCredentialsException,
ForbiddenException,
} from '../exceptions';
import { UsersService, MetaService, AuthenticationService } from '../services';
import useCollection from '../middleware/use-collection';
const router = express.Router();
@@ -200,7 +202,7 @@ router.post(
}
const service = new UsersService({ accountability: req.accountability });
const authService = new AuthService({ accountability: req.accountability });
const authService = new AuthenticationService({ accountability: req.accountability });
const otpValid = await authService.verifyOTP(req.accountability.user, req.body.otp);

View File

@@ -4,7 +4,7 @@ import { nanoid } from 'nanoid';
import { InvalidQueryException, InvalidPayloadException } from '../exceptions';
import argon2 from 'argon2';
import collectionExists from '../middleware/collection-exists';
import UtilsService from '../services/utils';
import { UtilsService } from '../services';
import Joi from 'joi';
const router = Router();

View File

@@ -1,7 +1,6 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import WebhooksService from '../services/webhooks';
import MetaService from '../services/meta';
import { WebhooksService, MetaService } from '../services';
import { ForbiddenException } from '../exceptions';
import useCollection from '../middleware/use-collection';

View File

@@ -7,13 +7,16 @@ type Migration = {
version: string;
name: string;
timestamp: Date;
}
};
export default async function run(database: Knex, direction: 'up' | 'down' | 'latest') {
let migrationFiles = await fse.readdir(__dirname);
migrationFiles = migrationFiles.filter((file: string) => file !== 'run.ts');
const completedMigrations = await database.select<Migration[]>('*').from('directus_migrations').orderBy('version');
const completedMigrations = await database
.select<Migration[]>('*')
.from('directus_migrations')
.orderBy('version');
const migrations = migrationFiles.map((migrationFile) => {
const version = migrationFile.split('-')[0];
@@ -24,7 +27,7 @@ export default async function run(database: Knex, direction: 'up' | 'down' | 'la
file: migrationFile,
version,
name,
completed
completed,
};
});
@@ -51,7 +54,9 @@ export default async function run(database: Knex, direction: 'up' | 'down' | 'la
const { up } = require(path.join(__dirname, nextVersion.file));
await up(database);
await database.insert({ version: nextVersion.version, name: nextVersion.name }).into('directus_migrations');
await database
.insert({ version: nextVersion.version, name: nextVersion.name })
.into('directus_migrations');
}
async function down() {
@@ -61,7 +66,9 @@ export default async function run(database: Knex, direction: 'up' | 'down' | 'la
throw Error('Nothing to downgrade');
}
const migration = migrations.find((migration) => migration.version === currentVersion.version);
const migration = migrations.find(
(migration) => migration.version === currentVersion.version
);
if (!migration) {
throw new Error('Couldnt find migration');
@@ -77,7 +84,9 @@ export default async function run(database: Knex, direction: 'up' | 'down' | 'la
if (migration.completed === false) {
const { up } = require(path.join(__dirname, migration.file));
await up(database);
await database.insert({ version: migration.version, name: migration.name }).into('directus_migrations');
await database
.insert({ version: migration.version, name: migration.name })
.into('directus_migrations');
}
}
}

View File

@@ -3,14 +3,14 @@ import { clone, uniq, pick } from 'lodash';
import database from './index';
import SchemaInspector from 'knex-schema-inspector';
import { Query, Item } from '../types';
import PayloadService from '../services/payload';
import { PayloadService } from '../services/payload';
import applyQuery from '../utils/apply-query';
import Knex from 'knex';
type RunASTOptions = {
query?: AST['query'],
knex?: Knex
}
query?: AST['query'];
knex?: Knex;
};
export default async function runAST(ast: AST, options?: RunASTOptions) {
const query = options?.query || ast.query;

View File

@@ -22,14 +22,14 @@ type TableSeed = {
column: string;
};
};
}
}
};
};
type RowSeed = {
table: string;
defaults: Record<string, any>;
data: Record<string, any>[];
}
};
type FieldSeed = {
table: string;
@@ -50,7 +50,7 @@ type FieldSeed = {
translation: Record<string, any> | null;
note: string | null;
}[];
}
};
export default async function runSeed(database: Knex) {
const exists = await database.schema.hasTable('directus_collections');
@@ -68,10 +68,13 @@ async function createTables(database: Knex) {
const tableSeeds = await fse.readdir(path.resolve(__dirname, './01-tables/'));
for (const tableSeedFile of tableSeeds) {
const yamlRaw = await fse.readFile(path.resolve(__dirname, './01-tables', tableSeedFile), 'utf8');
const yamlRaw = await fse.readFile(
path.resolve(__dirname, './01-tables', tableSeedFile),
'utf8'
);
const seedData = yaml.safeLoad(yamlRaw) as TableSeed;
await database.schema.createTable(seedData.table, tableBuilder => {
await database.schema.createTable(seedData.table, (tableBuilder) => {
for (const [columnName, columnInfo] of Object.entries(seedData.columns)) {
let column: ColumnBuilder;
@@ -129,7 +132,10 @@ async function insertRows(database: Knex) {
const rowSeeds = await fse.readdir(path.resolve(__dirname, './02-rows/'));
for (const rowSeedFile of rowSeeds) {
const yamlRaw = await fse.readFile(path.resolve(__dirname, './02-rows', rowSeedFile), 'utf8');
const yamlRaw = await fse.readFile(
path.resolve(__dirname, './02-rows', rowSeedFile),
'utf8'
);
const seedData = yaml.safeLoad(yamlRaw) as RowSeed;
const dataWithDefaults = seedData.data.map((row) => {
@@ -149,11 +155,17 @@ async function insertRows(database: Knex) {
async function insertFields(database: Knex) {
const fieldSeeds = await fse.readdir(path.resolve(__dirname, './03-fields/'));
const defaultsYaml = await fse.readFile(path.resolve(__dirname, './03-fields/_defaults.yaml'), 'utf8');
const defaultsYaml = await fse.readFile(
path.resolve(__dirname, './03-fields/_defaults.yaml'),
'utf8'
);
const defaults = yaml.safeLoad(defaultsYaml) as FieldSeed;
for (const fieldSeedFile of fieldSeeds) {
const yamlRaw = await fse.readFile(path.resolve(__dirname, './03-fields', fieldSeedFile), 'utf8');
const yamlRaw = await fse.readFile(
path.resolve(__dirname, './03-fields', fieldSeedFile),
'utf8'
);
const seedData = yaml.safeLoad(yamlRaw) as FieldSeed;
if (fieldSeedFile === '_defaults.yaml') {

View File

@@ -1,5 +1,8 @@
import { EventEmitter2 } from 'eventemitter2';
const emitter = new EventEmitter2({ wildcard: true, verboseMemoryLeak: true });
const emitter = new EventEmitter2({ wildcard: true, verboseMemoryLeak: true, delimiter: '.' });
// No-op function to ensure we never end up with no data
emitter.on('item.*.*.before', (input) => input);
export default emitter;

98
api/src/extensions.ts Normal file
View File

@@ -0,0 +1,98 @@
import listFolders from './utils/list-folders';
import path from 'path';
import env from './env';
import { ServiceUnavailableException } from './exceptions';
import express, { Router } from 'express';
import emitter from './emitter';
import logger from './logger';
import { HookRegisterFunction, EndpointRegisterFunction } from './types';
import * as exceptions from './exceptions';
import * as services from './services';
import database from './database';
export async function listExtensions(type: string) {
const extensionsPath = env.EXTENSIONS_PATH as string;
const location = path.join(extensionsPath, type);
try {
return await listFolders(location);
} catch (err) {
if (err.code === 'ENOENT') {
throw new ServiceUnavailableException(
`Extension folder "extensions/${type}" couldn't be opened`,
{
service: 'extensions',
}
);
}
throw err;
}
}
export async function registerExtensions(router: Router) {
let hooks: string[] = [];
let endpoints: string[] = [];
try {
hooks = await listExtensions('hooks');
registerHooks(hooks);
} catch (err) {
logger.warn(err);
}
try {
endpoints = await listExtensions('endpoints');
registerEndpoints(endpoints, router);
} catch (err) {
logger.warn(err);
}
console.log(hooks, endpoints);
}
function registerHooks(hooks: string[]) {
const extensionsPath = env.EXTENSIONS_PATH as string;
for (const hook of hooks) {
try {
registerHook(hook);
} catch (error) {
logger.warn(`Couldn't register hook "${hook}"`);
logger.info(error);
}
}
function registerHook(hook: string) {
const hookPath = path.resolve(extensionsPath, 'hooks', hook, 'index.js');
const register: HookRegisterFunction = require(hookPath);
const events = register({ services, exceptions, env, database });
for (const [event, handler] of Object.entries(events)) {
emitter.on(event, handler);
}
}
}
function registerEndpoints(endpoints: string[], router: Router) {
const extensionsPath = env.EXTENSIONS_PATH as string;
for (const endpoint of endpoints) {
try {
registerEndpoint(endpoint);
} catch (error) {
logger.warn(`Couldn't register endpoint "${endpoint}"`);
logger.info(error);
}
}
function registerEndpoint(endpoint: string) {
const endpointPath = path.resolve(extensionsPath, 'endpoints', endpoint, 'index.js');
const register: EndpointRegisterFunction = require(endpointPath);
const scopedRouter = express.Router();
router.use(`/${endpoint}/`, scopedRouter);
register(scopedRouter, { services, exceptions, env, database });
}
}

View File

@@ -1,13 +1,18 @@
import { RequestHandler } from "express";
import asyncHandler from "express-async-handler";
import env from "../env";
import { getCacheKey } from "../utils/get-cache-key";
import { RequestHandler } from 'express';
import asyncHandler from 'express-async-handler';
import env from '../env';
import { getCacheKey } from '../utils/get-cache-key';
import cache from '../cache';
import { Transform, transforms } from 'json2csv';
import { PassThrough } from 'stream';
export const respond: RequestHandler = asyncHandler(async (req, res) => {
if (req.method.toLowerCase() === 'get' && env.CACHE_ENABLED === true && cache && !req.sanitizedQuery.export) {
if (
req.method.toLowerCase() === 'get' &&
env.CACHE_ENABLED === true &&
cache &&
!req.sanitizedQuery.export
) {
const key = getCacheKey(req);
await cache.set(key, res.locals.payload);
}
@@ -34,7 +39,9 @@ export const respond: RequestHandler = asyncHandler(async (req, res) => {
res.set('Content-Type', 'text/csv');
const stream = new PassThrough();
stream.end(Buffer.from(JSON.stringify(res.locals.payload.data), 'utf-8'));
const json2csv = new Transform({ transforms: [transforms.flatten({ separator: '.' })] });
const json2csv = new Transform({
transforms: [transforms.flatten({ separator: '.' })],
});
return stream.pipe(json2csv).pipe(res);
}
}

View File

@@ -56,7 +56,11 @@ const sanitizeQuery: RequestHandler = (req, res, next) => {
query.search = req.query.search;
}
if (req.query.export && typeof req.query.export === 'string' && ['json', 'csv'].includes(req.query.export)) {
if (
req.query.export &&
typeof req.query.export === 'string' &&
['json', 'csv'].includes(req.query.export)
) {
query.export = req.query.export as 'json' | 'csv';
}

View File

@@ -1,11 +1,11 @@
import ItemsService from './items';
import { ItemsService } from './items';
import { AbstractServiceOptions } from '../types';
/**
* @TODO only return activity of the collections you have access to
*/
export default class ActivityService extends ItemsService {
export class ActivityService extends ItemsService {
constructor(options?: AbstractServiceOptions) {
super('directus_activity', options);
}

View File

@@ -1,12 +1,11 @@
import { Transformation } from '../types/assets';
import storage from '../storage';
import sharp, { ResizeOptions } from 'sharp';
import database from '../database';
import path from 'path';
import Knex from 'knex';
import { Accountability, AbstractServiceOptions } from '../types';
import { Accountability, AbstractServiceOptions, Transformation } from '../types';
export default class AssetsService {
export class AssetsService {
knex: Knex;
accountability: Accountability | null;

View File

@@ -10,7 +10,7 @@ import {
} from '../exceptions';
import { Session, Accountability, AbstractServiceOptions, Action } from '../types';
import Knex from 'knex';
import ActivityService from '../services/activity';
import { ActivityService } from '../services/activity';
import env from '../env';
import { authenticator } from 'otplib';
@@ -22,7 +22,7 @@ type AuthenticateOptions = {
otp?: string;
};
export default class AuthenticationService {
export class AuthenticationService {
knex: Knex;
accountability: Accountability | null;
activityService: ActivityService;

View File

@@ -15,10 +15,10 @@ import Knex from 'knex';
import { ForbiddenException, FailedValidationException } from '../exceptions';
import { uniq, merge } from 'lodash';
import generateJoi from '../utils/generate-joi';
import ItemsService from './items';
import { ItemsService } from './items';
import { parseFilter } from '../utils/parse-filter';
export default class AuthorizationService {
export class AuthorizationService {
knex: Knex;
accountability: Accountability | null;

View File

@@ -3,12 +3,11 @@ import { AbstractServiceOptions, Accountability, Collection, Relation } from '..
import Knex from 'knex';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import SchemaInspector from 'knex-schema-inspector';
import FieldsService from '../services/fields';
import { omit } from 'lodash';
import ItemsService from '../services/items';
import { FieldsService } from '../services/fields';
import { ItemsService } from '../services/items';
import cache from '../cache';
export default class CollectionsService {
export class CollectionsService {
knex: Knex;
accountability: Accountability | null;

View File

@@ -1,22 +0,0 @@
import listFolders from '../utils/list-folders';
import path from 'path';
import env from '../env';
import { ServiceUnavailableException } from '../exceptions';
export default class ExtensionsService {
async listExtensions(type: string) {
const extensionsPath = env.EXTENSIONS_PATH as string;
const location = path.join(extensionsPath, type);
try {
return await listFolders(location);
} catch (err) {
if (err.code === 'ENOENT') {
throw new ServiceUnavailableException(`Extension folder couldn't be opened`, {
service: 'extensions',
});
}
console.log(err);
}
}
}

View File

@@ -1,13 +1,13 @@
import database, { schemaInspector } from '../database';
import { Field } from '../types/field';
import { Accountability, AbstractServiceOptions, FieldMeta, Relation } from '../types';
import ItemsService from '../services/items';
import { ItemsService } from '../services/items';
import { ColumnBuilder } from 'knex';
import getLocalType from '../utils/get-local-type';
import { types } from '../types';
import { ForbiddenException } from '../exceptions';
import Knex, { CreateTableBuilder } from 'knex';
import PayloadService from '../services/payload';
import { PayloadService } from '../services/payload';
import getDefaultValue from '../utils/get-default-value';
import cache from '../cache';
@@ -21,7 +21,7 @@ type RawField = Partial<Field> & { field: string; type: typeof types[number] };
* - Don't use items service, as this is a different case than regular collections
*/
export default class FieldsService {
export class FieldsService {
knex: Knex;
accountability: Accountability | null;
itemsService: ItemsService;

View File

@@ -1,4 +1,4 @@
import ItemsService from './items';
import { ItemsService } from './items';
import storage from '../storage';
import sharp from 'sharp';
import { parse as parseICC } from 'icc';
@@ -9,7 +9,7 @@ import { AbstractServiceOptions, File, PrimaryKey } from '../types';
import { clone } from 'lodash';
import cache from '../cache';
export default class FilesService extends ItemsService {
export class FilesService extends ItemsService {
constructor(options?: AbstractServiceOptions) {
super('directus_files', options);
}

View File

@@ -1,7 +1,7 @@
import ItemsService from './items';
import { ItemsService } from './items';
import { AbstractServiceOptions } from '../types';
export default class FoldersService extends ItemsService {
export class FoldersService extends ItemsService {
constructor(options?: AbstractServiceOptions) {
super('directus_folders', options);
}

20
api/src/services/index.ts Normal file
View File

@@ -0,0 +1,20 @@
export * from './activity';
export * from './assets';
export * from './authentication';
export * from './collections';
export * from './fields';
export * from './files';
export * from './folders';
export * from './items';
export * from './meta';
export * from './payload';
export * from './permissions';
export * from './presets';
export * from './relations';
export * from './revisions';
export * from './roles';
export * from './server';
export * from './settings';
export * from './users';
export * from './utils';
export * from './webhooks';

View File

@@ -15,15 +15,16 @@ import {
import Knex from 'knex';
import cache from '../cache';
import emitter from '../emitter';
import logger from '../logger';
import PayloadService from './payload';
import AuthorizationService from './authorization';
import { PayloadService } from './payload';
import { AuthorizationService } from './authorization';
import { pick, clone } from 'lodash';
import getDefaultValue from '../utils/get-default-value';
import { InvalidPayloadException } from '../exceptions';
export default class ItemsService implements AbstractService {
export class ItemsService implements AbstractService {
collection: string;
knex: Knex;
accountability: Accountability | null;
@@ -51,10 +52,35 @@ export default class ItemsService implements AbstractService {
knex: trx,
});
const authorizationService = new AuthorizationService({
accountability: this.accountability,
knex: trx,
});
if (this.collection.startsWith('directus_') === false) {
const customProcessed = await emitter.emitAsync(
`item.create.${this.collection}.before`,
payloads,
{
event: `item.create.${this.collection}.before`,
accountability: this.accountability,
collection: this.collection,
item: null,
action: 'create',
payload: payloads,
}
);
payloads = customProcessed[customProcessed.length - 1];
}
if (this.accountability && this.accountability.admin !== true) {
const authorizationService = new AuthorizationService({
accountability: this.accountability,
knex: trx,
});
payloads = await authorizationService.validatePayload(
'create',
this.collection,
payloads
);
}
payloads = await payloadService.processM2O(payloads);
@@ -70,14 +96,6 @@ export default class ItemsService implements AbstractService {
payloadsWithoutAliases
);
if (this.accountability && this.accountability.admin !== true) {
payloads = await authorizationService.validatePayload(
'create',
this.collection,
payloads
);
}
const primaryKeys: PrimaryKey[] = [];
for (const payloadWithoutAlias of payloadsWithoutAliases) {
@@ -151,12 +169,18 @@ export default class ItemsService implements AbstractService {
await cache.clear();
}
emitter.emitAsync(`item.create.${this.collection}`, {
collection: this.collection,
item: primaryKeys,
action: 'create',
payload: payloads,
});
if (this.collection.startsWith('directus_') === false) {
emitter
.emitAsync(`item.create.${this.collection}`, {
event: `item.create.${this.collection}`,
accountability: this.accountability,
collection: this.collection,
item: primaryKeys,
action: 'create',
payload: payloads,
})
.catch((err) => logger.warn(err));
}
return primaryKeys;
});
@@ -168,7 +192,10 @@ export default class ItemsService implements AbstractService {
const authorizationService = new AuthorizationService({
accountability: this.accountability,
});
let ast = await getASTFromQuery(this.collection, query, { accountability: this.accountability, knex: this.knex });
let ast = await getASTFromQuery(this.collection, query, {
accountability: this.accountability,
knex: this.knex,
});
if (this.accountability && this.accountability.admin !== true) {
ast = await authorizationService.processAST(ast);
@@ -201,15 +228,11 @@ export default class ItemsService implements AbstractService {
},
};
let ast = await getASTFromQuery(
this.collection,
queryWithFilter,
{
accountability: this.accountability,
action,
knex: this.knex,
}
);
let ast = await getASTFromQuery(this.collection, queryWithFilter, {
accountability: this.accountability,
action,
knex: this.knex,
});
if (this.accountability && this.accountability.admin !== true) {
const authorizationService = new AuthorizationService({
@@ -221,7 +244,6 @@ export default class ItemsService implements AbstractService {
const records = await runAST(ast, { knex: this.knex });
return Array.isArray(key) ? records : records[0];
return [] as Item;
}
update(data: Partial<Item>, keys: PrimaryKey[]): Promise<PrimaryKey[]>;
@@ -241,6 +263,23 @@ export default class ItemsService implements AbstractService {
let payload = clone(data);
if (this.collection.startsWith('directus_') === false) {
const customProcessed = await emitter.emitAsync(
`item.update.${this.collection}.before`,
payload,
{
event: `item.update.${this.collection}.before`,
accountability: this.accountability,
collection: this.collection,
item: null,
action: 'update',
payload,
}
);
payload = customProcessed[customProcessed.length - 1];
}
if (this.accountability && this.accountability.admin !== true) {
const authorizationService = new AuthorizationService({
accountability: this.accountability,
@@ -324,12 +363,16 @@ export default class ItemsService implements AbstractService {
await cache.clear();
}
emitter.emitAsync(`item.update.${this.collection}`, {
collection: this.collection,
item: key,
action: 'update',
payload,
});
emitter
.emitAsync(`item.update.${this.collection}`, {
event: `item.update.${this.collection}`,
accountability: this.accountability,
collection: this.collection,
item: key,
action: 'update',
payload,
})
.catch((err) => logger.warn(err));
return key;
}
@@ -347,9 +390,13 @@ export default class ItemsService implements AbstractService {
for (const single of payloads as Partial<Item>[]) {
let payload = clone(single);
const key = payload[primaryKeyField];
if (!key)
if (!key) {
throw new InvalidPayloadException('Primary key is missing in update payload.');
}
keys.push(key);
await itemsService.update(payload, key);
}
});
@@ -372,6 +419,15 @@ export default class ItemsService implements AbstractService {
await authorizationService.checkAccess('delete', this.collection, key);
}
await emitter.emitAsync(`item.delete.${this.collection}.before`, {
event: `item.update.${this.collection}`,
accountability: this.accountability,
collection: this.collection,
item: keys,
action: 'delete',
payload: null,
});
await this.knex.transaction(async (trx) => {
await trx(this.collection).whereIn(primaryKeyField, keys).delete();
@@ -393,11 +449,16 @@ export default class ItemsService implements AbstractService {
await cache.clear();
}
emitter.emitAsync(`item.delete.${this.collection}`, {
collection: this.collection,
item: key,
action: 'delete',
});
emitter
.emitAsync(`item.delete.${this.collection}`, {
event: `item.delete.${this.collection}`,
accountability: this.accountability,
collection: this.collection,
item: keys,
action: 'delete',
payload: null,
})
.catch((err) => logger.warn(err));
return key;
}

View File

@@ -4,7 +4,7 @@ import { AbstractServiceOptions, Accountability } from '../types';
import Knex from 'knex';
import { applyFilter } from '../utils/apply-query';
export default class MetaService {
export class MetaService {
knex: Knex;
accountability: Accountability | null;

View File

@@ -3,13 +3,12 @@
* handled correctly.
*/
import { FieldMeta } from '../types/field';
import argon2 from 'argon2';
import { v4 as uuidv4 } from 'uuid';
import database from '../database';
import { clone, isObject } from 'lodash';
import { Relation, Item, AbstractServiceOptions, Accountability, PrimaryKey } from '../types';
import ItemsService from './items';
import { ItemsService } from './items';
import { URL } from 'url';
import Knex from 'knex';
import env from '../env';
@@ -25,7 +24,7 @@ type Transformers = {
) => Promise<any>;
};
export default class PayloadService {
export class PayloadService {
accountability: Accountability | null;
knex: Knex;
collection: string;
@@ -176,7 +175,12 @@ export default class PayloadService {
if (['create', 'update'].includes(action)) {
processedPayload.forEach((record) => {
for (const [key, value] of Object.entries(record)) {
if (Array.isArray(value) || (typeof value === 'object' && (value instanceof Date) !== true && value !== null)) {
if (
Array.isArray(value) ||
(typeof value === 'object' &&
value instanceof Date !== true &&
value !== null)
) {
record[key] = JSON.stringify(value);
}
}

View File

@@ -1,7 +1,7 @@
import { AbstractServiceOptions, PermissionsAction } from '../types';
import ItemsService from '../services/items';
import { AbstractServiceOptions } from '../types';
import { ItemsService } from '../services/items';
export default class PermissionsService extends ItemsService {
export class PermissionsService extends ItemsService {
constructor(options?: AbstractServiceOptions) {
super('directus_permissions', options);
}

View File

@@ -1,7 +1,7 @@
import ItemsService from './items';
import { ItemsService } from './items';
import { AbstractServiceOptions } from '../types';
export default class PresetsService extends ItemsService {
export class PresetsService extends ItemsService {
constructor(options?: AbstractServiceOptions) {
super('directus_presets', options);
}

View File

@@ -1,11 +1,11 @@
import ItemsService from './items';
import { ItemsService } from './items';
import { AbstractServiceOptions } from '../types';
/**
* @TODO update foreign key constraints when relations are updated
*/
export default class RelationsService extends ItemsService {
export class RelationsService extends ItemsService {
constructor(options?: AbstractServiceOptions) {
super('directus_relations', options);
}

View File

@@ -1,11 +1,11 @@
import ItemsService from './items';
import { ItemsService } from './items';
import { AbstractServiceOptions } from '../types';
/**
* @TODO only return data / delta based on permissions you have for the requested collection
*/
export default class RevisionsService extends ItemsService {
export class RevisionsService extends ItemsService {
constructor(options?: AbstractServiceOptions) {
super('directus_revisions', options);
}

View File

@@ -1,10 +1,10 @@
import ItemsService from './items';
import { ItemsService } from './items';
import { AbstractServiceOptions, PrimaryKey } from '../types';
import PermissionsService from './permissions';
import UsersService from './users';
import PresetsService from './presets';
import { PermissionsService } from './permissions';
import { UsersService } from './users';
import { PresetsService } from './presets';
export default class RolesService extends ItemsService {
export class RolesService extends ItemsService {
constructor(options?: AbstractServiceOptions) {
super('directus_roles', options);
}

View File

@@ -7,7 +7,7 @@ import { ForbiddenException } from '../exceptions';
import { version } from '../../package.json';
import macosRelease from 'macos-release';
export default class ServerService {
export class ServerService {
knex: Knex;
accountability: Accountability | null;

View File

@@ -1,7 +1,7 @@
import ItemsService from './items';
import { ItemsService } from './items';
import { AbstractServiceOptions } from '../types';
export default class SettingsService extends ItemsService {
export class SettingsService extends ItemsService {
constructor(options?: AbstractServiceOptions) {
super('directus_settings', options);
}

View File

@@ -1,5 +1,5 @@
import AuthService from './authentication';
import ItemsService from './items';
import { AuthenticationService } from './authentication';
import { ItemsService } from './items';
import jwt from 'jsonwebtoken';
import { sendInviteMail, sendPasswordResetMail } from '../mail';
import database from '../database';
@@ -10,7 +10,7 @@ import Knex from 'knex';
import env from '../env';
import cache from '../cache';
export default class UsersService extends ItemsService {
export class UsersService extends ItemsService {
knex: Knex;
accountability: Accountability | null;
service: ItemsService;
@@ -140,7 +140,7 @@ export default class UsersService extends ItemsService {
throw new InvalidPayloadException('TFA Secret is already set for this user');
}
const authService = new AuthService();
const authService = new AuthenticationService();
const secret = authService.generateTFASecret();
await this.knex('directus_users').update({ tfa_secret: secret }).where({ id: pk });

View File

@@ -4,7 +4,7 @@ import Knex from 'knex';
import { InvalidPayloadException, ForbiddenException } from '../exceptions';
import SchemaInspector from 'knex-schema-inspector';
export default class UtilsService {
export class UtilsService {
knex: Knex;
accountability: Accountability | null;

View File

@@ -1,4 +1,4 @@
import ItemsService from './items';
import { ItemsService } from './items';
import { Item, PrimaryKey, AbstractServiceOptions } from '../types';
import emitter from '../emitter';
import { ListenerFn } from 'eventemitter2';
@@ -8,7 +8,7 @@ import logger from '../logger';
let registered: { event: string; handler: ListenerFn }[] = [];
export default class WebhooksService extends ItemsService {
export class WebhooksService extends ItemsService {
constructor(options?: AbstractServiceOptions) {
super('directus_webhooks', options);
}

View File

@@ -0,0 +1,16 @@
import { ListenerFn } from 'eventemitter2';
import * as services from '../services';
import * as exceptions from '../exceptions';
import env from '../env';
import Knex from 'knex';
import { Router } from 'express';
type ExtensionContext = {
services: typeof services;
exceptions: typeof exceptions;
database: Knex;
env: typeof env;
};
export type HookRegisterFunction = (context: ExtensionContext) => Record<string, ListenerFn>;
export type EndpointRegisterFunction = (router: Router, context: ExtensionContext) => void;

View File

@@ -3,6 +3,7 @@ export * from './activity';
export * from './assets';
export * from './ast';
export * from './collection';
export * from './extensions';
export * from './field';
export * from './files';
export * from './items';

View File

@@ -19,7 +19,7 @@ type GetASTOptions = {
accountability?: Accountability | null;
action?: PermissionsAction;
knex?: Knex;
}
};
export default async function getASTFromQuery(
collection: string,

View File

@@ -1,8 +1,10 @@
import { Request } from "express";
import { Request } from 'express';
import url from 'url';
export function getCacheKey(req: Request) {
const path = url.parse(req.originalUrl).pathname;
const key = `${req.accountability?.user || 'null'}-${path}-${JSON.stringify(req.sanitizedQuery)}`;
const key = `${req.accountability?.user || 'null'}-${path}-${JSON.stringify(
req.sanitizedQuery
)}`;
return key;
}

View File

@@ -48,7 +48,7 @@ async function getEnvInfo(event: string) {
store: env.CACHE_STORE,
},
storage: {
drivers: getStorageDrivers()
drivers: getStorageDrivers(),
},
cors: {
enabled: env.CORS_ENABLED,
@@ -57,15 +57,19 @@ async function getEnvInfo(event: string) {
transport: env.EMAIL_TRANSPORT,
},
oauth: {
providers: env.OAUTH_PROVIDERS.split(',').filter((p?: string) => p).map((p: string) => p.trim()),
providers: env.OAUTH_PROVIDERS.split(',')
.filter((p?: string) => p)
.map((p: string) => p.trim()),
},
db_client: env.DB_CLIENT
}
db_client: env.DB_CLIENT,
};
}
function getStorageDrivers() {
const drivers: string[] = [];
const locations = env.STORAGE_LOCATIONS.split(',').filter((l?: string) => l).map((l: string) => l.trim());
const locations = env.STORAGE_LOCATIONS.split(',')
.filter((l?: string) => l)
.map((l: string) => l.trim());
for (const location of locations) {
const driver = env[`STORAGE_${location.toUpperCase()}_DRIVER`];

View File

@@ -1,3 +1,43 @@
# Creating a Custom API Endpoint
> TK
Custom endpoints are dynamically loaded from your configured extensions folder.
Custom endpoints are registered using a registration function:
```js
// extensions/endpoints/my-endpoint/index.js
module.exports = function registerEndpoint(router) {
router.get('/', (req, res) => res.send('Hello, World!'));
}
```
The `registerEndpoint` function receives two parameters: `router` and `context`. Router is an express Router
instance that's scoped to `/custom/<extension-name>`. `context` holds the following properties:
* `services` — All API interal services
* `exceptions` API exception objects that can be used to throw "proper" errors
* `database` — Knex instance that's connected to the current DB
* `env` Parsed environment variables
---
## Full example:
```js
// extensions/endpoints/recipes/index.js
module.exports = function registerEndpoint(router, { services, exceptions }) {
const { ItemsService } = services;
const { ServiceUnavailableException } = exceptions;
const recipeService = new ItemsService('recipes');
router.get('/', (req, res) => {
recipeService
.readByQuery({ sort: 'name', fields: '*' })
.then(results => res.json(results))
.catch(error => { throw new ServiceUnavailableException(error.message) });
});
}
```

View File

@@ -1,3 +1,68 @@
# Creating a Custom API Hook
> TK
Custom hooks are dynamically loaded from your configured extensions folder.
Custom hooks are registered using a registration function:
```js
// extensions/hooks/my-hook/index.js
module.exports = function registerHook() {
return {
'item.create.articles': function() {
axios.post('http://example.com/webhook');
}
}
}
```
Register function return an object with key = event, value = handler function.
The `registerHook` function receives one parameter: `context`. `context` holds the following properties:
* `services` — All API interal services
* `exceptions` API exception objects that can be used to throw "proper" errors
* `database` — Knex instance that's connected to the current DB
* `env` Parsed environment variables
Each handler function gets a `context` parameter with the following properties:
* `event` — Full event string
* `accountability` — Information about the current user
* `collection` — Collection that's being modified
* `item` Primary key(s) of the item(s) that's being modified
* `action` — Action that's performed
* `payload` Payload of the request
Events that are prefixed with `.before` run before the event is completed, and are blocking. These allow you to check / modify the payload before it's processed.
---
## Full example:
```js
// extensions/hooks/sync-with-external/index.js
module.exports = function registerHook({ services, exceptions }) {
const { ServiceUnavailableException, ForbiddenException } = exceptions;
return {
// Force everything to be admin only at all times
'item.*.*': async function({ item, accountability }) {
if (accountability.admin !== true) throw new ForbiddenException();
},
// Sync with external recipes service, cancel creation on failure
'item.recipes.create.before': async function(input) {
try {
await axios.post('https://example.com/recipes', input);
} catch (error) {
throw new ServiceUnavailableException(error);
}
input[0].syncedWithExample = true;
return input;
}
}
}
```