diff --git a/.eslintignore b/.eslintignore index 8baaa3a82..5072732e8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,3 +4,4 @@ node_modules /data/schema.sql /@app/graphql/index.* /@app/client/.next +**/build diff --git a/@app/lib/src/withApollo.ts b/@app/lib/src/withApollo.ts deleted file mode 100644 index 93858b814..000000000 --- a/@app/lib/src/withApollo.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { getDataFromTree } from "@apollo/react-ssr"; -import { InMemoryCache } from "apollo-cache-inmemory"; -import { ApolloClient } from "apollo-client"; -import { ApolloLink, split } from "apollo-link"; -import { onError } from "apollo-link-error"; -import { HttpLink } from "apollo-link-http"; -import { WebSocketLink } from "apollo-link-ws"; -import { getOperationAST } from "graphql"; -import withApolloBase from "next-with-apollo"; -import { SubscriptionClient } from "subscriptions-transport-ws"; -import ws from "ws"; - -import { GraphileApolloLink } from "./GraphileApolloLink"; - -let wsClient: SubscriptionClient | null = null; - -export function resetWebsocketConnection(): void { - if (wsClient) { - wsClient.close(false, false); - } -} - -function makeServerSideLink(req: any, res: any) { - return new GraphileApolloLink({ - req, - res, - postgraphileMiddleware: req.app.get("postgraphileMiddleware"), - }); -} - -function makeClientSideLink(ROOT_URL: string) { - const nextDataEl = document.getElementById("__NEXT_DATA__"); - if (!nextDataEl || !nextDataEl.textContent) { - throw new Error("Cannot read from __NEXT_DATA__ element"); - } - const data = JSON.parse(nextDataEl.textContent); - const CSRF_TOKEN = data.query.CSRF_TOKEN; - const httpLink = new HttpLink({ - uri: `${ROOT_URL}/graphql`, - credentials: "same-origin", - headers: { - "CSRF-Token": CSRF_TOKEN, - }, - }); - wsClient = new SubscriptionClient( - `${ROOT_URL.replace(/^http/, "ws")}/graphql`, - { - reconnect: true, - }, - typeof WebSocket !== "undefined" ? WebSocket : ws - ); - const wsLink = new WebSocketLink(wsClient); - - // Using the ability to split links, you can send data to each link - // depending on what kind of operation is being sent. - const mainLink = split( - // split based on operation type - ({ query, operationName }) => { - const op = getOperationAST(query, operationName); - return (op && op.operation === "subscription") || false; - }, - wsLink, - httpLink - ); - return mainLink; -} - -export const withApollo = withApolloBase( - ({ initialState, ctx }) => { - const ROOT_URL = process.env.ROOT_URL; - if (!ROOT_URL) { - throw new Error("ROOT_URL envvar is not set"); - } - - const onErrorLink = onError(({ graphQLErrors, networkError }) => { - if (graphQLErrors) - graphQLErrors.map(({ message, locations, path }) => - console.error( - `[GraphQL error]: message: ${message}, location: ${JSON.stringify( - locations - )}, path: ${JSON.stringify(path)}` - ) - ); - if (networkError) console.error(`[Network error]: ${networkError}`); - }); - - const { req, res }: any = ctx || {}; - const isServer = typeof window === "undefined"; - const mainLink = - isServer && req && res - ? makeServerSideLink(req, res) - : makeClientSideLink(ROOT_URL); - - const client = new ApolloClient({ - link: ApolloLink.from([onErrorLink, mainLink]), - cache: new InMemoryCache({ - dataIdFromObject: (o) => - o.__typename === "Query" - ? "ROOT_QUERY" - : o.id - ? `${o.__typename}:${o.id}` - : null, - }).restore(initialState || {}), - }); - - return client; - }, - { - getDataFromTree, - } -); diff --git a/@app/lib/src/withApollo.tsx b/@app/lib/src/withApollo.tsx new file mode 100644 index 000000000..97348484b --- /dev/null +++ b/@app/lib/src/withApollo.tsx @@ -0,0 +1,131 @@ +import { getDataFromTree } from "@apollo/react-ssr"; +import { InMemoryCache, NormalizedCacheObject } from "apollo-cache-inmemory"; +import { ApolloClient } from "apollo-client"; +import { ApolloLink, split } from "apollo-link"; +import { onError } from "apollo-link-error"; +import { HttpLink } from "apollo-link-http"; +import { WebSocketLink } from "apollo-link-ws"; +import { getOperationAST } from "graphql"; +import withApolloBase, { InitApolloOptions } from "next-with-apollo"; +import React from "react"; +import { SubscriptionClient } from "subscriptions-transport-ws"; +import ws from "ws"; + +import { GraphileApolloLink } from "./GraphileApolloLink"; + +interface WithApolloOptions { + useNext?: boolean; + rootUrl?: string; +} + +let wsClient: SubscriptionClient | null = null; + +export function resetWebsocketConnection(): void { + if (wsClient) { + wsClient.close(false, false); + } +} + +function makeServerSideLink(req: any, res: any) { + return new GraphileApolloLink({ + req, + res, + postgraphileMiddleware: req.app.get("postgraphileMiddleware"), + }); +} + +function makeClientSideLink(ROOT_URL: string) { + const nextDataEl = + typeof document !== "undefined" && document.getElementById("__NEXT_DATA__"); + const headers = {}; + if (nextDataEl && nextDataEl.textContent) { + const data = JSON.parse(nextDataEl.textContent); + headers["CSRF-Token"] = data.query.CSRF_TOKEN; + } + const httpLink = new HttpLink({ + uri: `${ROOT_URL}/graphql`, + credentials: + process.env.NODE_ENV === "development" ? "include" : "same-origin", + headers, + }); + wsClient = new SubscriptionClient( + `${ROOT_URL.replace(/^http/, "ws")}/graphql`, + { + reconnect: true, + }, + typeof WebSocket !== "undefined" ? WebSocket : ws + ); + const wsLink = new WebSocketLink(wsClient); + + // Using the ability to split links, you can send data to each link + // depending on what kind of operation is being sent. + const mainLink = split( + // split based on operation type + ({ query, operationName }) => { + const op = getOperationAST(query, operationName); + return (op && op.operation === "subscription") || false; + }, + wsLink, + httpLink + ); + return mainLink; +} + +const getApolloClient = ( + { initialState, ctx }: InitApolloOptions, + withApolloOptions?: WithApolloOptions +): ApolloClient => { + const ROOT_URL = process.env.ROOT_URL || withApolloOptions?.rootUrl; + if (!ROOT_URL) { + throw new Error("ROOT_URL envvar is not set"); + } + + const onErrorLink = onError(({ graphQLErrors, networkError }) => { + if (graphQLErrors) + graphQLErrors.map(({ message, locations, path }) => + console.error( + `[GraphQL error]: message: ${message}, location: ${JSON.stringify( + locations + )}, path: ${JSON.stringify(path)}` + ) + ); + if (networkError) console.error(`[Network error]: ${networkError}`); + }); + + const { req, res }: any = ctx || {}; + const isServer = typeof window === "undefined"; + const mainLink = + isServer && req && res + ? makeServerSideLink(req, res) + : makeClientSideLink(ROOT_URL); + + const client = new ApolloClient({ + link: ApolloLink.from([onErrorLink, mainLink]), + cache: new InMemoryCache({ + dataIdFromObject: (o) => + o.__typename === "Query" + ? "ROOT_QUERY" + : o.id + ? `${o.__typename}:${o.id}` + : null, + }).restore(initialState || {}), + }); + + return client; +}; + +const withApolloWithNext = withApolloBase(getApolloClient, { + getDataFromTree, +}); + +const withApolloWithoutNext = (Component: any, options?: WithApolloOptions) => ( + props: any +) => { + const apollo = getApolloClient({}, options); + return ; +}; + +export const withApollo = (Component: any, options?: WithApolloOptions) => + options?.useNext === false + ? withApolloWithoutNext(Component, options) + : withApolloWithNext(Component); diff --git a/@app/server/package.json b/@app/server/package.json index 672110a0a..343995210 100644 --- a/@app/server/package.json +++ b/@app/server/package.json @@ -18,6 +18,7 @@ "@graphile/pro": "^0.10.0", "@types/connect-pg-simple": "^4.2.0", "@types/connect-redis": "^0.0.13", + "@types/cors": "^2.8.7", "@types/csurf": "^1.9.36", "@types/express-session": "^1.17.0", "@types/helmet": "^0.0.46", @@ -30,6 +31,7 @@ "chalk": "^4.0.0", "connect-pg-simple": "^6.1.0", "connect-redis": "^4.0.4", + "cors": "^2.8.5", "csurf": "^1.11.0", "express": "^4.17.1", "express-session": "^1.17.1", diff --git a/@app/server/src/app.ts b/@app/server/src/app.ts index 8679031c8..6cc58c245 100644 --- a/@app/server/src/app.ts +++ b/@app/server/src/app.ts @@ -99,6 +99,7 @@ export async function makeApp({ * express middleware. These helpers may be asynchronous, but they should * operate very rapidly to enable quick as possible server startup. */ + await middleware.installCors(app); await middleware.installDatabasePools(app); await middleware.installWorkerUtils(app); await middleware.installHelmet(app); diff --git a/@app/server/src/middleware/index.ts b/@app/server/src/middleware/index.ts index 2bc8e645b..79b019714 100644 --- a/@app/server/src/middleware/index.ts +++ b/@app/server/src/middleware/index.ts @@ -1,3 +1,4 @@ +import installCors from "./installCors"; import installCSRFProtection from "./installCSRFProtection"; import installCypressServerCommand from "./installCypressServerCommand"; import installDatabasePools from "./installDatabasePools"; @@ -13,6 +14,7 @@ import installSSR from "./installSSR"; import installWorkerUtils from "./installWorkerUtils"; export { + installCors, installCSRFProtection, installDatabasePools, installWorkerUtils, diff --git a/@app/server/src/middleware/installCSRFProtection.ts b/@app/server/src/middleware/installCSRFProtection.ts index e26c27770..17c696952 100644 --- a/@app/server/src/middleware/installCSRFProtection.ts +++ b/@app/server/src/middleware/installCSRFProtection.ts @@ -1,5 +1,20 @@ import csrf from "csurf"; import { Express } from "express"; +import url from "url"; + +const skipList = process.env.CSRF_SKIP_REFERERS + ? process.env.CSRF_SKIP_REFERERS?.replace(/s\s/g, "") + .split(",") + .map((s) => { + // It is prefixed with a protocol + if (s.indexOf("//") !== -1) { + const { host: skipHost } = url.parse(s); + return skipHost; + } + + return s; + }) + : []; export default (app: Express) => { const csrfProtection = csrf({ @@ -21,6 +36,12 @@ export default (app: Express) => { ) { // Bypass CSRF for GraphiQL next(); + } else if ( + skipList && + skipList.includes(url.parse(req.headers.referer || "").host) + ) { + // Bypass CSRF for named referers + next(); } else { csrfProtection(req, res, next); } diff --git a/@app/server/src/middleware/installCors.ts b/@app/server/src/middleware/installCors.ts new file mode 100644 index 000000000..a13418a42 --- /dev/null +++ b/@app/server/src/middleware/installCors.ts @@ -0,0 +1,19 @@ +import cors from "cors"; +import { Express } from "express"; + +export default (app: Express) => { + const origin = []; + if (process.env.ROOT_URL) { + origin.push(process.env.ROOT_URL); + } + if (process.env.CORS_ALLOWED_URLS) { + origin.push( + ...(process.env.CORS_ALLOWED_URLS?.replace(/s\s/g, "").split(",") || []) + ); + } + const corsOptions = { + origin, + credentials: true, + }; + app.use(cors(corsOptions)); +}; diff --git a/package.json b/package.json index 69a2250ce..11dff46b0 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "description": "Description of project here", "scripts": { - "setup": "yarn && node ./scripts/setup.js", + "setup": "yarn && node ./scripts/setup.js && lerna run setup", "start": "node ./scripts/start.js", "pretest": "lerna run pretest", "test": "node scripts/test.js", diff --git a/yarn.lock b/yarn.lock index 5fe5ed3b6..056c893d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2793,6 +2793,13 @@ "@types/keygrip" "*" "@types/node" "*" +"@types/cors@^2.8.7": + version "2.8.7" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.7.tgz#ab2f47f1cba93bce27dfd3639b006cc0e5600889" + integrity sha512-sOdDRU3oRS7LBNTIqwDkPJyq0lpHYcbMTt0TrjzsXbk/e37hcLTH6eZX7CdbDeN0yJJvzw9hFBZkbtCSbk/jAQ== + dependencies: + "@types/express" "*" + "@types/csurf@^1.9.36": version "1.9.36" resolved "https://registry.yarnpkg.com/@types/csurf/-/csurf-1.9.36.tgz#f0139ef24f4d2a1a59674e2fcd7a13d86cff626a" @@ -5456,6 +5463,14 @@ core_d@^1.0.1: dependencies: supports-color "^5.5.0" +cors@^2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + cosmiconfig@6.0.0, cosmiconfig@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" @@ -11399,7 +11414,7 @@ oauth@0.9.x: resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE= -object-assign@4.x, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: +object-assign@4.x, object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= @@ -15711,7 +15726,7 @@ validate-npm-package-name@^3.0.0: dependencies: builtins "^1.0.3" -vary@~1.1.2: +vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=