Merge branch 'main' into aggregation

This commit is contained in:
rijkvanzanten
2021-08-06 16:14:29 -04:00
676 changed files with 15648 additions and 8806 deletions

View File

@@ -1,9 +1,11 @@
import { ApiExtensionType, AppExtensionType, ExtensionType } from '../types';
export const APP_SHARED_DEPS = ['@directus/extension-sdk', 'vue'];
export const API_SHARED_DEPS = ['axios'];
export const SHARED_DEPS = ['@directus/extension-sdk', 'vue'];
export const APP_EXTENSION_TYPES: AppExtensionType[] = ['interface', 'display', 'layout', 'module'];
export const API_EXTENSION_TYPES: ApiExtensionType[] = ['endpoint', 'hook'];
export const EXTENSION_TYPES: ExtensionType[] = [...APP_EXTENSION_TYPES, ...API_EXTENSION_TYPES];
export const APP_EXTENSION_TYPES = ['interface', 'display', 'layout', 'module'] as const;
export const API_EXTENSION_TYPES = ['hook', 'endpoint'] as const;
export const EXTENSION_TYPES = [...APP_EXTENSION_TYPES, ...API_EXTENSION_TYPES] as const;
export const EXTENSION_PACKAGE_TYPES = [...EXTENSION_TYPES, 'pack'] as const;
export const EXTENSION_NAME_REGEX = /^(?:(?:@[^/]+\/)?directus-extension-|@directus\/extension-).+$/;
export const EXTENSION_PKG_KEY = 'directus:extension';

View File

@@ -0,0 +1,33 @@
export const TYPES = [
'alias',
'bigInteger',
'boolean',
'date',
'dateTime',
'decimal',
'float',
'integer',
'json',
'string',
'text',
'time',
'timestamp',
'binary',
'uuid',
'hash',
'csv',
'unknown',
] as const;
export const LOCAL_TYPES = [
'standard',
'file',
'files',
'm2o',
'o2m',
'm2m',
'm2a',
'presentation',
'translations',
'group',
] as const;

View File

@@ -1,2 +1,3 @@
export * from './extensions';
export * from './fields';
export * from './symbols';

View File

@@ -0,0 +1,13 @@
export class BaseException extends Error {
status: number;
code: string;
extensions: Record<string, any>;
constructor(message: string, status: number, code: string, extensions?: Record<string, any>) {
super(message);
this.status = status;
this.code = code;
this.extensions = extensions || {};
}
}

View File

@@ -0,0 +1,107 @@
import { ValidationErrorItem } from 'joi';
import { FilterOperator } from '../types';
import { BaseException } from './base';
type FailedValidationExtensions = {
field: string;
type: FilterOperator | 'required' | 'regex';
valid?: number | string | (number | string)[];
invalid?: number | string | (number | string)[];
substring?: string;
};
export class FailedValidationException extends BaseException {
constructor(error: ValidationErrorItem) {
const extensions: Partial<FailedValidationExtensions> = {
field: error.path[0] as string,
};
const joiType = error.type;
// eq | in | null | empty
if (joiType.endsWith('only')) {
if (error.context?.valids.length > 1) {
extensions.type = 'in';
extensions.valid = error.context?.valids;
} else {
const valid = error.context?.valids[0];
if (valid === null) {
extensions.type = 'null';
} else if (valid === '') {
extensions.type = 'empty';
} else {
extensions.type = 'eq';
extensions.valid = error.context?.valids[0];
}
}
}
// neq | nin | nnull | nempty
if (joiType.endsWith('invalid')) {
if (error.context?.invalids.length > 1) {
extensions.type = 'nin';
extensions.invalid = error.context?.invalids;
} else {
const invalid = error.context?.invalids[0];
if (invalid === null) {
extensions.type = 'nnull';
} else if (invalid === '') {
extensions.type = 'nempty';
} else {
extensions.type = 'neq';
extensions.invalid = invalid;
}
}
}
// gt
if (joiType.endsWith('greater')) {
extensions.type = 'gt';
extensions.valid = error.context?.limit;
}
// gte
if (joiType.endsWith('min')) {
extensions.type = 'gte';
extensions.valid = error.context?.limit;
}
// lt
if (joiType.endsWith('less')) {
extensions.type = 'lt';
extensions.valid = error.context?.limit;
}
// lte
if (joiType.endsWith('max')) {
extensions.type = 'lte';
extensions.valid = error.context?.limit;
}
// contains
if (joiType.endsWith('contains')) {
extensions.type = 'contains';
extensions.substring = error.context?.substring;
}
// ncontains
if (joiType.endsWith('ncontains')) {
extensions.type = 'ncontains';
extensions.substring = error.context?.substring;
}
// required
if (joiType.endsWith('required')) {
extensions.type = 'required';
}
if (joiType.endsWith('.pattern.base')) {
extensions.type = 'regex';
extensions.invalid = error.context?.value;
}
super(error.message, 400, 'FAILED_VALIDATION', extensions);
}
}

