This commit is contained in:
James
2020-11-21 10:03:18 -05:00
13 changed files with 354 additions and 65 deletions

View File

@@ -104,8 +104,8 @@ module.exports = {
}, },
graphQL: { graphQL: {
maxComplexity: 1000, maxComplexity: 1000,
mutations: {}, mutations: {}, // TODO: needs typing
queries: {}, queries: {}, // TODO: needs typing
disablePlaygroundInProduction: true, disablePlaygroundInProduction: true,
}, },
rateLimit: { rateLimit: {

View File

@@ -12,8 +12,7 @@ payload.init({
mongoURL: 'mongodb://localhost/payload', mongoURL: 'mongodb://localhost/payload',
express: expressApp, express: expressApp,
onInit: () => { onInit: () => {
console.log('Payload is initialized'); console.log('Payload Demo Initialized');
// console.log('Payload is initialized');
}, },
}); });

View File

@@ -3,11 +3,12 @@
".git", ".git",
"node_modules", "node_modules",
"node_modules/**/node_modules", "node_modules/**/node_modules",
"src/client" "src/client",
"src/**/*.spec.ts"
], ],
"watch": [ "watch": [
"src/", "src/**/*.ts",
"demo/" "demo/"
], ],
"ext": "js,json" "ext": "ts,js,json"
} }

View File

@@ -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 mockHandler from './mockHandler';
import Logger from '../utilities/logger';
const logger = Logger();
async function buildEmail() { export default async function buildEmail(emailConfig: PayloadEmailOptions) {
if (!this.config.email.transport || this.config.email.transport === 'mock') { if (!emailConfig.transport || emailConfig.transport === 'mock') {
this.logger.info('E-mail configured with mock configuration'); const mockAccount = await mockHandler(emailConfig);
const mockAccount = await mockHandler(this.config.email); // Only log mock credentials if was explicitly set in config
if (this.config.email.transport === 'mock') { if (emailConfig.transport === 'mock') {
const { account: { web, user, pass } } = mockAccount; const { account: { web, user, pass } } = mockAccount;
this.logger.info(`Log into mock email provider at ${web}`); logger.info('E-mail configured with mock configuration');
this.logger.info(`Mock email account username: ${user}`); logger.info(`Log into mock email provider at ${web}`);
this.logger.info(`Mock email account password: ${pass}`); logger.info(`Mock email account username: ${user}`);
logger.info(`Mock email account password: ${pass}`);
} }
return mockAccount; return mockAccount;
} }
const email = { ...this.config.email }; const email = { ...emailConfig };
if (this.config.email.transport) { if (!email.fromName || !email.fromAddress) {
email.transport = this.config.email.transport; throw new InvalidConfiguration('Email fromName and fromAddress must be configured when transport is configured');
} }
if (this.config.email.transportOptions) { let transport: Transporter;
email.transport = nodemailer.createTransport(this.config.email.transportOptions); // 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 { try {
await email.transport.verify(); await transport.verify();
email.transport = transport;
} catch (err) { } 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; return email;
} }
export default buildEmail;

View File

