Files
directus/api/src/services/graphql.ts
ian 5068ca096b Add lock for system cache (#12017)
* Add lock for system cache

* Add lock when forcing a flush

* Simplify code

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
2022-03-18 15:54:02 -04:00

2635 lines
76 KiB
TypeScript

import argon2 from 'argon2';
import { validateQuery } from '../utils/validate-query';
import {
ArgumentNode,
BooleanValueNode,
execute,
ExecutionResult,
FieldNode,
formatError,
FormattedExecutionResult,
FragmentDefinitionNode,
GraphQLBoolean,
GraphQLEnumType,
GraphQLError,
GraphQLFloat,
GraphQLID,
GraphQLInt,
GraphQLList,
GraphQLNonNull,
GraphQLNullableType,
GraphQLObjectType,
GraphQLResolveInfo,
GraphQLScalarType,
GraphQLSchema,
GraphQLString,
GraphQLUnionType,
InlineFragmentNode,
IntValueNode,
ObjectFieldNode,
ObjectValueNode,
SelectionNode,
specifiedRules,
StringValueNode,
validate,
} from 'graphql';
import { Filter, SchemaOverview } from '@directus/shared/types';
import {
GraphQLJSON,
InputTypeComposer,
InputTypeComposerFieldConfigMapDefinition,
ObjectTypeComposer,
ObjectTypeComposerFieldConfigMapDefinition,
SchemaComposer,
toInputObjectType,
} from 'graphql-compose';
import { Knex } from 'knex';
import { flatten, get, mapKeys, merge, set, uniq, pick, transform, isObject, omit } from 'lodash';
import ms from 'ms';
import { getCache, clearSystemCache } from '../cache';
import getDatabase from '../database';
import env from '../env';
import { BaseException } from '@directus/shared/exceptions';
import { ForbiddenException, GraphQLValidationException, InvalidPayloadException } from '../exceptions';
import { getExtensionManager } from '../extensions';
import { Accountability, Query, Aggregate } from '@directus/shared/types';
import { AbstractServiceOptions, Action, GraphQLParams, Item } from '../types';
import { getGraphQLType } from '../utils/get-graphql-type';
import { reduceSchema } from '../utils/reduce-schema';
import { sanitizeQuery } from '../utils/sanitize-query';
import { ActivityService } from './activity';
import { AuthenticationService } from './authentication';
import { CollectionsService } from './collections';
import { FieldsService } from './fields';
import { FilesService } from './files';
import { FoldersService } from './folders';
import { ItemsService } from './items';
import { PermissionsService } from './permissions';
import { PresetsService } from './presets';
import { NotificationsService } from './notifications';
import { RelationsService } from './relations';
import { RevisionsService } from './revisions';
import { RolesService } from './roles';
import { ServerService } from './server';
import { SettingsService } from './settings';
import { SharesService } from './shares';
import { SpecificationService } from './specifications';
import { TFAService } from './tfa';
import { UsersService } from './users';
import { UtilsService } from './utils';
import { WebhooksService } from './webhooks';
import { generateHash } from '../utils/generate-hash';
import { DEFAULT_AUTH_PROVIDER } from '../constants';
const GraphQLVoid = new GraphQLScalarType({
name: 'Void',
description: 'Represents NULL values',
serialize() {
return null;
},
parseValue() {
return null;
},
parseLiteral() {
return null;
},
});
export const GraphQLGeoJSON = new GraphQLScalarType({
...GraphQLJSON,
name: 'GraphQLGeoJSON',
description: 'GeoJSON value',
});
export const GraphQLDate = new GraphQLScalarType({
...GraphQLString,
name: 'Date',
description: 'ISO8601 Date values',
});
/**
* These should be ignored in the context of GraphQL, and/or are replaced by a custom resolver (for non-standard structures)
*/
const SYSTEM_DENY_LIST = [
'directus_collections',
'directus_fields',
'directus_relations',
'directus_migrations',
'directus_sessions',
];
const READ_ONLY = ['directus_activity', 'directus_revisions'];
export class GraphQLService {
accountability: Accountability | null;
knex: Knex;
schema: SchemaOverview;
scope: 'items' | 'system';
constructor(options: AbstractServiceOptions & { scope: 'items' | 'system' }) {
this.accountability = options?.accountability || null;
this.knex = options?.knex || getDatabase();
this.schema = options.schema;
this.scope = options.scope;
}
/**
* Execute a GraphQL structure
*/
async execute({
document,
variables,
operationName,
contextValue,
}: GraphQLParams): Promise<FormattedExecutionResult> {
const schema = this.getSchema();
const validationErrors = validate(schema, document, specifiedRules);
if (validationErrors.length > 0) {
throw new GraphQLValidationException({ graphqlErrors: validationErrors });
}
let result: ExecutionResult;
try {
result = await execute({
schema,
document,
contextValue,
variableValues: variables,
operationName,
});
} catch (err: any) {
throw new InvalidPayloadException('GraphQL execution error.', { graphqlErrors: [err.message] });
}
const formattedResult: FormattedExecutionResult = {
...result,
errors: result.errors?.map(formatError),
};
return formattedResult;
}
/**
* Generate the GraphQL schema. Pulls from the schema information generated by the get-schema util.
*/
getSchema(): GraphQLSchema;
getSchema(type: 'schema'): GraphQLSchema;
getSchema(type: 'sdl'): GraphQLSchema | string;
getSchema(type: 'schema' | 'sdl' = 'schema'): GraphQLSchema | string {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
const schemaComposer = new SchemaComposer<GraphQLParams['contextValue']>();
const schema = {
read:
this.accountability?.admin === true
? this.schema
: reduceSchema(this.schema, this.accountability?.permissions || null, ['read']),
create:
this.accountability?.admin === true
? this.schema
: reduceSchema(this.schema, this.accountability?.permissions || null, ['create']),
update:
this.accountability?.admin === true
? this.schema
: reduceSchema(this.schema, this.accountability?.permissions || null, ['update']),
delete:
this.accountability?.admin === true
? this.schema
: reduceSchema(this.schema, this.accountability?.permissions || null, ['delete']),
};
const { ReadCollectionTypes } = getReadableTypes();
const { CreateCollectionTypes, UpdateCollectionTypes, DeleteCollectionTypes } = getWritableTypes();
const scopeFilter = (collection: SchemaOverview['collections'][string]) => {
if (this.scope === 'items' && collection.collection.startsWith('directus_') === true) return false;
if (this.scope === 'system') {
if (collection.collection.startsWith('directus_') === false) return false;
if (SYSTEM_DENY_LIST.includes(collection.collection)) return false;
}
return true;
};
if (this.scope === 'system') {
this.injectSystemResolvers(
schemaComposer,
{
CreateCollectionTypes,
ReadCollectionTypes,
UpdateCollectionTypes,
DeleteCollectionTypes,
},
schema
);
}
const readableCollections = Object.values(schema.read.collections)
.filter((collection) => collection.collection in ReadCollectionTypes)
.filter(scopeFilter);
if (readableCollections.length > 0) {
schemaComposer.Query.addFields(
readableCollections.reduce((acc, collection) => {
const collectionName = this.scope === 'items' ? collection.collection : collection.collection.substring(9);
acc[collectionName] = ReadCollectionTypes[collection.collection].getResolver(collection.collection);
if (this.schema.collections[collection.collection].singleton === false) {
acc[`${collectionName}_by_id`] = ReadCollectionTypes[collection.collection].getResolver(
`${collection.collection}_by_id`
);
const hasAggregate = Object.values(collection.fields).some((field) => {
const graphqlType = getGraphQLType(field.type);
if (graphqlType === GraphQLInt || graphqlType === GraphQLFloat) {
return true;
}
return false;
});
if (hasAggregate) {
acc[`${collectionName}_aggregated`] = ReadCollectionTypes[collection.collection].getResolver(
`${collection.collection}_aggregated`
);
}
}
return acc;
}, {} as ObjectTypeComposerFieldConfigMapDefinition<any, any>)
);
} else {
schemaComposer.Query.addFields({
_empty: {
type: GraphQLVoid,
description: "There's no data to query.",
},
});
}
if (Object.keys(schema.create.collections).length > 0) {
schemaComposer.Mutation.addFields(
Object.values(schema.create.collections)
.filter((collection) => collection.collection in CreateCollectionTypes && collection.singleton === false)
.filter(scopeFilter)
.filter((collection) => READ_ONLY.includes(collection.collection) === false)
.reduce((acc, collection) => {
const collectionName = this.scope === 'items' ? collection.collection : collection.collection.substring(9);
acc[`create_${collectionName}_items`] = CreateCollectionTypes[collection.collection].getResolver(
`create_${collection.collection}_items`
);
acc[`create_${collectionName}_item`] = CreateCollectionTypes[collection.collection].getResolver(
`create_${collection.collection}_item`
);
return acc;
}, {} as ObjectTypeComposerFieldConfigMapDefinition<any, any>)
);
}
if (Object.keys(schema.update.collections).length > 0) {
schemaComposer.Mutation.addFields(
Object.values(schema.update.collections)
.filter((collection) => collection.collection in UpdateCollectionTypes)
.filter(scopeFilter)
.filter((collection) => READ_ONLY.includes(collection.collection) === false)
.reduce((acc, collection) => {
const collectionName = this.scope === 'items' ? collection.collection : collection.collection.substring(9);
if (collection.singleton) {
acc[`update_${collectionName}`] = UpdateCollectionTypes[collection.collection].getResolver(
`update_${collection.collection}`
);
} else {
acc[`update_${collectionName}_items`] = UpdateCollectionTypes[collection.collection].getResolver(
`update_${collection.collection}_items`
);
acc[`update_${collectionName}_item`] = UpdateCollectionTypes[collection.collection].getResolver(
`update_${collection.collection}_item`
);
}
return acc;
}, {} as ObjectTypeComposerFieldConfigMapDefinition<any, any>)
);
}
if (Object.keys(schema.delete.collections).length > 0) {
schemaComposer.Mutation.addFields(
Object.values(schema.delete.collections)
.filter((collection) => collection.singleton === false)
.filter(scopeFilter)
.filter((collection) => READ_ONLY.includes(collection.collection) === false)
.reduce((acc, collection) => {
const collectionName = this.scope === 'items' ? collection.collection : collection.collection.substring(9);
acc[`delete_${collectionName}_items`] = DeleteCollectionTypes.many.getResolver(
`delete_${collection.collection}_items`
);
acc[`delete_${collectionName}_item`] = DeleteCollectionTypes.one.getResolver(
`delete_${collection.collection}_item`
);
return acc;
}, {} as ObjectTypeComposerFieldConfigMapDefinition<any, any>)
);
}
if (type === 'sdl') {
return schemaComposer.toSDL();
}
return schemaComposer.buildSchema();
/**
* Construct an object of types for every collection, using the permitted fields per action type
* as it's fields.
*/
function getTypes(action: 'read' | 'create' | 'update' | 'delete') {
const CollectionTypes: Record<string, ObjectTypeComposer> = {};
const DateFunctions = schemaComposer.createObjectTC({
name: 'date_functions',
fields: {
year: {
type: GraphQLInt,
},
month: {
type: GraphQLInt,
},
week: {
type: GraphQLInt,
},
day: {
type: GraphQLInt,
},
weekday: {
type: GraphQLInt,
},
},
});
const TimeFunctions = schemaComposer.createObjectTC({
name: 'time_functions',
fields: {
hour: {
type: GraphQLInt,
},
minute: {
type: GraphQLInt,
},
second: {
type: GraphQLInt,
},
},
});
const DateTimeFunctions = schemaComposer.createObjectTC({
name: 'datetime_functions',
fields: {
...DateFunctions.getFields(),
...TimeFunctions.getFields(),
},
});
for (const collection of Object.values(schema[action].collections)) {
if (Object.keys(collection.fields).length === 0) continue;
if (SYSTEM_DENY_LIST.includes(collection.collection)) continue;
CollectionTypes[collection.collection] = schemaComposer.createObjectTC({
name: action === 'read' ? collection.collection : `${action}_${collection.collection}`,
fields: Object.values(collection.fields).reduce((acc, field) => {
let type: GraphQLScalarType | GraphQLNonNull<GraphQLNullableType> = getGraphQLType(field.type);
// GraphQL doesn't differentiate between not-null and has-to-be-submitted. We
// can't non-null in update, as that would require every not-nullable field to be
// submitted on updates
if (field.nullable === false && action !== 'update') {
type = GraphQLNonNull(type);
}
if (collection.primary === field.field) {
type = GraphQLID;
}
acc[field.field] = {
type,
description: field.note,
resolve: (obj: Record<string, any>) => {
return obj[field.field];
},
};
if (field.type === 'date') {
acc[`${field.field}_func`] = {
type: DateFunctions,
resolve: (obj: Record<string, any>) => {
const funcFields = Object.keys(DateFunctions.getFields()).map((key) => `${field.field}_${key}`);
return mapKeys(pick(obj, funcFields), (_value, key) => key.substring(field.field.length + 1));
},
};
}
if (field.type === 'time') {
acc[`${field.field}_func`] = {
type: TimeFunctions,
resolve: (obj: Record<string, any>) => {
const funcFields = Object.keys(TimeFunctions.getFields()).map((key) => `${field.field}_${key}`);
return mapKeys(pick(obj, funcFields), (_value, key) => key.substring(field.field.length + 1));
},
};
}
if (field.type === 'dateTime' || field.type === 'timestamp') {
acc[`${field.field}_func`] = {
type: DateTimeFunctions,
resolve: (obj: Record<string, any>) => {
const funcFields = Object.keys(DateTimeFunctions.getFields()).map((key) => `${field.field}_${key}`);
return mapKeys(pick(obj, funcFields), (_value, key) => key.substring(field.field.length + 1));
},
};
}
return acc;
}, {} as ObjectTypeComposerFieldConfigMapDefinition<any, any>),
});
}
for (const relation of schema[action].relations) {
if (relation.related_collection) {
if (SYSTEM_DENY_LIST.includes(relation.related_collection)) continue;
CollectionTypes[relation.collection]?.addFields({
[relation.field]: {
type: CollectionTypes[relation.related_collection],
resolve: (obj: Record<string, any>, _, __, info) => {
return obj[info?.path?.key ?? relation.field];
},
},
});
if (relation.meta?.one_field) {
CollectionTypes[relation.related_collection]?.addFields({
[relation.meta.one_field]: {
type: [CollectionTypes[relation.collection]],
resolve: (obj: Record<string, any>, _, __, info) => {
return obj[info?.path?.key ?? relation.meta!.one_field];
},
},
});
}
} else if (relation.meta?.one_allowed_collections && action === 'read') {
// NOTE: There are no union input types in GraphQL, so this only applies to Read actions
CollectionTypes[relation.collection]?.addFields({
[relation.field]: {
type: new GraphQLUnionType({
name: `${relation.collection}_${relation.field}_union`,
types: relation.meta.one_allowed_collections.map((collection) => CollectionTypes[collection].getType()),
resolveType(value, context, info) {
let path: (string | number)[] = [];
let currentPath = info.path;
while (currentPath.prev) {
path.push(currentPath.key);
currentPath = currentPath.prev;
}
path = path.reverse().slice(0, -1);
let parent = context.data;
for (const pathPart of path) {
parent = parent[pathPart];
}
const collection = parent[relation.meta!.one_collection_field!];
return CollectionTypes[collection].getType();
},
}),
resolve: (obj: Record<string, any>, _, __, info) => {
return obj[info?.path?.key ?? relation.field];
},
},
});
}
}
return { CollectionTypes };
}
/**
* Create readable types and attach resolvers for each. Also prepares full filter argument structures
*/
function getReadableTypes() {
const { CollectionTypes: ReadCollectionTypes } = getTypes('read');
const ReadableCollectionFilterTypes: Record<string, InputTypeComposer> = {};
const AggregatedFunctions: Record<string, ObjectTypeComposer<any, any>> = {};
const AggregatedFilters: Record<string, ObjectTypeComposer<any, any>> = {};
const StringFilterOperators = schemaComposer.createInputTC({
name: 'string_filter_operators',
fields: {
_eq: {
type: GraphQLString,
},
_neq: {
type: GraphQLString,
},
_contains: {
type: GraphQLString,
},
_ncontains: {
type: GraphQLString,
},
_starts_with: {
type: GraphQLString,
},
_nstarts_with: {
type: GraphQLString,
},
_ends_with: {
type: GraphQLString,
},
_nends_with: {
type: GraphQLString,
},
_in: {
type: new GraphQLList(GraphQLString),
},
_nin: {
type: new GraphQLList(GraphQLString),
},
_null: {
type: GraphQLBoolean,
},
_nnull: {
type: GraphQLBoolean,
},
_empty: {
type: GraphQLBoolean,
},
_nempty: {
type: GraphQLBoolean,
},
},
});
const BooleanFilterOperators = schemaComposer.createInputTC({
name: 'boolean_filter_operators',
fields: {
_eq: {
type: GraphQLBoolean,
},
_neq: {
type: GraphQLBoolean,
},
_null: {
type: GraphQLBoolean,
},
_nnull: {
type: GraphQLBoolean,
},
},
});
const DateFilterOperators = schemaComposer.createInputTC({
name: 'date_filter_operators',
fields: {
_eq: {
type: GraphQLString,
},
_neq: {
type: GraphQLString,
},
_gt: {
type: GraphQLString,
},
_gte: {
type: GraphQLString,
},
_lt: {
type: GraphQLString,
},
_lte: {
type: GraphQLString,
},
_null: {
type: GraphQLBoolean,
},
_nnull: {
type: GraphQLBoolean,
},
},
});
const NumberFilterOperators = schemaComposer.createInputTC({
name: 'number_filter_operators',
fields: {
_eq: {
type: GraphQLFloat,
},
_neq: {
type: GraphQLFloat,
},
_in: {
type: new GraphQLList(GraphQLFloat),
},
_nin: {
type: new GraphQLList(GraphQLFloat),
},
_gt: {
type: GraphQLFloat,
},
_gte: {
type: GraphQLFloat,
},
_lt: {
type: GraphQLFloat,
},
_lte: {
type: GraphQLFloat,
},
_null: {
type: GraphQLBoolean,
},
_nnull: {
type: GraphQLBoolean,
},
},
});
const GeometryFilterOperators = schemaComposer.createInputTC({
name: 'geometry_filter_operators',
fields: {
_eq: {
type: GraphQLGeoJSON,
},
_neq: {
type: GraphQLGeoJSON,
},
_intersects: {
type: GraphQLGeoJSON,
},
_nintersects: {
type: GraphQLGeoJSON,
},
_intersects_bbox: {
type: GraphQLGeoJSON,
},
_nintersects_bbox: {
type: GraphQLGeoJSON,
},
},
});
const DateFunctionFilterOperators = schemaComposer.createInputTC({
name: 'date_function_filter_operators',
fields: {
year: {
type: NumberFilterOperators,
},
month: {
type: NumberFilterOperators,
},
week: {
type: NumberFilterOperators,
},
day: {
type: NumberFilterOperators,
},
weekday: {
type: NumberFilterOperators,
},
},
});
const TimeFunctionFilterOperators = schemaComposer.createInputTC({
name: 'time_function_filter_operators',
fields: {
hour: {
type: NumberFilterOperators,
},
minute: {
type: NumberFilterOperators,
},
second: {
type: NumberFilterOperators,
},
},
});
const DateTimeFunctionFilterOperators = schemaComposer.createInputTC({
name: 'datetime_function_filter_operators',
fields: {
...DateFunctionFilterOperators.getFields(),
...TimeFunctionFilterOperators.getFields(),
},
});
for (const collection of Object.values(schema.read.collections)) {
if (Object.keys(collection.fields).length === 0) continue;
if (SYSTEM_DENY_LIST.includes(collection.collection)) continue;
ReadableCollectionFilterTypes[collection.collection] = schemaComposer.createInputTC({
name: `${collection.collection}_filter`,
fields: Object.values(collection.fields).reduce((acc, field) => {
const graphqlType = getGraphQLType(field.type);
let filterOperatorType: InputTypeComposer;
switch (graphqlType) {
case GraphQLBoolean:
filterOperatorType = BooleanFilterOperators;
break;
case GraphQLInt:
case GraphQLFloat:
filterOperatorType = NumberFilterOperators;
break;
case GraphQLDate:
filterOperatorType = DateFilterOperators;
break;
case GraphQLGeoJSON:
filterOperatorType = GeometryFilterOperators;
break;
default:
filterOperatorType = StringFilterOperators;
}
acc[field.field] = filterOperatorType;
if (field.type === 'date') {
acc[`${field.field}_func`] = {
type: DateFunctionFilterOperators,
};
}
if (field.type === 'time') {
acc[`${field.field}_func`] = {
type: TimeFunctionFilterOperators,
};
}
if (field.type === 'dateTime' || field.type === 'timestamp') {
acc[`${field.field}_func`] = {
type: DateTimeFunctionFilterOperators,
};
}
return acc;
}, {} as InputTypeComposerFieldConfigMapDefinition),
});
ReadableCollectionFilterTypes[collection.collection].addFields({
_and: [ReadableCollectionFilterTypes[collection.collection]],
_or: [ReadableCollectionFilterTypes[collection.collection]],
});
AggregatedFilters[collection.collection] = schemaComposer.createObjectTC({
name: `${collection.collection}_aggregated_fields`,
fields: Object.values(collection.fields).reduce((acc, field) => {
const graphqlType = getGraphQLType(field.type);
switch (graphqlType) {
case GraphQLInt:
case GraphQLFloat:
acc[field.field] = {
type: GraphQLFloat,
description: field.note,
};
break;
default:
break;
}
return acc;
}, {} as ObjectTypeComposerFieldConfigMapDefinition<any, any>),
});
AggregatedFunctions[collection.collection] = schemaComposer.createObjectTC({
name: `${collection.collection}_aggregated`,
fields: {
group: {
name: 'group',
type: GraphQLJSON,
},
avg: {
name: 'avg',
type: AggregatedFilters[collection.collection],
},
sum: {
name: 'sum',
type: AggregatedFilters[collection.collection],
},
count: {
name: 'count',
type: AggregatedFilters[collection.collection],
},
countDistinct: {
name: 'countDistinct',
type: AggregatedFilters[collection.collection],
},
avgDistinct: {
name: 'avgDistinct',
type: AggregatedFilters[collection.collection],
},
sumDistinct: {
name: 'sumDistinct',
type: AggregatedFilters[collection.collection],
},
min: {
name: 'min',
type: AggregatedFilters[collection.collection],
},
max: {
name: 'max',
type: AggregatedFilters[collection.collection],
},
},
});
ReadCollectionTypes[collection.collection].addResolver({
name: collection.collection,
args: collection.singleton
? undefined
: {
filter: ReadableCollectionFilterTypes[collection.collection],
sort: {
type: new GraphQLList(GraphQLString),
},
limit: {
type: GraphQLInt,
},
offset: {
type: GraphQLInt,
},
page: {
type: GraphQLInt,
},
search: {
type: GraphQLString,
},
},
type: collection.singleton
? ReadCollectionTypes[collection.collection]
: [ReadCollectionTypes[collection.collection]],
resolve: async ({ info, context }: { info: GraphQLResolveInfo; context: Record<string, any> }) => {
const result = await self.resolveQuery(info);
context.data = result;
return result;
},
});
ReadCollectionTypes[collection.collection].addResolver({
name: `${collection.collection}_aggregated`,
type: [AggregatedFunctions[collection.collection]],
args: {
groupBy: new GraphQLList(GraphQLString),
filter: ReadableCollectionFilterTypes[collection.collection],
limit: {
type: GraphQLInt,
},
search: {
type: GraphQLString,
},
sort: {
type: new GraphQLList(GraphQLString),
},
},
resolve: async ({ info, context }: { info: GraphQLResolveInfo; context: Record<string, any> }) => {
const result = await self.resolveQuery(info);
context.data = result;
return result;
},
});
if (collection.singleton === false) {
ReadCollectionTypes[collection.collection].addResolver({
name: `${collection.collection}_by_id`,
type: ReadCollectionTypes[collection.collection],
args: {
id: GraphQLNonNull(GraphQLID),
},
resolve: async ({ info, context }: { info: GraphQLResolveInfo; context: Record<string, any> }) => {
const result = await self.resolveQuery(info);
context.data = result;
return result;
},
});
}
}
for (const relation of schema.read.relations) {
if (relation.related_collection) {
if (SYSTEM_DENY_LIST.includes(relation.related_collection)) continue;
ReadableCollectionFilterTypes[relation.collection]?.addFields({
[relation.field]: ReadableCollectionFilterTypes[relation.related_collection],
});
ReadCollectionTypes[relation.collection]?.addFieldArgs(relation.field, {
filter: ReadableCollectionFilterTypes[relation.related_collection],
sort: {
type: new GraphQLList(GraphQLString),
},
limit: {
type: GraphQLInt,
},
offset: {
type: GraphQLInt,
},
page: {
type: GraphQLInt,
},
search: {
type: GraphQLString,
},
});
if (relation.meta?.one_field) {
ReadableCollectionFilterTypes[relation.related_collection]?.addFields({
[relation.meta.one_field]: ReadableCollectionFilterTypes[relation.collection],
});
ReadCollectionTypes[relation.related_collection]?.addFieldArgs(relation.meta.one_field, {
filter: ReadableCollectionFilterTypes[relation.collection],
sort: {
type: new GraphQLList(GraphQLString),
},
limit: {
type: GraphQLInt,
},
offset: {
type: GraphQLInt,
},
page: {
type: GraphQLInt,
},
search: {
type: GraphQLString,
},
});
}
} else if (relation.meta?.one_allowed_collections) {
/**
* @TODO
* Looking to add nested typed filters per union type? This is where that's supposed to go.
*/
}
}
return { ReadCollectionTypes, ReadableCollectionFilterTypes };
}
function getWritableTypes() {
const { CollectionTypes: CreateCollectionTypes } = getTypes('create');
const { CollectionTypes: UpdateCollectionTypes } = getTypes('update');
const DeleteCollectionTypes: Record<string, ObjectTypeComposer<any, any>> = {};
for (const collection of Object.values(schema.create.collections)) {
if (Object.keys(collection.fields).length === 0) continue;
if (SYSTEM_DENY_LIST.includes(collection.collection)) continue;
if (collection.collection in CreateCollectionTypes === false) continue;
const collectionIsReadable = collection.collection in ReadCollectionTypes;
const creatableFields = CreateCollectionTypes[collection.collection]?.getFields() || {};
if (Object.keys(creatableFields).length > 0) {
CreateCollectionTypes[collection.collection].addResolver({
name: `create_${collection.collection}_items`,
type: collectionIsReadable ? [ReadCollectionTypes[collection.collection]] : GraphQLBoolean,
args: collectionIsReadable
? ReadCollectionTypes[collection.collection].getResolver(collection.collection).getArgs()
: undefined,
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await self.resolveMutation(args, info),
});
CreateCollectionTypes[collection.collection].addResolver({
name: `create_${collection.collection}_item`,
type: collectionIsReadable ? ReadCollectionTypes[collection.collection] : GraphQLBoolean,
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await self.resolveMutation(args, info),
});
CreateCollectionTypes[collection.collection].getResolver(`create_${collection.collection}_items`).addArgs({
...CreateCollectionTypes[collection.collection]
.getResolver(`create_${collection.collection}_items`)
.getArgs(),
data: [
toInputObjectType(CreateCollectionTypes[collection.collection]).setTypeName(
`create_${collection.collection}_input`
).NonNull,
],
});
CreateCollectionTypes[collection.collection].getResolver(`create_${collection.collection}_item`).addArgs({
...CreateCollectionTypes[collection.collection]
.getResolver(`create_${collection.collection}_item`)
.getArgs(),
data: toInputObjectType(CreateCollectionTypes[collection.collection]).setTypeName(
`create_${collection.collection}_input`
).NonNull,
});
}
}
for (const collection of Object.values(schema.update.collections)) {
if (Object.keys(collection.fields).length === 0) continue;
if (SYSTEM_DENY_LIST.includes(collection.collection)) continue;
if (collection.collection in UpdateCollectionTypes === false) continue;
const collectionIsReadable = collection.collection in ReadCollectionTypes;
const updatableFields = UpdateCollectionTypes[collection.collection]?.getFields() || {};
if (Object.keys(updatableFields).length > 0) {
if (collection.singleton) {
UpdateCollectionTypes[collection.collection].addResolver({
name: `update_${collection.collection}`,
type: collectionIsReadable ? ReadCollectionTypes[collection.collection] : GraphQLBoolean,
args: {
data: toInputObjectType(UpdateCollectionTypes[collection.collection]).setTypeName(
`update_${collection.collection}_input`
).NonNull,
},
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await self.resolveMutation(args, info),
});
} else {
UpdateCollectionTypes[collection.collection].addResolver({
name: `update_${collection.collection}_items`,
type: collectionIsReadable ? [ReadCollectionTypes[collection.collection]] : GraphQLBoolean,
args: {
...(collectionIsReadable
? ReadCollectionTypes[collection.collection].getResolver(collection.collection).getArgs()
: {}),
ids: GraphQLNonNull(new GraphQLList(GraphQLID)),
data: toInputObjectType(UpdateCollectionTypes[collection.collection]).setTypeName(
`update_${collection.collection}_input`
).NonNull,
},
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await self.resolveMutation(args, info),
});
UpdateCollectionTypes[collection.collection].addResolver({
name: `update_${collection.collection}_item`,
type: collectionIsReadable ? ReadCollectionTypes[collection.collection] : GraphQLBoolean,
args: {
id: GraphQLNonNull(GraphQLID),
data: toInputObjectType(UpdateCollectionTypes[collection.collection]).setTypeName(
`update_${collection.collection}_input`
).NonNull,
},
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await self.resolveMutation(args, info),
});
}
}
}
DeleteCollectionTypes.many = schemaComposer.createObjectTC({
name: `delete_many`,
fields: {
ids: GraphQLNonNull(new GraphQLList(GraphQLID)),
},
});
DeleteCollectionTypes.one = schemaComposer.createObjectTC({
name: `delete_one`,
fields: {
id: GraphQLNonNull(GraphQLID),
},
});
for (const collection of Object.values(schema.delete.collections)) {
DeleteCollectionTypes.many.addResolver({
name: `delete_${collection.collection}_items`,
type: DeleteCollectionTypes.many,
args: {
ids: GraphQLNonNull(new GraphQLList(GraphQLID)),
},
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await self.resolveMutation(args, info),
});
DeleteCollectionTypes.one.addResolver({
name: `delete_${collection.collection}_item`,
type: DeleteCollectionTypes.one,
args: {
id: GraphQLNonNull(GraphQLID),
},
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await self.resolveMutation(args, info),
});
}
return { CreateCollectionTypes, UpdateCollectionTypes, DeleteCollectionTypes };
}
}
/**
* Generic resolver that's used for every "regular" items/system query. Converts the incoming GraphQL AST / fragments into
* Directus' query structure which is then executed by the services.
*/
async resolveQuery(info: GraphQLResolveInfo): Promise<Partial<Item> | null> {
let collection = info.fieldName;
if (this.scope === 'system') collection = `directus_${collection}`;
const selections = this.replaceFragmentsInSelections(info.fieldNodes[0]?.selectionSet?.selections, info.fragments);
if (!selections) return null;
const args: Record<string, any> = this.parseArgs(info.fieldNodes[0].arguments || [], info.variableValues);
let query: Record<string, any>;
const isAggregate = collection.endsWith('_aggregated') && collection in this.schema.collections === false;
if (isAggregate) {
query = this.getAggregateQuery(args, selections);
collection = collection.slice(0, -11);
} else {
query = this.getQuery(args, selections, info.variableValues);
if (collection.endsWith('_by_id') && collection in this.schema.collections === false) {
collection = collection.slice(0, -6);
}
}
if (args.id) {
query.filter = {
_and: [
query.filter || {},
{
[this.schema.collections[collection].primary]: {
_eq: args.id,
},
},
],
};
query.limit = 1;
}
const result = await this.read(collection, query);
if (args.id) {
return result?.[0] || null;
}
if (query.group) {
// for every entry in result add a group field based on query.group;
const aggregateKeys = Object.keys(query.aggregate ?? {});
result.map((field: Item) => {
field.group = omit(field, aggregateKeys);
});
}
return result;
}
async resolveMutation(
args: Record<string, any>,
info: GraphQLResolveInfo
): Promise<Partial<Item> | boolean | undefined> {
const action = info.fieldName.split('_')[0] as 'create' | 'update' | 'delete';
let collection = info.fieldName.substring(action.length + 1);
if (this.scope === 'system') collection = `directus_${collection}`;
const selections = this.replaceFragmentsInSelections(info.fieldNodes[0]?.selectionSet?.selections, info.fragments);
const query = this.getQuery(args, selections || [], info.variableValues);
const singleton =
collection.endsWith('_items') === false &&
collection.endsWith('_item') === false &&
collection in this.schema.collections;
const single = collection.endsWith('_items') === false;
if (collection.endsWith('_items')) collection = collection.slice(0, -6);
if (collection.endsWith('_item')) collection = collection.slice(0, -5);
if (singleton && action === 'update') {
return await this.upsertSingleton(collection, args.data, query);
}
const service = this.getService(collection);
const hasQuery = (query.fields || []).length > 0;
try {
if (single) {
if (action === 'create') {
const key = await service.createOne(args.data);
return hasQuery ? await service.readOne(key, query) : true;
}
if (action === 'update') {
const key = await service.updateOne(args.id, args.data);
return hasQuery ? await service.readOne(key, query) : true;
}
if (action === 'delete') {
await service.deleteOne(args.id);
return { id: args.id };
}
} else {
if (action === 'create') {
const keys = await service.createMany(args.data);
return hasQuery ? await service.readMany(keys, query) : true;
}
if (action === 'update') {
const keys = await service.updateMany(args.ids, args.data);
return hasQuery ? await service.readMany(keys, query) : true;
}
if (action === 'delete') {
const keys = await service.deleteMany(args.ids);
return { ids: keys };
}
}
} catch (err: any) {
return this.formatError(err);
}
}
/**
* Execute the read action on the correct service. Checks for singleton as well.
*/
async read(collection: string, query: Query): Promise<Partial<Item>> {
const service = this.getService(collection);
const result = this.schema.collections[collection].singleton
? await service.readSingleton(query, { stripNonRequested: false })
: await service.readByQuery(query, { stripNonRequested: false });
return result;
}
/**
* Upsert and read singleton item
*/
async upsertSingleton(
collection: string,
body: Record<string, any> | Record<string, any>[],
query: Query
): Promise<Partial<Item> | boolean> {
const service = this.getService(collection);
try {
await service.upsertSingleton(body);
if ((query.fields || []).length > 0) {
const result = await service.readSingleton(query);
return result;
}
return true;
} catch (err: any) {
throw this.formatError(err);
}
}
/**
* GraphQL's regular resolver `args` variable only contains the "top-level" arguments. Seeing that we convert the
* whole nested tree into one big query using Directus' own query resolver, we want to have a nested structure of
* arguments for the whole resolving tree, which can later be transformed into Directus' AST using `deep`.
* In order to do that, we'll parse over all ArgumentNodes and ObjectFieldNodes to manually recreate an object structure
* of arguments
*/
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, variableValues);
};
const argsObject: any = {};
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, variableValues));
} else {
values.push((valueNode as any).value);
}
}
argsObject[argument.name.value] = values;
} else {
argsObject[argument.name.value] = (argument.value as IntValueNode | StringValueNode | BooleanValueNode).value;
}
}
return argsObject;
}
/**
* Get a Directus Query object from the parsed arguments (rawQuery) and GraphQL AST selectionSet. Converts SelectionSet into
* Directus' `fields` query for use in the resolver. Also applies variables where appropriate.
*/
getQuery(
rawQuery: Query,
selections: readonly SelectionNode[],
variableValues: GraphQLResolveInfo['variableValues']
): Query {
const query: Query = sanitizeQuery(rawQuery, this.accountability);
const parseAliases = (selections: readonly SelectionNode[]) => {
const aliases: Record<string, string> = {};
for (const selection of selections) {
if (selection.kind !== 'Field') continue;
if (selection.alias?.value) {
aliases[selection.alias.value] = selection.name.value;
}
}
return aliases;
};
const parseFields = (selections: readonly SelectionNode[], parent?: string): string[] => {
const fields: string[] = [];
for (let selection of selections) {
if ((selection.kind === 'Field' || selection.kind === 'InlineFragment') !== true) continue;
selection = selection as FieldNode | InlineFragmentNode;
let current: string;
// Union type (Many-to-Any)
if (selection.kind === 'InlineFragment') {
if (selection.typeCondition!.name.value.startsWith('__')) continue;
current = `${parent}:${selection.typeCondition!.name.value}`;
}
// Any other field type
else {
// filter out graphql pointers, like __typename
if (selection.name.value.startsWith('__')) continue;
current = selection.name.value;
if (parent) {
current = `${parent}.${current}`;
}
}
if (selection.selectionSet) {
let children: string[];
if (current.endsWith('_func')) {
children = [];
const rootField = current.slice(0, -5);
for (const subSelection of selection.selectionSet.selections) {
if (subSelection.kind !== 'Field') continue;
children.push(`${subSelection.name!.value}(${rootField})`);
}
} else {
children = parseFields(selection.selectionSet.selections, current);
}
fields.push(...children);
} else {
fields.push(current);
}
if (selection.kind === 'Field' && selection.arguments && selection.arguments.length > 0) {
if (selection.arguments && selection.arguments.length > 0) {
if (!query.deep) query.deep = {};
const args: Record<string, any> = this.parseArgs(selection.arguments, variableValues);
set(
query.deep,
current,
merge(
get(query.deep, current),
mapKeys(sanitizeQuery(args, this.accountability), (value, key) => `_${key}`)
)
);
}
}
}
return uniq(fields);
};
const replaceFuncs = (filter?: Filter | null): null | undefined | Filter => {
if (!filter) return filter;
return replaceFuncDeep(filter);
function replaceFuncDeep(filter: Record<string, any>) {
return transform(filter, (result: Record<string, any>, value, key) => {
let currentKey = key;
if (typeof key === 'string' && key.endsWith('_func')) {
const functionName = Object.keys(value)[0]!;
currentKey = `${functionName}(${currentKey.slice(0, -5)})`;
result[currentKey] = Object.values(value)[0]!;
} else {
result[currentKey] = isObject(value) ? replaceFuncDeep(value) : value;
}
});
}
};
query.alias = parseAliases(selections);
query.fields = parseFields(selections);
query.filter = replaceFuncs(query.filter);
validateQuery(query);
return query;
}
/**
* Resolve the aggregation query based on the requested aggregated fields
*/
getAggregateQuery(rawQuery: Query, selections: readonly SelectionNode[]): Query {
const query: Query = sanitizeQuery(rawQuery, this.accountability);
query.aggregate = {};
for (let aggregationGroup of selections) {
if ((aggregationGroup.kind === 'Field') !== true) continue;
aggregationGroup = aggregationGroup as FieldNode;
// filter out graphql pointers, like __typename
if (aggregationGroup.name.value.startsWith('__')) continue;
const aggregateProperty = aggregationGroup.name.value as keyof Aggregate;
query.aggregate[aggregateProperty] =
aggregationGroup.selectionSet?.selections
// filter out graphql pointers, like __typename
.filter((selectionNode) => !(selectionNode as FieldNode)?.name.value.startsWith('__'))
.map((selectionNode) => {
selectionNode = selectionNode as FieldNode;
return selectionNode.name.value;
}) ?? [];
}
validateQuery(query);
return query;
}
/**
* Convert Directus-Exception into a GraphQL format, so it can be returned by GraphQL properly.
*/
formatError(error: BaseException | BaseException[]): GraphQLError {
if (Array.isArray(error)) {
return new GraphQLError(error[0].message, undefined, undefined, undefined, undefined, error[0]);
}
return new GraphQLError(error.message, undefined, undefined, undefined, undefined, error);
}
/**
* Select the correct service for the given collection. This allows the individual services to run
* their custom checks (f.e. it allows UsersService to prevent updating TFA secret from outside)
*/
getService(collection: string): ItemsService {
const opts = {
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
};
switch (collection) {
case 'directus_activity':
return new ActivityService(opts);
case 'directus_files':
return new FilesService(opts);
case 'directus_folders':
return new FoldersService(opts);
case 'directus_permissions':
return new PermissionsService(opts);
case 'directus_presets':
return new PresetsService(opts);
case 'directus_notifications':
return new NotificationsService(opts);
case 'directus_revisions':
return new RevisionsService(opts);
case 'directus_roles':
return new RolesService(opts);
case 'directus_settings':
return new SettingsService(opts);
case 'directus_users':
return new UsersService(opts);
case 'directus_webhooks':
return new WebhooksService(opts);
case 'directus_shares':
return new SharesService(opts);
default:
return new ItemsService(collection, opts);
}
}
/**
* Replace all fragments in a selectionset for the actual selection set as defined in the fragment
* Effectively merges the selections with the fragments used in those selections
*/
replaceFragmentsInSelections(
selections: readonly SelectionNode[] | undefined,
fragments: Record<string, FragmentDefinitionNode>
): readonly SelectionNode[] | null {
if (!selections) return null;
const result = flatten(
selections.map((selection) => {
// Fragments can contains fragments themselves. This allows for nested fragments
if (selection.kind === 'FragmentSpread') {
return this.replaceFragmentsInSelections(fragments[selection.name.value].selectionSet.selections, fragments);
}
// Nested relational fields can also contain fragments
if ((selection.kind === 'Field' || selection.kind === 'InlineFragment') && selection.selectionSet) {
selection.selectionSet.selections = this.replaceFragmentsInSelections(
selection.selectionSet.selections,
fragments
) as readonly SelectionNode[];
}
return selection;
})
).filter((s) => s) as SelectionNode[];
return result;
}
injectSystemResolvers(
schemaComposer: SchemaComposer<GraphQLParams['contextValue']>,
{
CreateCollectionTypes,
ReadCollectionTypes,
UpdateCollectionTypes,
DeleteCollectionTypes,
}: {
CreateCollectionTypes: Record<string, ObjectTypeComposer<any, any>>;
ReadCollectionTypes: Record<string, ObjectTypeComposer<any, any>>;
UpdateCollectionTypes: Record<string, ObjectTypeComposer<any, any>>;
DeleteCollectionTypes: Record<string, ObjectTypeComposer<any, any>>;
},
schema: {
create: SchemaOverview;
read: SchemaOverview;
update: SchemaOverview;
delete: SchemaOverview;
}
): SchemaComposer<any> {
const AuthTokens = schemaComposer.createObjectTC({
name: 'auth_tokens',
fields: {
access_token: GraphQLString,
expires: GraphQLInt,
refresh_token: GraphQLString,
},
});
const AuthMode = new GraphQLEnumType({
name: 'auth_mode',
values: {
json: { value: 'json' },
cookie: { value: 'cookie' },
},
});
const ServerInfo = schemaComposer.createObjectTC({
name: 'server_info',
fields: {
project_name: { type: GraphQLString },
project_logo: { type: GraphQLString },
project_color: { type: GraphQLString },
project_foreground: { type: GraphQLString },
project_background: { type: GraphQLString },
project_note: { type: GraphQLString },
custom_css: { type: GraphQLString },
},
});
if (this.accountability?.admin === true) {
ServerInfo.addFields({
directus: {
type: new GraphQLObjectType({
name: 'server_info_directus',
fields: {
version: {
type: GraphQLString,
},
},
}),
},
node: {
type: new GraphQLObjectType({
name: 'server_info_node',
fields: {
version: {
type: GraphQLString,
},
uptime: {
type: GraphQLInt,
},
},
}),
},
os: {
type: new GraphQLObjectType({
name: 'server_info_os',
fields: {
type: {
type: GraphQLString,
},
version: {
type: GraphQLString,
},
uptime: {
type: GraphQLInt,
},
totalmem: {
type: GraphQLInt,
},
},
}),
},
});
}
/** Globally available query */
schemaComposer.Query.addFields({
extensions: {
type: schemaComposer.createObjectTC({
name: 'extensions',
fields: {
interfaces: new GraphQLList(GraphQLString),
displays: new GraphQLList(GraphQLString),
layouts: new GraphQLList(GraphQLString),
modules: new GraphQLList(GraphQLString),
},
}),
resolve: async () => {
const extensionManager = getExtensionManager();
return {
interfaces: extensionManager.getExtensionsList('interface'),
displays: extensionManager.getExtensionsList('display'),
layouts: extensionManager.getExtensionsList('layout'),
modules: extensionManager.getExtensionsList('module'),
};
},
},
server_specs_oas: {
type: GraphQLJSON,
resolve: async () => {
const service = new SpecificationService({ schema: this.schema, accountability: this.accountability });
return await service.oas.generate();
},
},
server_specs_graphql: {
type: GraphQLString,
args: {
scope: new GraphQLEnumType({
name: 'graphql_sdl_scope',
values: {
items: { value: 'items' },
system: { value: 'system' },
},
}),
},
resolve: async (_, args) => {
const service = new GraphQLService({
schema: this.schema,
accountability: this.accountability,
scope: args.scope ?? 'items',
});
return service.getSchema('sdl');
},
},
server_ping: {
type: GraphQLString,
resolve: () => 'pong',
},
server_info: {
type: ServerInfo,
resolve: async () => {
const service = new ServerService({
accountability: this.accountability,
schema: this.schema,
});
return await service.serverInfo();
},
},
server_health: {
type: GraphQLJSON,
resolve: async () => {
const service = new ServerService({
accountability: this.accountability,
schema: this.schema,
});
return await service.serverInfo();
},
},
});
const Collection = schemaComposer.createObjectTC({
name: 'directus_collections',
});
const Field = schemaComposer.createObjectTC({
name: 'directus_fields',
});
const Relation = schemaComposer.createObjectTC({
name: 'directus_relations',
});
/**
* Globally available mutations
*/
schemaComposer.Mutation.addFields({
auth_login: {
type: AuthTokens,
args: {
email: GraphQLNonNull(GraphQLString),
password: GraphQLNonNull(GraphQLString),
mode: AuthMode,
otp: GraphQLString,
},
resolve: async (_, args, { req, res }) => {
const accountability = {
ip: req?.ip,
userAgent: req?.get('user-agent'),
role: null,
};
const authenticationService = new AuthenticationService({
accountability: accountability,
schema: this.schema,
});
const result = await authenticationService.login(DEFAULT_AUTH_PROVIDER, args, args?.otp);
if (args.mode === 'cookie') {
res?.cookie(env.REFRESH_TOKEN_COOKIE_NAME, result.refreshToken, {
httpOnly: true,
domain: env.REFRESH_TOKEN_COOKIE_DOMAIN,
maxAge: ms(env.REFRESH_TOKEN_TTL as string),
secure: env.REFRESH_TOKEN_COOKIE_SECURE ?? false,
sameSite: (env.REFRESH_TOKEN_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') || 'strict',
});
}
return {
access_token: result.accessToken,
expires: result.expires,
refresh_token: result.refreshToken,
};
},
},
auth_refresh: {
type: AuthTokens,
args: {
refresh_token: GraphQLString,
mode: AuthMode,
},
resolve: async (_, args, { req, res }) => {
const accountability = {
ip: req?.ip,
userAgent: req?.get('user-agent'),
role: null,
};
const authenticationService = new AuthenticationService({
accountability: accountability,
schema: this.schema,
});
const currentRefreshToken = args.refresh_token || req?.cookies[env.REFRESH_TOKEN_COOKIE_NAME];
if (!currentRefreshToken) {
throw new InvalidPayloadException(`"refresh_token" is required in either the JSON payload or Cookie`);
}
const result = await authenticationService.refresh(currentRefreshToken);
if (args.mode === 'cookie') {
res?.cookie(env.REFRESH_TOKEN_COOKIE_NAME, result.refreshToken, {
httpOnly: true,
domain: env.REFRESH_TOKEN_COOKIE_DOMAIN,
maxAge: ms(env.REFRESH_TOKEN_TTL as string),
secure: env.REFRESH_TOKEN_COOKIE_SECURE ?? false,
sameSite: (env.REFRESH_TOKEN_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') || 'strict',
});
}
return {
access_token: result.accessToken,
expires: result.expires,
refresh_token: result.refreshToken,
};
},
},
auth_logout: {
type: GraphQLBoolean,
args: {
refresh_token: GraphQLString,
},
resolve: async (_, args, { req }) => {
const accountability = {
ip: req?.ip,
userAgent: req?.get('user-agent'),
role: null,
};
const authenticationService = new AuthenticationService({
accountability: accountability,
schema: this.schema,
});
const currentRefreshToken = args.refresh_token || req?.cookies[env.REFRESH_TOKEN_COOKIE_NAME];
if (!currentRefreshToken) {
throw new InvalidPayloadException(`"refresh_token" is required in either the JSON payload or Cookie`);
}
await authenticationService.logout(currentRefreshToken);
return true;
},
},
auth_password_request: {
type: GraphQLBoolean,
args: {
email: GraphQLNonNull(GraphQLString),
reset_url: GraphQLString,
},
resolve: async (_, args, { req }) => {
const accountability = {
ip: req?.ip,
userAgent: req?.get('user-agent'),
role: null,
};
const service = new UsersService({ accountability, schema: this.schema });
try {
await service.requestPasswordReset(args.email, args.reset_url || null);
} catch (err: any) {
if (err instanceof InvalidPayloadException) {
throw err;
}
}
return true;
},
},
auth_password_reset: {
type: GraphQLBoolean,
args: {
token: GraphQLNonNull(GraphQLString),
password: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args, { req }) => {
const accountability = {
ip: req?.ip,
userAgent: req?.get('user-agent'),
role: null,
};
const service = new UsersService({ accountability, schema: this.schema });
await service.resetPassword(args.token, args.password);
return true;
},
},
users_me_tfa_generate: {
type: new GraphQLObjectType({
name: 'users_me_tfa_generate_data',
fields: {
secret: { type: GraphQLString },
otpauth_url: { type: GraphQLString },
},
}),
args: {
password: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
if (!this.accountability?.user) return null;
const service = new TFAService({
accountability: this.accountability,
schema: this.schema,
});
const authService = new AuthenticationService({
accountability: this.accountability,
schema: this.schema,
});
await authService.verifyPassword(this.accountability.user, args.password);
const { url, secret } = await service.generateTFA(this.accountability.user);
return { secret, otpauth_url: url };
},
},
users_me_tfa_enable: {
type: GraphQLBoolean,
args: {
otp: GraphQLNonNull(GraphQLString),
secret: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
if (!this.accountability?.user) return null;
const service = new TFAService({
accountability: this.accountability,
schema: this.schema,
});
await service.enableTFA(this.accountability.user, args.otp, args.secret);
return true;
},
},
users_me_tfa_disable: {
type: GraphQLBoolean,
args: {
otp: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
if (!this.accountability?.user) return null;
const service = new TFAService({
accountability: this.accountability,
schema: this.schema,
});
const otpValid = await service.verifyOTP(this.accountability.user, args.otp);
if (otpValid === false) {
throw new InvalidPayloadException(`"otp" is invalid`);
}
await service.disableTFA(this.accountability.user);
return true;
},
},
utils_hash_generate: {
type: GraphQLString,
args: {
string: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
return await generateHash(args.string);
},
},
utils_hash_verify: {
type: GraphQLBoolean,
args: {
string: GraphQLNonNull(GraphQLString),
hash: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
return await argon2.verify(args.hash, args.string);
},
},
utils_sort: {
type: GraphQLBoolean,
args: {
collection: GraphQLNonNull(GraphQLString),
item: GraphQLNonNull(GraphQLID),
to: GraphQLNonNull(GraphQLID),
},
resolve: async (_, args) => {
const service = new UtilsService({
accountability: this.accountability,
schema: this.schema,
});
const { item, to } = args;
await service.sort(args.collection, { item, to });
return true;
},
},
utils_revert: {
type: GraphQLBoolean,
args: {
revision: GraphQLNonNull(GraphQLID),
},
resolve: async (_, args) => {
const service = new RevisionsService({
accountability: this.accountability,
schema: this.schema,
});
await service.revert(args.revision);
return true;
},
},
utils_cache_clear: {
type: GraphQLVoid,
resolve: async () => {
if (this.accountability?.admin !== true) {
throw new ForbiddenException();
}
const { cache } = getCache();
await cache?.clear();
await clearSystemCache();
return;
},
},
users_invite_accept: {
type: GraphQLBoolean,
args: {
token: GraphQLNonNull(GraphQLString),
password: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new UsersService({
accountability: this.accountability,
schema: this.schema,
});
await service.acceptInvite(args.token, args.password);
return true;
},
},
});
if ('directus_collections' in schema.read.collections) {
Collection.addFields({
collection: GraphQLString,
meta: schemaComposer.createObjectTC({
name: 'directus_collections_meta',
fields: Object.values(schema.read.collections['directus_collections'].fields).reduce((acc, field) => {
acc[field.field] = {
type: field.nullable ? getGraphQLType(field.type) : GraphQLNonNull(getGraphQLType(field.type)),
description: field.note,
};
return acc;
}, {} as ObjectTypeComposerFieldConfigMapDefinition<any, any>),
}),
schema: schemaComposer.createObjectTC({
name: 'directus_collections_schema',
fields: {
name: GraphQLString,
comment: GraphQLString,
},
}),
});
schemaComposer.Query.addFields({
collections: {
type: [Collection],
resolve: async () => {
const collectionsService = new CollectionsService({
accountability: this.accountability,
schema: this.schema,
});
return await collectionsService.readByQuery();
},
},
collections_by_name: {
type: Collection,
args: {
name: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const collectionsService = new CollectionsService({
accountability: this.accountability,
schema: this.schema,
});
return await collectionsService.readOne(args.name);
},
},
});
}
if ('directus_fields' in schema.read.collections) {
Field.addFields({
collection: GraphQLString,
field: GraphQLString,
type: GraphQLString,
meta: schemaComposer.createObjectTC({
name: 'directus_fields_meta',
fields: Object.values(schema.read.collections['directus_fields'].fields).reduce((acc, field) => {
acc[field.field] = {
type: field.nullable ? getGraphQLType(field.type) : GraphQLNonNull(getGraphQLType(field.type)),
description: field.note,
};
return acc;
}, {} as ObjectTypeComposerFieldConfigMapDefinition<any, any>),
}),
schema: schemaComposer.createObjectTC({
name: 'directus_fields_schema',
fields: {
name: GraphQLString,
table: GraphQLString,
data_type: GraphQLString,
default_value: GraphQLString,
max_length: GraphQLInt,
numeric_precision: GraphQLInt,
numeric_scale: GraphQLInt,
is_nullable: GraphQLBoolean,
is_unique: GraphQLBoolean,
is_primary_key: GraphQLBoolean,
has_auto_increment: GraphQLBoolean,
foreign_key_column: GraphQLString,
foreign_key_table: GraphQLString,
comment: GraphQLString,
},
}),
});
schemaComposer.Query.addFields({
fields: {
type: [Field],
resolve: async () => {
const service = new FieldsService({
accountability: this.accountability,
schema: this.schema,
});
return await service.readAll();
},
},
fields_in_collection: {
type: [Field],
args: {
collection: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new FieldsService({
accountability: this.accountability,
schema: this.schema,
});
return await service.readAll(args.collection);
},
},
fields_by_name: {
type: Field,
args: {
collection: GraphQLNonNull(GraphQLString),
field: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new FieldsService({
accountability: this.accountability,
schema: this.schema,
});
return await service.readOne(args.collection, args.field);
},
},
});
}
if ('directus_relations' in schema.read.collections) {
Relation.addFields({
collection: GraphQLString,
field: GraphQLString,
related_collection: GraphQLString,
schema: schemaComposer.createObjectTC({
name: 'directus_relations_schema',
fields: {
table: GraphQLNonNull(GraphQLString),
column: GraphQLNonNull(GraphQLString),
foreign_key_table: GraphQLNonNull(GraphQLString),
foreign_key_column: GraphQLNonNull(GraphQLString),
constraint_name: GraphQLString,
on_update: GraphQLNonNull(GraphQLString),
on_delete: GraphQLNonNull(GraphQLString),
},
}),
meta: schemaComposer.createObjectTC({
name: 'directus_relations_meta',
fields: Object.values(schema.read.collections['directus_relations'].fields).reduce((acc, field) => {
acc[field.field] = {
type: getGraphQLType(field.type),
description: field.note,
};
return acc;
}, {} as ObjectTypeComposerFieldConfigMapDefinition<any, any>),
}),
});
schemaComposer.Query.addFields({
relations: {
type: [Relation],
resolve: async () => {
const service = new RelationsService({
accountability: this.accountability,
schema: this.schema,
});
return await service.readAll();
},
},
relations_in_collection: {
type: [Relation],
args: {
collection: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new RelationsService({
accountability: this.accountability,
schema: this.schema,
});
return await service.readAll(args.collection);
},
},
relations_by_name: {
type: Relation,
args: {
collection: GraphQLNonNull(GraphQLString),
field: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new RelationsService({
accountability: this.accountability,
schema: this.schema,
});
return await service.readOne(args.collection, args.field);
},
},
});
}
if (this.accountability?.admin === true) {
schemaComposer.Mutation.addFields({
create_collections_item: {
type: Collection,
args: {
data: toInputObjectType(Collection.clone('create_directus_collections'), {
postfix: '_input',
}).addFields({
fields: [
toInputObjectType(Field.clone('create_directus_collections_fields'), { postfix: '_input' }).NonNull,
],
}).NonNull,
},
resolve: async (_, args) => {
const collectionsService = new CollectionsService({
accountability: this.accountability,
schema: this.schema,
});
const collectionKey = await collectionsService.createOne(args.data);
return await collectionsService.readOne(collectionKey);
},
},
update_collections_item: {
type: Collection,
args: {
collection: GraphQLNonNull(GraphQLString),
data: toInputObjectType(Collection.clone('update_directus_collections'), {
postfix: '_input',
}).removeField(['collection', 'schema']).NonNull,
},
resolve: async (_, args) => {
const collectionsService = new CollectionsService({
accountability: this.accountability,
schema: this.schema,
});
const collectionKey = await collectionsService.updateOne(args.collection, args.data);
return await collectionsService.readOne(collectionKey);
},
},
delete_collections_item: {
type: schemaComposer.createObjectTC({
name: 'delete_collection',
fields: {
collection: GraphQLString,
},
}),
args: {
collection: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const collectionsService = new CollectionsService({
accountability: this.accountability,
schema: this.schema,
});
await collectionsService.deleteOne(args.collection);
return { collection: args.collection };
},
},
});
schemaComposer.Mutation.addFields({
create_fields_item: {
type: Field,
args: {
collection: GraphQLNonNull(GraphQLString),
data: toInputObjectType(Field.clone('create_directus_fields'), { postfix: '_input' }).NonNull,
},
resolve: async (_, args) => {
const service = new FieldsService({
accountability: this.accountability,
schema: this.schema,
});
await service.createField(args.collection, args.data);
return await service.readOne(args.collection, args.data.field);
},
},
update_fields_item: {
type: Field,
args: {
collection: GraphQLNonNull(GraphQLString),
field: GraphQLNonNull(GraphQLString),
data: toInputObjectType(Field.clone('update_directus_fields'), { postfix: '_input' }).NonNull,
},
resolve: async (_, args) => {
const service = new FieldsService({
accountability: this.accountability,
schema: this.schema,
});
await service.updateField(args.collection, {
...args.data,
field: args.field,
});
return await service.readOne(args.collection, args.data.field);
},
},
delete_fields_item: {
type: schemaComposer.createObjectTC({
name: 'delete_field',
fields: {
collection: GraphQLString,
field: GraphQLString,
},
}),
args: {
collection: GraphQLNonNull(GraphQLString),
field: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new FieldsService({
accountability: this.accountability,
schema: this.schema,
});
await service.deleteField(args.collection, args.field);
const { collection, field } = args;
return { collection, field };
},
},
});
schemaComposer.Mutation.addFields({
create_relations_item: {
type: Relation,
args: {
data: toInputObjectType(Relation.clone('create_directus_relations'), { postfix: '_input' }).NonNull,
},
resolve: async (_, args) => {
const relationsService = new RelationsService({
accountability: this.accountability,
schema: this.schema,
});
await relationsService.createOne(args.data);
return await relationsService.readOne(args.data.collection, args.data.field);
},
},
update_relations_item: {
type: Relation,
args: {
collection: GraphQLNonNull(GraphQLString),
field: GraphQLNonNull(GraphQLString),
data: toInputObjectType(Relation.clone('update_directus_relations'), { postfix: '_input' }).NonNull,
},
resolve: async (_, args) => {
const relationsService = new RelationsService({
accountability: this.accountability,
schema: this.schema,
});
await relationsService.updateOne(args.collection, args.field, args.data);
return await relationsService.readOne(args.data.collection, args.data.field);
},
},
delete_relations_item: {
type: schemaComposer.createObjectTC({
name: 'delete_relation',
fields: {
collection: GraphQLString,
field: GraphQLString,
},
}),
args: {
collection: GraphQLNonNull(GraphQLString),
field: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const relationsService = new RelationsService({
accountability: this.accountability,
schema: this.schema,
});
await relationsService.deleteOne(args.collection, args.field);
return { collection: args.collection, field: args.field };
},
},
});
}
if ('directus_users' in schema.read.collections) {
schemaComposer.Query.addFields({
users_me: {
type: ReadCollectionTypes['directus_users'],
resolve: async (_, args, __, info) => {
if (!this.accountability?.user) return null;
const service = new UsersService({ schema: this.schema, accountability: this.accountability });
const selections = this.replaceFragmentsInSelections(
info.fieldNodes[0]?.selectionSet?.selections,
info.fragments
);
const query = this.getQuery(args, selections || [], info.variableValues);
return await service.readOne(this.accountability.user, query);
},
},
});
}
if ('directus_users' in schema.update.collections) {
schemaComposer.Mutation.addFields({
update_users_me: {
type: ReadCollectionTypes['directus_users'],
args: {
data: toInputObjectType(UpdateCollectionTypes['directus_users']),
},
resolve: async (_, args, __, info) => {
if (!this.accountability?.user) return null;
const service = new UsersService({
schema: this.schema,
accountability: this.accountability,
});
await service.updateOne(this.accountability.user, args.data);
if ('directus_users' in ReadCollectionTypes) {
const selections = this.replaceFragmentsInSelections(
info.fieldNodes[0]?.selectionSet?.selections,
info.fragments
);
const query = this.getQuery(args, selections || [], info.variableValues);
return await service.readOne(this.accountability.user, query);
}
return true;
},
},
});
}
if ('directus_activity' in schema.create.collections) {
schemaComposer.Mutation.addFields({
create_comment: {
type: ReadCollectionTypes['directus_activity'] ?? GraphQLBoolean,
args: {
collection: GraphQLNonNull(GraphQLString),
item: GraphQLNonNull(GraphQLID),
comment: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args, __, info) => {
const service = new ActivityService({
accountability: this.accountability,
schema: this.schema,
});
const primaryKey = await service.createOne({
...args,
action: Action.COMMENT,
user: this.accountability?.user,
ip: this.accountability?.ip,
user_agent: this.accountability?.userAgent,
});
if ('directus_activity' in ReadCollectionTypes) {
const selections = this.replaceFragmentsInSelections(
info.fieldNodes[0]?.selectionSet?.selections,
info.fragments
);
const query = this.getQuery(args, selections || [], info.variableValues);
return await service.readOne(primaryKey, query);
}
return true;
},
},
});
}
if ('directus_activity' in schema.update.collections) {
schemaComposer.Mutation.addFields({
update_comment: {
type: ReadCollectionTypes['directus_activity'] ?? GraphQLBoolean,
args: {
id: GraphQLNonNull(GraphQLID),
comment: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args, __, info) => {
const service = new ActivityService({
accountability: this.accountability,
schema: this.schema,
});
const primaryKey = await service.updateOne(args.id, { comment: args.comment });
if ('directus_activity' in ReadCollectionTypes) {
const selections = this.replaceFragmentsInSelections(
info.fieldNodes[0]?.selectionSet?.selections,
info.fragments
);
const query = this.getQuery(args, selections || [], info.variableValues);
return await service.readOne(primaryKey, query);
}
return true;
},
},
});
}
if ('directus_activity' in schema.delete.collections) {
schemaComposer.Mutation.addFields({
delete_comment: {
type: DeleteCollectionTypes.one,
args: {
id: GraphQLNonNull(GraphQLID),
},
resolve: async (_, args) => {
const service = new ActivityService({
accountability: this.accountability,
schema: this.schema,
});
await service.deleteOne(args.id);
return { id: args.id };
},
},
});
}
if ('directus_files' in schema.create.collections) {
schemaComposer.Mutation.addFields({
import_file: {
type: ReadCollectionTypes['directus_files'] ?? GraphQLBoolean,
args: {
url: GraphQLNonNull(GraphQLString),
data: toInputObjectType(CreateCollectionTypes['directus_files']).setTypeName('create_directus_files_input'),
},
resolve: async (_, args, __, info) => {
const service = new FilesService({
accountability: this.accountability,
schema: this.schema,
});
const primaryKey = await service.importOne(args.url, args.data);
if ('directus_files' in ReadCollectionTypes) {
const selections = this.replaceFragmentsInSelections(
info.fieldNodes[0]?.selectionSet?.selections,
info.fragments
);
const query = this.getQuery(args, selections || [], info.variableValues);
return await service.readOne(primaryKey, query);
}
return true;
},
},
});
}
if ('directus_users' in schema.create.collections) {
schemaComposer.Mutation.addFields({
users_invite: {
type: GraphQLBoolean,
args: {
email: GraphQLNonNull(GraphQLString),
role: GraphQLNonNull(GraphQLString),
invite_url: GraphQLString,
},
resolve: async (_, args) => {
const service = new UsersService({
accountability: this.accountability,
schema: this.schema,
});
await service.inviteUser(args.email, args.role, args.invite_url || null);
return true;
},
},
});
}
return schemaComposer;
}
}