View File

@@ -0,0 +1,2 @@
export * from './base';
export * from './failed-validation';

View File

@@ -0,0 +1,9 @@
export type Accountability = {
role: string | null;
user?: string | null;
admin?: boolean;
app?: boolean;
ip?: string;
userAgent?: string;
};

View File

@@ -0,0 +1,35 @@
import { Component, ComponentOptions } from 'vue';
import { Field, LocalType, Type } from './fields';
import { DeepPartial } from './misc';
export type DisplayHandlerFunctionContext = {
type: string;
};
export type DisplayHandlerFunction = (
value: any,
options: Record<string, any> | null,
context?: DisplayHandlerFunctionContext
) => string | null;
export type DisplayFieldsFunction = (
options: any,
context: {
collection: string;
field: string;
type: string;
}
) => string[];
export interface DisplayConfig {
id: string;
name: string;
icon: string;
description?: string;
handler: DisplayHandlerFunction | ComponentOptions;
options: DeepPartial<Field>[] | Component | null;
types: readonly Type[];
groups?: readonly LocalType[];
fields?: string[] | DisplayFieldsFunction;
}

View File

@@ -0,0 +1,4 @@
import { Router } from 'express';
import { ApiExtensionContext } from './extensions';
export type EndpointRegisterFunction = (router: Router, context: ApiExtensionContext) => void;

View File

