diff --git a/src/components/forms/GSForm.jsx b/src/components/forms/GSForm.jsx index 5d8ae56f5..0e7eabf30 100644 --- a/src/components/forms/GSForm.jsx +++ b/src/components/forms/GSForm.jsx @@ -2,7 +2,6 @@ import PropTypes from "prop-types"; import React from "react"; import Form from "react-formal"; import { StyleSheet, css } from "aphrodite"; -import { GraphQLRequestError } from "../../network/errors"; import { log } from "../../lib"; import withMuiTheme from "../../containers/hoc/withMuiTheme"; @@ -41,9 +40,7 @@ class GSForm extends React.Component { } handleFormError(err) { - if (err instanceof GraphQLRequestError) { - this.setState({ globalErrorMessage: err.message }); - } else if (err.message) { + if (err.message) { this.setState({ globalErrorMessage: err.message }); } else { log.error(err); diff --git a/src/containers/AssignmentTexterContact.jsx b/src/containers/AssignmentTexterContact.jsx index 26a388080..4840034f4 100644 --- a/src/containers/AssignmentTexterContact.jsx +++ b/src/containers/AssignmentTexterContact.jsx @@ -141,29 +141,32 @@ export class AssignmentTexterContact extends React.Component { }; handleSendMessageError = e => { - // NOTE: status codes don't currently work so all errors will appear - // as "Something went wrong" keeping this code in here because - // we want to replace status codes with Apollo 2 error codes. - if (e.status === 402) { - this.goBackToTodos(); - } else if (e.status === 400) { - const newState = { - snackbarError: e.message - }; - - if (e.message === "Your assignment has changed") { - newState.snackbarActionTitle = "Back to todos"; - newState.snackbarOnClick = this.goBackToTodos; - this.setState(newState); - } else { - // opt out or send message Error - this.setState({ - disabled: true, - disabledText: e.message - }); - this.skipContact(); - } - } else { + const error_code = e.graphQLErrors[0].code; + if (error_code === 'SENDERR_ASSIGNMENTCHANGED') { + this.setState({ + snackbarError: e.message, + snackbarActionTitle: "Back to todos", + snackbarOnClick: this.goBackToTodos, + }); + } + else if (error_code === 'SENDERR_OPTEDOUT') { + this.setState({ + disabled: true, + disabledText: e.message, + snackbarError: e.message, + }); + this.handleEditStatus('closed', false); + this.skipContact(); + } + else if (error_code === 'SENDERR_OFFHOURS') { + this.setState({ + disabled: true, + disabledText: e.message, + snackbarError: e.message, + }); + this.skipContact(); + } + else { console.error(e); this.setState({ disabled: true, diff --git a/src/network/errors.js b/src/network/errors.js deleted file mode 100644 index e09ae6032..000000000 --- a/src/network/errors.js +++ /dev/null @@ -1,34 +0,0 @@ -export function GraphQLRequestError(err) { - this.name = this.constructor.name; - this.message = err.message; - this.status = err.status; - this.stack = new Error().stack; -} -GraphQLRequestError.prototype = Object.create(Error.prototype); -GraphQLRequestError.prototype.constructor = GraphQLRequestError; - -export function graphQLErrorParser(response) { - if (response.errors && response.errors.length > 0) { - const error = response.errors[0]; - let parsedError = null; - try { - parsedError = JSON.parse(error.message); - } catch (ex) { - // Even if we can't parse an error messge into JSON, still render it as a string - // so that we still display some error message instead of no error message at all. - parsedError = { status: 500, message: error.message }; - } - if (parsedError) { - return { - status: parsedError.status, - message: parsedError.message - }; - } - return { - status: 500, - message: - "There was an error with your request. Try again in a little bit!" - }; - } - return null; -} diff --git a/src/server/api/errors.js b/src/server/api/errors.js index 4e9134650..77a406c4b 100644 --- a/src/server/api/errors.js +++ b/src/server/api/errors.js @@ -1,9 +1,18 @@ import { GraphQLError } from "graphql"; import { r, cacheableData } from "../models"; +// Use this error class for errors with messages that are safe/useful +// to display to users. BE CAREFUL! Revealing unnessecary error details +// can reveal information that an attacker can exploit. +export class SpokeError extends GraphQLError {} + export function authRequired(user) { if (!user) { - throw new GraphQLError("You must login to access that resource."); + throw new SpokeError("You must login to access that resource.", { + extensions: { + code: 'UNAUTHENTICATED', + }, + }); } } @@ -27,11 +36,11 @@ export async function accessRequired( role ); if (!hasRole) { - const error = new GraphQLError( - "You are not authorized to access that resource." - ); - error.code = "UNAUTHORIZED"; - throw error; + throw new SpokeError("You are not authorized to access that resource.", { + extensions: { + code: 'UNAUTHORIZED', + }, + }); } } @@ -73,11 +82,11 @@ export async function assignmentRequiredOrAdminRole( roleRequired ); if (!hasPermission) { - const error = new GraphQLError( - "You are not authorized to access that resource." - ); - error.code = "UNAUTHORIZED"; - throw error; + throw new SpokeError("You are not authorized to access that resource.", { + extensions: { + code: 'UNAUTHORIZED', + }, + }); } return userHasAssignment || true; } diff --git a/src/server/api/mutations/joinOrganization.js b/src/server/api/mutations/joinOrganization.js index 3ae4e59ac..fd90dbc57 100644 --- a/src/server/api/mutations/joinOrganization.js +++ b/src/server/api/mutations/joinOrganization.js @@ -4,11 +4,14 @@ import { r, cacheableData } from "../../models"; import { hasRole } from "../../../lib"; import { getConfig } from "../lib/config"; import telemetry from "../../telemetry"; +import { SpokeError } from "../errors"; const INVALID_JOIN = () => { - const error = new GraphQLError("Invalid join request"); - error.code = "INVALID_JOIN"; - return error; + return new GraphQLError("Invalid join request", { + extensions: { + code: 'INVALID_JOIN', + }, + }); }; // eslint-disable-next-line import/prefer-default-export @@ -43,11 +46,11 @@ export const joinOrganization = async ( r.knex("assignment").where("campaign_id", campaignId) ); if (campaignTexterCount >= maxTextersPerCampaign) { - const error = new GraphQLError( - "Sorry, this campaign has too many texters already" - ); - error.code = "FAILEDJOIN_TOOMANYTEXTERS"; - throw error; + throw new SpokeError("Sorry, this campaign has too many texters already.", { + extensions: { + code: 'FAILEDJOIN_TOOMANYTEXTERS', + }, + }); } } } else { diff --git a/src/server/api/mutations/sendMessage.js b/src/server/api/mutations/sendMessage.js index 4fd17ce9c..d827e23ec 100644 --- a/src/server/api/mutations/sendMessage.js +++ b/src/server/api/mutations/sendMessage.js @@ -1,4 +1,4 @@ -import { GraphQLError } from "graphql"; +import { SpokeError } from "../errors"; import { Message, cacheableData } from "../../models"; @@ -16,8 +16,11 @@ const JOBS_SAME_PROCESS = !!( ); const newError = (message, code, details = {}) => { - const err = new GraphQLError(message); - err.code = code; + const err = new SpokeError(message, { + extensions: { + code: code, + }, + }); if (process.env.DEBUGGING_EMAILS) { sendEmail({ to: process.env.DEBUGGING_EMAILS.split(","), diff --git a/src/server/api/mutations/updateServiceVendorConfig.js b/src/server/api/mutations/updateServiceVendorConfig.js index e688868c9..6635ba4d8 100644 --- a/src/server/api/mutations/updateServiceVendorConfig.js +++ b/src/server/api/mutations/updateServiceVendorConfig.js @@ -1,4 +1,4 @@ -import { GraphQLError } from "graphql"; +import { SpokeError } from "../errors"; import { getConfigKey, getService, @@ -19,14 +19,14 @@ export const updateServiceVendorConfig = async ( const organization = await orgCache.load(organizationId); const configuredServiceName = orgCache.getMessageService(organization); if (configuredServiceName !== serviceName) { - throw new GraphQLError( + throw new SpokeError( `Can't configure ${serviceName}. It's not the configured message service` ); } const service = getService(serviceName); if (!service) { - throw new GraphQLError(`${serviceName} is not a valid message service`); + throw new SpokeError(`${serviceName} is not a valid message service`); } const serviceConfigFunction = tryGetFunctionFromService( @@ -34,14 +34,14 @@ export const updateServiceVendorConfig = async ( "updateConfig" ); if (!serviceConfigFunction) { - throw new GraphQLError(`${serviceName} does not support configuration`); + throw new SpokeError(`${serviceName} does not support configuration`); } let configObject; try { configObject = JSON.parse(config); } catch (caught) { - throw new GraphQLError("Config is not valid JSON"); + throw new SpokeError("Config is not valid JSON"); } const configKey = getConfigKey(serviceName); @@ -72,7 +72,7 @@ export const updateServiceVendorConfig = async ( console.error( `Error updating config for ${serviceName}: ${JSON.stringify(caught)}` ); - throw new GraphQLError(caught.message); + throw new SpokeError(caught.message); } // TODO: put this into a transaction (so read of features record doesn't get clobbered) const dbOrganization = await Organization.get(organizationId); diff --git a/src/server/api/phone.js b/src/server/api/phone.js index 6e6a8317b..73984d448 100644 --- a/src/server/api/phone.js +++ b/src/server/api/phone.js @@ -1,6 +1,7 @@ import { GraphQLScalarType } from "graphql"; import { GraphQLError } from "graphql"; import { Kind } from "graphql/language"; +import { SpokeError } from "./errors"; const identity = value => value; @@ -20,7 +21,7 @@ export const GraphQLPhone = new GraphQLScalarType({ } if (!pattern.test(ast.value)) { - throw new GraphQLError("Query error: Not a valid Phone"); + throw new SpokeError("Query error: Not a valid Phone"); } return ast.value; diff --git a/src/server/api/schema.js b/src/server/api/schema.js index 86730b90a..6e00dce6f 100644 --- a/src/server/api/schema.js +++ b/src/server/api/schema.js @@ -1,6 +1,7 @@ import GraphQLDate from "graphql-date"; import GraphQLJSON from "graphql-type-json"; import { GraphQLError } from "graphql"; +import { SpokeError } from "./errors"; import isUrl from "is-url"; import _ from "lodash"; import { gzip, makeTree, getHighestRole } from "../../lib"; @@ -537,12 +538,7 @@ const rootMutations = { .limit(1); if (!lastMessage) { - const errorStatusAndMessage = { - status: 400, - message: - "Cannot fake a reply to a contact that has no existing thread yet" - }; - throw new GraphQLError(errorStatusAndMessage); + throw new SpokeError("Cannot fake a reply to a contact that has no existing thread yet"); } const userNumber = lastMessage.user_number; @@ -1073,7 +1069,7 @@ const rootMutations = { campaign.hasOwnProperty("contacts") && campaign.contacts ) { - throw new GraphQLError( + throw new SpokeError( "Not allowed to add contacts after the campaign starts" ); } @@ -1129,7 +1125,7 @@ const rootMutations = { authRequired(user); const invite = await Invite.get(inviteId); if (!invite || !invite.is_valid) { - throw new GraphQLError("That invitation is no longer valid"); + throw new SpokeError("That invitation is no longer valid"); } const newOrganization = await Organization.save({ @@ -1455,20 +1451,23 @@ const rootMutations = { join_token: joinToken, }) .first(); - const INVALID_REASSIGN = () => { - const error = new GraphQLError("Invalid reassign request - organization not found"); - error.code = "INVALID_REASSIGN"; - return error; - }; if (!campaign) { - throw INVALID_REASSIGN(); + throw new GraphQLError("Invalid reassign request - campaign not found", { + extensions: { + code: 'INVALID_REASSIGN', + }, + }); } const organization = await cacheableData.organization.load( campaign.organization_id ); if (!organization) { - throw INVALID_REASSIGN(); - } + throw new GraphQLError("Invalid reassign request - organization not found", { + extensions: { + code: 'INVALID_REASSIGN', + }, + }); + } const maxContacts = getConfig("MAX_REPLIES_PER_TEXTER", organization) ?? 200; let d = new Date(); d.setHours(d.getHours() - 1); @@ -1500,7 +1499,7 @@ const rootMutations = { const campaign = await cacheableData.campaign.load(campaignId); await accessRequired(user, campaign.organization_id, "ADMIN", true); if (campaign.is_started || campaign.is_archived) { - throw new GraphQLError( + throw new SpokeError( "Cannot import a campaign script for a campaign that is started or archived" ); } diff --git a/src/server/index.js b/src/server/index.js index 21458f5f7..05bfc91e7 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -19,12 +19,13 @@ import { setupUserNotificationObservers } from "./notifications"; import { existsSync } from "fs"; import { rawAllMethods } from "../extensions/contact-loaders"; import herokuSslRedirect from "heroku-ssl-redirect"; -import { GraphQLError } from "graphql"; import { ApolloServer } from "@apollo/server"; import { expressMiddleware } from "@apollo/server/express4"; import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHttpServer"; import http from "http"; import cors from "cors"; +import { SpokeError } from "./api/errors"; +import { unwrapResolverError } from '@apollo/server/errors'; process.on("uncaughtException", ex => { log.error(ex); @@ -70,21 +71,29 @@ const server = new ApolloServer({ resolvers, introspection: true, plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], - formatError: error => { + formatError: (formattedError, error) => { + log.error({ + // TODO: request is no longer available in formatError, figure out + // another way to do this. + // userId: request.user && request.user.id, + code: error?.extensions?.code ?? 'INTERNAL_SERVER_ERROR', + error: formattedError, + msg: "GraphQL error" + }); + if (process.env.SHOW_SERVER_ERROR || process.env.DEBUG) { - if (error instanceof GraphQLError) { - return error; - } - return new GraphQLError(error.message); + return formattedError; } - return new GraphQLError( - error && - error.originalError && - error.originalError.code === "UNAUTHORIZED" - ? "UNAUTHORIZED" - : "Internal server error" - ); + // Only display error messages we throw ourselves and have deemed safe. + if (unwrapResolverError(error) instanceof SpokeError) { + return { + message: formattedError.message, + code: formattedError?.extensions?.code ?? 'INTERNAL_SERVER_ERROR', + }; + } + + return { message: 'Internal Server Error' }; } });