Merge branch 'main' into sdk

This commit is contained in:
rijkvanzanten
2020-11-15 20:03:52 -05:00
549 changed files with 14560 additions and 63962 deletions

View File

@@ -4,6 +4,8 @@ import logger from './logger';
import expressLogger from 'express-pino-logger';
import path from 'path';
import { validateDBConnection, isInstalled } from './database';
import { validateEnv } from './utils/validate-env';
import env from './env';
import { track } from './utils/track';
@@ -34,19 +36,29 @@ import usersRouter from './controllers/users';
import utilsRouter from './controllers/utils';
import webhooksRouter from './controllers/webhooks';
import graphqlRouter from './controllers/graphql';
import schema from './middleware/schema';
import notFoundHandler from './controllers/not-found';
import sanitizeQuery from './middleware/sanitize-query';
import { checkIP } from './middleware/check-ip';
import { WebhooksService } from './services/webhooks';
import { InvalidPayloadException } from './exceptions';
import { registerExtensions } from './extensions';
import { register as registerWebhooks } from './webhooks';
import emitter from './emitter';
import fse from 'fs-extra';
export default async function createApp() {
validateEnv(['KEY', 'SECRET']);
await validateDBConnection();
if ((await isInstalled()) === false) {
logger.fatal(`Database doesn't have Directus tables installed.`);
process.exit(1);
}
const app = express();
const customRouter = express.Router();
@@ -80,11 +92,18 @@ export default async function createApp() {
if (env.NODE_ENV !== 'development') {
const adminPath = require.resolve('@directus/app/dist/index.html');
const publicUrl = env.PUBLIC_URL.endsWith('/') ? env.PUBLIC_URL : env.PUBLIC_URL + '/';
app.get('/', (req, res) => res.redirect('/admin/'));
// Prefix all href/src in the index html with the APIs public path
let html = fse.readFileSync(adminPath, 'utf-8');
html = html.replace(/href="\//g, `href="${publicUrl}`);
html = html.replace(/src="\//g, `src="${publicUrl}`);
app.get('/', (req, res) => res.redirect(`./admin/`));
app.get('/admin', (req, res) => res.send(html));
app.use('/admin', express.static(path.join(adminPath, '..')));
app.use('/admin/*', (req, res) => {
res.sendFile(adminPath);
res.send(html);
});
}
@@ -93,16 +112,18 @@ export default async function createApp() {
app.use(rateLimiter);
}
app.use(sanitizeQuery);
app.use('/auth', authRouter);
app.use(authenticate);
app.use(checkIP);
app.use(sanitizeQuery);
app.use(cache);
app.use(schema);
app.use('/auth', authRouter);
app.use('/graphql', graphqlRouter);
app.use('/activity', activityRouter);
@@ -128,8 +149,7 @@ export default async function createApp() {
app.use(errorHandler);
// Register all webhooks
const webhooksService = new WebhooksService();
await webhooksService.register();
await registerWebhooks();
// Register custom hooks / endpoints
await registerExtensions(customRouter);

View File

@@ -10,7 +10,7 @@ let cache: Keyv | null = null;
if (env.CACHE_ENABLED === true) {
validateEnv(['CACHE_NAMESPACE', 'CACHE_TTL', 'CACHE_STORE']);
cache = getKevyInstance();
cache.on('error', logger.error);
cache.on('error', (err) => logger.error(err));
}
export default cache;
@@ -34,12 +34,8 @@ function getConfig(store: 'memory' | 'redis' | 'memcache' = 'memory'): Options<a
};
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(env.CACHE_REDIS || getConfigFromEnv('CACHE_REDIS_'));
}
if (store === 'memcache') {

View File

@@ -1,4 +1,4 @@
export default async function rolesCreate(collection: string) {
export default async function count(collection: string) {
const database = require('../../../database/index').default;
if (!collection) {
@@ -6,10 +6,14 @@ export default async function rolesCreate(collection: string) {
process.exit(1);
}
const records = await database(collection).count('*', { as: 'count' });
const count = Number(records[0].count);
try {
const records = await database(collection).count('*', { as: 'count' });
const count = Number(records[0].count);
console.log(count);
database.destroy();
console.log(count);
} catch (err) {
console.error(err);
} finally {
database.destroy();
}
}

View File

@@ -1,10 +1,13 @@
import Knex from 'knex';
import run from '../../../database/seeds/run';
import installSeeds from '../../../database/seeds/run';
import runMigrations from '../../../database/migrations/run';
export default async function start() {
const database = require('../../../database/index').default as Knex;
try {
await run(database);
await installSeeds(database);
await runMigrations(database, 'latest');
} catch (err) {
console.log(err);
process.exit(1);

View File

@@ -1,10 +1,20 @@
import run from '../../../database/migrations/run';
import ora from 'ora';
export default async function migrate(direction: 'latest' | 'up' | 'down') {
const database = require('../../../database').default;
try {
const spinnerDriver = ora('Running migrations...').start();
await run(database, direction);
spinnerDriver.stop();
if (direction === 'down') {
console.log('✨ Downgrade successful');
} else {
console.log('✨ Database up to date');
}
} catch (err) {
console.log(err);
process.exit(1);

View File

@@ -10,6 +10,7 @@ import ora from 'ora';
import argon2 from 'argon2';
import runSeed from '../../../database/seeds/run';
import runMigrations from '../../../database/migrations/run';
import createDBConnection, { Credentials } from '../../utils/create-db-connection';
import Knex from 'knex';
@@ -28,13 +29,9 @@ export default async function init(options: Record<string, any>) {
const dbClient = getDriverForClient(client)!;
try {
require.resolve(dbClient);
} catch {
const spinnerDriver = ora('Installing Database Driver...').start();
await execa('npm', ['install', dbClient, '--production']);
spinnerDriver.stop();
}
const spinnerDriver = ora('Installing Database Driver...').start();
await execa('npm', ['install', dbClient, '--production']);
spinnerDriver.stop();
let attemptsRemaining = 5;
@@ -51,6 +48,7 @@ export default async function init(options: Record<string, any>) {
try {
await runSeed(db);
await runMigrations(db, 'latest');
} catch (err) {
console.log();
console.log('Something went wrong while seeding the database:');
@@ -102,7 +100,7 @@ export default async function init(options: Record<string, any>) {
await db('directus_roles').insert({
id: roleID,
name: 'Administrator',
icon: 'verified_user',
icon: 'verified',
admin_access: true,
description: 'Initial administrative role with unrestricted App/API access',
});

View File

@@ -1,5 +1,5 @@
export default async function rolesCreate({ name, admin }: any) {
const database = require('../../../database/index').default;
const { default: database, schemaInspector } = require('../../../database/index');
const { RolesService } = require('../../../services/roles');
if (!name) {
@@ -7,8 +7,15 @@ export default async function rolesCreate({ name, admin }: any) {
process.exit(1);
}
const service = new RolesService();
const id = await service.create({ name, admin_access: admin });
console.log(id);
database.destroy();
try {
const schema = await schemaInspector.overview();
const service = new RolesService({ schema: schema, knex: database });
const id = await service.create({ name, admin_access: admin });
console.log(id);
} catch (err) {
console.error(err);
} finally {
database.destroy();
}
}

View File

@@ -1,5 +1,5 @@
export default async function usersCreate({ email, password, role }: any) {
const database = require('../../../database/index').default;
const { default: database, schemaInspector } = require('../../../database/index');
const { UsersService } = require('../../../services/users');
if (!email || !password || !role) {
@@ -7,8 +7,15 @@ export default async function usersCreate({ email, password, role }: any) {
process.exit(1);
}
const service = new UsersService();
const id = await service.create({ email, password, role, status: 'active' });
console.log(id);
database.destroy();
try {
const schema = await schemaInspector.overview();
const service = new UsersService({ schema, knex: database });
const id = await service.create({ email, password, role, status: 'active' });
console.log(id);
} catch (err) {
console.error(err);
} finally {
database.destroy();
}
}

View File

@@ -57,4 +57,7 @@ program
.description('Count the amount of items in a given collection')
.action(count);
program.parse(process.argv);
program.parseAsync(process.argv).catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -3,37 +3,37 @@ import { Transformation } from './types/assets';
export const SYSTEM_ASSET_ALLOW_LIST: Transformation[] = [
{
key: 'system-small-cover',
w: 64,
h: 64,
f: 'cover',
width: 64,
height: 64,
fit: 'cover',
},
{
key: 'system-small-contain',
w: 64,
f: 'contain',
width: 64,
fit: 'contain',
},
{
key: 'system-medium-cover',
w: 300,
h: 300,
f: 'cover',
width: 300,
height: 300,
fit: 'cover',
},
{
key: 'system-medium-contain',
w: 300,
f: 'contain',
width: 300,
fit: 'contain',
},
{
key: 'system-large-cover',
w: 800,
h: 600,
f: 'cover',
width: 800,
height: 600,
fit: 'cover',
},
{
key: 'system-large-contain',
w: 800,
f: 'contain',
width: 800,
fit: 'contain',
},
];
export const ASSET_TRANSFORM_QUERY_KEYS = ['key', 'w', 'h', 'f'];
export const ASSET_TRANSFORM_QUERY_KEYS = ['key', 'width', 'height', 'fit', 'withoutEnlargement'];

View File

@@ -13,8 +13,14 @@ router.use(useCollection('directus_activity'));
router.get(
'/',
asyncHandler(async (req, res, next) => {
const service = new ActivityService({ accountability: req.accountability });
const metaService = new MetaService({ accountability: req.accountability });
const service = new ActivityService({
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
});
const records = await service.readByQuery(req.sanitizedQuery);
const meta = await metaService.getMetaForQuery('directus_activity', req.sanitizedQuery);
@@ -32,7 +38,10 @@ router.get(
router.get(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new ActivityService({ accountability: req.accountability });
const service = new ActivityService({
accountability: req.accountability,
schema: req.schema,
});
const record = await service.readByKey(req.params.pk, req.sanitizedQuery);
res.locals.payload = {
@@ -47,7 +56,10 @@ router.get(
router.post(
'/comment',
asyncHandler(async (req, res, next) => {
const service = new ActivityService({ accountability: req.accountability });
const service = new ActivityService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = await service.create({
...req.body,
@@ -79,7 +91,10 @@ router.post(
router.patch(
'/comment/:pk',
asyncHandler(async (req, res, next) => {
const service = new ActivityService({ accountability: req.accountability });
const service = new ActivityService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = await service.update(req.body, req.params.pk);
try {
@@ -104,7 +119,10 @@ router.patch(
router.delete(
'/comment/:pk',
asyncHandler(async (req, res, next) => {
const service = new ActivityService({ accountability: req.accountability });
const service = new ActivityService({
accountability: req.accountability,
schema: req.schema,
});
await service.delete(req.params.pk);
return next();

View File

@@ -47,7 +47,7 @@ router.get(
// Validate query params
asyncHandler(async (req, res, next) => {
const payloadService = new PayloadService('directus_settings');
const payloadService = new PayloadService('directus_settings', { schema: req.schema });
const defaults = { storage_asset_presets: [], storage_asset_transform: 'all' };
let savedAssetSettings = await database
@@ -78,18 +78,20 @@ router.get(
];
// For use in the next request handler
res.locals.shortcuts = [...SYSTEM_ASSET_ALLOW_LIST, assetSettings.storage_asset_presets];
res.locals.shortcuts = [
...SYSTEM_ASSET_ALLOW_LIST,
...(assetSettings.storage_asset_presets || []),
];
res.locals.transformation = transformation;
if (Object.keys(transformation).length === 0) {
return next();
}
if (assetSettings.asset_generation === 'all') {
if (assetSettings.storage_asset_transform === 'all') {
if (transformation.key && allKeys.includes(transformation.key as string) === false)
throw new InvalidQueryException(`Key "${transformation.key}" isn't configured.`);
return next();
} else if (assetSettings.asset_generation === 'shortcut') {
} else if (assetSettings.storage_asset_transform === 'shortcut') {
if (allKeys.includes(transformation.key as string)) return next();
throw new InvalidQueryException(
`Only configured shortcuts can be used in asset generation.`
@@ -105,7 +107,11 @@ router.get(
// Return file
asyncHandler(async (req, res) => {
const service = new AssetsService({ accountability: req.accountability });
const service = new AssetsService({
accountability: req.accountability,
schema: req.schema,
});
const transformation: Transformation = res.locals.transformation.key
? res.locals.shortcuts.find(
(transformation: Transformation) =>

View File

@@ -34,6 +34,7 @@ router.post(
const authenticationService = new AuthenticationService({
accountability: accountability,
schema: req.schema,
});
const { error } = loginSchema.validate(req.body);
@@ -46,15 +47,13 @@ router.post(
const ip = req.ip;
const userAgent = req.get('user-agent');
const { accessToken, refreshToken, expires, id } = await authenticationService.authenticate(
{
ip,
userAgent,
email,
password,
otp,
}
);
const { accessToken, refreshToken, expires } = await authenticationService.authenticate({
ip,
userAgent,
email,
password,
otp,
});
const payload = {
data: { access_token: accessToken, expires },
@@ -92,6 +91,7 @@ router.post(
const authenticationService = new AuthenticationService({
accountability: accountability,
schema: req.schema,
});
const currentRefreshToken = req.body.refresh_token || req.cookies.directus_refresh_token;
@@ -144,6 +144,7 @@ router.post(
const authenticationService = new AuthenticationService({
accountability: accountability,
schema: req.schema,
});
const currentRefreshToken = req.body.refresh_token || req.cookies.directus_refresh_token;
@@ -173,7 +174,7 @@ router.post(
role: null,
};
const service = new UsersService({ accountability });
const service = new UsersService({ accountability, schema: req.schema });
try {
await service.requestPasswordReset(req.body.email);
@@ -204,7 +205,7 @@ router.post(
role: null,
};
const service = new UsersService({ accountability });
const service = new UsersService({ accountability, schema: req.schema });
await service.resetPassword(req.body.token, req.body.password);
return next();
}),
@@ -239,7 +240,7 @@ router.get(
}
if (req.query?.redirect && req.session) {
req.session.redirect = req.query.redirect;
req.session.redirect = req.query.redirect as string;
}
next();
@@ -252,7 +253,7 @@ router.use(grant.express()(grantConfig));
router.get(
'/oauth/:provider/callback',
asyncHandler(async (req, res, next) => {
const redirect = req.session?.redirect;
const redirect = req.session.redirect;
const accountability = {
ip: req.ip,
@@ -262,12 +263,10 @@ router.get(
const authenticationService = new AuthenticationService({
accountability: accountability,
schema: req.schema,
});
const email = getEmailFromProfile(
req.params.provider,
req.session!.grant.response?.profile
);
const email = getEmailFromProfile(req.params.provider, req.session.grant.response?.profile);
req.session?.destroy(() => {});

View File

@@ -1,7 +1,7 @@
import { Router } from 'express';
import asyncHandler from 'express-async-handler';
import { CollectionsService, MetaService } from '../services';
import { ForbiddenException } from '../exceptions';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import { respond } from '../middleware/respond';
const router = Router();
@@ -9,7 +9,10 @@ const router = Router();
router.post(
'/',
asyncHandler(async (req, res, next) => {
const collectionsService = new CollectionsService({ accountability: req.accountability });
const collectionsService = new CollectionsService({
accountability: req.accountability,
schema: req.schema,
});
const collectionKey = await collectionsService.create(req.body);
const record = await collectionsService.readByKey(collectionKey);
@@ -23,8 +26,14 @@ router.post(
router.get(
'/',
asyncHandler(async (req, res, next) => {
const collectionsService = new CollectionsService({ accountability: req.accountability });
const metaService = new MetaService({ accountability: req.accountability });
const collectionsService = new CollectionsService({
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
});
const collections = await collectionsService.readByQuery();
const meta = await metaService.getMetaForQuery('directus_collections', {});
@@ -38,7 +47,10 @@ router.get(
router.get(
'/:collection',
asyncHandler(async (req, res, next) => {
const collectionsService = new CollectionsService({ accountability: req.accountability });
const collectionsService = new CollectionsService({
accountability: req.accountability,
schema: req.schema,
});
const collectionKey = req.params.collection.includes(',')
? req.params.collection.split(',')
: req.params.collection;
@@ -62,7 +74,10 @@ router.get(
router.patch(
'/:collection',
asyncHandler(async (req, res, next) => {
const collectionsService = new CollectionsService({ accountability: req.accountability });
const collectionsService = new CollectionsService({
accountability: req.accountability,
schema: req.schema,
});
const collectionKey = req.params.collection.includes(',')
? req.params.collection.split(',')
: req.params.collection;
@@ -84,10 +99,31 @@ router.patch(
respond
);
router.delete(
'/',
asyncHandler(async (req, res, next) => {
if (!req.body || Array.isArray(req.body) === false) {
throw new InvalidPayloadException(`Body has to be an array of primary keys`);
}
const collectionsService = new CollectionsService({
accountability: req.accountability,
schema: req.schema,
});
await collectionsService.delete(req.body as string[]);
return next();
}),
respond
);
router.delete(
'/:collection',
asyncHandler(async (req, res, next) => {
const collectionsService = new CollectionsService({ accountability: req.accountability });
const collectionsService = new CollectionsService({
accountability: req.accountability,
schema: req.schema,
});
const collectionKey = req.params.collection.includes(',')
? req.params.collection.split(',')
: req.params.collection;

View File

@@ -2,7 +2,6 @@ import { Router } from 'express';
import asyncHandler from 'express-async-handler';
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 { types, Field } from '../types';
@@ -16,7 +15,10 @@ router.use(useCollection('directus_fields'));
router.get(
'/',
asyncHandler(async (req, res, next) => {
const service = new FieldsService({ accountability: req.accountability });
const service = new FieldsService({
accountability: req.accountability,
schema: req.schema,
});
const fields = await service.readAll();
res.locals.payload = { data: fields || null };
@@ -29,7 +31,10 @@ router.get(
'/:collection',
validateCollection,
asyncHandler(async (req, res, next) => {
const service = new FieldsService({ accountability: req.accountability });
const service = new FieldsService({
accountability: req.accountability,
schema: req.schema,
});
const fields = await service.readAll(req.params.collection);
res.locals.payload = { data: fields || null };
@@ -42,10 +47,13 @@ router.get(
'/:collection/:field',
validateCollection,
asyncHandler(async (req, res, next) => {
const service = new FieldsService({ accountability: req.accountability });
const service = new FieldsService({
accountability: req.accountability,
schema: req.schema,
});
const exists = await schemaInspector.hasColumn(req.params.collection, req.params.field);
if (exists === false) throw new ForbiddenException();
if (req.params.field in req.schema[req.params.collection].columns === false)
throw new ForbiddenException();
const field = await service.readOne(req.params.collection, req.params.field);
@@ -75,7 +83,10 @@ router.post(
if (!req.body.schema && !req.body.meta)
throw new InvalidPayloadException(`"schema" or "meta" is required`);
const service = new FieldsService({ accountability: req.accountability });
const service = new FieldsService({
accountability: req.accountability,
schema: req.schema,
});
const { error } = newFieldSchema.validate(req.body);
@@ -107,7 +118,10 @@ router.patch(
'/:collection',
validateCollection,
asyncHandler(async (req, res, next) => {
const service = new FieldsService({ accountability: req.accountability });
const service = new FieldsService({
accountability: req.accountability,
schema: req.schema,
});
if (Array.isArray(req.body) === false) {
throw new InvalidPayloadException('Submitted body has to be an array.');
@@ -142,7 +156,10 @@ router.patch(
validateCollection,
// @todo: validate field
asyncHandler(async (req, res, next) => {
const service = new FieldsService({ accountability: req.accountability });
const service = new FieldsService({
accountability: req.accountability,
schema: req.schema,
});
const fieldData: Partial<Field> & { field: string; type: typeof types[number] } = req.body;
if (!fieldData.field) fieldData.field = req.params.field;
@@ -169,7 +186,10 @@ router.delete(
'/:collection/:field',
validateCollection,
asyncHandler(async (req, res, next) => {
const service = new FieldsService({ accountability: req.accountability });
const service = new FieldsService({
accountability: req.accountability,
schema: req.schema,
});
await service.deleteField(req.params.collection, req.params.field);
return next();
}),

View File

@@ -23,7 +23,7 @@ const multipartHandler = asyncHandler(async (req, res, next) => {
const busboy = new Busboy({ headers: req.headers });
const savedFiles: PrimaryKey[] = [];
const service = new FilesService({ accountability: req.accountability });
const service = new FilesService({ accountability: req.accountability, schema: req.schema });
const existingPrimaryKey = req.params.pk || undefined;
@@ -102,7 +102,10 @@ router.post(
'/',
multipartHandler,
asyncHandler(async (req, res, next) => {
const service = new FilesService({ accountability: req.accountability });
const service = new FilesService({
accountability: req.accountability,
schema: req.schema,
});
let keys: PrimaryKey | PrimaryKey[] = [];
if (req.is('multipart/form-data')) {
@@ -145,7 +148,10 @@ router.post(
throw new InvalidPayloadException(error.message);
}
const service = new FilesService({ accountability: req.accountability });
const service = new FilesService({
accountability: req.accountability,
schema: req.schema,
});
const fileResponse = await axios.get<NodeJS.ReadableStream>(req.body.url, {
responseType: 'stream',
@@ -183,8 +189,14 @@ router.post(
router.get(
'/',
asyncHandler(async (req, res, next) => {
const service = new FilesService({ accountability: req.accountability });
const metaService = new MetaService({ accountability: req.accountability });
const service = new FilesService({
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
});
const records = await service.readByQuery(req.sanitizedQuery);
const meta = await metaService.getMetaForQuery('directus_files', req.sanitizedQuery);
@@ -199,7 +211,10 @@ router.get(
'/:pk',
asyncHandler(async (req, res, next) => {
const keys = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const service = new FilesService({ accountability: req.accountability });
const service = new FilesService({
accountability: req.accountability,
schema: req.schema,
});
const record = await service.readByKey(keys as any, req.sanitizedQuery);
res.locals.payload = { data: record || null };
return next();
@@ -211,7 +226,10 @@ router.patch(
'/:pk',
multipartHandler,
asyncHandler(async (req, res, next) => {
const service = new FilesService({ accountability: req.accountability });
const service = new FilesService({
accountability: req.accountability,
schema: req.schema,
});
let keys: PrimaryKey | PrimaryKey[] = [];
if (req.is('multipart/form-data')) {
@@ -237,11 +255,31 @@ router.patch(
respond
);
router.delete(
'/',
asyncHandler(async (req, res, next) => {
if (!req.body || Array.isArray(req.body) === false) {
throw new InvalidPayloadException(`Body has to be an array of primary keys`);
}
const service = new FilesService({
accountability: req.accountability,
schema: req.schema,
});
await service.delete(req.body as PrimaryKey[]);
return next();
}),
respond
);
router.delete(
'/:pk',
asyncHandler(async (req, res, next) => {
const keys = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const service = new FilesService({ accountability: req.accountability });
const service = new FilesService({
accountability: req.accountability,
schema: req.schema,
});
await service.delete(keys as any);
return next();
}),

View File

@@ -1,9 +1,10 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import { FoldersService, MetaService } from '../services';
import { ForbiddenException } from '../exceptions';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import useCollection from '../middleware/use-collection';
import { respond } from '../middleware/respond';
import { PrimaryKey } from '../types';
const router = express.Router();
@@ -12,7 +13,10 @@ router.use(useCollection('directus_folders'));
router.post(
'/',
asyncHandler(async (req, res, next) => {
const service = new FoldersService({ accountability: req.accountability });
const service = new FoldersService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = await service.create(req.body);
try {
@@ -34,8 +38,14 @@ router.post(
router.get(
'/',
asyncHandler(async (req, res, next) => {
const service = new FoldersService({ accountability: req.accountability });
const metaService = new MetaService({ accountability: req.accountability });
const service = new FoldersService({
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
});
const records = await service.readByQuery(req.sanitizedQuery);
const meta = await metaService.getMetaForQuery('directus_files', req.sanitizedQuery);
@@ -49,7 +59,10 @@ router.get(
router.get(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new FoldersService({ accountability: req.accountability });
const service = new FoldersService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const record = await service.readByKey(primaryKey as any, req.sanitizedQuery);
@@ -62,7 +75,10 @@ router.get(
router.patch(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new FoldersService({ accountability: req.accountability });
const service = new FoldersService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const primaryKey = await service.update(req.body, pk as any);
@@ -82,10 +98,30 @@ router.patch(
respond
);
router.delete(
'/',
asyncHandler(async (req, res, next) => {
if (!req.body || Array.isArray(req.body) === false) {
throw new InvalidPayloadException(`Body has to be an array of primary keys`);
}
const service = new FoldersService({
accountability: req.accountability,
schema: req.schema,
});
await service.delete(req.body as PrimaryKey[]);
return next();
}),
respond
);
router.delete(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new FoldersService({ accountability: req.accountability });
const service = new FoldersService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
await service.delete(primaryKey as any);
return next();

View File

@@ -5,12 +5,16 @@ import asyncHandler from 'express-async-handler';
const router = Router();
router.use(asyncHandler(async (req, res) => {
const service = new GraphQLService({ accountability: req.accountability });
const schema = await service.getSchema();
router.use(
asyncHandler(async (req, res) => {
const service = new GraphQLService({
accountability: req.accountability,
schema: req.schema,
});
const schema = await service.getSchema();
graphqlHTTP({ schema, graphiql: true })(req, res);
}));
graphqlHTTP({ schema, graphiql: true })(req, res);
})
);
export default router;

View File

@@ -2,8 +2,15 @@ import express from 'express';
import asyncHandler from 'express-async-handler';
import collectionExists from '../middleware/collection-exists';
import { ItemsService, MetaService } from '../services';
import { RouteNotFoundException, ForbiddenException } from '../exceptions';
import {
RouteNotFoundException,
ForbiddenException,
FailedValidationException,
} from '../exceptions';
import { respond } from '../middleware/respond';
import { InvalidPayloadException } from '../exceptions';
import { PrimaryKey } from '../types';
import Joi from 'joi';
const router = express.Router();
@@ -15,7 +22,10 @@ router.post(
throw new RouteNotFoundException(req.path);
}
const service = new ItemsService(req.collection, { accountability: req.accountability });
const service = new ItemsService(req.collection, {
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = await service.create(req.body);
try {
@@ -38,8 +48,14 @@ router.get(
'/:collection',
collectionExists,
asyncHandler(async (req, res, next) => {
const service = new ItemsService(req.collection, { accountability: req.accountability });
const metaService = new MetaService({ accountability: req.accountability });
const service = new ItemsService(req.collection, {
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
});
const records = req.singleton
? await service.readSingleton(req.sanitizedQuery)
@@ -64,7 +80,10 @@ router.get(
throw new RouteNotFoundException(req.path);
}
const service = new ItemsService(req.collection, { accountability: req.accountability });
const service = new ItemsService(req.collection, {
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const result = await service.readByKey(primaryKey as any, req.sanitizedQuery);
@@ -80,7 +99,10 @@ router.patch(
'/:collection',
collectionExists,
asyncHandler(async (req, res, next) => {
const service = new ItemsService(req.collection, { accountability: req.accountability });
const service = new ItemsService(req.collection, {
accountability: req.accountability,
schema: req.schema,
});
if (req.singleton === true) {
await service.upsertSingleton(req.body);
@@ -90,7 +112,35 @@ router.patch(
return next();
}
const primaryKeys = await service.update(req.body);
if (Array.isArray(req.body)) {
const primaryKeys = await service.update(req.body);
try {
const result = await service.readByKey(primaryKeys, req.sanitizedQuery);
res.locals.payload = { data: result || null };
} catch (error) {
if (error instanceof ForbiddenException) {
return next();
}
throw error;
}
return next();
}
const updateSchema = Joi.object({
keys: Joi.array().items(Joi.alternatives(Joi.string(), Joi.number())).required(),
data: Joi.object().required().unknown(),
});
const { error } = updateSchema.validate(req.body);
if (error) {
throw new FailedValidationException(error.details[0]);
}
const primaryKeys = await service.update(req.body.data, req.body.keys);
try {
const result = await service.readByKey(primaryKeys, req.sanitizedQuery);
@@ -116,7 +166,10 @@ router.patch(
throw new RouteNotFoundException(req.path);
}
const service = new ItemsService(req.collection, { accountability: req.accountability });
const service = new ItemsService(req.collection, {
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const updatedPrimaryKey = await service.update(req.body, primaryKey as any);
@@ -137,11 +190,32 @@ router.patch(
respond
);
router.delete(
'/:collection',
collectionExists,
asyncHandler(async (req, res, next) => {
if (!req.body || Array.isArray(req.body) === false) {
throw new InvalidPayloadException(`Body has to be an array of primary keys`);
}
const service = new ItemsService(req.collection, {
accountability: req.accountability,
schema: req.schema,
});
await service.delete(req.body as PrimaryKey[]);
return next();
}),
respond
);
router.delete(
'/:collection/:pk',
collectionExists,
asyncHandler(async (req, res, next) => {
const service = new ItemsService(req.collection, { accountability: req.accountability });
const service = new ItemsService(req.collection, {
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
await service.delete(pk as any);
return next();

View File

@@ -2,9 +2,14 @@ import express from 'express';
import asyncHandler from 'express-async-handler';
import { PermissionsService, MetaService } from '../services';
import { clone } from 'lodash';
import { InvalidCredentialsException, ForbiddenException } from '../exceptions';
import {
InvalidCredentialsException,
ForbiddenException,
InvalidPayloadException,
} from '../exceptions';
import useCollection from '../middleware/use-collection';
import { respond } from '../middleware/respond';
import { PrimaryKey } from '../types';
const router = express.Router();
@@ -13,7 +18,10 @@ router.use(useCollection('directus_permissions'));
router.post(
'/',
asyncHandler(async (req, res, next) => {
const service = new PermissionsService({ accountability: req.accountability });
const service = new PermissionsService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = await service.create(req.body);
try {
@@ -34,8 +42,14 @@ router.post(
router.get(
'/',
asyncHandler(async (req, res, next) => {
const service = new PermissionsService({ accountability: req.accountability });
const metaService = new MetaService({ accountability: req.accountability });
const service = new PermissionsService({
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
});
const item = await service.readByQuery(req.sanitizedQuery);
const meta = await metaService.getMetaForQuery('directus_permissions', req.sanitizedQuery);
@@ -53,7 +67,7 @@ router.get(
throw new InvalidCredentialsException();
}
const service = new PermissionsService();
const service = new PermissionsService({ schema: req.schema });
const query = clone(req.sanitizedQuery || {});
query.filter = {
@@ -75,7 +89,10 @@ router.get(
'/:pk',
asyncHandler(async (req, res, next) => {
if (req.path.endsWith('me')) return next();
const service = new PermissionsService({ accountability: req.accountability });
const service = new PermissionsService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const record = await service.readByKey(primaryKey as any, req.sanitizedQuery);
@@ -88,7 +105,10 @@ router.get(
router.patch(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new PermissionsService({ accountability: req.accountability });
const service = new PermissionsService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const primaryKey = await service.update(req.body, pk as any);
@@ -108,10 +128,30 @@ router.patch(
respond
);
router.delete(
'/',
asyncHandler(async (req, res, next) => {
if (!req.body || Array.isArray(req.body) === false) {
throw new InvalidPayloadException(`Body has to be an array of primary keys`);
}
const service = new PermissionsService({
accountability: req.accountability,
schema: req.schema,
});
await service.delete(req.body as PrimaryKey[]);
return next();
}),
respond
);
router.delete(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new PermissionsService({ accountability: req.accountability });
const service = new PermissionsService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
await service.delete(pk as any);
return next();

View File

@@ -1,9 +1,10 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import { PresetsService, MetaService } from '../services';
import { ForbiddenException } from '../exceptions';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import useCollection from '../middleware/use-collection';
import { respond } from '../middleware/respond';
import { PrimaryKey } from '../types';
const router = express.Router();
@@ -12,7 +13,10 @@ router.use(useCollection('directus_presets'));
router.post(
'/',
asyncHandler(async (req, res, next) => {
const service = new PresetsService({ accountability: req.accountability });
const service = new PresetsService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = await service.create(req.body);
try {
@@ -34,8 +38,14 @@ router.post(
router.get(
'/',
asyncHandler(async (req, res, next) => {
const service = new PresetsService({ accountability: req.accountability });
const metaService = new MetaService({ accountability: req.accountability });
const service = new PresetsService({
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
});
const records = await service.readByQuery(req.sanitizedQuery);
const meta = await metaService.getMetaForQuery('directus_presets', req.sanitizedQuery);
@@ -49,7 +59,10 @@ router.get(
router.get(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new PresetsService({ accountability: req.accountability });
const service = new PresetsService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const record = await service.readByKey(pk as any, req.sanitizedQuery);
@@ -62,7 +75,10 @@ router.get(
router.patch(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new PresetsService({ accountability: req.accountability });
const service = new PresetsService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const primaryKey = await service.update(req.body, pk as any);
@@ -82,10 +98,30 @@ router.patch(
respond
);
router.delete(
'/',
asyncHandler(async (req, res, next) => {
if (!req.body || Array.isArray(req.body) === false) {
throw new InvalidPayloadException(`Body has to be an array of primary keys`);
}
const service = new PresetsService({
accountability: req.accountability,
schema: req.schema,
});
await service.delete(req.body as PrimaryKey[]);
return next();
}),
respond
);
router.delete(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new PresetsService({ accountability: req.accountability });
const service = new PresetsService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
await service.delete(pk as any);
return next();

View File

@@ -1,9 +1,10 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import { RelationsService, MetaService } from '../services';
import { ForbiddenException } from '../exceptions';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import useCollection from '../middleware/use-collection';
import { respond } from '../middleware/respond';
import { PrimaryKey } from '../types';
const router = express.Router();
@@ -12,7 +13,10 @@ router.use(useCollection('directus_relations'));
router.post(
'/',
asyncHandler(async (req, res, next) => {
const service = new RelationsService({ accountability: req.accountability });
const service = new RelationsService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = await service.create(req.body);
try {
@@ -34,8 +38,14 @@ router.post(
router.get(
'/',
asyncHandler(async (req, res, next) => {
const service = new RelationsService({ accountability: req.accountability });
const metaService = new MetaService({ accountability: req.accountability });
const service = new RelationsService({
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
});
const records = await service.readByQuery(req.sanitizedQuery);
const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery);
@@ -49,7 +59,10 @@ router.get(
router.get(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new RelationsService({ accountability: req.accountability });
const service = new RelationsService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const record = await service.readByKey(pk as any, req.sanitizedQuery);
res.locals.payload = { data: record || null };
@@ -61,7 +74,10 @@ router.get(
router.patch(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new RelationsService({ accountability: req.accountability });
const service = new RelationsService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const primaryKey = await service.update(req.body, pk as any);
@@ -81,10 +97,30 @@ router.patch(
respond
);
router.delete(
'/',
asyncHandler(async (req, res, next) => {
if (!req.body || Array.isArray(req.body) === false) {
throw new InvalidPayloadException(`Body has to be an array of primary keys`);
}
const service = new RelationsService({
accountability: req.accountability,
schema: req.schema,
});
await service.delete(req.body as PrimaryKey[]);
return next();
}),
respond
);
router.delete(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new RelationsService({ accountability: req.accountability });
const service = new RelationsService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
await service.delete(pk as any);
return next();

View File

@@ -11,8 +11,14 @@ router.use(useCollection('directus_revisions'));
router.get(
'/',
asyncHandler(async (req, res, next) => {
const service = new RevisionsService({ accountability: req.accountability });
const metaService = new MetaService({ accountability: req.accountability });
const service = new RevisionsService({
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
});
const records = await service.readByQuery(req.sanitizedQuery);
const meta = await metaService.getMetaForQuery('directus_revisions', req.sanitizedQuery);
@@ -26,7 +32,10 @@ router.get(
router.get(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new RevisionsService({ accountability: req.accountability });
const service = new RevisionsService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const record = await service.readByKey(pk as any, req.sanitizedQuery);
res.locals.payload = { data: record || null };

View File

@@ -1,9 +1,10 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import { RolesService, MetaService } from '../services';
import { ForbiddenException } from '../exceptions';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import useCollection from '../middleware/use-collection';
import { respond } from '../middleware/respond';
import { PrimaryKey } from '../types';
const router = express.Router();
@@ -12,7 +13,10 @@ router.use(useCollection('directus_roles'));
router.post(
'/',
asyncHandler(async (req, res, next) => {
const service = new RolesService({ accountability: req.accountability });
const service = new RolesService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = await service.create(req.body);
try {
@@ -34,8 +38,14 @@ router.post(
router.get(
'/',
asyncHandler(async (req, res, next) => {
const service = new RolesService({ accountability: req.accountability });
const metaService = new MetaService({ accountability: req.accountability });
const service = new RolesService({
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
});
const records = await service.readByQuery(req.sanitizedQuery);
const meta = await metaService.getMetaForQuery('directus_roles', req.sanitizedQuery);
@@ -49,7 +59,10 @@ router.get(
router.get(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new RolesService({ accountability: req.accountability });
const service = new RolesService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const record = await service.readByKey(pk as any, req.sanitizedQuery);
res.locals.payload = { data: record || null };
@@ -61,7 +74,10 @@ router.get(
router.patch(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new RolesService({ accountability: req.accountability });
const service = new RolesService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const primaryKey = await service.update(req.body, pk as any);
@@ -81,10 +97,30 @@ router.patch(
respond
);
router.delete(
'/',
asyncHandler(async (req, res, next) => {
if (!req.body || Array.isArray(req.body) === false) {
throw new InvalidPayloadException(`Body has to be an array of primary keys`);
}
const service = new RolesService({
accountability: req.accountability,
schema: req.schema,
});
await service.delete(req.body as PrimaryKey[]);
return next();
}),
respond
);
router.delete(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new RolesService({ accountability: req.accountability });
const service = new RolesService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
await service.delete(pk as any);
return next();

View File

@@ -9,7 +9,10 @@ const router = Router();
router.get(
'/specs/oas',
asyncHandler(async (req, res, next) => {
const service = new SpecificationService({ accountability: req.accountability });
const service = new SpecificationService({
accountability: req.accountability,
schema: req.schema,
});
res.locals.payload = await service.oas.generate();
return next();
}),
@@ -21,7 +24,10 @@ router.get('/ping', (req, res) => res.send('pong'));
router.get(
'/info',
asyncHandler(async (req, res, next) => {
const service = new ServerService({ accountability: req.accountability });
const service = new ServerService({
accountability: req.accountability,
schema: req.schema,
});
const data = await service.serverInfo();
res.locals.payload = { data };
return next();

View File

@@ -12,7 +12,10 @@ router.use(useCollection('directus_settings'));
router.get(
'/',
asyncHandler(async (req, res, next) => {
const service = new SettingsService({ accountability: req.accountability });
const service = new SettingsService({
accountability: req.accountability,
schema: req.schema,
});
const records = await service.readSingleton(req.sanitizedQuery);
res.locals.payload = { data: records || null };
return next();
@@ -23,7 +26,10 @@ router.get(
router.patch(
'/',
asyncHandler(async (req, res, next) => {
const service = new SettingsService({ accountability: req.accountability });
const service = new SettingsService({
accountability: req.accountability,
schema: req.schema,
});
await service.upsertSingleton(req.body);
try {

View File

@@ -9,6 +9,7 @@ import {
import { UsersService, MetaService, AuthenticationService } from '../services';
import useCollection from '../middleware/use-collection';
import { respond } from '../middleware/respond';
import { PrimaryKey } from '../types';
const router = express.Router();
@@ -17,7 +18,10 @@ router.use(useCollection('directus_users'));
router.post(
'/',
asyncHandler(async (req, res, next) => {
const service = new UsersService({ accountability: req.accountability });
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = await service.create(req.body);
try {
@@ -39,8 +43,14 @@ router.post(
router.get(
'/',
asyncHandler(async (req, res, next) => {
const service = new UsersService({ accountability: req.accountability });
const metaService = new MetaService({ accountability: req.accountability });
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
});
const item = await service.readByQuery(req.sanitizedQuery);
const meta = await metaService.getMetaForQuery('directus_users', req.sanitizedQuery);
@@ -58,7 +68,10 @@ router.get(
throw new InvalidCredentialsException();
}
const service = new UsersService({ accountability: req.accountability });
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
});
try {
const item = await service.readByKey(req.accountability.user, req.sanitizedQuery);
@@ -81,7 +94,10 @@ router.get(
'/:pk',
asyncHandler(async (req, res, next) => {
if (req.path.endsWith('me')) return next();
const service = new UsersService({ accountability: req.accountability });
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const items = await service.readByKey(pk as any, req.sanitizedQuery);
res.locals.payload = { data: items || null };
@@ -97,7 +113,10 @@ router.patch(
throw new InvalidCredentialsException();
}
const service = new UsersService({ accountability: req.accountability });
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = await service.update(req.body, req.accountability.user);
const item = await service.readByKey(primaryKey, req.sanitizedQuery);
@@ -118,7 +137,7 @@ router.patch(
throw new InvalidPayloadException(`"last_page" key is required.`);
}
const service = new UsersService();
const service = new UsersService({ schema: req.schema });
await service.update({ last_page: req.body.last_page }, req.accountability.user);
return next();
@@ -129,7 +148,10 @@ router.patch(
router.patch(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new UsersService({ accountability: req.accountability });
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const primaryKey = await service.update(req.body, pk as any);
@@ -149,10 +171,31 @@ router.patch(
respond
);
router.delete(
'/',
asyncHandler(async (req, res, next) => {
if (!req.body || Array.isArray(req.body) === false) {
throw new InvalidPayloadException(`Body has to be an array of primary keys`);
}
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
});
await service.delete(req.body as PrimaryKey[]);
return next();
}),
respond
);
router.delete(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new UsersService({ accountability: req.accountability });
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
await service.delete(pk as any);
@@ -162,7 +205,10 @@ router.delete(
);
const inviteSchema = Joi.object({
email: Joi.string().email().required(),
email: Joi.alternatives(
Joi.string().email(),
Joi.array().items(Joi.string().email())
).required(),
role: Joi.string().uuid({ version: 'uuidv4' }).required(),
});
@@ -172,7 +218,10 @@ router.post(
const { error } = inviteSchema.validate(req.body);
if (error) throw new InvalidPayloadException(error.message);
const service = new UsersService({ accountability: req.accountability });
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
});
await service.inviteUser(req.body.email, req.body.role);
return next();
}),
@@ -189,7 +238,10 @@ router.post(
asyncHandler(async (req, res, next) => {
const { error } = acceptInviteSchema.validate(req.body);
if (error) throw new InvalidPayloadException(error.message);
const service = new UsersService({ accountability: req.accountability });
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
});
await service.acceptInvite(req.body.token, req.body.password);
return next();
}),
@@ -207,9 +259,15 @@ router.post(
throw new InvalidPayloadException(`"password" is required`);
}
const service = new UsersService({ accountability: req.accountability });
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
});
const authService = new AuthenticationService({ accountability: req.accountability });
const authService = new AuthenticationService({
accountability: req.accountability,
schema: req.schema,
});
await authService.verifyPassword(req.accountability.user, req.body.password);
const { url, secret } = await service.enableTFA(req.accountability.user);
@@ -231,8 +289,14 @@ router.post(
throw new InvalidPayloadException(`"otp" is required`);
}
const service = new UsersService({ accountability: req.accountability });
const authService = new AuthenticationService({ accountability: req.accountability });
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
});
const authService = new AuthenticationService({
accountability: req.accountability,
schema: req.schema,
});
const otpValid = await authService.verifyOTP(req.accountability.user, req.body.otp);

View File

@@ -67,7 +67,10 @@ router.post(
const { error } = SortSchema.validate(req.body);
if (error) throw new InvalidPayloadException(error.message);
const service = new UtilsService({ accountability: req.accountability });
const service = new UtilsService({
accountability: req.accountability,
schema: req.schema,
});
await service.sort(req.collection, req.body);
return res.status(200).end();
@@ -78,7 +81,10 @@ router.post(
router.post(
'/revert/:revision',
asyncHandler(async (req, res, next) => {
const service = new RevisionsService({ accountability: req.accountability });
const service = new RevisionsService({
accountability: req.accountability,
schema: req.schema,
});
await service.revert(req.params.revision);
next();
}),

View File

@@ -1,9 +1,10 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import { WebhooksService, MetaService } from '../services';
import { ForbiddenException } from '../exceptions';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import useCollection from '../middleware/use-collection';
import { respond } from '../middleware/respond';
import { PrimaryKey } from '../types';
const router = express.Router();
@@ -12,7 +13,10 @@ router.use(useCollection('directus_webhooks'));
router.post(
'/',
asyncHandler(async (req, res, next) => {
const service = new WebhooksService({ accountability: req.accountability });
const service = new WebhooksService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = await service.create(req.body);
try {
@@ -34,8 +38,14 @@ router.post(
router.get(
'/',
asyncHandler(async (req, res, next) => {
const service = new WebhooksService({ accountability: req.accountability });
const metaService = new MetaService({ accountability: req.accountability });
const service = new WebhooksService({
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
});
const records = await service.readByQuery(req.sanitizedQuery);
const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery);
@@ -49,7 +59,10 @@ router.get(
router.get(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new WebhooksService({ accountability: req.accountability });
const service = new WebhooksService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const record = await service.readByKey(pk as any, req.sanitizedQuery);
@@ -62,7 +75,10 @@ router.get(
router.patch(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new WebhooksService({ accountability: req.accountability });
const service = new WebhooksService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
const primaryKey = await service.update(req.body, pk as any);
@@ -82,10 +98,31 @@ router.patch(
respond
);
router.delete(
'/',
asyncHandler(async (req, res, next) => {
if (!req.body || Array.isArray(req.body) === false) {
throw new InvalidPayloadException(`Body has to be an array of primary keys`);
}
const service = new WebhooksService({
accountability: req.accountability,
schema: req.schema,
});
await service.delete(req.body as PrimaryKey[]);
return next();
}),
respond
);
router.delete(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new WebhooksService({ accountability: req.accountability });
const service = new WebhooksService({
accountability: req.accountability,
schema: req.schema,
});
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
await service.delete(pk as any);

View File

@@ -4,8 +4,9 @@ import camelCase from 'camelcase';
import path from 'path';
import logger from '../logger';
import env from '../env';
import { performance } from 'perf_hooks';
import SchemaInspector from 'knex-schema-inspector';
import SchemaInspector from '@directus/schema';
dotenv.config({ path: path.resolve(__dirname, '../../', '.env') });
@@ -46,6 +47,17 @@ if (env.DB_CLIENT === 'sqlite3') {
const database = knex(knexConfig);
const times: Record<string, number> = {};
database
.on('query', (queryInfo) => {
times[queryInfo.__knexUid] = performance.now();
})
.on('query-response', (response, queryInfo) => {
const delta = performance.now() - times[queryInfo.__knexUid];
logger.trace(`[${delta.toFixed(3)}ms] ${queryInfo.sql} [${queryInfo.bindings.join(', ')}]`);
});
export async function validateDBConnection() {
try {
await database.raw('select 1+1 as result');
@@ -57,4 +69,12 @@ export async function validateDBConnection() {
}
export const schemaInspector = SchemaInspector(database);
export async function isInstalled() {
// The existence of a directus_collections table alone isn't a "proper" check to see if everything
// is installed correctly of course, but it's safe enough to assume that this collection only
// exists when using the installer CLI.
return await schemaInspector.hasTable('directus_collections');
}
export default database;

View File

@@ -0,0 +1,55 @@
import Knex from 'knex';
export async function up(knex: Knex) {
await knex.schema.alterTable('directus_fields', (table) => {
table.dropForeign(['collection']);
});
await knex.schema.alterTable('directus_activity', (table) => {
table.dropForeign(['collection']);
});
await knex.schema.alterTable('directus_permissions', (table) => {
table.dropForeign(['collection']);
});
await knex.schema.alterTable('directus_presets', (table) => {
table.dropForeign(['collection']);
});
await knex.schema.alterTable('directus_relations', (table) => {
table.dropForeign(['one_collection']);
table.dropForeign(['many_collection']);
});
await knex.schema.alterTable('directus_revisions', (table) => {
table.dropForeign(['collection']);
});
}
export async function down(knex: Knex) {
await knex.schema.alterTable('directus_fields', (table) => {
table.foreign('collection').references('directus_collections.collection');
});
await knex.schema.alterTable('directus_activity', (table) => {
table.foreign('collection').references('directus_collections.collection');
});
await knex.schema.alterTable('directus_permissions', (table) => {
table.foreign('collection').references('directus_collections.collection');
});
await knex.schema.alterTable('directus_presets', (table) => {
table.foreign('collection').references('directus_collections.collection');
});
await knex.schema.alterTable('directus_relations', (table) => {
table.foreign('one_collection').references('directus_collections.collection');
table.foreign('many_collection').references('directus_collections.collection');
});
await knex.schema.alterTable('directus_revisions', (table) => {
table.foreign('collection').references('directus_collections.collection');
});
}

View File

@@ -0,0 +1,128 @@
import Knex from 'knex';
import { merge } from 'lodash';
export async function up(knex: Knex) {
await knex('directus_relations')
.delete()
.where('many_collection', 'like', 'directus_%')
.andWhere('one_collection', 'like', 'directus_%');
}
export async function down(knex: Knex) {
const defaults = {
many_collection: 'directus_users',
many_field: null,
many_primary: null,
one_collection: null,
one_field: null,
one_primary: null,
junction_field: null,
};
const systemRelations = [
{
many_collection: 'directus_users',
many_field: 'role',
many_primary: 'id',
one_collection: 'directus_roles',
one_field: 'users',
one_primary: 'id',
},
{
many_collection: 'directus_users',
many_field: 'avatar',
many_primary: 'id',
one_collection: 'directus_files',
one_primary: 'id',
},
{
many_collection: 'directus_revisions',
many_field: 'activity',
many_primary: 'id',
one_collection: 'directus_activity',
one_field: 'revisions',
one_primary: 'id',
},
{
many_collection: 'directus_presets',
many_field: 'user',
many_primary: 'id',
one_collection: 'directus_users',
one_primary: 'id',
},
{
many_collection: 'directus_presets',
many_field: 'role',
many_primary: 'id',
one_collection: 'directus_roles',
one_primary: 'id',
},
{
many_collection: 'directus_folders',
many_field: 'parent',
many_primary: 'id',
one_collection: 'directus_folders',
one_primary: 'id',
},
{
many_collection: 'directus_files',
many_field: 'folder',
many_primary: 'id',
one_collection: 'directus_folders',
one_primary: 'id',
},
{
many_collection: 'directus_files',
many_field: 'uploaded_by',
many_primary: 'id',
one_collection: 'directus_users',
one_primary: 'id',
},
{
many_collection: 'directus_fields',
many_field: 'collection',
many_primary: 'id',
one_collection: 'directus_collections',
one_field: 'fields',
one_primary: 'collection',
},
{
many_collection: 'directus_activity',
many_field: 'user',
many_primary: 'id',
one_collection: 'directus_users',
one_primary: 'id',
},
{
many_collection: 'directus_settings',
many_field: 'project_logo',
many_primary: 'id',
one_collection: 'directus_files',
one_primary: 'id',
},
{
many_collection: 'directus_settings',
many_field: 'public_foreground',
many_primary: 'id',
one_collection: 'directus_files',
one_primary: 'id',
},
{
many_collection: 'directus_settings',
many_field: 'public_background',
many_primary: 'id',
one_collection: 'directus_files',
one_primary: 'id',
},
].map((row) => {
for (const [key, value] of Object.entries(row)) {
if (value !== null && (typeof value === 'object' || Array.isArray(value))) {
(row as any)[key] = JSON.stringify(value);
}
}
return merge({}, defaults, row);
});
await knex.insert(systemRelations).into('directus_relations');
}

View File

@@ -0,0 +1,99 @@
import Knex from 'knex';
import { merge } from 'lodash';
export async function up(knex: Knex) {
await knex('directus_collections').delete().where('collection', 'like', 'directus_%');
}
export async function down(knex: Knex) {
const defaults = {
collection: null,
hidden: false,
singleton: false,
icon: null,
note: null,
translations: null,
display_template: null,
};
const systemCollections = [
{
collection: 'directus_activity',
note: 'Accountability logs for all events',
},
{
collection: 'directus_collections',
icon: 'list_alt',
note: 'Additional collection configuration and metadata',
},
{
collection: 'directus_fields',
icon: 'input',
note: 'Additional field configuration and metadata',
},
{
collection: 'directus_files',
icon: 'folder',
note: 'Metadata for all managed file assets',
},
{
collection: 'directus_folders',
note: 'Provides virtual directories for files',
},
{
collection: 'directus_permissions',
icon: 'admin_panel_settings',
note: 'Access permissions for each role',
},
{
collection: 'directus_presets',
icon: 'bookmark_border',
note: 'Presets for collection defaults and bookmarks',
},
{
collection: 'directus_relations',
icon: 'merge_type',
note: 'Relationship configuration and metadata',
},
{
collection: 'directus_revisions',
note: 'Data snapshots for all activity',
},
{
collection: 'directus_roles',
icon: 'supervised_user_circle',
note: 'Permission groups for system users',
},
{
collection: 'directus_sessions',
note: 'User session information',
},
{
collection: 'directus_settings',
singleton: true,
note: 'Project configuration options',
},
{
collection: 'directus_users',
archive_field: 'status',
archive_value: 'archived',
unarchive_value: 'draft',
icon: 'people_alt',
note: 'System users for the platform',
},
{
collection: 'directus_webhooks',
note: 'Configuration for event-based HTTP requests',
},
].map((row) => {
for (const [key, value] of Object.entries(row)) {
if (value !== null && (typeof value === 'object' || Array.isArray(value))) {
(row as any)[key] = JSON.stringify(value);
}
}
return merge({}, defaults, row);
});
await knex.insert(systemCollections).into('directus_collections');
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,156 @@
import Knex from 'knex';
const updates = [
{
table: 'directus_fields',
constraints: [
{
column: 'group',
references: 'directus_fields.id',
onDelete: 'SET NULL',
},
],
},
{
table: 'directus_files',
constraints: [
{
column: 'folder',
references: 'directus_folders.id',
onDelete: 'SET NULL',
},
{
column: 'uploaded_by',
references: 'directus_users.id',
onDelete: 'SET NULL',
},
{
column: 'modified_by',
references: 'directus_users.id',
onDelete: 'SET NULL',
},
],
},
{
table: 'directus_folders',
constraints: [
{
column: 'parent',
references: 'directus_folders.id',
onDelete: 'CASCADE',
},
],
},
{
table: 'directus_permissions',
constraints: [
{
column: 'role',
references: 'directus_roles.id',
onDelete: 'CASCADE',
},
],
},
{
table: 'directus_presets',
constraints: [
{
column: 'user',
references: 'directus_users.id',
onDelete: 'CASCADE',
},
{
column: 'role',
references: 'directus_roles.id',
onDelete: 'CASCADE',
},
],
},
{
table: 'directus_revisions',
constraints: [
{
column: 'activity',
references: 'directus_activity.id',
onDelete: 'CASCADE',
},
{
column: 'parent',
references: 'directus_revisions.id',
onDelete: 'SET NULL',
},
],
},
{
table: 'directus_sessions',
constraints: [
{
column: 'user',
references: 'directus_users.id',
onDelete: 'CASCADE',
},
],
},
{
table: 'directus_settings',
constraints: [
{
column: 'project_logo',
references: 'directus_files.id',
onDelete: 'SET NULL',
},
{
column: 'public_foreground',
references: 'directus_files.id',
onDelete: 'SET NULL',
},
{
column: 'public_background',
references: 'directus_files.id',
onDelete: 'SET NULL',
},
],
},
{
table: 'directus_users',
constraints: [
{
column: 'role',
references: 'directus_roles.id',
onDelete: 'SET NULL',
},
],
},
];
export async function up(knex: Knex) {
for (const update of updates) {
await knex.schema.alterTable(update.table, (table) => {
for (const constraint of update.constraints) {
table.dropForeign([constraint.column]);
table
.foreign(constraint.column)
.references(constraint.references)
.onUpdate('CASCADE')
.onDelete(constraint.onDelete);
}
});
}
}
export async function down(knex: Knex) {
for (const update of updates) {
await knex.schema.alterTable(update.table, (table) => {
for (const constraint of update.constraints) {
table.dropForeign([constraint.column]);
table
.foreign(constraint.column)
.references(constraint.references)
.onUpdate('NO ACTION')
.onDelete('NO ACTION');
}
});
}
}

View File

@@ -0,0 +1,13 @@
import Knex from 'knex';
export async function up(knex: Knex) {
await knex.schema.alterTable('directus_webhooks', (table) => {
table.text('url').alter();
});
}
export async function down(knex: Knex) {
await knex.schema.alterTable('directus_webhooks', (table) => {
table.string('url').alter();
});
}

View File

@@ -11,7 +11,10 @@ type Migration = {
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');
migrationFiles = migrationFiles.filter(
(file: string) => file.startsWith('run') === false && file.endsWith('.d.ts') === false
);
const completedMigrations = await database
.select<Migration[]>('*')

View File

@@ -1,8 +1,7 @@
import { AST, NestedCollectionNode, FieldNode, M2ONode, O2MNode } from '../types/ast';
import { AST, NestedCollectionNode, FieldNode } from '../types/ast';
import { clone, cloneDeep, uniq, pick } from 'lodash';
import database from './index';
import SchemaInspector from 'knex-schema-inspector';
import { Query, Item } from '../types';
import { Query, Item, SchemaOverview } from '../types';
import { PayloadService } from '../services/payload';
import applyQuery from '../utils/apply-query';
import Knex, { QueryBuilder } from 'knex';
@@ -16,6 +15,7 @@ type RunASTOptions = {
export default async function runAST(
originalAST: AST | NestedCollectionNode,
schema: SchemaOverview,
options?: RunASTOptions
): Promise<null | Item | Item[]> {
const ast = cloneDeep(originalAST);
@@ -47,18 +47,25 @@ export default async function runAST(
const { columnsToSelect, primaryKeyField, nestedCollectionNodes } = await parseCurrentLevel(
collection,
children,
knex
schema
);
// The actual knex query builder instance. This is a promise that resolves with the raw items from the db
const dbQuery = await getDBQuery(knex, collection, columnsToSelect, query, primaryKeyField);
const dbQuery = await getDBQuery(
knex,
collection,
columnsToSelect,
query,
primaryKeyField,
schema
);
const rawItems: Item | Item[] = await dbQuery;
if (!rawItems) return null;
// Run the items through the special transforms
const payloadService = new PayloadService(collection, { knex });
const payloadService = new PayloadService(collection, { knex, schema });
let items: null | Item | Item[] = await payloadService.processValues('read', rawItems);
if (!items || items.length === 0) return items;
@@ -78,10 +85,11 @@ export default async function runAST(
nestedNode.query.limit = -1;
}
let nestedItems = await runAST(nestedNode, { knex, child: true });
let nestedItems = await runAST(nestedNode, schema, { knex, child: true });
if (nestedItems) {
// Merge all fetched nested records with the parent items
items = mergeWithParentItems(nestedItems, items, nestedNode, tempLimit);
}
}
@@ -101,15 +109,10 @@ export default async function runAST(
async function parseCurrentLevel(
collection: string,
children: (NestedCollectionNode | FieldNode)[],
knex: Knex
schema: SchemaOverview
) {
const schemaInspector = SchemaInspector(knex);
const primaryKeyField = await schemaInspector.primary(collection);
const columnsInCollection = (await schemaInspector.columns(collection)).map(
({ column }) => column
);
const primaryKeyField = schema[collection].primary;
const columnsInCollection = Object.keys(schema[collection].columns);
const columnsToSelect: string[] = [];
const nestedCollectionNodes: NestedCollectionNode[] = [];
@@ -150,7 +153,8 @@ async function getDBQuery(
table: string,
columns: string[],
query: Query,
primaryKeyField: string
primaryKeyField: string,
schema: SchemaOverview
): Promise<QueryBuilder> {
let dbQuery = knex.select(columns.map((column) => `${table}.${column}`)).from(table);
@@ -164,7 +168,7 @@ async function getDBQuery(
query.sort = query.sort || [{ column: primaryKeyField, order: 'asc' }];
await applyQuery(table, dbQuery, queryCopy);
await applyQuery(knex, table, dbQuery, queryCopy, schema);
return dbQuery;
}
@@ -269,7 +273,7 @@ function mergeWithParentItems(
nestedItem[nestedNode.relation.many_field] ===
parentItem[nestedNode.relation.one_primary!] ||
nestedItem[nestedNode.relation.many_field]?.[
nestedNode.relation.many_primary
nestedNode.relation.one_primary!
] === parentItem[nestedNode.relation.one_primary!]
);
});

View File

@@ -1,84 +0,0 @@
table: directus_presets
defaults:
bookmark: null
user: null
role: null
collection: null
search: null
filters: '[]'
layout: tabular
layout_query: null
layout_options: null
data:
- collection: directus_files
layout: cards
layout_query:
cards:
sort: -uploaded_on
layout_options:
cards:
icon: insert_drive_file
title: '{{ title }}'
subtitle: '{{ type }} • {{ filesize }}'
size: 4
imageFit: crop
- collection: directus_users
layout: cards
layout_options:
cards:
icon: account_circle
title: '{{ first_name }} {{ last_name }}'
subtitle: '{{ email }}'
size: 4
- collection: directus_activity
layout: tabular
layout_query:
tabular:
sort: -timestamp
fields:
- action
- collection
- timestamp
- user
layout_options:
tabular:
widths:
action: 100
collection: 210
timestamp: 240
user: 240
- collection: directus_webhooks
layout: tabular
layout_query:
tabular:
fields:
- status
- name
- method
- url
layout_options:
tabular:
widths:
status: 36
name: 300
- collection: directus_roles
layout: tabular
layout_query:
tabular:
fields:
- icon
- name
- description
layout_options:
tabular:
widths:
icon: 36
name: 248
description: 500

View File

@@ -1,39 +0,0 @@
# directus_fields isn't surfaced in the app
table: directus_fields
fields:
- collection: directus_fields
field: options
hidden: true
locked: true
special: json
- collection: directus_fields
field: display_options
hidden: true
locked: true
special: json
- collection: directus_fields
field: locked
hidden: true
locked: true
special: boolean
- collection: directus_fields
field: readonly
hidden: true
locked: true
special: boolean
- collection: directus_fields
field: hidden
hidden: true
locked: true
special: boolean
- collection: directus_fields
field: special
hidden: true
locked: true
special: csv
- collection: directus_fields
field: translations
hidden: true
locked: true
special: json

View File

@@ -1,8 +0,0 @@
table: directus_folders
fields:
- collection: directus_folders
field: id
interface: text-input
locked: true
special: uuid

View File

@@ -1,14 +0,0 @@
# directus_permissions isn't surfaced in the app
table: directus_permissions
fields:
- collection: directus_permissions
field: permissions
hidden: true
locked: true
special: json
- collection: directus_permissions
field: presets
hidden: true
locked: true
special: json

View File

@@ -1,19 +0,0 @@
table: directus_presets
fields:
# directus_presets isn't surfaced in the app
- collection: directus_presets
field: filters
hidden: true
locked: true
special: json
- collection: directus_presets
field: layout_query
hidden: true
locked: true
special: json
- collection: directus_presets
field: layout_options
hidden: true
locked: true
special: json

View File

@@ -1,14 +0,0 @@
table: directus_revisions
fields:
# directus_revisions isn't surfaced in the app
- collection: directus_revisions
field: data
hidden: true
locked: true
special: json
- collection: directus_revisions
field: delta
hidden: true
locked: true
special: json

View File

@@ -1,7 +0,0 @@
table: directus_relations
fields:
- collection: directus_relations
field: one_allowed_collections
locked: true
special: csv

View File

@@ -25,33 +25,6 @@ type TableSeed = {
};
};
type RowSeed = {
table: string;
defaults: Record<string, any>;
data: Record<string, any>[];
};
type FieldSeed = {
table: string;
fields: {
collection: string;
field: string;
special: string | null;
interface: string | null;
options: Record<string, any> | null;
display: string | null;
display_options: Record<string, any> | null;
locked: boolean;
readonly: boolean;
hidden: boolean;
sort: number | null;
width: string | null;
group: number | null;
translations: Record<string, any> | null;
note: string | null;
}[];
};
export default async function runSeed(database: Knex) {
const exists = await database.schema.hasTable('directus_collections');
@@ -59,19 +32,13 @@ export default async function runSeed(database: Knex) {
throw new Error('Database is already installed');
}
await createTables(database);
await insertRows(database);
await insertFields(database);
}
async function createTables(database: Knex) {
const tableSeeds = await fse.readdir(path.resolve(__dirname, './01-tables/'));
const tableSeeds = await fse.readdir(path.resolve(__dirname));
for (const tableSeedFile of tableSeeds) {
const yamlRaw = await fse.readFile(
path.resolve(__dirname, './01-tables', tableSeedFile),
'utf8'
);
if (tableSeedFile.startsWith('run')) continue;
const yamlRaw = await fse.readFile(path.resolve(__dirname, tableSeedFile), 'utf8');
const seedData = yaml.safeLoad(yamlRaw) as TableSeed;
await database.schema.createTable(seedData.table, (tableBuilder) => {
@@ -119,8 +86,7 @@ async function createTables(database: Knex) {
}
if (columnInfo.references) {
tableBuilder
.foreign(columnName)
column
.references(columnInfo.references.column)
.inTable(columnInfo.references.table);
}
@@ -128,61 +94,3 @@ async function createTables(database: Knex) {
});
}
}
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 seedData = yaml.safeLoad(yamlRaw) as RowSeed;
const dataWithDefaults = seedData.data.map((row) => {
for (const [key, value] of Object.entries(row)) {
if (value !== null && (typeof value === 'object' || Array.isArray(value))) {
row[key] = JSON.stringify(value);
}
}
return merge({}, seedData.defaults, row);
});
await database.batchInsert(seedData.table, dataWithDefaults);
}
}
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 defaults = yaml.safeLoad(defaultsYaml) as FieldSeed;
for (const fieldSeedFile of fieldSeeds) {
const yamlRaw = await fse.readFile(
path.resolve(__dirname, './03-fields', fieldSeedFile),
'utf8'
);
const seedData = yaml.safeLoad(yamlRaw) as FieldSeed;
if (fieldSeedFile === '_defaults.yaml') {
continue;
}
const dataWithDefaults = seedData.fields.map((row) => {
for (const [key, value] of Object.entries(row)) {
if (value !== null && (typeof value === 'object' || Array.isArray(value))) {
(row as any)[key] = JSON.stringify(value);
}
}
return merge({}, defaults, row);
});
await database.batchInsert('directus_fields', dataWithDefaults);
}
}

View File

@@ -23,6 +23,8 @@ data:
note: Metadata for all managed file assets
- collection: directus_folders
note: Provides virtual directories for files
- collection: directus_migrations
note: What version of the database you're using
- collection: directus_permissions
icon: admin_panel_settings
note: Access permissions for each role

View File

@@ -0,0 +1,11 @@
import { requireYAML } from '../../../utils/require-yaml';
import { merge } from 'lodash';
import { CollectionMeta } from '../../../types';
const systemData = requireYAML(require.resolve('./collections.yaml'));
export const systemCollectionRows: CollectionMeta[] = systemData.data.map(
(row: Record<string, any>) => {
return merge({ system: true }, systemData.defaults, row);
}
);

View File

@@ -1,8 +1,13 @@
table: directus_activity
fields:
- collection: directus_activity
field: action
- field: id
width: half
- field: item
width: half
- field: action
display: labels
display_options:
defaultForeground: '#263238'
@@ -24,41 +29,47 @@ fields:
value: authenticate
foreground: '#9b51e0'
background: '#e6d3f7'
- collection: directus_activity
field: collection
width: half
- field: collection
display: collection
display_options:
icon: true
- collection: directus_activity
field: timestamp
width: half
- field: timestamp
display: datetime
options:
relative: true
- collection: directus_activity
field: user
width: half
- field: user
display: user
- collection: directus_activity
field: comment
width: half
- field: comment
display: formatted-text
display_options:
subdued: true
- collection: directus_activity
field: user_agent
width: half
- field: user_agent
display: formatted-text
display_options:
font: monospace
- collection: directus_activity
field: ip
width: half
- field: ip
display: formatted-text
display_options:
font: monospace
- collection: directus_activity
field: revisions
width: half
- field: revisions
interface: one-to-many
locked: true
special: o2m
options:
fields:
- collection
- item
width: full
width: half

View File

@@ -1,69 +1,54 @@
table: directus_collections
fields:
- collection: directus_collections
field: collection_divider
- field: collection_divider
special: alias
interface: divider
options:
icon: box
title: Collection Setup
color: '#2F80ED'
locked: true
sort: 1
width: full
- collection: directus_collections
field: collection
- field: collection
interface: text-input
options:
font: monospace
locked: true
readonly: true
sort: 2
width: half
- collection: directus_collections
field: icon
- field: icon
interface: icon
options:
locked: true
sort: 3
width: half
- collection: directus_collections
field: note
- field: note
interface: text-input
options:
placeholder: A description of this collection...
locked: true
sort: 4
width: full
- collection: directus_collections
field: display_template
- field: display_template
interface: display-template
options:
collectionField: collection
locked: true
sort: 5
width: full
- collection: directus_collections
field: hidden
- field: hidden
special: boolean
interface: toggle
options:
label: Hide within the App
locked: true
sort: 6
width: half
- collection: directus_collections
field: singleton
- field: singleton
special: boolean
interface: toggle
options:
label: Treat as single object
locked: true
sort: 7
width: half
- collection: directus_collections
field: translations
- field: translations
special: json
interface: repeater
options:
@@ -85,72 +70,58 @@ fields:
width: half
options:
placeholder: Enter a translation...
locked: true
sort: 8
width: full
- collection: directus_collections
field: archive_divider
- field: archive_divider
special: alias
interface: divider
options:
icon: archive
title: Archive
color: '#2F80ED'
locked: true
sort: 9
width: full
- collection: directus_collections
field: archive_field
- field: archive_field
interface: field
options:
collectionField: collection
allowNone: true
placeholder: Choose a field...
locked: true
sort: 10
width: half
- collection: directus_collections
field: archive_app_filter
- field: archive_app_filter
interface: toggle
special: boolean
options:
label: Enable App Archive Filter
locked: true
sort: 11
width: half
- collection: directus_collections
field: archive_value
- field: archive_value
interface: text-input
options:
font: monospace
iconRight: archive
placeholder: Value set when archiving...
locked: true
sort: 12
width: half
- collection: directus_collections
field: unarchive_value
- field: unarchive_value
interface: text-input
options:
font: monospace
iconRight: unarchive
placeholder: Value set when unarchiving...
locked: true
sort: 13
width: half
- collection: directus_collections
field: sort_divider
- field: sort_divider
special: alias
interface: divider
options:
icon: sort
title: Sort
color: '#2F80ED'
locked: true
sort: 14
width: full
- collection: directus_collections
field: sort_field
- field: sort_field
interface: field
options:
collectionField: collection
@@ -160,6 +131,4 @@ fields:
- decimal
- integer
allowNone: true
locked: true
sort: 15
width: half

View File

@@ -0,0 +1,81 @@
# directus_fields isn't surfaced in the app
table: directus_fields
fields:
- collection: directus_fields
field: id
width: half
- collection: directus_fields
field: collection
width: half
- collection: directus_fields
field: field
width: half
- collection: directus_fields
field: special
hidden: true
special: csv
width: half
- collection: directus_fields
field: interface
width: half
- collection: directus_fields
field: options
hidden: true
special: json
width: half
- collection: directus_fields
field: display
width: half
- collection: directus_fields
field: display_options
hidden: true
special: json
width: half
- collection: directus_fields
field: locked
hidden: true
special: boolean
width: half
- collection: directus_fields
field: readonly
hidden: true
special: boolean
width: half
- collection: directus_fields
field: hidden
hidden: true
special: boolean
width: half
- collection: directus_fields
field: sort
width: half
- collection: directus_fields
field: width
width: half
- collection: directus_fields
field: group
width: half
- collection: directus_fields
field: translations
hidden: true
special: json
width: half
- collection: directus_fields
field: note
width: half

View File

@@ -1,114 +1,117 @@
table: directus_files
fields:
- collection: directus_files
field: id
- field: id
hidden: true
interface: text-input
locked: true
special: uuid
- collection: directus_files
field: title
- field: title
interface: text-input
locked: true
options:
iconRight: title
placeholder: A unique title...
sort: 1
width: full
- collection: directus_files
field: description
- field: description
interface: textarea
locked: true
sort: 2
width: full
options:
placeholder: An optional description...
- collection: directus_files
field: tags
- field: tags
interface: tags
locked: true
options:
iconRight: local_offer
special: json
sort: 3
width: full
display: tags
- collection: directus_files
field: location
- field: location
interface: text-input
locked: true
options:
iconRight: place
placeholder: An optional location...
sort: 4
width: half
- collection: directus_files
field: storage
- field: storage
interface: text-input
locked: true
options:
iconRight: storage
sort: 5
width: half
readonly: true
- collection: directus_files
field: storage_divider
- field: storage_divider
interface: divider
locked: true
options:
icon: insert_drive_file
title: File Naming
color: '#2F80ED'
special: alias
sort: 6
width: full
- collection: directus_files
field: filename_disk
- field: filename_disk
interface: text-input
locked: true
options:
iconRight: publish
placeholder: Name on disk storage...
sort: 7
width: half
- collection: directus_files
field: filename_download
- field: filename_download
interface: text-input
locked: true
options:
iconRight: get_app
placeholder: Name when downloading...
sort: 8
width: half
- collection: directus_files
field: metadata
- field: metadata
hidden: true
locked: true
special: json
- collection: directus_files
field: type
- field: type
display: mime-type
- collection: directus_files
field: filesize
- field: filesize
display: filesize
- collection: directus_files
field: modified_by
- field: modified_by
interface: user
locked: true
special: user-updated
width: half
display: user
- collection: directus_files
field: modified_on
- field: modified_on
interface: datetime
locked: true
special: date-updated
width: half
display: datetime
- collection: directus_files
field: created_on
- field: created_on
display: datetime
- collection: directus_files
field: created_by
- field: created_by
display: user
- field: embed
width: half
- field: uploaded_by
width: half
- field: folder
width: half
- field: width
width: half
- field: uploaded_on
width: half
- field: height
width: half
- field: charset
width: half
- field: duration
width: half

View File

@@ -0,0 +1,14 @@
table: directus_folders
fields:
- field: id
interface: text-input
special: uuid
width: half
- field: parent
width: half
- field: name
width: full

View File

@@ -0,0 +1,25 @@
import { requireYAML } from '../../../utils/require-yaml';
import { merge } from 'lodash';
import { FieldMeta } from '../../../types';
import fse from 'fs-extra';
import path from 'path';
const defaults = requireYAML(require.resolve('./_defaults.yaml'));
const fieldData = fse.readdirSync(path.resolve(__dirname));
export let systemFieldRows: FieldMeta[] = [];
for (const filepath of fieldData) {
if (filepath.includes('_defaults') || filepath.includes('index')) continue;
const systemFields = requireYAML(path.resolve(__dirname, filepath));
(systemFields.fields as FieldMeta[]).forEach((field, index) => {
systemFieldRows.push(
merge({ system: true }, defaults, field, {
collection: systemFields.table,
sort: index + 1,
})
);
});
}

View File

@@ -0,0 +1,36 @@
# directus_permissions isn't surfaced in the app
table: directus_permissions
fields:
- field: permissions
hidden: true
special: json
width: half
- field: presets
hidden: true
special: json
width: half
- field: role
width: half
- field: limit
width: half
- field: collection
width: half
- field: id
width: half
- field: fields
width: half
special: csv
- field: action
width: half
- field: validation
width: half
special: json

View File

@@ -0,0 +1,35 @@
table: directus_presets
fields:
- field: filters
hidden: true
special: json
- field: layout_query
hidden: true
special: json
- field: layout_options
hidden: true
special: json
- field: role
width: half
- field: user
width: half
- field: id
width: half
- field: bookmark
width: half
- field: search
width: half
- field: collection
width: half
- field: layout
width: half

View File

@@ -0,0 +1,33 @@
table: directus_relations
fields:
- field: id
width: half
- field: many_collection
width: half
- field: many_field
width: half
- field: many_primary
width: half
- field: one_collection
width: half
- field: one_field
width: half
- field: one_primary
width: half
- field: one_collection_field
width: half
- field: one_allowed_collections
special: csv
width: half
- field: junction_field
width: half

View File

@@ -0,0 +1,25 @@
table: directus_revisions
fields:
- field: id
width: half
- field: activity
width: half
- field: collection
width: half
- field: item
width: half
- field: data
hidden: true
special: json
- field: delta
hidden: true
special: json
- field: parent
width: half

View File

@@ -1,80 +1,61 @@
table: directus_roles
fields:
- collection: directus_roles
field: id
- field: id
hidden: true
interface: text-input
locked: true
special: uuid
- collection: directus_roles
field: name
- field: name
interface: text-input
options:
placeholder: The unique name for this role...
locked: true
sort: 1
width: half
- collection: directus_roles
field: icon
- field: icon
interface: icon
display: icon
locked: true
sort: 2
width: half
- collection: directus_roles
field: description
- field: description
interface: text-input
options:
placeholder: A description of this role...
locked: true
sort: 3
width: full
- collection: directus_roles
field: app_access
- field: app_access
interface: toggle
locked: true
special: boolean
sort: 4
width: half
- collection: directus_roles
field: admin_access
- field: admin_access
interface: toggle
locked: true
special: boolean
sort: 5
width: half
- collection: directus_roles
field: ip_access
- field: ip_access
interface: tags
options:
placeholder: Add allowed IP addresses, leave empty to allow all...
locked: true
special: csv
sort: 6
width: full
- collection: directus_roles
field: enforce_tfa
- field: enforce_tfa
interface: toggle
locked: true
sort: 7
special: boolean
width: half
- collection: directus_roles
field: users
- field: users
interface: one-to-many
locked: true
special: o2m
sort: 8
options:
fields:
- first_name
- last_name
width: full
- collection: directus_roles
field: module_list
- field: module_list
interface: repeater
locked: true
options:
template: '{{ name }}'
addLabel: Add New Module...
@@ -104,12 +85,10 @@ fields:
iconRight: link
placeholder: Relative or absolute URL...
special: json
sort: 9
width: full
- collection: directus_roles
field: collection_list
- field: collection_list
interface: repeater
locked: true
options:
template: '{{ group_name }}'
addLabel: Add New Group...
@@ -159,5 +138,4 @@ fields:
schema:
is_nullable: false
special: json
sort: 10
width: full

View File

@@ -0,0 +1,17 @@
table: directus_sessions
fields:
- field: token
width: half
- field: user
width: half
- field: expires
width: half
- field: ip
width: half
- field: user_agent
width: half

View File

@@ -1,102 +1,85 @@
table: directus_settings
fields:
- collection: directus_settings
field: project_name
- field: id
hidden: true
- field: project_name
interface: text-input
locked: true
options:
iconRight: title
placeholder: My project...
sort: 1
translations:
language: en-US
translations: Name
width: half
- collection: directus_settings
field: project_url
- field: project_url
interface: text-input
locked: true
options:
iconRight: link
placeholder: https://example.com
sort: 2
translations:
language: en-US
translations: Website
width: half
- collection: directus_settings
field: project_color
- field: project_color
interface: color
locked: true
note: Login & Logo Background
sort: 3
translations:
language: en-US
translations: Brand Color
width: half
- collection: directus_settings
field: project_logo
- field: project_logo
interface: file
locked: true
note: White 40x40 SVG/PNG
sort: 4
translations:
language: en-US
translations: Brand Logo
width: half
- collection: directus_settings
field: public_divider
- field: public_divider
interface: divider
locked: true
options:
icon: public
title: Public Pages
color: '#2F80ED'
special: alias
sort: 5
width: full
- collection: directus_settings
field: public_foreground
- field: public_foreground
interface: file
locked: true
sort: 6
translations:
language: en-US
translations: Login Foreground
width: half
- collection: directus_settings
field: public_background
- field: public_background
interface: file
locked: true
sort: 7
translations:
language: en-US
translations: Login Background
width: half
- collection: directus_settings
field: public_note
- field: public_note
interface: textarea
locked: true
options:
placeholder: A short, public message that supports markdown formatting...
sort: 8
width: full
- collection: directus_settings
field: security_divider
- field: security_divider
interface: divider
locked: true
options:
icon: security
title: Security
color: '#2F80ED'
special: alias
sort: 9
width: full
- collection: directus_settings
field: auth_password_policy
- field: auth_password_policy
interface: dropdown
locked: true
options:
choices:
- value: null
@@ -105,31 +88,25 @@ fields:
text: Weak Minimum 8 Characters
- value: "/(?=^.{8,}$)(?=.*\\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&*()_+}{';'?>.<,])(?!.*\\s).*$/"
text: Strong Upper / Lowercase / Numbers / Special
sort: 10
width: half
- collection: directus_settings
field: auth_login_attempts
- field: auth_login_attempts
interface: numeric
locked: true
options:
iconRight: lock
sort: 11
width: half
- collection: directus_settings
field: files_divider
- field: files_divider
interface: divider
locked: true
options:
icon: storage
title: Files & Thumbnails
color: '#2F80ED'
special: alias
sort: 12
width: full
- collection: directus_settings
field: storage_asset_presets
- field: storage_asset_presets
interface: repeater
locked: true
options:
fields:
- field: key
@@ -155,6 +132,11 @@ fields:
text: Contain (preserve aspect ratio)
- value: cover
text: Cover (forces exact size)
- value: inside
text: Fit inside
- value: outside
text: Fit outside
required: true
width: half
- field: width
name: Width
@@ -184,15 +166,23 @@ fields:
max: 100
min: 0
step: 1
width: full
required: true
width: half
- field: withoutEnlargement
type: boolean
schema:
default_value: false
meta:
interface: toggle
width: half
options:
label: Don't upscale images
template: '{{key}}'
special: json
sort: 13
width: full
- collection: directus_settings
field: storage_asset_transform
- field: storage_asset_transform
interface: dropdown
locked: true
options:
choices:
- value: all
@@ -201,29 +191,20 @@ fields:
text: None
- value: presets
text: Presets Only
sort: 14
width: half
- collection: directus_settings
field: id
hidden: true
locked: true
- collection: directus_settings
field: overrides_divider
- field: overrides_divider
interface: divider
locked: true
options:
icon: brush
title: App Overrides
color: '#2F80ED'
special: alias
sort: 15
width: full
- collection: directus_settings
field: custom_css
- field: custom_css
interface: code
locked: true
options:
language: css
lineNumber: true
sort: 16
width: full

View File

@@ -1,87 +1,70 @@
table: directus_users
fields:
- collection: directus_users
field: first_name
- field: first_name
interface: text-input
locked: true
options:
iconRight: account_circle
sort: 1
width: half
- collection: directus_users
field: last_name
- field: last_name
interface: text-input
locked: true
options:
iconRight: account_circle
sort: 2
width: half
- collection: directus_users
field: email
- field: email
interface: text-input
locked: true
options:
iconRight: email
sort: 3
width: half
- collection: directus_users
field: password
special: hash, conceal
- field: password
special: hash,conceal
interface: hash
locked: true
options:
iconRight: lock
masked: true
sort: 4
width: half
- collection: directus_users
field: avatar
- field: avatar
interface: file
locked: true
sort: 5
width: full
- collection: directus_users
field: location
- field: location
interface: text-input
options:
iconRight: place
sort: 6
width: half
- collection: directus_users
field: title
- field: title
interface: text-input
options:
iconRight: work
sort: 7
width: half
- collection: directus_users
field: description
- field: description
interface: textarea
sort: 8
width: full
- collection: directus_users
field: tags
- field: tags
interface: tags
special: json
sort: 9
width: full
options:
iconRight: local_offer
- collection: directus_users
field: preferences_divider
- field: preferences_divider
interface: divider
options:
icon: face
title: User Preferences
color: '#2F80ED'
special: alias
sort: 10
width: full
- collection: directus_users
field: language
- field: language
interface: dropdown
locked: true
options:
choices:
- text: Afrikaans (South Africa)
@@ -146,12 +129,10 @@ fields:
value: uk-UA
- text: Vietnamese (Vietnam)
value: vi-VN
sort: 11
width: half
- collection: directus_users
field: theme
- field: theme
interface: dropdown
locked: true
options:
choices:
- value: auto
@@ -160,30 +141,24 @@ fields:
text: Light Mode
- value: dark
text: Dark Mode
sort: 12
width: half
- collection: directus_users
field: tfa_secret
- field: tfa_secret
interface: tfa-setup
locked: true
special: conceal
sort: 13
width: half
- collection: directus_users
field: admin_divider
- field: admin_divider
interface: divider
locked: true
options:
icon: verified_user
title: Admin Options
color: '#F2994A'
special: alias
sort: 14
width: full
- collection: directus_users
field: status
- field: status
interface: dropdown
locked: true
options:
choices:
- text: Draft
@@ -196,32 +171,31 @@ fields:
value: suspended
- text: Archived
value: archived
sort: 15
width: half
- collection: directus_users
field: role
- field: role
interface: many-to-one
locked: true
options:
template: '{{ name }}'
special: m2o
sort: 16
width: half
- collection: directus_users
field: token
- field: token
interface: token
locked: true
options:
iconRight: vpn_key
placeholder: Enter a secure access token...
sort: 17
width: full
- collection: directus_users
field: id
- field: id
special: uuid
interface: text-input
locked: true
options:
iconRight: vpn_key
sort: 18
width: full
- field: last_page
width: half
- field: last_access
width: half

View File

@@ -1,43 +1,35 @@
table: directus_webhooks
fields:
- collection: directus_webhooks
field: id
- field: id
hidden: true
locked: true
- collection: directus_webhooks
field: name
- field: name
interface: text-input
locked: true
options:
iconRight: title
sort: 1
width: full
- collection: directus_webhooks
field: method
- field: method
interface: dropdown
display: labels
display_options:
defaultBackground: "#ECEFF1"
choices: null
format: false
locked: true
options:
choices:
- GET
- POST
sort: 2
width: half
- collection: directus_webhooks
field: url
- field: url
interface: text-input
locked: true
options:
iconRight: link
sort: 3
width: half
- collection: directus_webhooks
field: status
- field: status
interface: dropdown
display: labels
display_options:
@@ -53,36 +45,31 @@ fields:
value: inactive
foreground: "#607D8B"
background: "#ECEFF1"
locked: true
options:
choices:
- text: Active
value: active
- text: Inactive
value: inactive
sort: 4
width: half
- collection: directus_webhooks
field: data
- field: data
interface: toggle
locked: true
options:
label: Send Event Data
special: boolean
sort: 5
width: half
- collection: directus_webhooks
field: triggers_divider
- field: triggers_divider
interface: divider
options:
icon: api
title: Triggers
color: '#2F80ED'
special: alias
sort: 6
width: full
- collection: directus_webhooks
field: actions
- field: actions
interface: checkboxes
options:
choices:
@@ -93,11 +80,9 @@ fields:
- text: Delete
value: delete
special: csv
sort: 7
width: full
- collection: directus_webhooks
field: collections
- field: collections
interface: collections
special: csv
sort: 8
width: full

View File

@@ -0,0 +1,9 @@
import { requireYAML } from '../../../utils/require-yaml';
import { merge } from 'lodash';
import { Relation } from '../../../types';
const systemData = requireYAML(require.resolve('./relations.yaml'));
export const systemRelationRows: Relation[] = systemData.data.map((row: Record<string, any>) => {
return merge({ system: true }, systemData.defaults, row);
});

View File

@@ -27,7 +27,7 @@ const defaults: Record<string, any> = {
REFRESH_TOKEN_COOKIE_SECURE: false,
REFRESH_TOKEN_COOKIE_SAME_SITE: 'lax',
CORS_ENABLED: false,
CORS_ENABLED: true,
CACHE_ENABLED: false,
CACHE_STORE: 'memory',

View File

@@ -1,3 +1,4 @@
import database from '../database';
import logger from '../logger';
import nodemailer, { Transporter } from 'nodemailer';
import { Liquid } from 'liquidjs';
@@ -42,6 +43,34 @@ export type EmailOptions = {
html: string;
};
/**
* Get an object with default template options to pass to the email templates.
*/
async function getDefaultTemplateOptions() {
const projectInfo = await database
.select(['project_name', 'project_logo', 'project_color'])
.from('directus_settings')
.first();
return {
projectName: projectInfo?.project_name || 'Directus',
projectColor: projectInfo?.project_color || '#546e7a',
projectLogo: projectInfo?.project_logo
? getProjectLogoURL(projectInfo.project_logo)
: 'https://directus.io/assets/directus-white.png',
};
function getProjectLogoURL(logoID: string) {
let projectLogoURL = env.PUBLIC_URL;
if (projectLogoURL.endsWith('/') === false) {
projectLogoURL += '/';
}
projectLogoURL += `assets/${logoID}`;
}
}
export default async function sendMail(options: EmailOptions) {
const templateString = await readFile(path.join(__dirname, 'templates/base.liquid'), 'utf8');
const html = await liquidEngine.parseAndRender(templateString, { html: options.html });
@@ -57,31 +86,35 @@ export default async function sendMail(options: EmailOptions) {
}
export async function sendInviteMail(email: string, url: string) {
/**
* @TODO pull this from directus_settings
*/
const projectName = 'Directus';
const defaultOptions = await getDefaultTemplateOptions();
const html = await liquidEngine.renderFile('user-invitation', {
...defaultOptions,
email,
url,
});
const html = await liquidEngine.renderFile('user-invitation', { email, url, projectName });
await transporter.sendMail({
from: env.EMAIL_FROM,
to: email,
html: html,
subject: `[${projectName}] You've been invited`,
subject: `[${defaultOptions.projectName}] You've been invited`,
});
}
export async function sendPasswordResetMail(email: string, url: string) {
/**
* @TODO pull this from directus_settings
*/
const projectName = 'Directus';
const defaultOptions = await getDefaultTemplateOptions();
const html = await liquidEngine.renderFile('password-reset', {
...defaultOptions,
email,
url,
});
const html = await liquidEngine.renderFile('password-reset', { email, url, projectName });
await transporter.sendMail({
from: env.EMAIL_FROM,
to: email,
html: html,
subject: `[${projectName}] Password Reset Request`,
subject: `[${defaultOptions.projectName}] Password Reset Request`,
});
}

View File

@@ -3,20 +3,30 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Directus Email Service</title>
<meta name="viewport" content="width=device-width">
<meta name="format-detection" content="telephone=no">
<title>{{ projectName }} Email Service</title>
<style type="text/css">
a {
border: none;
text-decoration: none;
color: #2196f3;
outline: none !important;
color: #2196f3 !important;
display: inline-block;
height: 52px;
width: auto;
min-width: 154px;
padding: 0 20px;
font-size: 16px;
font-weight: bold;
line-height: 52px;
cursor: pointer;
border-radius: 4px;
text-decoration: none !important;
color: white !important;
background-color: {{ projectColor }};
}
a:hover {
color: #2196f3 !important;
filter: brightness(105%);
}
p {
@@ -33,14 +43,14 @@
<table align="center" border="0" cellpadding="0" cellspacing="0" width="600"
style="border: 0 solid #263238; border-collapse: collapse;">
<tr>
<td align="center" bgcolor="#263238"
<td align="center" bgcolor="{{ projectColor }}"
style="padding: 30px 0 30px 0; border-radius: 4px 4px 0 0;">
<img src="https://directus.io/assets/directus-white.png" alt="Directus" width="130"
<img src="{{ projectLogo }}" alt="{{ projectName }}" width="130"
style="display: block;" />
</td>
</tr>
<tr>
<td bgcolor="#ffffff" style="padding: 60px 30px 60px 30px; border-radius: 0 0 4px 4px;">
<td bgcolor="#ffffff" style="padding: 30px; border-radius: 0 0 4px 4px;">
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td

View File

@@ -1,15 +1,23 @@
{% layout "base" %}
{% block content %}
<p>You requested to reset your password. Please click the link below to reset your password:</p>
<p>
We have received a request to reset the password for your <i>{{ projectName }}</i> account. If you did not make this change, please contact one of your administrators. Otherwise, to complete the process, click the following link to confirm your email address and enter your new password.
</p>
<p><a href="{{ url }}">{{ url }}</a></p>
<p style="text-align: center; padding: 20px 0;">
<a href="{{ url }}">
Click to reset your password
</a>
</p>
{% comment %}
@TODO
Make this white-labeled
{% endcomment %}
<p>
<b>Important: This link will expire in 24 hours.</b>
</p>
<p>Love,<br>Directus</p>
<p>
Thank you,<br>
{{ projectName }}
</p>
{% endblock %}

View File

@@ -1,15 +1,19 @@
{% layout "base" %}
{% block content %}
<p>You have been invited to {{ projectName }}. Please click the link below to join:</p>
<p>
You have been invited to join {{ projectName }}. Please click the button below to join:
</p>
<p><a href="{{ url }}">{{ url }}</a></p>
<p style="text-align: center; padding: 20px 0;">
<a href="{{ url }}">
Click to join {{ projectName }}
</a>
</p>
{% comment %}
@TODO
Make this white-labeled
{% endcomment %}
<p>Love,<br>Directus</p>
<p>
Thank You,<br>
{{ projectName }}
</p>
{% endblock %}

View File

@@ -6,25 +6,32 @@ import { RequestHandler } from 'express';
import asyncHandler from 'express-async-handler';
import database from '../database';
import { ForbiddenException } from '../exceptions';
import { systemCollectionRows } from '../database/system-data/collections';
const collectionExists: RequestHandler = asyncHandler(async (req, res, next) => {
if (!req.params.collection) return next();
const exists = await database.schema.hasTable(req.params.collection);
if (exists === false) {
if (req.params.collection in req.schema === false) {
throw new ForbiddenException();
}
req.collection = req.params.collection;
const collectionInfo = await database
.select('singleton')
.from('directus_collections')
.where({ collection: req.collection })
.first();
if (req.collection.startsWith('directus_')) {
const systemRow = systemCollectionRows.find((collection) => {
return collection?.collection === req.collection;
});
req.singleton = collectionInfo?.singleton === true || collectionInfo?.singleton === 1;
req.singleton = !!systemRow?.singleton;
} else {
const collectionInfo = await database
.select('singleton')
.from('directus_collections')
.where({ collection: req.collection })
.first();
req.singleton = collectionInfo?.singleton === true || collectionInfo?.singleton === 1;
}
return next();
});

View File

@@ -63,6 +63,7 @@ function getConfig(
if (store === 'redis') {
const Redis = require('ioredis');
delete config.redis;
config.storeClient = new Redis(
env.RATE_LIMITER_REDIS || getConfigFromEnv('RATE_LIMITER_REDIS_')
);

View File

@@ -0,0 +1,12 @@
import { RequestHandler } from 'express';
import asyncHandler from 'express-async-handler';
import { schemaInspector } from '../database';
const getSchema: RequestHandler = asyncHandler(async (req, res, next) => {
const schemaOverview = await schemaInspector.overview();
req.schema = schemaOverview;
return next();
});
export default getSchema;

View File

@@ -6,7 +6,7 @@ import { AbstractServiceOptions } from '../types';
*/
export class ActivityService extends ItemsService {
constructor(options?: AbstractServiceOptions) {
constructor(options: AbstractServiceOptions) {
super('directus_activity', options);
}
}

View File

@@ -4,17 +4,31 @@ import database from '../database';
import path from 'path';
import Knex from 'knex';
import { Accountability, AbstractServiceOptions, Transformation } from '../types';
import { AuthorizationService } from './authorization';
export class AssetsService {
knex: Knex;
accountability: Accountability | null;
authorizationService: AuthorizationService;
constructor(options?: AbstractServiceOptions) {
this.knex = options?.knex || database;
this.accountability = options?.accountability || null;
constructor(options: AbstractServiceOptions) {
this.knex = options.knex || database;
this.accountability = options.accountability || null;
this.authorizationService = new AuthorizationService(options);
}
async getAsset(id: string, transformation: Transformation) {
const publicSettings = await this.knex
.select('project_logo', 'public_background', 'public_foreground')
.from('directus_settings')
.first();
const systemPublicKeys = Object.values(publicSettings || {});
if (systemPublicKeys.includes(id) === false && this.accountability?.admin !== true) {
await this.authorizationService.checkAccess('read', 'directus_files', id);
}
const file = await database.select('*').from('directus_files').where({ id }).first();
const type = file.type;
@@ -48,9 +62,11 @@ export class AssetsService {
private parseTransformation(transformation: Transformation): ResizeOptions {
const resizeOptions: ResizeOptions = {};
if (transformation.w) resizeOptions.width = Number(transformation.w);
if (transformation.h) resizeOptions.height = Number(transformation.h);
if (transformation.f) resizeOptions.fit = transformation.f;
if (transformation.width) resizeOptions.width = Number(transformation.width);
if (transformation.height) resizeOptions.height = Number(transformation.height);
if (transformation.fit) resizeOptions.fit = transformation.fit;
if (transformation.withoutEnlargement)
resizeOptions.withoutEnlargement = Boolean(transformation.withoutEnlargement);
return resizeOptions;
}

View File

@@ -27,10 +27,10 @@ export class AuthenticationService {
accountability: Accountability | null;
activityService: ActivityService;
constructor(options?: AbstractServiceOptions) {
this.knex = options?.knex || database;
this.accountability = options?.accountability || null;
this.activityService = new ActivityService();
constructor(options: AbstractServiceOptions) {
this.knex = options.knex || database;
this.accountability = options.accountability || null;
this.activityService = new ActivityService({ knex: this.knex, schema: options.schema });
}
/**

View File

@@ -10,8 +10,8 @@ import {
PermissionsAction,
Item,
PrimaryKey,
SchemaOverview,
} from '../types';
import SchemaInspector from 'knex-schema-inspector';
import Knex from 'knex';
import { ForbiddenException, FailedValidationException } from '../exceptions';
import { uniq, merge, flatten } from 'lodash';
@@ -20,16 +20,22 @@ import { ItemsService } from './items';
import { PayloadService } from './payload';
import { parseFilter } from '../utils/parse-filter';
import { toArray } from '../utils/to-array';
import { systemFieldRows } from '../database/system-data/fields';
export class AuthorizationService {
knex: Knex;
accountability: Accountability | null;
payloadService: PayloadService;
schema: SchemaOverview;
constructor(options?: AbstractServiceOptions) {
this.knex = options?.knex || database;
this.accountability = options?.accountability || null;
this.payloadService = new PayloadService('directus_permissions', { knex: this.knex });
constructor(options: AbstractServiceOptions) {
this.knex = options.knex || database;
this.accountability = options.accountability || null;
this.schema = options.schema;
this.payloadService = new PayloadService('directus_permissions', {
knex: this.knex,
schema: this.schema,
});
}
async processAST(ast: AST, action: PermissionsAction = 'read'): Promise<AST> {
@@ -231,6 +237,8 @@ export class AuthorizationService {
.where({ action, collection, role: this.accountability?.role || null })
.first();
if (!permission) throw new ForbiddenException();
permission = (await this.payloadService.processValues(
'read',
permission as Item
@@ -238,8 +246,6 @@ export class AuthorizationService {
// Check if you have permission to access the fields you're trying to acces
if (!permission) throw new ForbiddenException();
const allowedFields = permission.fields || [];
if (allowedFields.includes('*') === false) {
@@ -258,23 +264,28 @@ export class AuthorizationService {
}
}
const preset = permission.presets || {};
const preset = parseFilter(permission.presets || {}, this.accountability);
payloads = payloads.map((payload) => merge({}, preset, payload));
const schemaInspector = SchemaInspector(this.knex);
const columns = await schemaInspector.columnInfo(collection);
const columns = Object.values(this.schema[collection].columns);
let requiredColumns: string[] = [];
for (const column of columns) {
const field = await this.knex
.select<{ special: string }>('special')
.from('directus_fields')
.where({ collection, field: column.name })
.first();
const field =
(await this.knex
.select<{ special: string }>('special')
.from('directus_fields')
.where({ collection, field: column.column_name })
.first()) ||
systemFieldRows.find(
(fieldMeta) =>
fieldMeta.field === column.column_name &&
fieldMeta.collection === collection
);
const specials = (field?.special || '').split(',');
const specials = field?.special ? toArray(field.special) : [];
const hasGenerateSpecial = [
'uuid',
@@ -285,12 +296,11 @@ export class AuthorizationService {
const isRequired =
column.is_nullable === false &&
column.has_auto_increment === false &&
column.default_value === null &&
hasGenerateSpecial === false;
if (isRequired) {
requiredColumns.push(column.name);
requiredColumns.push(column.column_name);
}
}
@@ -326,9 +336,11 @@ export class AuthorizationService {
}
validateJoi(
validation: Record<string, any>,
validation: null | Record<string, any>,
payloads: Partial<Record<string, any>>[]
): FailedValidationException[] {
if (!validation) return [];
const errors: FailedValidationException[] = [];
/**
@@ -381,7 +393,11 @@ export class AuthorizationService {
) {
if (this.accountability?.admin === true) return;
const itemsService = new ItemsService(collection, { accountability: this.accountability });
const itemsService = new ItemsService(collection, {
accountability: this.accountability,
knex: this.knex,
schema: this.schema,
});
try {
const query: Query = {
@@ -391,7 +407,7 @@ export class AuthorizationService {
const result = await itemsService.readByKey(pk as any, query, action);
if (!result) throw '';
if (Array.isArray(pk) && result.length !== pk.length) throw '';
if (Array.isArray(pk) && pk.length > 1 && result.length !== pk.length) throw '';
} catch {
throw new ForbiddenException(
`You're not allowed to ${action} item "${pk}" in collection "${collection}".`,

View File

@@ -1,20 +1,29 @@
import database, { schemaInspector } from '../database';
import { AbstractServiceOptions, Accountability, Collection, Relation } from '../types';
import {
AbstractServiceOptions,
Accountability,
Collection,
CollectionMeta,
Relation,
SchemaOverview,
} from '../types';
import Knex from 'knex';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import SchemaInspector from 'knex-schema-inspector';
import { FieldsService } from '../services/fields';
import { ItemsService } from '../services/items';
import cache from '../cache';
import { toArray } from '../utils/to-array';
import { systemCollectionRows } from '../database/system-data/collections';
export class CollectionsService {
knex: Knex;
accountability: Accountability | null;
schema: SchemaOverview;
constructor(options?: AbstractServiceOptions) {
this.knex = options?.knex || database;
this.accountability = options?.accountability || null;
constructor(options: AbstractServiceOptions) {
this.knex = options.knex || database;
this.accountability = options.accountability || null;
this.schema = options.schema;
}
create(data: Partial<Collection>[]): Promise<string[]>;
@@ -45,15 +54,18 @@ export class CollectionsService {
const createdCollections: string[] = [];
await this.knex.transaction(async (trx) => {
const schemaInspector = SchemaInspector(trx);
const fieldsService = new FieldsService({ knex: trx });
const fieldsService = new FieldsService({ knex: trx, schema: this.schema });
const collectionItemsService = new ItemsService('directus_collections', {
knex: trx,
accountability: this.accountability,
schema: this.schema,
});
const fieldItemsService = new ItemsService('directus_fields', {
knex: trx,
accountability: this.accountability,
schema: this.schema,
});
for (const payload of payloads) {
@@ -65,7 +77,7 @@ export class CollectionsService {
throw new InvalidPayloadException(`Collections can't start with "directus_"`);
}
if (await schemaInspector.hasTable(payload.collection)) {
if (payload.collection in this.schema) {
throw new InvalidPayloadException(
`Collection "${payload.collection}" already exists.`
);
@@ -105,7 +117,9 @@ export class CollectionsService {
const collectionItemsService = new ItemsService('directus_collections', {
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
const collectionKeys = toArray(collection);
if (this.accountability && this.accountability.admin !== true) {
@@ -135,7 +149,9 @@ export class CollectionsService {
const tables = tablesInDatabase.filter((table) => collectionKeys.includes(table.name));
const meta = (await collectionItemsService.readByQuery({
filter: { collection: { _in: collectionKeys } },
})) as Collection['meta'][];
})) as CollectionMeta[];
meta.push(...systemCollectionRows);
const collections: Collection[] = [];
@@ -154,7 +170,10 @@ export class CollectionsService {
/** @todo, read by query without query support is a bit ironic, isnt it */
async readByQuery(): Promise<Collection[]> {
const collectionItemsService = new ItemsService('directus_collections');
const collectionItemsService = new ItemsService('directus_collections', {
knex: this.knex,
schema: this.schema,
});
let tablesInDatabase = await schemaInspector.tableInfo();
if (this.accountability && this.accountability.admin !== true) {
@@ -173,7 +192,9 @@ export class CollectionsService {
const tablesToFetchInfoFor = tablesInDatabase.map((table) => table.name);
const meta = (await collectionItemsService.readByQuery({
filter: { collection: { _in: tablesToFetchInfoFor } },
})) as Collection['meta'][];
})) as CollectionMeta[];
meta.push(...systemCollectionRows);
const collections: Collection[] = [];
@@ -204,6 +225,7 @@ export class CollectionsService {
const collectionItemsService = new ItemsService('directus_collections', {
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
if (data && key) {
@@ -261,9 +283,10 @@ export class CollectionsService {
const fieldsService = new FieldsService({
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
const tablesInDatabase = await schemaInspector.tables();
const tablesInDatabase = Object.keys(this.schema);
const collectionKeys = toArray(collection);
@@ -307,7 +330,9 @@ export class CollectionsService {
const collectionItemsService = new ItemsService('directus_collections', {
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
await collectionItemsService.delete(collectionKeys);
for (const collectionKey of collectionKeys) {

View File

@@ -1,6 +1,12 @@
import database, { schemaInspector } from '../database';
import { Field } from '../types/field';
import { Accountability, AbstractServiceOptions, FieldMeta, Relation } from '../types';
import {
Accountability,
AbstractServiceOptions,
FieldMeta,
Relation,
SchemaOverview,
} from '../types';
import { ItemsService } from '../services/items';
import { ColumnBuilder } from 'knex';
import getLocalType from '../utils/get-local-type';
@@ -10,7 +16,10 @@ import Knex, { CreateTableBuilder } from 'knex';
import { PayloadService } from '../services/payload';
import getDefaultValue from '../utils/get-default-value';
import cache from '../cache';
import SchemaInspector from 'knex-schema-inspector';
import SchemaInspector from '@directus/schema';
import { toArray } from '../utils/to-array';
import { systemFieldRows } from '../database/system-data/fields/';
type RawField = Partial<Field> & { field: string; type: typeof types[number] };
@@ -20,29 +29,39 @@ export class FieldsService {
itemsService: ItemsService;
payloadService: PayloadService;
schemaInspector: typeof schemaInspector;
schema: SchemaOverview;
constructor(options?: AbstractServiceOptions) {
this.knex = options?.knex || database;
this.schemaInspector = options?.knex ? SchemaInspector(options.knex) : schemaInspector;
this.accountability = options?.accountability || null;
constructor(options: AbstractServiceOptions) {
this.knex = options.knex || database;
this.schemaInspector = options.knex ? SchemaInspector(options.knex) : schemaInspector;
this.accountability = options.accountability || null;
this.itemsService = new ItemsService('directus_fields', options);
this.payloadService = new PayloadService('directus_fields');
this.payloadService = new PayloadService('directus_fields', options);
this.schema = options.schema;
}
async readAll(collection?: string): Promise<Field[]> {
let fields: FieldMeta[];
const nonAuthorizedItemsService = new ItemsService('directus_fields', { knex: this.knex });
const nonAuthorizedItemsService = new ItemsService('directus_fields', {
knex: this.knex,
schema: this.schema,
});
if (collection) {
fields = (await nonAuthorizedItemsService.readByQuery({
filter: { collection: { _eq: collection } },
limit: -1,
})) as FieldMeta[];
fields.push(
...systemFieldRows.filter((fieldMeta) => fieldMeta.collection === collection)
);
} else {
fields = (await nonAuthorizedItemsService.readByQuery({ limit: -1 })) as FieldMeta[];
fields.push(...systemFieldRows);
}
let columns = await schemaInspector.columnInfo(collection);
let columns = await this.schemaInspector.columnInfo(collection);
columns = columns.map((column) => {
return {
@@ -73,12 +92,22 @@ export class FieldsService {
aliasQuery.andWhere('collection', collection);
}
let aliasFields = await aliasQuery;
let aliasFields = [
...((await this.payloadService.processValues('read', await aliasQuery)) as FieldMeta[]),
];
if (collection) {
aliasFields.push(
...systemFieldRows.filter((fieldMeta) => fieldMeta.collection === collection)
);
} else {
aliasFields.push(...systemFieldRows);
}
const aliasTypes = ['alias', 'o2m', 'm2m', 'files', 'files', 'translations'];
aliasFields = aliasFields.filter((field) => {
const specials = (field.special || '').split(',');
const specials = toArray(field.special);
for (const type of aliasTypes) {
if (specials.includes(type)) return true;
@@ -87,19 +116,17 @@ export class FieldsService {
return false;
});
aliasFields = (await this.payloadService.processValues('read', aliasFields)) as FieldMeta[];
const aliasFieldsAsField = aliasFields.map((field) => {
const data = {
collection: field.collection,
field: field.field,
type: field.special[0],
type: Array.isArray(field.special) ? field.special[0] : field.special,
schema: null,
meta: field,
};
return data;
});
}) as Field[];
const result = [...columnsWithSystem, ...aliasFieldsAsField];
@@ -163,8 +190,14 @@ export class FieldsService {
fieldInfo = (await this.payloadService.processValues('read', fieldInfo)) as FieldMeta[];
}
fieldInfo =
fieldInfo ||
systemFieldRows.find(
(fieldMeta) => fieldMeta.collection === collection && fieldMeta.field === field
);
try {
column = await schemaInspector.columnInfo(collection, field);
column = await this.schemaInspector.columnInfo(collection, field);
column.default_value = getDefaultValue(column);
} catch {}
@@ -189,7 +222,7 @@ export class FieldsService {
}
// Check if field already exists, either as a column, or as a row in directus_fields
if (await this.schemaInspector.hasColumn(collection, field.field)) {
if (field.field in this.schema[collection].columns) {
throw new InvalidPayloadException(
`Field "${field.field}" already exists in collection "${collection}"`
);
@@ -250,8 +283,8 @@ export class FieldsService {
const type = field.type as 'float' | 'decimal';
column = table[type](
field.field,
field.schema?.precision || 10,
field.schema?.scale || 5
field.schema?.numeric_precision || 10,
field.schema?.numeric_scale || 5
);
} else if (field.type === 'csv') {
column = table.string(field.field);
@@ -259,10 +292,11 @@ export class FieldsService {
column = table[field.type](field.field);
}
if (field.schema.default_value) {
const defaultValue = field.schema.default_value.toLowerCase();
if (defaultValue === 'now()') {
if (field.schema.default_value !== undefined) {
if (
typeof field.schema.default_value === 'string' &&
field.schema.default_value.toLowerCase() === 'now()'
) {
column.defaultTo(this.knex.fn.now());
} else {
column.defaultTo(field.schema.default_value);
@@ -319,7 +353,7 @@ export class FieldsService {
await this.knex('directus_fields').delete().where({ collection, field });
if (await schemaInspector.hasColumn(collection, field)) {
if (field in this.schema[collection].columns) {
await this.knex.schema.table(collection, (table) => {
table.dropColumn(field);
});

View File

@@ -13,7 +13,7 @@ import { extension } from 'mime-types';
import path from 'path';
export class FilesService extends ItemsService {
constructor(options?: AbstractServiceOptions) {
constructor(options: AbstractServiceOptions) {
super('directus_files', options);
}
@@ -41,7 +41,7 @@ export class FilesService extends ItemsService {
const fileExtension =
(payload.type && extension(payload.type)) || path.extname(payload.filename_download);
payload.filename_disk = primaryKey + fileExtension;
payload.filename_disk = primaryKey + '.' + fileExtension;
if (!payload.type) {
payload.type = 'application/octet-stream';
@@ -81,7 +81,10 @@ export class FilesService extends ItemsService {
// We do this in a service without accountability. Even if you don't have update permissions to the file,
// we still want to be able to set the extracted values from the file on create
const sudoService = new ItemsService('directus_files');
const sudoService = new ItemsService('directus_files', {
knex: this.knex,
schema: this.schema,
});
await sudoService.update(payload, primaryKey);
if (cache) {

View File

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

View File

@@ -7,7 +7,7 @@ import {
Field,
Relation,
Query,
AbstractService,
SchemaOverview,
} from '../types';
import {
GraphQLString,
@@ -49,6 +49,7 @@ import { UsersService } from './users';
import { WebhooksService } from './webhooks';
import { getRelationType } from '../utils/get-relation-type';
import { systemCollectionRows } from '../database/system-data/collections';
export class GraphQLService {
accountability: Accountability | null;
@@ -56,13 +57,15 @@ export class GraphQLService {
fieldsService: FieldsService;
collectionsService: CollectionsService;
relationsService: RelationsService;
schema: SchemaOverview;
constructor(options?: AbstractServiceOptions) {
constructor(options: AbstractServiceOptions) {
this.accountability = options?.accountability || null;
this.knex = options?.knex || database;
this.fieldsService = new FieldsService(options);
this.collectionsService = new CollectionsService(options);
this.relationsService = new RelationsService(options);
this.schema = options.schema;
}
args = {
@@ -204,8 +207,9 @@ export class GraphQLService {
return fieldsObject;
},
}),
resolve: (source: any, args: any, context: any, info: GraphQLResolveInfo) =>
this.resolve(info),
resolve: (source: any, args: any, context: any, info: GraphQLResolveInfo) => {
return this.resolve(info);
},
args: {
...this.args,
filter: {
@@ -240,19 +244,36 @@ export class GraphQLService {
}
}
schemaWithLists.items = {
type: new GraphQLObjectType({
name: 'items',
fields: schemaWithLists.items,
}),
resolve: () => ({}),
const queryBase: any = {
name: 'Directus',
fields: {
server: {
type: new GraphQLObjectType({
name: 'server',
fields: {
ping: {
type: GraphQLString,
resolve: () => 'pong',
},
},
}),
resolve: () => ({}),
},
},
};
if (Object.keys(schemaWithLists.items).length > 0) {
queryBase.fields.items = {
type: new GraphQLObjectType({
name: 'items',
fields: schemaWithLists.items,
}),
resolve: () => ({}),
};
}
return new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Directus',
fields: schemaWithLists,
}),
query: new GraphQLObjectType(queryBase),
});
}
@@ -380,20 +401,28 @@ export class GraphQLService {
const systemField = info.path.prev?.key !== 'items';
const collection = systemField ? `directus_${info.fieldName}` : info.fieldName;
const selections = info.fieldNodes[0]?.selectionSet?.selections?.filter(
(node) => node.kind === 'Field'
) as FieldNode[] | undefined;
if (!selections) return null;
return await this.getData(collection, selections, info.fieldNodes[0].arguments);
return await this.getData(
collection,
selections,
info.fieldNodes[0].arguments || [],
info.variableValues
);
}
async getData(
collection: string,
selections: FieldNode[],
argsArray?: readonly ArgumentNode[]
argsArray: readonly ArgumentNode[],
variableValues: GraphQLResolveInfo['variableValues']
) {
const args: Record<string, any> = this.parseArgs(argsArray);
const args: Record<string, any> = this.parseArgs(argsArray, variableValues);
const query: Query = sanitizeQuery(args, this.accountability);
@@ -418,7 +447,10 @@ export class GraphQLService {
if (selection.arguments && selection.arguments.length > 0) {
if (!query.deep) query.deep = {};
const args: Record<string, any> = this.parseArgs(selection.arguments);
const args: Record<string, any> = this.parseArgs(
selection.arguments,
variableValues
);
query.deep[current] = sanitizeQuery(args, this.accountability);
}
}
@@ -437,6 +469,7 @@ export class GraphQLService {
service = new ActivityService({
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
// case 'directus_collections':
// service = new CollectionsService({ knex: this.knex, accountability: this.accountability });
@@ -446,82 +479,101 @@ export class GraphQLService {
service = new FilesService({
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
case 'directus_folders':
service = new FoldersService({
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
case 'directus_folders':
service = new FoldersService({
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
case 'directus_permissions':
service = new PermissionsService({
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
case 'directus_presets':
service = new PresetsService({
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
case 'directus_relations':
service = new RelationsService({
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
case 'directus_revisions':
service = new RevisionsService({
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
case 'directus_roles':
service = new RolesService({
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
case 'directus_settings':
service = new SettingsService({
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
case 'directus_users':
service = new UsersService({
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
case 'directus_webhooks':
service = new WebhooksService({
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
default:
service = new ItemsService(collection, {
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
}
const collectionInfo = await this.knex
.select('singleton')
.from('directus_collections')
.where({ collection: collection })
.first();
const result =
collectionInfo?.singleton === true
? await service.readSingleton(query)
: await service.readByQuery(query);
const collectionInfo =
(await this.knex
.select('singleton')
.from('directus_collections')
.where({ collection: collection })
.first()) ||
systemCollectionRows.find(
(collectionMeta) => collectionMeta?.collection === collection
);
const result = collectionInfo?.singleton
? await service.readSingleton(query)
: await service.readByQuery(query);
return result;
}
parseArgs(args?: readonly ArgumentNode[] | readonly ObjectFieldNode[]): Record<string, any> {
if (!args) return {};
parseArgs(
args: readonly ArgumentNode[] | readonly ObjectFieldNode[],
variableValues: GraphQLResolveInfo['variableValues']
): Record<string, any> {
if (!args || args.length === 0) return {};
const parseObjectValue = (arg: ObjectValueNode) => {
return this.parseArgs(arg.fields);
return this.parseArgs(arg.fields, variableValues);
};
const argsObject: any = {};
@@ -529,12 +581,14 @@ export class GraphQLService {
for (const argument of args) {
if (argument.value.kind === 'ObjectValue') {
argsObject[argument.name.value] = parseObjectValue(argument.value);
} else if (argument.value.kind === 'Variable') {
argsObject[argument.name.value] = variableValues[argument.value.name.value];
} else if (argument.value.kind === 'ListValue') {
const values: any = [];
for (const valueNode of argument.value.values) {
if (valueNode.kind === 'ObjectValue') {
values.push(this.parseArgs(valueNode.fields));
values.push(this.parseArgs(valueNode.fields, variableValues));
} else {
values.push((valueNode as any).value);
}

View File

@@ -1,5 +1,4 @@
import database from '../database';
import SchemaInspector from 'knex-schema-inspector';
import runAST from '../database/run-ast';
import getASTFromQuery from '../utils/get-ast-from-query';
import {
@@ -11,6 +10,7 @@ import {
PrimaryKey,
AbstractService,
AbstractServiceOptions,
SchemaOverview,
} from '../types';
import Knex from 'knex';
import cache from '../cache';
@@ -31,17 +31,16 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
knex: Knex;
accountability: Accountability | null;
eventScope: string;
schemaInspector: ReturnType<typeof SchemaInspector>;
schema: SchemaOverview;
constructor(collection: string, options?: AbstractServiceOptions) {
constructor(collection: string, options: AbstractServiceOptions) {
this.collection = collection;
this.knex = options?.knex || database;
this.accountability = options?.accountability || null;
this.knex = options.knex || database;
this.accountability = options.accountability || null;
this.eventScope = this.collection.startsWith('directus_')
? this.collection.substring(9)
: 'items';
this.schemaInspector = SchemaInspector(this.knex);
this.schema = options.schema;
return this;
}
@@ -49,8 +48,8 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
async create(data: Partial<Item>[]): Promise<PrimaryKey[]>;
async create(data: Partial<Item>): Promise<PrimaryKey>;
async create(data: Partial<Item> | Partial<Item>[]): Promise<PrimaryKey | PrimaryKey[]> {
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const columns = await this.schemaInspector.columns(this.collection);
const primaryKeyField = this.schema[this.collection].primary;
const columns = Object.keys(this.schema[this.collection].columns);
let payloads: AnyItem[] = clone(toArray(data));
@@ -58,6 +57,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
const payloadService = new PayloadService(this.collection, {
accountability: this.accountability,
knex: trx,
schema: this.schema,
});
const customProcessed = await emitter.emitAsync(
@@ -70,6 +70,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
item: null,
action: 'create',
payload: payloads,
schema: this.schema,
}
);
@@ -81,6 +82,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
const authorizationService = new AuthorizationService({
accountability: this.accountability,
knex: trx,
schema: this.schema,
});
payloads = await authorizationService.validatePayload(
@@ -92,12 +94,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
payloads = await payloadService.processM2O(payloads);
let payloadsWithoutAliases = payloads.map((payload) =>
pick(
payload,
columns.map(({ column }) => column)
)
);
let payloadsWithoutAliases = payloads.map((payload) => pick(payload, columns));
payloadsWithoutAliases = await payloadService.processValues(
'create',
@@ -185,6 +182,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
item: primaryKeys,
action: 'create',
payload: payloads,
schema: this.schema,
})
.catch((err) => logger.warn(err));
@@ -198,9 +196,10 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
const authorizationService = new AuthorizationService({
accountability: this.accountability,
knex: this.knex,
schema: this.schema,
});
let ast = await getASTFromQuery(this.collection, query, {
let ast = await getASTFromQuery(this.collection, query, this.schema, {
accountability: this.accountability,
knex: this.knex,
});
@@ -209,7 +208,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
ast = await authorizationService.processAST(ast);
}
const records = await runAST(ast, { knex: this.knex });
const records = await runAST(ast, this.schema, { knex: this.knex });
return records as Partial<Item> | Partial<Item>[] | null;
}
@@ -229,7 +228,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
action: PermissionsAction = 'read'
): Promise<null | Partial<Item> | Partial<Item>[]> {
query = clone(query);
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const primaryKeyField = this.schema[this.collection].primary;
const keys = toArray(key);
if (keys.length === 1) {
@@ -246,7 +245,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
},
};
let ast = await getASTFromQuery(this.collection, queryWithFilter, {
let ast = await getASTFromQuery(this.collection, queryWithFilter, this.schema, {
accountability: this.accountability,
action,
knex: this.knex,
@@ -256,11 +255,13 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
const authorizationService = new AuthorizationService({
accountability: this.accountability,
knex: this.knex,
schema: this.schema,
});
ast = await authorizationService.processAST(ast, action);
}
const result = await runAST(ast, { knex: this.knex });
const result = await runAST(ast, this.schema, { knex: this.knex });
if (result === null) throw new ForbiddenException();
@@ -274,8 +275,8 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
data: Partial<Item> | Partial<Item>[],
key?: PrimaryKey | PrimaryKey[]
): Promise<PrimaryKey | PrimaryKey[]> {
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const columns = await this.schemaInspector.columns(this.collection);
const primaryKeyField = this.schema[this.collection].primary;
const columns = Object.keys(this.schema[this.collection].columns);
// Updating one or more items to the same payload
if (data && key) {
@@ -290,9 +291,10 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
event: `${this.eventScope}.update.before`,
accountability: this.accountability,
collection: this.collection,
item: null,
item: key,
action: 'update',
payload,
schema: this.schema,
}
);
@@ -304,6 +306,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
const authorizationService = new AuthorizationService({
accountability: this.accountability,
knex: this.knex,
schema: this.schema,
});
await authorizationService.checkAccess('update', this.collection, keys);
@@ -319,14 +322,12 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
const payloadService = new PayloadService(this.collection, {
accountability: this.accountability,
knex: trx,
schema: this.schema,
});
payload = await payloadService.processM2O(payload);
let payloadWithoutAliases = pick(
payload,
columns.map(({ column }) => column)
);
let payloadWithoutAliases = pick(payload, columns);
payloadWithoutAliases = await payloadService.processValues(
'update',
@@ -369,7 +370,10 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
activityPrimaryKeys.push(primaryKey);
}
const itemsService = new ItemsService(this.collection, { knex: trx });
const itemsService = new ItemsService(this.collection, {
knex: trx,
schema: this.schema,
});
const snapshots = await itemsService.readByKey(keys);
const revisionRecords = activityPrimaryKeys.map((key, index) => ({
@@ -399,6 +403,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
item: key,
action: 'update',
payload,
schema: this.schema,
})
.catch((err) => logger.warn(err));
@@ -411,6 +416,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
const itemsService = new ItemsService(this.collection, {
accountability: this.accountability,
knex: trx,
schema: this.schema,
});
const payloads = toArray(data);
@@ -433,12 +439,15 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
}
async updateByQuery(data: Partial<Item>, query: Query): Promise<PrimaryKey[]> {
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const primaryKeyField = this.schema[this.collection].primary;
const readQuery = cloneDeep(query);
readQuery.fields = [primaryKeyField];
// Not authenticated:
const itemsService = new ItemsService(this.collection, { knex: this.knex });
const itemsService = new ItemsService(this.collection, {
knex: this.knex,
schema: this.schema,
});
let itemsToUpdate = await itemsService.readByQuery(readQuery);
itemsToUpdate = toArray(itemsToUpdate);
@@ -453,7 +462,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
upsert(data: Partial<Item>[]): Promise<PrimaryKey[]>;
upsert(data: Partial<Item>): Promise<PrimaryKey>;
async upsert(data: Partial<Item> | Partial<Item>[]): Promise<PrimaryKey | PrimaryKey[]> {
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const primaryKeyField = this.schema[this.collection].primary;
const payloads = toArray(data);
const primaryKeys: PrimaryKey[] = [];
@@ -483,11 +492,12 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
delete(keys: PrimaryKey[]): Promise<PrimaryKey[]>;
async delete(key: PrimaryKey | PrimaryKey[]): Promise<PrimaryKey | PrimaryKey[]> {
const keys = toArray(key);
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const primaryKeyField = this.schema[this.collection].primary;
if (this.accountability && this.accountability.admin !== true) {
const authorizationService = new AuthorizationService({
accountability: this.accountability,
schema: this.schema,
});
await authorizationService.checkAccess('delete', this.collection, key);
@@ -500,6 +510,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
item: keys,
action: 'delete',
payload: null,
schema: this.schema,
});
await this.knex.transaction(async (trx) => {
@@ -531,6 +542,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
item: keys,
action: 'delete',
payload: null,
schema: this.schema,
})
.catch((err) => logger.warn(err));
@@ -538,12 +550,15 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
}
async deleteByQuery(query: Query): Promise<PrimaryKey[]> {
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const primaryKeyField = this.schema[this.collection].primary;
const readQuery = cloneDeep(query);
readQuery.fields = [primaryKeyField];
// Not authenticated:
const itemsService = new ItemsService(this.collection);
const itemsService = new ItemsService(this.collection, {
knex: this.knex,
schema: this.schema,
});
let itemsToDelete = await itemsService.readByQuery(readQuery);
itemsToDelete = toArray(itemsToDelete);
@@ -561,11 +576,17 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
const record = (await this.readByQuery(query)) as Partial<Item>;
if (!record) {
const columns = await this.schemaInspector.columnInfo(this.collection);
let columns = Object.values(this.schema[this.collection].columns);
const defaults: Record<string, any> = {};
if (query.fields && query.fields.includes('*') === false) {
columns = columns.filter((column) => {
return query.fields!.includes(column.column_name);
});
}
for (const column of columns) {
defaults[column.name] = getDefaultValue(column);
defaults[column.column_name] = getDefaultValue(column);
}
return defaults as Partial<Item>;
@@ -575,7 +596,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
}
async upsertSingleton(data: Partial<Item>) {
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const primaryKeyField = this.schema[this.collection].primary;
const record = await this.knex
.select(primaryKeyField)

View File

@@ -32,15 +32,15 @@ export class MetaService {
}
async totalCount(collection: string) {
const records = await database(collection).count('*', { as: 'count' });
const records = await this.knex(collection).count('*', { as: 'count' });
return Number(records[0].count);
}
async filterCount(collection: string, query: Query) {
const dbQuery = database(collection).count('*', { as: 'count' });
const dbQuery = this.knex(collection).count('*', { as: 'count' });
if (query.filter) {
await applyFilter(dbQuery, query.filter, collection);
await applyFilter(this.knex, dbQuery, query.filter, collection);
}
const records = await dbQuery;

Some files were not shown because too many files have changed in this diff Show More