@@ -1,7 +1,15 @@
export type ApiExtensionType = 'endpoint' | 'hook';
export type AppExtensionType = 'interface' | 'display' | 'layout' | 'module';
export type ExtensionType = ApiExtensionType | AppExtensionType;
export type ExtensionPackageType = ExtensionType | 'pack';
import {
API_EXTENSION_TYPES,
APP_EXTENSION_TYPES,
EXTENSION_PACKAGE_TYPES,
EXTENSION_PKG_KEY,
EXTENSION_TYPES,
} from '../constants';
export type ApiExtensionType = typeof API_EXTENSION_TYPES[number];
export type AppExtensionType = typeof APP_EXTENSION_TYPES[number];
export type ExtensionType = typeof EXTENSION_TYPES[number];
export type ExtensionPackageType = typeof EXTENSION_PACKAGE_TYPES[number];
export type Extension = {
path: string;
@@ -16,3 +24,39 @@ export type Extension = {
local: boolean;
root: boolean;
};
export type ExtensionManifestRaw = {
name?: string;
version?: string;
dependencies?: Record<string, string>;
[EXTENSION_PKG_KEY]?: {
type?: string;
path?: string;
source?: string;
host?: string;
hidden?: boolean;
};
};
export type ExtensionManifest = {
name: string;
version: string;
dependencies?: Record<string, string>;
[EXTENSION_PKG_KEY]: {
type: ExtensionPackageType;
path: string;
source: string;
host: string;
hidden: boolean;
};
};
export type ApiExtensionContext = {
services: any;
exceptions: any;
database: any;
env: any;
getSchema: any;
};

View File

@@ -0,0 +1,67 @@
import { FilterOperator } from './filter';
import { Column } from 'knex-schema-inspector/dist/types/column';
import { LOCAL_TYPES, TYPES } from '../constants';
type Translations = {
language: string;
translation: string;
};
export type Width = 'half' | 'half-left' | 'half-right' | 'full' | 'fill';
export type Type = typeof TYPES[number];
export type LocalType = typeof LOCAL_TYPES[number];
export type FieldMeta = {
id: number;
collection: string;
field: string;
group: number | null;
hidden: boolean;
interface: string | null;
display: string | null;
options: Record<string, any> | null;
display_options: Record<string, any> | null;
readonly: boolean;
required: boolean;
sort: number | null;
special: string[] | null;
translations: Translations[] | null;
width: Width | null;
note: string | null;
conditions: Condition[] | null;
system?: true;
};
export interface FieldRaw {
collection: string;
field: string;
type: Type;
schema: Column | null;
meta: FieldMeta | null;
}
export interface Field extends FieldRaw {
name: string;
children?: Field[] | null;
}
export type ValidationError = {
code: string;
field: string;
type: FilterOperator;
valid?: number | string | (number | string)[];
invalid?: number | string | (number | string)[];
substring?: string;
};
export type Condition = {
name: string;
rule: Record<string, any>;
readonly?: boolean;
hidden?: boolean;
options?: Record<string, any>;
required?: boolean;
};

View File

@@ -0,0 +1,52 @@
export type FilterOperator =
| 'eq'
| 'neq'
| 'lt'
| 'lte'
| 'gt'
| 'gte'
| 'in'
| 'nin'
| 'null'
| 'nnull'
| 'contains'
| 'ncontains'
| 'between'
| 'nbetween'
| 'empty'
| 'nempty';
export type ClientFilterOperator = FilterOperator | 'starts_with' | 'nstarts_with' | 'ends_with' | 'nends_with';
export type Filter = FieldFilter & {
_and?: FieldFilter[];
_or?: FieldFilter[];
};
export type FieldFilter = {
[field: string]: FieldFilterOperator | FieldValidationOperator | FieldFilter;
};
export type FieldFilterOperator = {
_eq?: string | number | boolean;
_neq?: string | number | boolean;
_lt?: string | number;
_lte?: string | number;
_gt?: string | number;
_gte?: string | number;
_in?: (string | number)[];
_nin?: (string | number)[];
_null?: boolean;
_nnull?: boolean;
_contains?: string;
_ncontains?: string;
_between?: (string | number)[];
_nbetween?: (string | number)[];
_empty?: boolean;
_nempty?: boolean;
};
export type FieldValidationOperator = {
_submitted?: boolean;
_regex?: string;
};

View File

@@ -0,0 +1,3 @@
import { ApiExtensionContext } from './extensions';
export type HookRegisterFunction = (context: ApiExtensionContext) => Record<string, (...values: any[]) => void>;

View File

@@ -1,5 +1,15 @@
export * from './accountability';
export * from './displays';
export * from './endpoints';
export * from './extensions';
export * from './fields';
export * from './filter';
export * from './hooks';
export * from './interfaces';
export * from './items';
export * from './layouts';
export * from './misc';
export * from './modules';
export * from './permissions';
export * from './presets';
export * from './users';

View File

@@ -0,0 +1,19 @@
import { Field, LocalType, Type } from './fields';
import { Component } from 'vue';
import { DeepPartial } from './misc';
export interface InterfaceConfig {
id: string;
name: string;
icon: string;
description?: string;
component: Component;
options: DeepPartial<Field>[] | Component | null;
types: readonly Type[];
groups?: readonly LocalType[];
relational?: boolean;
hideLabel?: boolean;
hideLoader?: boolean;
system?: boolean;
recommendedDisplays?: string[];
}

View File

@@ -1,12 +1,26 @@
import { Component } from 'vue';
import { Item } from './items';
import { Filter } from './presets';
import { AppFilter } from './presets';
export interface LayoutConfig<Options = any, Query = any> {
id: string;
name: string;
icon: string;
component: Component;
slots: {
options: Component;
sidebar: Component;
actions: Component;
};
setup: (LayoutOptions: LayoutProps<Options, Query>) => any;
}
export interface LayoutProps<Options = any, Query = any> {
collection: string | null;
selection: Item[];
layoutOptions: Options;
layoutQuery: Query;
filters: Filter[];
filters: AppFilter[];
searchQuery: string | null;
selectMode: boolean;
readonly: boolean;

View File

@@ -1 +1,36 @@
type Primitive = undefined | null | string | number | boolean | bigint | symbol;
type Builtin = Primitive | Date | Error | RegExp | ((...args: any[]) => unknown);
type Tuple =
| [unknown]
| [unknown, unknown]
| [unknown, unknown, unknown]
| [unknown, unknown, unknown, unknown]
| [unknown, unknown, unknown, unknown, unknown];
export type DeepPartial<T> = T extends Builtin
? T
: T extends Tuple
? { [K in keyof T]?: DeepPartial<T[K]> }
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends Map<infer K, infer V>
? Map<DeepPartial<K>, DeepPartial<V>>
: T extends ReadonlyMap<infer K, infer V>
? ReadonlyMap<DeepPartial<K>, DeepPartial<V>>
: T extends WeakMap<infer K, infer V>
? WeakMap<DeepPartial<K>, DeepPartial<V>>
: T extends Set<infer U>
? Set<DeepPartial<U>>
: T extends ReadonlySet<infer U>
? ReadonlySet<DeepPartial<U>>
: T extends WeakSet<infer U>
? WeakSet<DeepPartial<U>>
: T extends Promise<infer U>
? Promise<DeepPartial<U>>
: T extends Record<any, any>
? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
export type Plural<T extends string> = `${T}s`;

View File

@@ -0,0 +1,16 @@
import { Ref } from 'vue';
import { RouteRecordRaw } from 'vue-router';
import { Permission, User } from '../types';
export interface ModuleConfig {
id: string;
name: string;
hidden?: boolean | Ref<boolean>;
icon: string;
routes?: RouteRecordRaw[];
link?: string;
color?: string;
preRegisterCheck?: (user: User, permissions: Permission[]) => Promise<boolean> | boolean;
order?: number;
persistent?: boolean;
}

View File

@@ -0,0 +1,11 @@
export type Permission = {
id: number;
role: string | null;
collection: string;
action: 'create' | 'read' | 'update' | 'delete';
permissions: Record<string, any> | null;
validation: Record<string, any> | null;
presets: Record<string, any> | null;
fields: string[] | null;
limit: number | null;
};

View File

@@ -1,22 +1,6 @@
export type FilterOperator =
| 'eq'
| 'neq'
| 'lt'
| 'lte'
| 'gt'
| 'gte'
| 'in'
| 'nin'
| 'null'
| 'nnull'
| 'contains'
| 'ncontains'
| 'between'
| 'nbetween'
| 'empty'
| 'nempty';
import { FilterOperator } from './filter';
export type Filter = {
export type AppFilter = {
key: string;
field: string;
operator: FilterOperator;
@@ -31,7 +15,7 @@ export type Preset = {
role: string | null;
collection: string;
search: string | null;
filters: readonly Filter[] | null;
filters: readonly AppFilter[] | null;
layout: string | null;
layout_query: { [layout: string]: any } | null;
layout_options: { [layout: string]: any } | null;

View File

@@ -0,0 +1,55 @@
export type Role = {
id: string;
name: string;
description: string;
collection_list:
| null
| {
group_name: string;
accordion: string;
collections: {
collection: string;
}[];
}[];
module_list:
| null
| {
link: string;
name: string;
icon: string;
}[];
enforce_2fa: null | boolean;
external_id: null | string;
ip_whitelist: string[];
app_access: boolean;
admin_access: boolean;
};
export type Avatar = {
id: string;
};
// There's more data returned in thumbnails and the avatar data, but we
// only care about the thumbnails in this context
export type User = {
id: string;
status: string;
first_name: string;
last_name: string;
email: string;
token: string;
last_login: string;
last_page: string;
external_id: string;
'2fa_secret': string;
theme: 'auto' | 'dark' | 'light';
role: Role;
password_reset_token: string | null;
timezone: string;
language: string;
avatar: null | Avatar;
company: string | null;
title: string | null;
email_notifications: boolean;
};

View File

@@ -0,0 +1,95 @@
import {
addYears,
subWeeks,
subYears,
addWeeks,
subMonths,
addMonths,
subDays,
addDays,
subHours,
addHours,
subMinutes,
addMinutes,
subSeconds,
addSeconds,
addMilliseconds,
subMilliseconds,
} from 'date-fns';
import { clone } from 'lodash';
/**
* Adjust a given date by a given change in duration. The adjustment value uses the exact same syntax
* and logic as Vercel's `ms`.
*
* The conversion is lifted straight from `ms`.
*/
export function adjustDate(date: Date, adjustment: string): Date | undefined {
date = clone(date);
const subtract = adjustment.startsWith('-');
if (subtract || adjustment.startsWith('+')) {
adjustment = adjustment.substring(1);
}
const match =
/^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|months?|mth|mo|years?|yrs?|y)?$/i.exec(
adjustment
);
if (!match || !match[1]) {
return;
}
const amount = parseFloat(match[1]);
const type = (match[2] || 'days').toLowerCase();
switch (type) {
case 'years':
case 'year':
case 'yrs':
case 'yr':
case 'y':
return subtract ? subYears(date, amount) : addYears(date, amount);
case 'months':
case 'month':
case 'mth':
case 'mo':
return subtract ? subMonths(date, amount) : addMonths(date, amount);
case 'weeks':
case 'week':
case 'w':
return subtract ? subWeeks(date, amount) : addWeeks(date, amount);
case 'days':
case 'day':
case 'd':
return subtract ? subDays(date, amount) : addDays(date, amount);
case 'hours':
case 'hour':
case 'hrs':
case 'hr':
case 'h':
return subtract ? subHours(date, amount) : addHours(date, amount);
case 'minutes':
case 'minute':
case 'mins':
case 'min':
case 'm':
return subtract ? subMinutes(date, amount) : addMinutes(date, amount);
case 'seconds':
case 'second':
case 'secs':
case 'sec':
case 's':
return subtract ? subSeconds(date, amount) : addSeconds(date, amount);
case 'milliseconds':
case 'millisecond':
case 'msecs':
case 'msec':
case 'ms':
return subtract ? subMilliseconds(date, amount) : addMilliseconds(date, amount);
default:
return undefined;
}
}

View File

@@ -0,0 +1,2 @@
// nothing to see here
export {};

View File

@@ -0,0 +1,28 @@
export function deepMap(
object: Record<string, any>,
iterator: (value: any, key: string | number) => any,
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
context?: any
): any {
if (Array.isArray(object)) {
return object.map(function (val, key) {
return typeof val === 'object' ? deepMap(val, iterator, context) : iterator.call(context, val, key);
});
} else if (typeof object === 'object') {
const res: Record<string, any> = {};
for (const key in object) {
const val = object[key];
if (typeof val === 'object') {
res[key] = deepMap(val, iterator, context);
} else {
res[key] = iterator.call(context, val, key);
}
}
return res;
} else {
return object;
}
}

View File

@@ -0,0 +1,34 @@
import {
InterfaceConfig,
DisplayConfig,
LayoutConfig,
ModuleConfig,
HookRegisterFunction,
EndpointRegisterFunction,
} from '../types';
export function defineInterface(config: InterfaceConfig): InterfaceConfig {
return config;
}
export function defineDisplay(config: DisplayConfig): DisplayConfig {
return config;
}
export function defineLayout<Options = any, Query = any>(
config: LayoutConfig<Options, Query>
): LayoutConfig<Options, Query> {
return config;
}
export function defineModule(config: ModuleConfig): ModuleConfig {
return config;
}
export function defineHook(config: HookRegisterFunction): HookRegisterFunction {
return config;
}
export function defineEndpoint(config: EndpointRegisterFunction): EndpointRegisterFunction {
return config;
}

View File

@@ -0,0 +1,207 @@
import BaseJoi, { AnySchema } from 'joi';
import { escapeRegExp, merge } from 'lodash';
import { FieldFilter } from '../types/filter';
const Joi = BaseJoi.extend({
type: 'string',
base: BaseJoi.string(),
messages: {
'string.contains': '{{#label}} must contain [{{#substring}}]',
'string.ncontains': "{{#label}} can't contain [{{#substring}}]",
},
rules: {
contains: {
args: [
{
name: 'substring',
ref: true,
assert: (val) => typeof val === 'string',
message: 'must be a string',
},
],
method(substring) {
return this.$_addRule({ name: 'contains', args: { substring } });
},
validate(value, helpers, { substring }) {
if (value.includes(substring) === false) {
return helpers.error('string.contains', { substring });
}
return value;
},
},
ncontains: {
args: [
{
name: 'substring',
ref: true,
assert: (val) => typeof val === 'string',
message: 'must be a string',
},
],
method(substring) {
return this.$_addRule({ name: 'ncontains', args: { substring } });
},
validate(value, helpers, { substring }) {
if (value.includes(substring) === true) {
return helpers.error('string.ncontains', { substring });
}
return value;
},
},
},
});
export type JoiOptions = {
requireAll?: boolean;
};
const defaults: JoiOptions = {
requireAll: false,
};
/**
* Generate a Joi schema from a filter object.
*
* @param {FieldFilter} filter - Field filter object. Note: does not support _and/_or filters.
* @param {JoiOptions} [options] - Options for the schema generation.
* @returns {AnySchema} Joi schema.
*/
export function generateJoi(filter: FieldFilter, options?: JoiOptions): AnySchema {
filter = filter || {};
options = merge({}, defaults, options);
const schema: Record<string, AnySchema> = {};
const key = Object.keys(filter)[0];
if (!key) {
throw new Error(`[generateJoi] Filter doesn't contain field key. Passed filter: ${JSON.stringify(filter)}`);
}
const value = Object.values(filter)[0];
if (!value) {
throw new Error(`[generateJoi] Filter doesn't contain filter rule. Passed filter: ${JSON.stringify(filter)}`);
}
if (Object.keys(value)[0]?.startsWith('_') === false) {
schema[key] = Joi.object({
[key]: generateJoi(value as FieldFilter, options),
});
} else {
const operator = Object.keys(value)[0];
const compareValue = Object.values(value)[0];
if (operator === '_eq') {
schema[key] = (schema[key] ?? Joi.any()).equal(compareValue);
}
if (operator === '_neq') {
schema[key] = (schema[key] ?? Joi.any()).not(compareValue);
}
if (operator === '_contains') {
schema[key] = (schema[key] ?? Joi.string()).contains(compareValue);
}
if (operator === '_ncontains') {
schema[key] = (schema[key] ?? Joi.string()).ncontains(compareValue);
}
if (operator === '_starts_with') {
schema[key] = (schema[key] ?? Joi.string()).pattern(new RegExp(`^${escapeRegExp(compareValue as string)}.*`), {
name: 'starts_with',
});
}
if (operator === '_nstarts_with') {
schema[key] = (schema[key] ?? Joi.string()).pattern(new RegExp(`^${escapeRegExp(compareValue as string)}.*`), {
name: 'starts_with',
invert: true,
});
}
if (operator === '_ends_with') {
schema[key] = (schema[key] ?? Joi.string()).pattern(new RegExp(`.*${escapeRegExp(compareValue as string)}$`), {
name: 'ends_with',
});
}
if (operator === '_nends_with') {
schema[key] = (schema[key] ?? Joi.string()).pattern(new RegExp(`.*${escapeRegExp(compareValue as string)}$`), {
name: 'ends_with',
invert: true,
});
}
if (operator === '_in') {
schema[key] = (schema[key] ?? Joi.any()).equal(...(compareValue as (string | number)[]));
}
if (operator === '_nin') {
schema[key] = (schema[key] ?? Joi.any()).not(...(compareValue as (string | number)[]));
}
if (operator === '_gt') {
schema[key] = (schema[key] ?? Joi.number()).greater(Number(compareValue));
}
if (operator === '_gte') {
schema[key] = (schema[key] ?? Joi.number()).min(Number(compareValue));
}
if (operator === '_lt') {
schema[key] = (schema[key] ?? Joi.number()).less(Number(compareValue));
}
if (operator === '_lte') {
schema[key] = (schema[key] ?? Joi.number()).max(Number(compareValue));
}
if (operator === '_null') {
schema[key] = (schema[key] ?? Joi.any()).valid(null);
}
if (operator === '_nnull') {
schema[key] = (schema[key] ?? Joi.any()).invalid(null);
}
if (operator === '_empty') {
schema[key] = (schema[key] ?? Joi.any()).valid('');
}
if (operator === '_nempty') {
schema[key] = (schema[key] ?? Joi.any()).invalid('');
}
if (operator === '_between') {
const values = compareValue as number[];
schema[key] = (schema[key] ?? Joi.number()).greater(values[0]).less(values[1]);
}
if (operator === '_nbetween') {
const values = compareValue as number[];
schema[key] = (schema[key] ?? Joi.number()).less(values[0]).greater(values[1]);
}
if (operator === '_submitted') {
schema[key] = (schema[key] ?? Joi.any()).required();
}
if (operator === '_regex') {
const wrapped = compareValue.startsWith('/') && compareValue.endsWith('/');
schema[key] = (schema[key] ?? Joi.string()).regex(new RegExp(wrapped ? compareValue.slice(1, -1) : compareValue));
}
}
schema[key] = schema[key] ?? Joi.any();
if (options.requireAll) {
schema[key] = schema[key]!.required();
}
return Joi.object(schema).unknown();
}

View File

@@ -0,0 +1,60 @@
import { ClientFilterOperator, Type } from '../types';
export function getFilterOperatorsForType(type: Type): ClientFilterOperator[] {
switch (type) {
// Text
case 'binary':
case 'json':
case 'hash':
case 'string':
return [
'contains',
'ncontains',
'starts_with',
'nstarts_with',
'ends_with',
'nends_with',
'eq',
'neq',
'empty',
'nempty',
'in',
'nin',
];
case 'uuid':
return ['eq', 'neq', 'empty', 'nempty', 'in', 'nin'];
// Boolean
case 'boolean':
return ['eq', 'neq', 'empty', 'nempty'];
// Numbers
case 'integer':
case 'decimal':
return ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'between', 'nbetween', 'empty', 'nempty', 'in', 'nin'];
// Datetime
case 'dateTime':
case 'date':
case 'time':
return ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'between', 'nbetween', 'empty', 'nempty', 'in', 'nin'];
default:
return [
'eq',
'neq',
'lt',
'lte',
'gt',
'gte',
'contains',
'ncontains',
'between',
'nbetween',
'empty',
'nempty',
'in',
'nin',
];
}
}

View File

@@ -1,6 +1,11 @@
export * from './ensure-extensions-dirs';
export * from './generate-extensions-entry';
export * from './get-extensions';
export * from './list-folders';
export * from './adjust-date';
export * from './deep-map';
export * from './define-extension';
export * from './generate-joi';
export * from './get-filter-operators-for-type';
export * from './is-extension';
export * from './parse-filter';
export * from './pluralize';
export * from './resolve-package';
export * from './to-array';
export * from './validate-extension-manifest';
export * from './validate-payload';

View File

@@ -0,0 +1,18 @@
import { API_EXTENSION_TYPES, APP_EXTENSION_TYPES, EXTENSION_PACKAGE_TYPES, EXTENSION_TYPES } from '../constants';
import { ApiExtensionType, AppExtensionType, ExtensionPackageType, ExtensionType } from '../types';
export function isExtension(type: string): type is ExtensionType {
return (EXTENSION_TYPES as readonly string[]).includes(type);
}
export function isAppExtension(type: string): type is AppExtensionType {
return (APP_EXTENSION_TYPES as readonly string[]).includes(type);
}
export function isApiExtension(type: string): type is ApiExtensionType {
return (API_EXTENSION_TYPES as readonly string[]).includes(type);
}
export function isExtensionPackage(type: string): type is ExtensionPackageType {
return (EXTENSION_PACKAGE_TYPES as readonly string[]).includes(type);
}

View File

@@ -1,11 +1,15 @@
import path from 'path';
import fse from 'fs-extra';
import { pluralize } from './pluralize';
import { EXTENSION_TYPES } from '../constants';
import { pluralize } from '../pluralize';
import { EXTENSION_TYPES } from '../../constants';
export async function ensureExtensionDirs(extensionsPath: string): Promise<void> {
for (const extensionType of EXTENSION_TYPES) {
const dirPath = path.resolve(extensionsPath, pluralize(extensionType));
await fse.ensureDir(dirPath);
try {
await fse.ensureDir(dirPath);
} catch {
throw new Error(`Extension folder "${dirPath}" couldn't be opened`);
}
}
}

View File

@@ -1,5 +1,5 @@
import path from 'path/posix';
import { AppExtensionType, Extension } from '../types';
import path from 'path';
import { AppExtensionType, Extension } from '../../types';
export function generateExtensionsEntry(type: AppExtensionType, extensions: Extension[]): string {
const filteredExtensions = extensions.filter((extension) => extension.type === type);
@@ -7,7 +7,10 @@ export function generateExtensionsEntry(type: AppExtensionType, extensions: Exte
return `${filteredExtensions
.map(
(extension, i) =>
`import e${i} from './${path.relative('.', path.resolve(extension.path, extension.entrypoint || ''))}';\n`
`import e${i} from './${path
.relative('.', path.resolve(extension.path, extension.entrypoint || ''))
.split(path.sep)
.join(path.posix.sep)}';\n`
)
.join('')}export default [${filteredExtensions.map((_, i) => `e${i}`).join(',')}];`;
}

View File

@@ -1,14 +1,22 @@
import path from 'path';
import fse from 'fs-extra';
import { Extension } from '../types';
import { Extension, ExtensionManifestRaw } from '../../types';
import { resolvePackage } from './resolve-package';
import { listFolders } from './list-folders';
import { EXTENSION_NAME_REGEX, EXTENSION_TYPES } from '../constants';
import { pluralize } from './pluralize';
import { EXTENSION_NAME_REGEX, EXTENSION_PKG_KEY, EXTENSION_TYPES } from '../../constants';
import { pluralize } from '../pluralize';
import { validateExtensionManifest } from '../validate-extension-manifest';
export async function getPackageExtensions(root: string): Promise<Extension[]> {
const pkg = await fse.readJSON(path.resolve(path.join(root, 'package.json')));
const extensionNames = Object.keys(pkg.dependencies).filter((dep) => EXTENSION_NAME_REGEX.test(dep));
let pkg: { dependencies?: Record<string, string> };
try {
pkg = await fse.readJSON(path.resolve(root, 'package.json'));
} catch {
throw new Error('Current folder does not contain a package.json file');
}
const extensionNames = Object.keys(pkg.dependencies ?? {}).filter((dep) => EXTENSION_NAME_REGEX.test(dep));
return listExtensionsChildren(extensionNames);
@@ -17,19 +25,23 @@ export async function getPackageExtensions(root: string): Promise<Extension[]> {
for (const extensionName of extensionNames) {
const extensionPath = resolvePackage(extensionName, root);
const extensionPkg = await fse.readJSON(path.join(extensionPath, 'package.json'));
const extensionManifest: ExtensionManifestRaw = await fse.readJSON(path.join(extensionPath, 'package.json'));
if (extensionPkg['directus:extension'].type === 'pack') {
const extensionChildren = Object.keys(extensionPkg.dependencies).filter((dep) =>
if (!validateExtensionManifest(extensionManifest)) {
throw new Error(`The extension manifest of "${extensionName}" is not valid.`);
}
if (extensionManifest[EXTENSION_PKG_KEY].type === 'pack') {
const extensionChildren = Object.keys(extensionManifest.dependencies ?? {}).filter((dep) =>
EXTENSION_NAME_REGEX.test(dep)
);
const extension: Extension = {
path: extensionPath,
name: extensionName,
version: extensionPkg.version,
type: extensionPkg['directus:extension'].type,
host: extensionPkg['directus:extension'].host,
version: extensionManifest.version,
type: extensionManifest[EXTENSION_PKG_KEY].type,
host: extensionManifest[EXTENSION_PKG_KEY].host,
children: extensionChildren,
local: false,
root: root === undefined,
@@ -41,10 +53,10 @@ export async function getPackageExtensions(root: string): Promise<Extension[]> {
extensions.push({
path: extensionPath,
name: extensionName,
version: extensionPkg.version,
type: extensionPkg['directus:extension'].type,
entrypoint: extensionPkg['directus:extension'].path,
host: extensionPkg['directus:extension'].host,
version: extensionManifest.version,
type: extensionManifest[EXTENSION_PKG_KEY].type,
entrypoint: extensionManifest[EXTENSION_PKG_KEY].path,
host: extensionManifest[EXTENSION_PKG_KEY].host,
local: false,
root: root === undefined,
});
@@ -60,7 +72,7 @@ export async function getLocalExtensions(root: string): Promise<Extension[]> {
for (const extensionType of EXTENSION_TYPES) {
const typeDir = pluralize(extensionType);
const typePath = path.resolve(path.join(root, typeDir));
const typePath = path.resolve(root, typeDir);
try {
const extensionNames = await listFolders(typePath);
@@ -77,9 +89,8 @@ export async function getLocalExtensions(root: string): Promise<Extension[]> {
root: true,
});
}
} catch (err) {
if (err.code === 'ENOENT') throw new Error(`Extension folder "${typePath}" couldn't be opened`);
throw err;
} catch {
throw new Error(`Extension folder "${typePath}" couldn't be opened`);
}
}

