diff --git a/api/src/routes/collections.ts b/api/src/routes/collections.ts index 0b3c003062..ce8713395a 100644 --- a/api/src/routes/collections.ts +++ b/api/src/routes/collections.ts @@ -2,8 +2,6 @@ import { Router } from 'express'; import asyncHandler from 'express-async-handler'; import sanitizeQuery from '../middleware/sanitize-query'; import CollectionsService from '../services/collections'; -import { schemaInspector } from '../database'; -import { CollectionNotFoundException } from '../exceptions'; import useCollection from '../middleware/use-collection'; const router = Router(); @@ -24,10 +22,9 @@ router.post( router.get( '/', useCollection('directus_collections'), - sanitizeQuery, asyncHandler(async (req, res) => { const collectionsService = new CollectionsService({ accountability: req.accountability }); - const collections = await collectionsService.readByQuery(req.sanitizedQuery); + const collections = await collectionsService.readByQuery(); res.json({ data: collections || null }); }) @@ -38,17 +35,25 @@ router.get( useCollection('directus_collections'), sanitizeQuery, asyncHandler(async (req, res) => { - /** @todo move this validation to CollectionsService methods */ - const exists = await schemaInspector.hasTable(req.params.collection); - if (exists === false) throw new CollectionNotFoundException(req.params.collection); - const collectionsService = new CollectionsService({ accountability: req.accountability }); + const collectionKey = req.params.collection.includes(',') + ? req.params.collection.split(',') + : req.params.collection; + const collection = await collectionsService.readByKey(collectionKey as any); + res.json({ data: collection || null }); + }) +); - const collection = await collectionsService.readByKey( - req.params.collection, - req.sanitizedQuery - ); - +router.patch( + '/:collection', + useCollection('directus_collections'), + asyncHandler(async (req, res) => { + const collectionsService = new CollectionsService({ accountability: req.accountability }); + const collectionKey = req.params.collection.includes(',') + ? req.params.collection.split(',') + : req.params.collection; + await collectionsService.update(req.body, collectionKey as any); + const collection = await collectionsService.readByKey(collectionKey as any); res.json({ data: collection || null }); }) ); @@ -57,13 +62,11 @@ router.delete( '/:collection', useCollection('directus_collections'), asyncHandler(async (req, res) => { - /** @todo move this validation to CollectionsService methods */ - if ((await schemaInspector.hasTable(req.params.collection)) === false) { - throw new CollectionNotFoundException(req.params.collection); - } - const collectionsService = new CollectionsService({ accountability: req.accountability }); - await collectionsService.delete(req.params.collection); + const collectionKey = req.params.collection.includes(',') + ? req.params.collection.split(',') + : req.params.collection; + await collectionsService.delete(collectionKey as any); res.end(); }) diff --git a/api/src/services/collections.ts b/api/src/services/collections.ts index e68112f01d..ccceb686ee 100644 --- a/api/src/services/collections.ts +++ b/api/src/services/collections.ts @@ -1,74 +1,254 @@ -import database from '../database'; +import database, { schemaInspector } from '../database'; import { AbstractServiceOptions, Accountability, Collection } from '../types'; import Knex from 'knex'; -import ItemsService from '../services/items'; +import { ForbiddenException, InvalidPayloadException } from '../exceptions'; +import SchemaInspector from 'knex-schema-inspector'; import FieldsService from '../services/fields'; import { omit } from 'lodash'; +import ItemsService from '../services/items'; -export default class CollectionsService extends ItemsService { +export default class CollectionsService { knex: Knex; accountability: Accountability | null; - itemsService: ItemsService; - fieldsService: FieldsService; constructor(options?: AbstractServiceOptions) { - super('directus_collections', options); - this.knex = options?.knex || database; this.accountability = options?.accountability || null; - this.itemsService = new ItemsService('directus_collections', options); - this.fieldsService = new FieldsService(options); } create(data: Partial[]): Promise; create(data: Partial): Promise; async create(data: Partial | Partial[]): Promise { - const payloads = Array.isArray(data) ? data : [data]; + if (this.accountability && this.accountability.admin !== true) { + throw new ForbiddenException('Only admins can perform this action.'); + } - // We'll create the fields separately. We don't want them to be inserted relationally - const payloadsWithoutFields = payloads.map((payload) => omit(payload, 'fields')); + const payloads = (Array.isArray(data) ? data : [data]).map((collection) => { + if (!collection.fields) collection.fields = []; - await this.itemsService.create(payloadsWithoutFields); - - for (const payload of payloads) { - // @TODO add basic validation to ensure all used fields are provided before attempting to save - await this.knex.schema.createTable(payload.collection!, async (table) => { - for (const field of payload.fields!) { - await this.fieldsService.createField(payload.collection!, field, table); + collection.fields = collection.fields.map((field) => { + if (field.system) { + field.system = { + ...field.system, + field: field.field, + collection: collection.collection!, + }; } + + return field; + }); + + return collection; + }); + + const createdCollections: string[] = []; + + await this.knex.transaction(async (trx) => { + const schemaInspector = SchemaInspector(trx); + const fieldsService = new FieldsService({ knex: trx }); + const collectionItemsService = new ItemsService('directus_collections', { + knex: trx, + accountability: this.accountability, + }); + const fieldItemsService = new ItemsService('directus_fields', { + knex: trx, + accountability: this.accountability, + }); + + for (const payload of payloads) { + if (!payload.collection) { + throw new InvalidPayloadException(`The "collection" key is required.`); + } + + if (await schemaInspector.hasTable(payload.collection)) { + throw new InvalidPayloadException( + `Collection "${payload.collection}" already exists.` + ); + } + + await trx.schema.createTable(payload.collection, (table) => { + for (const field of payload.fields!) { + fieldsService.addColumnToTable(table, field); + } + }); + + const collectionInfo = omit(payload, 'fields'); + await collectionItemsService.create(collectionInfo); + const fieldPayloads = payload + .fields!.filter((field) => field.system) + .map((field) => field.system); + await fieldItemsService.create(fieldPayloads); + createdCollections.push(payload.collection); + } + }); + + return Array.isArray(data) ? createdCollections : createdCollections[0]; + } + + readByKey(collection: string[]): Promise; + readByKey(collection: string): Promise; + async readByKey(collection: string | string[]): Promise { + const collectionItemsService = new ItemsService('directus_collections', { + knex: this.knex, + accountability: this.accountability, + }); + const collectionKeys = Array.isArray(collection) ? collection : [collection]; + + if (this.accountability && this.accountability.admin !== true) { + const permissions = await this.knex + .select('collection') + .from('directus_permissions') + .where({ operation: 'read' }) + .where({ role: this.accountability.role }) + .whereIn('collection', collectionKeys); + + if (collectionKeys.length !== permissions.length) { + const collectionsYouHavePermissionToRead = permissions.map( + ({ collection }) => collection + ); + + for (const collectionKey of collectionKeys) { + if (collectionsYouHavePermissionToRead.includes(collectionKey) === false) { + throw new ForbiddenException( + `You don't have access to the "${collectionKey}" collection.` + ); + } + } + } + } + + const tablesInDatabase = await schemaInspector.tableInfo(); + const tables = tablesInDatabase.filter((table) => collectionKeys.includes(table.name)); + const system: any[] = await collectionItemsService.readByQuery({ + filter: { collection: { _in: collectionKeys } }, + }); + + const collections: Collection[] = []; + + for (const table of tables) { + const collection: Collection = { + collection: table.name, + system: system.find((systemInfo) => systemInfo.collection === table.name) || null, + database: table, + }; + + collections.push(collection); + } + + return Array.isArray(collection) ? collections : collections[0]; + } + + /** @todo, read by query without query support is a bit ironic, isnt it */ + async readByQuery(): Promise { + const collectionItemsService = new ItemsService('directus_collections'); + let tablesInDatabase = await schemaInspector.tableInfo(); + + if (this.accountability && this.accountability.admin !== true) { + const collectionsYouHavePermissionToRead: string[] = ( + await this.knex.select('collection').from('directus_permissions').where({ + role: this.accountability.role, + operation: 'read', + }) + ).map(({ collection }) => collection); + + tablesInDatabase = tablesInDatabase.filter((table) => { + return collectionsYouHavePermissionToRead.includes(table.name); }); } - const collectionNames = payloads.map((payload) => payload.collection!); - return Array.isArray(data) ? collectionNames : collectionNames[0]; + const tablesToFetchInfoFor = tablesInDatabase.map((table) => table.name); + const system: any[] = await collectionItemsService.readByQuery({ + filter: { collection: { _in: tablesToFetchInfoFor } }, + }); + + const collections: Collection[] = []; + + for (const table of tablesInDatabase) { + const collection: Collection = { + collection: table.name, + system: system.find((systemInfo) => systemInfo.collection === table.name), + database: table, + }; + + collections.push(collection); + } + + return collections; } /** - * @todo - * update w/ nested fields + * @NOTE + * We only suppport updating the content in directus_collections */ + update(data: Partial, keys: string[]): Promise; + update(data: Partial, key: string): Promise; + update(data: Partial[]): Promise; + async update( + data: Partial | Partial[], + key?: string | string[] + ): Promise { + const collectionItemsService = new ItemsService('directus_collections', { + knex: this.knex, + accountability: this.accountability, + }); + + if (data && key) { + const payload = data as Partial; + + if (!payload.system) { + throw new InvalidPayloadException(`"system" key is required`); + } + + return (await collectionItemsService.update(payload.system!, key as any)) as + | string + | string[]; + } + + const payloads = Array.isArray(data) ? data : [data]; + + const collectionUpdates = payloads.map((collection) => { + return { + ...collection.system, + collection: collection.collection, + }; + }); + + await collectionItemsService.update(collectionUpdates); + + return key!; + } - delete(collection: string): Promise; delete(collections: string[]): Promise; - async delete(collection: string | string[]): Promise { - const collections = Array.isArray(collection) ? collection : [collection]; + delete(collection: string): Promise; + async delete(collection: string[] | string): Promise { + if (this.accountability && this.accountability.admin !== true) { + throw new ForbiddenException('Only admins can perform this action.'); + } - /** - * @todo check permissions manually - * this.itemsService.delete does the permissions check, but we have to delete the records from fields/relations first - * to adhere to the foreign key constraints - */ + const tablesInDatabase = await schemaInspector.tables(); - await this.knex('directus_fields').delete().whereIn('collection', collections); + const collectionKeys = Array.isArray(collection) ? collection : [collection]; + + for (const collectionKey of collectionKeys) { + if (tablesInDatabase.includes(collectionKey) === false) { + throw new InvalidPayloadException(`Collection "${collectionKey}" doesn't exist.`); + } + } + + await this.knex('directus_fields').delete().whereIn('collection', collectionKeys); await this.knex('directus_relations') .delete() - .whereIn('many_collection', collections) - .orWhereIn('one_collection', collections); + .whereIn('many_collection', collectionKeys) + .orWhereIn('one_collection', collectionKeys); - await this.itemsService.delete(collection as any); + const collectionItemsService = new ItemsService('directus_collections', { + knex: this.knex, + accountability: this.accountability, + }); + await collectionItemsService.delete(collectionKeys); - for (const collectionName of collections) { - await this.knex.schema.dropTable(collectionName); + for (const collectionKey of collectionKeys) { + await this.knex.schema.dropTable(collectionKey); } return collection;