diff --git a/demo/payload.config.js b/demo/payload.config.js index c2458897d..5b4439ab2 100644 --- a/demo/payload.config.js +++ b/demo/payload.config.js @@ -104,8 +104,8 @@ module.exports = { }, graphQL: { maxComplexity: 1000, - mutations: {}, - queries: {}, + mutations: {}, // TODO: needs typing + queries: {}, // TODO: needs typing disablePlaygroundInProduction: true, }, rateLimit: { diff --git a/demo/server.js b/demo/server.js index d229ff644..213ccb94e 100644 --- a/demo/server.js +++ b/demo/server.js @@ -12,8 +12,7 @@ payload.init({ mongoURL: 'mongodb://localhost/payload', express: expressApp, onInit: () => { - console.log('Payload is initialized'); - // console.log('Payload is initialized'); + console.log('Payload Demo Initialized'); }, }); diff --git a/nodemon.json b/nodemon.json index 8044708cc..e5d6587dd 100644 --- a/nodemon.json +++ b/nodemon.json @@ -3,11 +3,12 @@ ".git", "node_modules", "node_modules/**/node_modules", - "src/client" + "src/client", + "src/**/*.spec.ts" ], "watch": [ - "src/", + "src/**/*.ts", "demo/" ], - "ext": "js,json" + "ext": "ts,js,json" } diff --git a/src/email/build.ts b/src/email/build.ts index 34dc12c14..b0737dc29 100644 --- a/src/email/build.ts +++ b/src/email/build.ts @@ -1,37 +1,47 @@ -import nodemailer from 'nodemailer'; +import nodemailer, { Transporter } from 'nodemailer'; +import { PayloadEmailOptions } from '../types'; +import { InvalidConfiguration } from '../errors'; import mockHandler from './mockHandler'; +import Logger from '../utilities/logger'; +const logger = Logger(); -async function buildEmail() { - if (!this.config.email.transport || this.config.email.transport === 'mock') { - this.logger.info('E-mail configured with mock configuration'); - const mockAccount = await mockHandler(this.config.email); - if (this.config.email.transport === 'mock') { +export default async function buildEmail(emailConfig: PayloadEmailOptions) { + if (!emailConfig.transport || emailConfig.transport === 'mock') { + const mockAccount = await mockHandler(emailConfig); + // Only log mock credentials if was explicitly set in config + if (emailConfig.transport === 'mock') { const { account: { web, user, pass } } = mockAccount; - this.logger.info(`Log into mock email provider at ${web}`); - this.logger.info(`Mock email account username: ${user}`); - this.logger.info(`Mock email account password: ${pass}`); + logger.info('E-mail configured with mock configuration'); + logger.info(`Log into mock email provider at ${web}`); + logger.info(`Mock email account username: ${user}`); + logger.info(`Mock email account password: ${pass}`); } return mockAccount; } - const email = { ...this.config.email }; + const email = { ...emailConfig }; - if (this.config.email.transport) { - email.transport = this.config.email.transport; + if (!email.fromName || !email.fromAddress) { + throw new InvalidConfiguration('Email fromName and fromAddress must be configured when transport is configured'); } - if (this.config.email.transportOptions) { - email.transport = nodemailer.createTransport(this.config.email.transportOptions); + let transport: Transporter; + // TODO: Is this ever populated when not using 'mock'? + if (emailConfig.transport) { + transport = emailConfig.transport; + } else if (emailConfig.transportOptions) { + transport = nodemailer.createTransport(emailConfig.transportOptions); } try { - await email.transport.verify(); + await transport.verify(); + email.transport = transport; } catch (err) { - this.logger.error('There is an error with the email configuration you have provided.', err); + logger.error( + "There is an error with the email configuration you have provided.", + err + ); } return email; } - - -export default buildEmail; diff --git a/src/email/mockHandler.ts b/src/email/mockHandler.ts index 4f04eaefd..6b7b9cea7 100644 --- a/src/email/mockHandler.ts +++ b/src/email/mockHandler.ts @@ -1,15 +1,17 @@ -import nodemailer from 'nodemailer'; +import nodemailer, { TestAccount, Transporter } from 'nodemailer'; +import { PayloadEmailOptions } from '../types'; +import { MockEmailHandler } from './types'; -const mockEmailHandler = async (emailConfig) => { +const mockEmailHandler = async (emailConfig: PayloadEmailOptions): Promise => { const testAccount = await nodemailer.createTestAccount(); const smtpOptions = { ...emailConfig, - host: 'smtp.ethereal.email', + host: "smtp.ethereal.email", port: 587, secure: false, - fromName: emailConfig.fromName || 'Payload CMS', - fromAddress: emailConfig.fromAddress || 'info@payloadcms.com', + fromName: emailConfig.fromName || "Payload CMS", + fromAddress: emailConfig.fromAddress || "info@payloadcms.com", auth: { user: testAccount.user, pass: testAccount.pass, diff --git a/src/email/types.ts b/src/email/types.ts new file mode 100644 index 000000000..e8090845d --- /dev/null +++ b/src/email/types.ts @@ -0,0 +1,3 @@ +import { TestAccount, Transporter } from 'nodemailer'; + +export type MockEmailHandler = { account: TestAccount; transport: Transporter }; diff --git a/src/errors/APIError.ts b/src/errors/APIError.ts index 54caddd52..edf711cd1 100644 --- a/src/errors/APIError.ts +++ b/src/errors/APIError.ts @@ -5,7 +5,7 @@ import httpStatus from 'http-status'; * @extends Error */ class ExtendableError extends Error { - constructor(message, status, data, isPublic) { + constructor(message: string, status: number, data: any, isPublic: boolean) { super(message); this.name = this.constructor.name; this.message = message; @@ -29,7 +29,7 @@ class APIError extends ExtendableError { * @param {object} data - response data to be returned. * @param {boolean} isPublic - Whether the message should be visible to user or not. */ - constructor(message, status = httpStatus.INTERNAL_SERVER_ERROR, data, isPublic = false) { + constructor(message: string, status: number = httpStatus.INTERNAL_SERVER_ERROR, data: any, isPublic = false) { super(message, status, data, isPublic); } } diff --git a/src/errors/InvalidConfiguration.ts b/src/errors/InvalidConfiguration.ts index f9f544797..f2b85277d 100644 --- a/src/errors/InvalidConfiguration.ts +++ b/src/errors/InvalidConfiguration.ts @@ -2,7 +2,7 @@ import httpStatus from 'http-status'; import APIError from './APIError'; class InvalidConfiguration extends APIError { - constructor(message, results) { + constructor(message, results?) { super(message, httpStatus.INTERNAL_SERVER_ERROR, results); } } diff --git a/src/index.ts b/src/index.ts index d5c48539d..3f361dc53 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,19 @@ import express from 'express'; import crypto from 'crypto'; +import { Router } from 'express'; +import { + PayloadConfig, + PayloadCollection, + PayloadInitOptions, + PayloadLogger, + CreateOptions, + FindOptions, + FindGlobalOptions, + UpdateGlobalOptions, + FindByIDOptions, + UpdateOptions, + DeleteOptions, +} from './types'; import Logger from './utilities/logger'; import bindOperations from './init/bindOperations'; import bindRequestHandlers from './init/bindRequestHandlers'; @@ -23,21 +37,27 @@ import performFieldOperations from './fields/performFieldOperations'; import localOperations from './collections/operations/local'; import localGlobalOperations from './globals/operations/local'; import { encrypt, decrypt } from './auth/crypto'; +import { TestAccount } from 'nodemailer'; +import { MockEmailHandler } from './email/types'; require('es6-promise').polyfill(); require('isomorphic-fetch'); -const logger = Logger(); - class Payload { - logger: any; + config: PayloadConfig; + collections: PayloadCollection[] = []; + logger: PayloadLogger; + router: Router; + email: any; - init(options) { - this.logger = logger; + init(options: PayloadInitOptions) { + this.logger = Logger(); this.logger.info('Starting Payload...'); if (!options.secret) { - throw new Error('Error: missing secret key. A secret key is needed to secure Payload.'); + throw new Error( + 'Error: missing secret key. A secret key is needed to secure Payload.' + ); } if (!options.mongoURL) { @@ -51,14 +71,18 @@ class Payload { ...config, email, license: options.license, - secret: crypto.createHash('sha256').update(options.secret).digest('hex').slice(0, 32), + secret: crypto + .createHash('sha256') + .update(options.secret) + .digest('hex') + .slice(0, 32), mongoURL: options.mongoURL, local: options.local, }); if (typeof this.config.paths === 'undefined') this.config.paths = {}; - this.collections = {}; + // this.collections = {}; bindOperations(this); bindRequestHandlers(this); @@ -70,7 +94,7 @@ class Payload { this.initCollections = initCollections.bind(this); this.initGlobals = initGlobals.bind(this); this.initGraphQLPlayground = initGraphQLPlayground.bind(this); - this.buildEmail = buildEmail.bind(this); + // this.buildEmail = buildEmail.bind(this); this.sendEmail = this.sendEmail.bind(this); this.getMockEmailCredentials = this.getMockEmailCredentials.bind(this); this.initStatic = initStatic.bind(this); @@ -97,7 +121,7 @@ class Payload { } // Configure email service - this.email = this.buildEmail(); + this.email = buildEmail(this.config.email); // Initialize collections & globals this.initCollections(); @@ -114,7 +138,8 @@ class Payload { // If not initializing locally, set up HTTP routing if (!this.config.local) { this.express = options.express; - if (this.config.rateLimit && this.config.rateLimit.trustProxy) this.express.set('trust proxy', 1); + if (this.config.rateLimit && this.config.rateLimit.trustProxy) + this.express.set('trust proxy', 1); this.initAdmin(); @@ -125,7 +150,7 @@ class Payload { this.router.use( this.config.routes.graphQL, identifyAPI('GraphQL'), - (req, res) => graphQLHandler.init(req, res)(req, res), + (req, res) => graphQLHandler.init(req, res)(req, res) ); this.initGraphQLPlayground(); @@ -145,54 +170,54 @@ class Payload { if (typeof options.onInit === 'function') options.onInit(); } - async sendEmail(message) { + async sendEmail(message: string) { const email = await this.email; const result = email.transport.sendMail(message); return result; } - async getMockEmailCredentials() { - const email = await this.email; + async getMockEmailCredentials(): Promise { + const email = await this.email as MockEmailHandler; return email.account; } - async create(options) { + async create(options: CreateOptions) { let { create } = localOperations; create = create.bind(this); return create(options); } - async find(options) { + async find(options: FindOptions) { let { find } = localOperations; find = find.bind(this); return find(options); } - async findGlobal(options) { + async findGlobal(options: FindGlobalOptions) { let { findOne } = localGlobalOperations; findOne = findOne.bind(this); return findOne(options); } - async updateGlobal(options) { + async updateGlobal(options: UpdateGlobalOptions) { let { update } = localGlobalOperations; update = update.bind(this); return update(options); } - async findByID(options) { + async findByID(options: FindByIDOptions) { let { findByID } = localOperations; findByID = findByID.bind(this); return findByID(options); } - async update(options) { + async update(options: UpdateOptions) { let { update } = localOperations; update = update.bind(this); return update(options); } - async delete(options) { + async delete(options: DeleteOptions) { let { delete: deleteOperation } = localOperations; deleteOperation = deleteOperation.bind(this); return deleteOperation(options); diff --git a/src/schema/validateSchema.ts b/src/schema/validateSchema.ts index 41e238da4..34931dfd1 100644 --- a/src/schema/validateSchema.ts +++ b/src/schema/validateSchema.ts @@ -3,8 +3,9 @@ import * as payloadSchema from './payload.schema.json'; import * as collectionSchema from './collection.schema.json'; import InvalidSchema from '../errors/InvalidSchema'; +import { PayloadConfig } from '../types'; -const validateSchema = (config) => { +const validateSchema = (config: PayloadConfig) => { const ajv = new Ajv({ useDefaults: true }); const validate = ajv.addSchema(collectionSchema, '/collection.schema.json') .compile(payloadSchema); diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 000000000..e3b3b5163 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,251 @@ +// TODO: Split out init options from config types into own file + +import { Express, Request } from 'express'; +import { Transporter } from 'nodemailer'; +import SMTPConnection from 'nodemailer/lib/smtp-connection'; +import { Logger } from 'pino'; + +type MockEmailTransport = { + transport?: 'mock'; + fromName?: string; + fromAddress?: string; +}; +type ValidEmailTransport = { + transport: Transporter; + transportOptions?: SMTPConnection.Options; + fromName: string; + fromAddress: string; +}; +export type PayloadEmailOptions = ValidEmailTransport | MockEmailTransport; + +export type PayloadInitOptions = { + express?: Express; + mongoURL: string; + secret: string; + license?: string; + email?: PayloadEmailOptions; + local?: boolean; // I have no idea what this is + onInit?: () => void; +}; + +export type Document = { + id: string; +}; + +export type CreateOptions = { + collection: string; + data: any; +}; + +export type FindOptions = { + collection: string; + where?: { [key: string]: any }; +}; + +export type FindResponse = { + docs: Document[]; + totalDocs: number; + limit: number; + totalPages: number; + page: number; + pagingCounter: number; + hasPrevPage: boolean; + hasNextPage: boolean; + prevPage: number | null; + nextPage: number | null; +}; + +export type FindGlobalOptions = { + global: string; +}; +export type UpdateGlobalOptions = { + global: string; + data: any; +}; + +export type FindByIDOptions = { + collection: string; + id: string; +}; +export type UpdateOptions = { + collection: string; + id: string; + data: any; +}; + +export type DeleteOptions = { + collection: string; + id: string; +}; + +export type ForgotPasswordOptions = { + collection: string; + generateEmailHTML?: (token: string) => string; + expiration: Date; + data: any; +}; + +export type SendEmailOptions = { + from: string; + to: string; + subject: string; + html: string; +}; + +export type MockEmailCredentials = { + user: string; + pass: string; + web: string; +}; + +export type PayloadField = { + name: string; + label: string; + type: + | 'number' + | 'text' + | 'email' + | 'textarea' + | 'richText' + | 'code' + | 'radio' + | 'checkbox' + | 'date' + | 'upload' + | 'relationship' + | 'row' + | 'array' + | 'group' + | 'select' + | 'blocks'; + localized?: boolean; + fields?: PayloadField[]; + admin?: { + position?: string; + width?: string; + style?: Object; + }; +}; + +export type PayloadCollectionHook = (...args: any[]) => any | void; +export type PayloadAccess = (args?: any) => boolean; + +export type PayloadCollection = { + slug: string; + labels?: { + singular: string; + plural: string; + }; + admin?: { + useAsTitle?: string; + defaultColumns?: string[]; + components?: any; + }; + hooks?: { + beforeOperation?: PayloadCollectionHook[]; + beforeValidate?: PayloadCollectionHook[]; + beforeChange?: PayloadCollectionHook[]; + afterChange?: PayloadCollectionHook[]; + beforeRead?: PayloadCollectionHook[]; + afterRead?: PayloadCollectionHook[]; + beforeDelete?: PayloadCollectionHook[]; + afterDelete?: PayloadCollectionHook[]; + }; + access?: { + create?: PayloadAccess; + read?: PayloadAccess; + update?: PayloadAccess; + delete?: PayloadAccess; + admin?: PayloadAccess; + }; + auth?: { + tokenExpiration?: number; + verify?: + | boolean + | { generateEmailHTML: string; generateEmailSubject: string }; + maxLoginAttempts?: number; + lockTime?: number; + useAPIKey?: boolean; + cookies?: + | { + secure?: boolean; + sameSite?: string; + domain?: string | undefined; + } + | boolean; + }; + fields: PayloadField[]; +}; + +export type PayloadGlobal = { + slug: string; + label: string; + access?: { + create?: PayloadAccess; + read?: PayloadAccess; + update?: PayloadAccess; + delete?: PayloadAccess; + admin?: PayloadAccess; + }; + fields: PayloadField[]; +}; + +export type PayloadConfig = { + admin?: { + user?: string; + meta?: { + titleSuffix?: string; + ogImage?: string; + favicon?: string; + }; + disable?: boolean; + }; + collections?: PayloadCollection[]; + globals?: PayloadGlobal[]; + serverURL?: string; + cookiePrefix?: string; + csrf?: string[]; + cors?: string[]; + publicENV: { [key: string]: string }; + routes?: { + api?: string; + admin?: string; + graphQL?: string; + graphQLPlayground?: string; + }; + email?: PayloadEmailOptions; + local?: boolean; + defaultDepth?: number; + maxDepth?: number; + rateLimit?: { + window?: number; + max?: number; + trustProxy?: boolean; + skip?: (req: Request) => boolean; // TODO: Type join Request w/ PayloadRequest + }; + upload?: { + limits?: { + fileSize?: number; + }; + }; + localization?: { + locales: string[]; + }; + defaultLocale?: string; + fallback?: boolean; + graphQL?: { + mutations?: Object; + queries?: Object; + maxComplexity?: number; + disablePlaygroundInProduction?: boolean; + }; + components: { [key: string]: JSX.Element | (() => JSX.Element) }; + paths?: { [key: string]: string }; + hooks?: { + afterError?: () => void; + }; + webpack?: (config: any) => any; + serverModules?: string[]; +}; + +export type PayloadLogger = Logger; diff --git a/src/utilities/logger.ts b/src/utilities/logger.ts index abcb3b98a..99df2b98e 100644 --- a/src/utilities/logger.ts +++ b/src/utilities/logger.ts @@ -1,6 +1,7 @@ import falsey from 'falsey'; import pino from 'pino'; import memoize from 'micro-memoize'; +import { PayloadLogger } from '../types'; export default memoize((name = 'payload') => pino({ name, @@ -9,4 +10,4 @@ export default memoize((name = 'payload') => pino({ ignore: 'pid,hostname', translateTime: 'HH:MM:ss', }, -})); +}) as PayloadLogger); diff --git a/src/utilities/sanitizeConfig.ts b/src/utilities/sanitizeConfig.ts index 5d26ca3eb..264c52556 100644 --- a/src/utilities/sanitizeConfig.ts +++ b/src/utilities/sanitizeConfig.ts @@ -1,3 +1,4 @@ +import { PayloadConfig } from '../types'; import defaultUser from '../auth/default'; import sanitizeCollection from '../collections/sanitize'; import { InvalidConfiguration } from '../errors'; @@ -5,7 +6,7 @@ import sanitizeGlobals from '../globals/sanitize'; import validateSchema from '../schema/validateSchema'; import checkDuplicateCollections from './checkDuplicateCollections'; -const sanitizeConfig = (config) => { +const sanitizeConfig = (config: PayloadConfig) => { const sanitizedConfig = validateSchema({ ...config }); // TODO: remove default values from sanitize in favor of assigning in the schema within validateSchema and use https://www.npmjs.com/package/ajv#coercing-data-types where needed @@ -42,12 +43,7 @@ const sanitizeConfig = (config) => { } sanitizedConfig.email = config.email || {}; - // TODO: This should likely be moved to the payload.schema.json - if (sanitizedConfig.email.transports) { - if (!sanitizedConfig.email.email.fromName || !sanitizedConfig.email.email.fromAddress) { - throw new InvalidConfiguration('Email fromName and fromAddress must be configured when transport is configured'); - } - } + // if (!sanitizedConfig.email.transport) sanitizedConfig.email.transport = 'mock'; sanitizedConfig.graphQL = config.graphQL || {}; sanitizedConfig.graphQL.maxComplexity = (sanitizedConfig.graphQL && sanitizedConfig.graphQL.maxComplexity) ? sanitizedConfig.graphQL.maxComplexity : 1000;