View File

@@ -0,0 +1,5 @@
export * from './ensure-extension-dirs';
export * from './generate-extensions-entry';
export * from './get-extensions';
export * from './list-folders';
export * from './resolve-package';

View File

@@ -0,0 +1,33 @@
import { REGEX_BETWEEN_PARENS } from '../constants';
import { Accountability, Filter } from '../types';
import { toArray } from './to-array';
import { adjustDate } from './adjust-date';
import { deepMap } from './deep-map';
export function parseFilter(filter: Filter, accountability: Accountability | null): any {
return deepMap(filter, (val, key) => {
if (val === 'true') return true;
if (val === 'false') return false;
if (val === 'null' || val === 'NULL') return null;
if (['_in', '_nin', '_between', '_nbetween'].includes(String(key))) {
if (typeof val === 'string' && val.includes(',')) return val.split(',');
else return toArray(val);
}
if (val && typeof val === 'string' && val.startsWith('$NOW')) {
if (val.includes('(') && val.includes(')')) {
const adjustment = val.match(REGEX_BETWEEN_PARENS)?.[1];
if (!adjustment) return new Date();
return adjustDate(new Date(), adjustment);
}
return new Date();
}
if (val === '$CURRENT_USER') return accountability?.user || null;
if (val === '$CURRENT_ROLE') return accountability?.role || null;
return val;
});
}