@@ -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<MockEmailHandler> => {
const testAccount = await nodemailer.createTestAccount(); const testAccount = await nodemailer.createTestAccount();
const smtpOptions = { const smtpOptions = {
...emailConfig, ...emailConfig,
host: 'smtp.ethereal.email', host: "smtp.ethereal.email",
port: 587, port: 587,
secure: false, secure: false,
fromName: emailConfig.fromName || 'Payload CMS', fromName: emailConfig.fromName || "Payload CMS",
fromAddress: emailConfig.fromAddress || 'info@payloadcms.com', fromAddress: emailConfig.fromAddress || "info@payloadcms.com",
auth: { auth: {
user: testAccount.user, user: testAccount.user,
pass: testAccount.pass, pass: testAccount.pass,

3
src/email/types.ts Normal file
View File

@@ -0,0 +1,3 @@
import { TestAccount, Transporter } from 'nodemailer';
export type MockEmailHandler = { account: TestAccount; transport: Transporter };

View File

@@ -5,7 +5,7 @@ import httpStatus from 'http-status';
* @extends Error * @extends Error
*/ */
class ExtendableError extends Error { class ExtendableError extends Error {
constructor(message, status, data, isPublic) { constructor(message: string, status: number, data: any, isPublic: boolean) {
super(message); super(message);
this.name = this.constructor.name; this.name = this.constructor.name;
this.message = message; this.message = message;
@@ -29,7 +29,7 @@ class APIError extends ExtendableError {
* @param {object} data - response data to be returned. * @param {object} data - response data to be returned.
* @param {boolean} isPublic - Whether the message should be visible to user or not. * @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); super(message, status, data, isPublic);
} }
} }

View File

@@ -2,7 +2,7 @@ import httpStatus from 'http-status';
import APIError from './APIError'; import APIError from './APIError';
class InvalidConfiguration extends APIError { class InvalidConfiguration extends APIError {
constructor(message, results) { constructor(message, results?) {
super(message, httpStatus.INTERNAL_SERVER_ERROR, results); super(message, httpStatus.INTERNAL_SERVER_ERROR, results);
} }
} }

View File

@@ -1,5 +1,19 @@
import express from 'express'; import express from 'express';
import crypto from 'crypto'; 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 Logger from './utilities/logger';
import bindOperations from './init/bindOperations'; import bindOperations from './init/bindOperations';
import bindRequestHandlers from './init/bindRequestHandlers'; import bindRequestHandlers from './init/bindRequestHandlers';
@@ -23,21 +37,27 @@ import performFieldOperations from './fields/performFieldOperations';
import localOperations from './collections/operations/local'; import localOperations from './collections/operations/local';
import localGlobalOperations from './globals/operations/local'; import localGlobalOperations from './globals/operations/local';
import { encrypt, decrypt } from './auth/crypto'; import { encrypt, decrypt } from './auth/crypto';
import { TestAccount } from 'nodemailer';
import { MockEmailHandler } from './email/types';
require('es6-promise').polyfill(); require('es6-promise').polyfill();
require('isomorphic-fetch'); require('isomorphic-fetch');
const logger = Logger();
class Payload { class Payload {
logger: any; config: PayloadConfig;
collections: PayloadCollection[] = [];
logger: PayloadLogger;
router: Router;
email: any;
init(options) { init(options: PayloadInitOptions) {
this.logger = logger; this.logger = Logger();
this.logger.info('Starting Payload...'); this.logger.info('Starting Payload...');
if (!options.secret) { 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) { if (!options.mongoURL) {
@@ -51,14 +71,18 @@ class Payload {
...config, ...config,
email, email,
license: options.license, 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, mongoURL: options.mongoURL,
local: options.local, local: options.local,
}); });
if (typeof this.config.paths === 'undefined') this.config.paths = {}; if (typeof this.config.paths === 'undefined') this.config.paths = {};
this.collections = {}; // this.collections = {};
bindOperations(this); bindOperations(this);
bindRequestHandlers(this); bindRequestHandlers(this);
@@ -70,7 +94,7 @@ class Payload {
this.initCollections = initCollections.bind(this); this.initCollections = initCollections.bind(this);
this.initGlobals = initGlobals.bind(this); this.initGlobals = initGlobals.bind(this);
this.initGraphQLPlayground = initGraphQLPlayground.bind(this); this.initGraphQLPlayground = initGraphQLPlayground.bind(this);
this.buildEmail = buildEmail.bind(this); // this.buildEmail = buildEmail.bind(this);
this.sendEmail = this.sendEmail.bind(this); this.sendEmail = this.sendEmail.bind(this);
this.getMockEmailCredentials = this.getMockEmailCredentials.bind(this); this.getMockEmailCredentials = this.getMockEmailCredentials.bind(this);
this.initStatic = initStatic.bind(this); this.initStatic = initStatic.bind(this);
@@ -97,7 +121,7 @@ class Payload {
} }
// Configure email service // Configure email service
this.email = this.buildEmail(); this.email = buildEmail(this.config.email);
// Initialize collections & globals // Initialize collections & globals
this.initCollections(); this.initCollections();
@@ -114,7 +138,8 @@ class Payload {
// If not initializing locally, set up HTTP routing // If not initializing locally, set up HTTP routing
if (!this.config.local) { if (!this.config.local) {
this.express = options.express; 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(); this.initAdmin();
@@ -125,7 +150,7 @@ class Payload {
this.router.use( this.router.use(
this.config.routes.graphQL, this.config.routes.graphQL,
identifyAPI('GraphQL'), identifyAPI('GraphQL'),
(req, res) => graphQLHandler.init(req, res)(req, res), (req, res) => graphQLHandler.init(req, res)(req, res)
); );
this.initGraphQLPlayground(); this.initGraphQLPlayground();
@@ -145,54 +170,54 @@ class Payload {
if (typeof options.onInit === 'function') options.onInit(); if (typeof options.onInit === 'function') options.onInit();
} }
async sendEmail(message) { async sendEmail(message: string) {
const email = await this.email; const email = await this.email;
const result = email.transport.sendMail(message); const result = email.transport.sendMail(message);
return result; return result;
} }
async getMockEmailCredentials() { async getMockEmailCredentials(): Promise<TestAccount> {
const email = await this.email; const email = await this.email as MockEmailHandler;
return email.account; return email.account;
} }
async create(options) { async create(options: CreateOptions) {
let { create } = localOperations; let { create } = localOperations;
create = create.bind(this); create = create.bind(this);
return create(options); return create(options);
} }
async find(options) { async find(options: FindOptions) {
let { find } = localOperations; let { find } = localOperations;
find = find.bind(this); find = find.bind(this);
return find(options); return find(options);
} }
async findGlobal(options) { async findGlobal(options: FindGlobalOptions) {
let { findOne } = localGlobalOperations; let { findOne } = localGlobalOperations;
findOne = findOne.bind(this); findOne = findOne.bind(this);
return findOne(options); return findOne(options);
} }
async updateGlobal(options) { async updateGlobal(options: UpdateGlobalOptions) {
let { update } = localGlobalOperations; let { update } = localGlobalOperations;
update = update.bind(this); update = update.bind(this);
return update(options); return update(options);
} }
async findByID(options) { async findByID(options: FindByIDOptions) {
let { findByID } = localOperations; let { findByID } = localOperations;
findByID = findByID.bind(this); findByID = findByID.bind(this);
return findByID(options); return findByID(options);
} }
async update(options) { async update(options: UpdateOptions) {
let { update } = localOperations; let { update } = localOperations;
update = update.bind(this); update = update.bind(this);
return update(options); return update(options);
} }
async delete(options) { async delete(options: DeleteOptions) {
let { delete: deleteOperation } = localOperations; let { delete: deleteOperation } = localOperations;
deleteOperation = deleteOperation.bind(this); deleteOperation = deleteOperation.bind(this);
return deleteOperation(options); return deleteOperation(options);

View File

@@ -3,8 +3,9 @@ import * as payloadSchema from './payload.schema.json';
import * as collectionSchema from './collection.schema.json'; import * as collectionSchema from './collection.schema.json';
import InvalidSchema from '../errors/InvalidSchema'; import InvalidSchema from '../errors/InvalidSchema';
import { PayloadConfig } from '../types';
const validateSchema = (config) => { const validateSchema = (config: PayloadConfig) => {
const ajv = new Ajv({ useDefaults: true }); const ajv = new Ajv({ useDefaults: true });
const validate = ajv.addSchema(collectionSchema, '/collection.schema.json') const validate = ajv.addSchema(collectionSchema, '/collection.schema.json')
.compile(payloadSchema); .compile(payloadSchema);

251
src/types/index.ts Normal file
View File

@@ -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;

View File

@@ -1,6 +1,7 @@
import falsey from 'falsey'; import falsey from 'falsey';
import pino from 'pino'; import pino from 'pino';
import memoize from 'micro-memoize'; import memoize from 'micro-memoize';
import { PayloadLogger } from '../types';
export default memoize((name = 'payload') => pino({ export default memoize((name = 'payload') => pino({
name, name,
@@ -9,4 +10,4 @@ export default memoize((name = 'payload') => pino({
ignore: 'pid,hostname', ignore: 'pid,hostname',
translateTime: 'HH:MM:ss', translateTime: 'HH:MM:ss',
}, },
})); }) as PayloadLogger);

View File

@@ -1,3 +1,4 @@
import { PayloadConfig } from '../types';
import defaultUser from '../auth/default'; import defaultUser from '../auth/default';
import sanitizeCollection from '../collections/sanitize'; import sanitizeCollection from '../collections/sanitize';
import { InvalidConfiguration } from '../errors'; import { InvalidConfiguration } from '../errors';
@@ -5,7 +6,7 @@ import sanitizeGlobals from '../globals/sanitize';
import validateSchema from '../schema/validateSchema'; import validateSchema from '../schema/validateSchema';
import checkDuplicateCollections from './checkDuplicateCollections'; import checkDuplicateCollections from './checkDuplicateCollections';
const sanitizeConfig = (config) => { const sanitizeConfig = (config: PayloadConfig) => {
const sanitizedConfig = validateSchema({ ...config }); 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 // 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 || {}; sanitizedConfig.email = config.email || {};
// TODO: This should likely be moved to the payload.schema.json // if (!sanitizedConfig.email.transport) sanitizedConfig.email.transport = 'mock';
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');
}
}
sanitizedConfig.graphQL = config.graphQL || {}; sanitizedConfig.graphQL = config.graphQL || {};
sanitizedConfig.graphQL.maxComplexity = (sanitizedConfig.graphQL && sanitizedConfig.graphQL.maxComplexity) ? sanitizedConfig.graphQL.maxComplexity : 1000; sanitizedConfig.graphQL.maxComplexity = (sanitizedConfig.graphQL && sanitizedConfig.graphQL.maxComplexity) ? sanitizedConfig.graphQL.maxComplexity : 1000;