diff --git a/package-lock.json b/package-lock.json index b1fc71fa57..3f88056850 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,13 @@ "tunnel": "0.0.6", "uuid": "^3.2.1", "xml2js": "^0.4.19" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } } }, "@azure/ms-rest-nodeauth": { @@ -316,6 +323,12 @@ "@types/node": "*" } }, + "@types/uuid": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.0.0.tgz", + "integrity": "sha512-xSQfNcvOiE5f9dyd4Kzxbof1aTrLobL278pGLKOZI6esGfZ7ts9Ka16CzIN6Y8hFHE1C7jIBZokULhK1bOgjRw==", + "dev": true + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -362,6 +375,11 @@ "version": "8.10.61", "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.61.tgz", "integrity": "sha512-l+zSbvT8TPRaCxL1l9cwHCb0tSqGAGcjPJFItGGYat5oCTiq1uQQKYg5m7AF1mgnEBzFXGLJ2LRmNjtreRX76Q==" + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" } } }, @@ -1089,11 +1107,6 @@ "assert-plus": "^1.0.0" } }, - "dataloader": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.0.0.tgz", - "integrity": "sha512-YzhyDAwA4TaQIhM5go+vCLmU0UikghC/t9DTQYZR2M/UvZ1MdOhPezSDZcjj9uqQJOMqjLcpWtyW2iNINdlatQ==" - }, "date-utils": { "version": "1.2.21", "resolved": "https://registry.npmjs.org/date-utils/-/date-utils-1.2.21.tgz", @@ -4623,6 +4636,11 @@ "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" } } }, @@ -4645,6 +4663,11 @@ "version": "6.9.4", "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz", "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==" + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" } } }, @@ -5737,9 +5760,9 @@ "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.2.0.tgz", + "integrity": "sha512-CYpGiFTUrmI6OBMkAdjSDM0k5h8SkkiTP4WAjQgDgNB1S3Ou9VBEvr6q0Kv2H1mMk7IWfxYGpMH5sd5AvcIV2Q==" }, "v8-compile-cache": { "version": "2.1.1", diff --git a/package.json b/package.json index 50c5f6db84..fd57d832cf 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@types/lodash": "^4.14.156", "@types/nodemailer": "^6.4.0", "@types/pino": "^6.3.0", + "@types/uuid": "^8.0.0", "copyfiles": "^2.3.0", "eslint": "^7.3.1", "eslint-plugin-prettier": "^3.1.4", @@ -65,7 +66,6 @@ "atob": "^2.1.2", "bcrypt": "^5.0.0", "body-parser": "^1.19.0", - "dataloader": "^2.0.0", "dotenv": "^8.2.0", "express": "^4.17.1", "express-async-handler": "^1.1.4", @@ -85,6 +85,7 @@ "pg": "^8.2.1", "pino": "^6.3.2", "sqlite3": "^4.2.0", - "ts-node-dev": "^1.0.0-pre.49" + "ts-node-dev": "^1.0.0-pre.49", + "uuid": "^8.2.0" } } diff --git a/src/app.ts b/src/app.ts index c07c0e673a..cfec9db7be 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,7 +7,6 @@ import logger from 'express-pino-logger'; import { errorHandler } from './error'; -import initLoaders from './middleware/init-loaders'; import extractToken from './middleware/extract-token'; import authenticate from './middleware/authenticate'; @@ -33,7 +32,6 @@ const app = express() .disable('x-powered-by') .use(logger()) .use(bodyParser.json()) - .use(initLoaders) .use(extractToken) .use(authenticate) .use('/activity', activityRouter) diff --git a/src/loaders.ts b/src/loaders.ts deleted file mode 100644 index 41a7df4cff..0000000000 --- a/src/loaders.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Loaders are individual DataLoader instances that can be used to query often used information more - * efficiently. This is relied on for fetching field information for example, seeing that's an - * operation we'll do often in various middleware checks - */ - -import DataLoader from 'dataloader'; -import { FieldInfo } from './types/field'; -import database from './database'; - -async function getCollections(keys: string[]) { - const records = await database - .select('*') - .from('directus_collections') - .whereIn('collection', keys); - - return keys.map((key) => records.find((collection) => collection.collection === key)); -} - -async function getFields(keys: { collection: string; field: string }[]) { - const records = await database - .select('*') - .from('directus_fields') - .whereIn( - ['collection', 'field'], - keys.map((key) => [key.collection, key.field]) - ); - - return keys.map((key) => - records.find((record) => record.collection === key.collection && record.field === key.field) - ); -} - -export default function createSystemLoaders() { - return { - collections: new DataLoader(getCollections), - fields: new DataLoader(getFields, { - cacheKeyFn: (key: { collection: string; field: string }) => - key.collection + '__' + key.field, - }), - }; -} diff --git a/src/middleware/authenticate.ts b/src/middleware/authenticate.ts index f2c69bab29..60fbaaec29 100644 --- a/src/middleware/authenticate.ts +++ b/src/middleware/authenticate.ts @@ -4,6 +4,9 @@ import isJWT from '../utils/is-jwt'; import database from '../database'; import asyncHandler from 'express-async-handler'; +/** + * Verify the passed JWT and assign the user ID and role to `req` + */ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => { if (!req.token) return next(); diff --git a/src/middleware/init-loaders.ts b/src/middleware/init-loaders.ts deleted file mode 100644 index 5d61de7e8a..0000000000 --- a/src/middleware/init-loaders.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Sets up the data loaders for this individual request. This allows us to reuse the same system - * loaders in the other middleware and route handlers - */ - -import { RequestHandler } from 'express'; -import createSystemLoaders from '../loaders'; - -const initLoaders: RequestHandler = (req, res, next) => { - req.loaders = createSystemLoaders(); - next(); -}; - -export default initLoaders; diff --git a/src/middleware/process-payload.ts b/src/middleware/process-payload.ts deleted file mode 100644 index d767fa5179..0000000000 --- a/src/middleware/process-payload.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Will check the fields system table for any special operations that need to be done on the field - * value, this can include hashing the value or generating a UUID - */ - -import { RequestHandler } from 'express'; -import asyncHandler from 'express-async-handler'; -import { FieldInfo } from '../types/field'; -import bcrypt from 'bcrypt'; - -type Operation = 'create' | 'update'; - -/** - * @TODO - * - * This needs a bit of extra thinking. - * - There's a difference between update / create payload processing - * - Some processing types need the whole payload (slug) - * - What happens for fields that aren't in the payload but need to be set on create? - */ - -const processPayload = (operation: Operation) => { - const middleware: RequestHandler = asyncHandler(async (req, res, next) => { - // Get the fields that have a special operation associated with them - const fieldsInPayload = Object.keys(req.body); - - const fieldInfoForFields = await req.loaders.fields.loadMany( - fieldsInPayload.map((field) => ({ - collection: req.collection, - field: field, - })) - ); - - const specialFields = fieldInfoForFields.filter((field) => { - if (field instanceof Error) return false; - return field.special !== null; - }) as FieldInfo[]; - - for (const field of specialFields) { - req.body[field.field] = await processField(req.collection, field, req.body, operation); - } - - next(); - }); - - return middleware; -}; - -async function processField( - collection: string, - field: FieldInfo, - payload: Record, - operation: Operation -) { - switch (field.special) { - case 'hash': - return await hash(payload[field.field]); - } -} - -async function hash(value: string | number) { - return await bcrypt.hash(value, Number(process.env.SALT_ROUNDS)); -} - -export default processPayload; diff --git a/src/routes/items.ts b/src/routes/items.ts index 1d0c4fbeec..dfecda47be 100644 --- a/src/routes/items.ts +++ b/src/routes/items.ts @@ -6,7 +6,7 @@ import validateCollection from '../middleware/validate-collection'; import validateSingleton from '../middleware/validate-singleton'; import validateQuery from '../middleware/validate-query'; import * as MetaService from '../services/meta'; -import processPayload from '../middleware/process-payload'; +import * as PayloadService from '../services/payload'; const router = express.Router(); @@ -14,9 +14,9 @@ router.post( '/:collection', validateCollection, validateSingleton, - processPayload('create'), asyncHandler(async (req, res) => { - await createItem(req.params.collection, req.body); + const payload = await PayloadService.processValues('create', req.collection, req.body); + await createItem(req.params.collection, payload); res.status(200).end(); }) ); @@ -55,7 +55,8 @@ router.patch( '/:collection/:pk', validateCollection, asyncHandler(async (req, res) => { - await updateItem(req.params.collection, req.params.pk, req.body); + const payload = await PayloadService.processValues('update', req.collection, req.body); + await updateItem(req.params.collection, req.params.pk, payload); return res.status(200).end(); }) ); diff --git a/src/services/payload.ts b/src/services/payload.ts new file mode 100644 index 0000000000..5a99765781 --- /dev/null +++ b/src/services/payload.ts @@ -0,0 +1,62 @@ +/** + * # PayloadService + * + * Process a given payload for a collection to ensure the special fields (hash, uuid, date etc) are + * handled correctly. + */ + +import { FieldInfo } from '../types/field'; +import bcrypt from 'bcrypt'; +import { v4 as uuidv4 } from 'uuid'; +import database from '../database'; +import { clone } from 'lodash'; + +/** + * Process and update all the special fields in the given payload + * + * @param collection Collection the payload goes in + * @param operation If this is on create or on update + * @param payload The actual payload itself + * @returns The updated payload + */ +export const processValues = async ( + operation: 'create' | 'update', + collection: string, + payload: Record +) => { + const processedPayload = clone(payload); + const specialFieldsInCollection = await database + .select('field', 'special') + .from('directus_fields') + .where({ collection: collection }) + .whereNotNull('special'); + + for (const field of specialFieldsInCollection) { + processedPayload[field.field] = await processField(field, processedPayload, operation); + } + + return processedPayload; +}; + +async function processField( + field: FieldInfo, + payload: Record, + operation: 'create' | 'update' +) { + switch (field.special) { + case 'hash': + return await genHash(payload[field.field]); + case 'uuid': + return await genUUID(operation); + } +} + +async function genHash(value: string | number) { + return await bcrypt.hash(value, Number(process.env.SALT_ROUNDS)); +} + +async function genUUID(operation: 'create' | 'update') { + if (operation === 'create') { + return uuidv4(); + } +} diff --git a/src/services/users.ts b/src/services/users.ts index 6448632d4a..a2669356a9 100644 --- a/src/services/users.ts +++ b/src/services/users.ts @@ -5,6 +5,7 @@ import { sendInviteMail } from '../mail'; import database from '../database'; import APIError, { ErrorCode } from '../error'; import bcrypt from 'bcrypt'; +import * as PayloadService from '../services/payload'; export const createUser = async (data: Record, query?: Query) => { return await ItemsService.createItem('directus_users', data, query); @@ -27,7 +28,12 @@ export const deleteUser = async (pk: string | number) => { }; export const inviteUser = async (email: string, role: string) => { - await createUser({ email, role, status: 'invited' }); + const userPayload = await PayloadService.processValues('create', 'directus_users', { + email, + role, + status: 'invited', + }); + await createUser(userPayload); const payload = { email }; const token = jwt.sign(payload, process.env.SECRET, { expiresIn: '7d' });