View File

@@ -0,0 +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];
}

View File

@@ -0,0 +1,33 @@
import { EXTENSION_PKG_KEY } from '../constants';
import { ExtensionManifest, ExtensionManifestRaw } from '../types';
import { isExtensionPackage } from './is-extension';
export function validateExtensionManifest(
extensionManifest: ExtensionManifestRaw
): extensionManifest is ExtensionManifest {
if (extensionManifest.name === undefined || extensionManifest.version === undefined) {
return false;
}
const extensionOptions = extensionManifest[EXTENSION_PKG_KEY];
if (extensionOptions === undefined) {
return false;
}
if (
extensionOptions.type === undefined ||
extensionOptions.path === undefined ||
extensionOptions.source === undefined ||
extensionOptions.host === undefined ||
extensionOptions.hidden === undefined
) {
return false;
}
if (!isExtensionPackage(extensionOptions.type)) {
return false;
}
return true;
}

View File

@@ -0,0 +1,58 @@
import { FieldFilter, Filter } from '../types/filter';
import { flatten } from 'lodash';
import { generateJoi, JoiOptions } from './generate-joi';
import Joi from 'joi';
/**
* Validate the payload against the given filter rules
*
* @param {Filter} filter - The filter rules to check against
* @param {Record<string, any>} payload - The payload to validate
* @param {JoiOptions} [options] - Optional options to pass to Joi
* @returns Array of errors
*/
export function validatePayload(
filter: Filter,
payload: Record<string, any>,
options?: JoiOptions
): Joi.ValidationError[] {
const errors: Joi.ValidationError[] = [];
/**
* Note there can only be a single _and / _or per level
*/
if (Object.keys(filter)[0] === '_and') {
const subValidation = Object.values(filter)[0] as FieldFilter[];
const nestedErrors = flatten<Joi.ValidationError>(
subValidation.map((subObj: Record<string, any>) => {
return validatePayload(subObj, payload, options);
})
).filter((err?: Joi.ValidationError) => err);
errors.push(...nestedErrors);
} else if (Object.keys(filter)[0] === '_or') {
const subValidation = Object.values(filter)[0] as FieldFilter[];
const nestedErrors = flatten<Joi.ValidationError>(
subValidation.map((subObj: Record<string, any>) => validatePayload(subObj, payload, options))
);
const allErrored = subValidation.length === nestedErrors.length;
if (allErrored) {
errors.push(...nestedErrors);
}
} else {
const schema = generateJoi(filter, options);
const { error } = schema.validate(payload, { abortEarly: false });
if (error) {
errors.push(error);
}
}
return errors;
}