Merge branch 'main' into sdk

This commit is contained in:
rijkvanzanten
2020-10-27 10:56:06 +01:00
24 changed files with 433 additions and 255 deletions

View File

@@ -12,6 +12,7 @@ import url from 'url';
import path from 'path';
import useCollection from '../middleware/use-collection';
import { respond } from '../middleware/respond';
import { toArray } from '../utils/to-array';
const router = express.Router();
@@ -32,7 +33,7 @@ const multipartHandler = asyncHandler(async (req, res, next) => {
* the row in directus_files async during the upload of the actual file.
*/
let disk: string = (env.STORAGE_LOCATIONS as string).split(',')[0].trim();
let disk: string = toArray(env.STORAGE_LOCATIONS)[0];
let payload: Partial<File> = {};
let fileCount = 0;
@@ -155,7 +156,7 @@ router.post(
const payload = {
filename_download: filename,
storage: (env.STORAGE_LOCATIONS as string).split(',')[0].trim(),
storage: toArray(env.STORAGE_LOCATIONS)[0],
type: fileResponse.headers['content-type'],
title: formatTitle(filename),
...(req.body.data || {}),

View File

@@ -20,12 +20,12 @@ router.get('/ping', (req, res) => res.send('pong'));
router.get(
'/info',
(req, res, next) => {
asyncHandler(async (req, res, next) => {
const service = new ServerService({ accountability: req.accountability });
const data = service.serverInfo();
const data = await service.serverInfo();
res.locals.payload = { data };
return next();
},
}),
respond
);

View File

@@ -1,17 +0,0 @@
table: directus_permissions
defaults:
role: null
collection: null
action: null
permissions: null
validation: null
presets: null
fields: null
limit: null
data:
- collection: directus_settings
action: read
permissions: {}
fields: 'project_name,project_logo,project_color,public_foreground,public_background,public_note,custom_css'

View File

@@ -63,11 +63,6 @@ function processValues(env: Record<string, any>) {
if (value === 'false') env[key] = false;
if (value === 'null') env[key] = null;
if (isNaN(value) === false && value.length > 0) env[key] = Number(value);
if (typeof value === 'string' && value.includes(','))
env[key] = value
.split(',')
.map((val) => val.trim())
.filter((val) => val);
}
return env;

View File

@@ -60,18 +60,28 @@ export async function sendInviteMail(email: string, url: string) {
/**
* @TODO pull this from directus_settings
*/
const projectName = 'directus';
const projectName = 'Directus';
const html = await liquidEngine.renderFile('user-invitation', { email, url, projectName });
await transporter.sendMail({ from: env.EMAIL_FROM, to: email, html: html });
await transporter.sendMail({
from: env.EMAIL_FROM,
to: email,
html: html,
subject: `[${projectName}] You've been invited`,
});
}
export async function sendPasswordResetMail(email: string, url: string) {
/**
* @TODO pull this from directus_settings
*/
const projectName = 'directus';
const projectName = 'Directus';
const html = await liquidEngine.renderFile('password-reset', { email, url, projectName });
await transporter.sendMail({ from: env.EMAIL_FROM, to: email, html: html });
await transporter.sendMail({
from: env.EMAIL_FROM,
to: email,
html: html,
subject: `[${projectName}] Password Reset Request`,
});
}

View File

@@ -6,7 +6,7 @@ import {
Action,
Accountability,
PermissionsAction,
Item,
Item as AnyItem,
Query,
PrimaryKey,
AbstractService,
@@ -26,7 +26,7 @@ import getDefaultValue from '../utils/get-default-value';
import { InvalidPayloadException } from '../exceptions';
import { ForbiddenException } from '../exceptions';
export class ItemsService implements AbstractService {
export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractService {
collection: string;
knex: Knex;
accountability: Accountability | null;
@@ -52,7 +52,7 @@ export class ItemsService implements AbstractService {
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const columns = await this.schemaInspector.columns(this.collection);
let payloads = clone(toArray(data));
let payloads: AnyItem[] = clone(toArray(data));
const savedPrimaryKeys = await this.knex.transaction(async (trx) => {
const payloadService = new PayloadService(this.collection, {
@@ -194,7 +194,7 @@ export class ItemsService implements AbstractService {
return Array.isArray(data) ? savedPrimaryKeys : savedPrimaryKeys[0];
}
async readByQuery(query: Query): Promise<null | Item | Item[]> {
async readByQuery(query: Query): Promise<null | Partial<Item> | Partial<Item>[]> {
const authorizationService = new AuthorizationService({
accountability: this.accountability,
knex: this.knex,
@@ -210,20 +210,24 @@ export class ItemsService implements AbstractService {
}
const records = await runAST(ast, { knex: this.knex });
return records;
return records as Partial<Item> | Partial<Item>[] | null;
}
readByKey(
keys: PrimaryKey[],
query?: Query,
action?: PermissionsAction
): Promise<null | Item[]>;
readByKey(key: PrimaryKey, query?: Query, action?: PermissionsAction): Promise<null | Item>;
): Promise<null | Partial<Item>[]>;
readByKey(
key: PrimaryKey,
query?: Query,
action?: PermissionsAction
): Promise<null | Partial<Item>>;
async readByKey(
key: PrimaryKey | PrimaryKey[],
query: Query = {},
action: PermissionsAction = 'read'
): Promise<null | Item | Item[]> {
): Promise<null | Partial<Item> | Partial<Item>[]> {
query = clone(query);
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const keys = toArray(key);
@@ -260,7 +264,7 @@ export class ItemsService implements AbstractService {
if (result === null) throw new ForbiddenException();
return result;
return result as Partial<Item> | Partial<Item>[] | null;
}
update(data: Partial<Item>, keys: PrimaryKey[]): Promise<PrimaryKey[]>;
@@ -277,7 +281,7 @@ export class ItemsService implements AbstractService {
if (data && key) {
const keys = toArray(key);
let payload = clone(data);
let payload: Partial<AnyItem> | Partial<AnyItem>[] = clone(data);
const customProcessed = await emitter.emitAsync(
`${this.eventScope}.update.before`,
@@ -550,11 +554,11 @@ export class ItemsService implements AbstractService {
return await this.delete(keys);
}
async readSingleton(query: Query) {
async readSingleton(query: Query): Promise<Partial<Item>> {
query = clone(query);
query.single = true;
const record = (await this.readByQuery(query)) as Item;
const record = (await this.readByQuery(query)) as Partial<Item>;
if (!record) {
const columns = await this.schemaInspector.columnInfo(this.collection);
@@ -564,7 +568,7 @@ export class ItemsService implements AbstractService {
defaults[column.name] = getDefaultValue(column);
}
return defaults;
return defaults as Partial<Item>;
}
return record;

View File

@@ -40,7 +40,7 @@ export class MetaService {
const dbQuery = database(collection).count('*', { as: 'count' });
if (query.filter) {
applyFilter(dbQuery, query.filter, collection);
await applyFilter(dbQuery, query.filter, collection);
}
const records = await dbQuery;

View File

@@ -2,7 +2,6 @@ import { AbstractServiceOptions, Accountability } from '../types';
import Knex from 'knex';
import database from '../database';
import os from 'os';
import { ForbiddenException } from '../exceptions';
// @ts-ignore
import { version } from '../../package.json';
import macosRelease from 'macos-release';
@@ -16,31 +15,57 @@ export class ServerService {
this.accountability = options?.accountability || null;
}
serverInfo() {
if (this.accountability?.admin !== true) {
throw new ForbiddenException();
}
async serverInfo() {
const info: Record<string, any> = {};
const osType = os.type() === 'Darwin' ? 'macOS' : os.type();
const osVersion =
osType === 'macOS'
? `${macosRelease().name} (${macosRelease().version})`
: os.release();
const projectInfo = await this.knex
.select(
'project_name',
'project_logo',
'project_color',
'public_foreground',
'public_background',
'public_note',
'custom_css'
)
.from('directus_settings')
.first();
return {
directus: {
info.project = projectInfo
? {
project_name: projectInfo.project_name,
project_logo: projectInfo.project_logo,
project_color: projectInfo.project_color,
public_foreground: projectInfo.public_foreground,
public_background: projectInfo.public_background,
public_note: projectInfo.public_note,
custom_css: projectInfo.custom_css,
}
: null;
if (this.accountability?.admin === true) {
const osType = os.type() === 'Darwin' ? 'macOS' : os.type();
const osVersion =
osType === 'macOS'
? `${macosRelease().name} (${macosRelease().version})`
: os.release();
info.directus = {
version,
},
node: {
};
info.node = {
version: process.versions.node,
uptime: Math.round(process.uptime()),
},
os: {
};
info.os = {
type: osType,
version: osVersion,
uptime: Math.round(os.uptime()),
totalmem: os.totalmem(),
},
};
};
}
return info;
}
}

View File

@@ -1,7 +1,7 @@
import { QueryBuilder } from 'knex';
import { Query, Filter } from '../types';
import database, { schemaInspector } from '../database';
import { clone } from 'lodash';
import { clone, isPlainObject } from 'lodash';
export default async function applyQuery(collection: string, dbQuery: QueryBuilder, query: Query) {
if (query.filter) {
@@ -46,116 +46,244 @@ export default async function applyQuery(collection: string, dbQuery: QueryBuild
}
}
export async function applyFilter(dbQuery: QueryBuilder, filter: Filter, collection: string) {
for (const [key, value] of Object.entries(filter)) {
if (key === '_or') {
value.forEach((subFilter: Record<string, any>) => {
dbQuery.orWhere((subQuery) => applyFilter(subQuery, subFilter, collection));
});
export async function applyFilter(rootQuery: QueryBuilder, rootFilter: Filter, collection: string) {
const relations = await database.select('*').from('directus_relations');
continue;
addWhereClauses(rootQuery, rootFilter, collection);
addJoins(rootQuery, rootFilter, collection);
function addWhereClauses(dbQuery: QueryBuilder, filter: Filter, collection: string) {
for (const [key, value] of Object.entries(filter)) {
if (key === '_or') {
/** @NOTE these callback functions aren't called until Knex runs the query */
dbQuery.orWhere((subQuery) => {
value.forEach((subFilter: Record<string, any>) => {
addWhereClauses(subQuery, subFilter, collection);
});
});
continue;
}
if (key === '_and') {
/** @NOTE these callback functions aren't called until Knex runs the query */
dbQuery.andWhere((subQuery) => {
value.forEach((subFilter: Record<string, any>) => {
addWhereClauses(subQuery, subFilter, collection);
});
});
continue;
}
const filterPath = getFilterPath(key, value);
const { operator: filterOperator, value: filterValue } = getOperation(key, value);
if (filterPath.length > 1) {
const columnName = getWhereColumn(filterPath, collection);
applyFilterToQuery(columnName, filterOperator, filterValue);
} else {
applyFilterToQuery(`${collection}.${filterPath[0]}`, filterOperator, filterValue);
}
}
if (key === '_and') {
value.forEach((subFilter: Record<string, any>) => {
dbQuery.andWhere((subQuery) => applyFilter(subQuery, subFilter, collection));
});
function applyFilterToQuery(key: string, operator: string, compareValue: any) {
if (operator === '_eq') {
dbQuery.where({ [key]: compareValue });
}
continue;
if (operator === '_neq') {
dbQuery.whereNot({ [key]: compareValue });
}
if (operator === '_contains') {
dbQuery.where(key, 'like', `%${compareValue}%`);
}
if (operator === '_ncontains') {
dbQuery.where(key, 'like', `%${compareValue}%`);
}
if (operator === '_gt') {
dbQuery.where(key, '>', compareValue);
}
if (operator === '_gte') {
dbQuery.where(key, '>=', compareValue);
}
if (operator === '_lt') {
dbQuery.where(key, '<', compareValue);
}
if (operator === '_lte') {
dbQuery.where(key, '<=', compareValue);
}
if (operator === '_in') {
let value = compareValue;
if (typeof value === 'string') value = value.split(',');
dbQuery.whereIn(key, value as string[]);
}
if (operator === '_nin') {
let value = compareValue;
if (typeof value === 'string') value = value.split(',');
dbQuery.whereNotIn(key, value as string[]);
}
if (operator === '_null') {
dbQuery.whereNull(key);
}
if (operator === '_nnull') {
dbQuery.whereNotNull(key);
}
if (operator === '_empty') {
dbQuery.andWhere((query) => {
query.whereNull(key);
query.orWhere(key, '=', '');
});
}
if (operator === '_nempty') {
dbQuery.andWhere((query) => {
query.whereNotNull(key);
query.orWhere(key, '!=', '');
});
}
if (operator === '_between') {
let value = compareValue;
if (typeof value === 'string') value = value.split(',');
dbQuery.whereBetween(key, value);
}
if (operator === '_nbetween') {
let value = compareValue;
if (typeof value === 'string') value = value.split(',');
dbQuery.whereNotBetween(key, value);
}
}
const filterPath = getFilterPath(key, value);
const { operator: filterOperator, value: filterValue } = getOperation(key, value);
function getWhereColumn(path: string[], collection: string) {
path = clone(path);
const column =
filterPath.length > 1
? await applyJoins(dbQuery, filterPath, collection)
: `${collection}.${filterPath[0]}`;
let columnName = '';
applyFilterToQuery(column, filterOperator, filterValue);
followRelation(path);
return columnName;
function followRelation(pathParts: string[], parentCollection: string = collection) {
const relation = relations.find((relation) => {
return (
(relation.many_collection === parentCollection &&
relation.many_field === pathParts[0]) ||
(relation.one_collection === parentCollection &&
relation.one_field === pathParts[0])
);
});
if (!relation) return;
const isM2O =
relation.many_collection === parentCollection &&
relation.many_field === pathParts[0];
pathParts.shift();
const parent = isM2O ? relation.one_collection! : relation.many_collection;
if (pathParts.length === 1) {
columnName = `${parent}.${pathParts[0]}`;
}
if (pathParts.length) {
followRelation(pathParts, parent);
}
}
}
}
function applyFilterToQuery(key: string, operator: string, compareValue: any) {
if (operator === '_eq') {
dbQuery.where({ [key]: compareValue });
/**
* @NOTE Yes this is very similar in structure and functionality as the other loop. However,
* due to the order of execution that Knex has in the nested andWhere / orWhere structures,
* joins that are added in there aren't added in time
*/
function addJoins(dbQuery: QueryBuilder, filter: Filter, collection: string) {
for (const [key, value] of Object.entries(filter)) {
if (key === '_or') {
value.forEach((subFilter: Record<string, any>) => {
addJoins(dbQuery, subFilter, collection);
});
continue;
}
if (key === '_and') {
value.forEach((subFilter: Record<string, any>) => {
addJoins(dbQuery, subFilter, collection);
});
continue;
}
const filterPath = getFilterPath(key, value);
if (filterPath.length > 1) {
addJoin(filterPath, collection);
}
}
if (operator === '_neq') {
dbQuery.whereNot({ [key]: compareValue });
}
function addJoin(path: string[], collection: string) {
path = clone(path);
if (operator === '_contains') {
dbQuery.where(key, 'like', `%${compareValue}%`);
}
followRelation(path);
if (operator === '_ncontains') {
dbQuery.where(key, 'like', `%${compareValue}%`);
}
function followRelation(pathParts: string[], parentCollection: string = collection) {
const relation = relations.find((relation) => {
return (
(relation.many_collection === parentCollection &&
relation.many_field === pathParts[0]) ||
(relation.one_collection === parentCollection &&
relation.one_field === pathParts[0])
);
});
if (operator === '_gt') {
dbQuery.where(key, '>', compareValue);
}
if (!relation) return;
if (operator === '_gte') {
dbQuery.where(key, '>=', compareValue);
}
const isM2O =
relation.many_collection === parentCollection &&
relation.many_field === pathParts[0];
if (operator === '_lt') {
dbQuery.where(key, '<', compareValue);
}
if (isM2O) {
dbQuery.leftJoin(
relation.one_collection!,
`${parentCollection}.${relation.many_field}`,
`${relation.one_collection}.${relation.one_primary}`
);
} else {
dbQuery.leftJoin(
relation.many_collection,
`${parentCollection}.${relation.one_primary}`,
`${relation.many_collection}.${relation.many_field}`
);
}
if (operator === '_lte') {
dbQuery.where(key, '<=', compareValue);
}
pathParts.shift();
if (operator === '_in') {
let value = compareValue;
if (typeof value === 'string') value = value.split(',');
const parent = isM2O ? relation.one_collection! : relation.many_collection;
dbQuery.whereIn(key, value as string[]);
}
if (operator === '_nin') {
let value = compareValue;
if (typeof value === 'string') value = value.split(',');
dbQuery.whereNotIn(key, value as string[]);
}
if (operator === '_null') {
dbQuery.whereNull(key);
}
if (operator === '_nnull') {
dbQuery.whereNotNull(key);
}
if (operator === '_empty') {
dbQuery.andWhere((query) => {
query.whereNull(key);
query.orWhere(key, '=', '');
});
}
if (operator === '_nempty') {
dbQuery.andWhere((query) => {
query.whereNotNull(key);
query.orWhere(key, '!=', '');
});
}
if (operator === '_between') {
let value = compareValue;
if (typeof value === 'string') value = value.split(',');
dbQuery.whereBetween(key, value);
}
if (operator === '_nbetween') {
let value = compareValue;
if (typeof value === 'string') value = value.split(',');
dbQuery.whereNotBetween(key, value);
if (pathParts.length) {
followRelation(pathParts, parent);
}
}
}
}
}
@@ -163,7 +291,11 @@ export async function applyFilter(dbQuery: QueryBuilder, filter: Filter, collect
function getFilterPath(key: string, value: Record<string, any>) {
const path = [key];
if (Object.keys(value)[0].startsWith('_') === false) {
if (Object.keys(value)[0].startsWith('_') === true) {
return path;
}
if (isPlainObject(value)) {
path.push(...getFilterPath(Object.keys(value)[0], Object.values(value)[0]));
}
@@ -171,57 +303,11 @@ function getFilterPath(key: string, value: Record<string, any>) {
}
function getOperation(key: string, value: Record<string, any>): { operator: string; value: any } {
if (key.startsWith('_') && key !== '_and' && key !== '_or')
if (key.startsWith('_') && key !== '_and' && key !== '_or') {
return { operator: key as string, value };
} else if (isPlainObject(value) === false) {
return { operator: '_eq', value };
}
return getOperation(Object.keys(value)[0], Object.values(value)[0]);
}
async function applyJoins(dbQuery: QueryBuilder, path: string[], collection: string) {
path = clone(path);
let keyName = '';
await addJoins(path);
return keyName;
async function addJoins(pathParts: string[], parentCollection: string = collection) {
const relation = await database
.select('*')
.from('directus_relations')
.where({ one_collection: parentCollection, one_field: pathParts[0] })
.orWhere({ many_collection: parentCollection, many_field: pathParts[0] })
.first();
if (!relation) return;
const isM2O =
relation.many_collection === parentCollection && relation.many_field === pathParts[0];
if (isM2O) {
dbQuery.leftJoin(
relation.one_collection,
`${parentCollection}.${relation.many_field}`,
`${relation.one_collection}.${relation.one_primary}`
);
} else {
dbQuery.leftJoin(
relation.many_collection,
`${relation.one_collection}.${relation.one_primary}`,
`${relation.many_collection}.${relation.many_field}`
);
}
pathParts.shift();
const parent = isM2O ? relation.one_collection : relation.many_collection;
if (pathParts.length === 1) {
keyName = `${parent}.${pathParts[0]}`;
}
if (pathParts.length) {
await addJoins(pathParts, parent);
}
}
}

View File

@@ -1,3 +1,7 @@
export function toArray<T = any>(val: T | T[]): T[] {
if (typeof val === 'string') {
return (val.split(',') as unknown) as T[];
}
return Array.isArray(val) ? val : [val];
}