diff --git a/config/env.js b/config/env.js deleted file mode 100644 index 2240e824f..000000000 --- a/config/env.js +++ /dev/null @@ -1,93 +0,0 @@ -"use strict"; - -const fs = require("fs"); -const path = require("path"); -const paths = require("./paths"); - -// Make sure that including paths.js after env.js will read .env variables. -delete require.cache[require.resolve("./paths")]; - -const NODE_ENV = process.env.NODE_ENV; -if (!NODE_ENV) { - throw new Error( - "The NODE_ENV environment variable is required but was not specified." - ); -} - -// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use -var dotenvFiles = [ - `${paths.dotenv}.${NODE_ENV}.local`, - `${paths.dotenv}.${NODE_ENV}`, - // Don't include `.env.local` for `test` environment - // since normally you expect tests to produce the same - // results for everyone - NODE_ENV !== "test" && `${paths.dotenv}.local`, - paths.dotenv, -].filter(Boolean); - -// Load environment variables from .env* files. Suppress warnings using silent -// if this file is missing. dotenv will never modify any environment variables -// that have already been set. Variable expansion is supported in .env files. -// https://github.com/motdotla/dotenv -// https://github.com/motdotla/dotenv-expand -dotenvFiles.forEach(dotenvFile => { - if (fs.existsSync(dotenvFile)) { - require("dotenv-expand")( - require("dotenv").config({ - path: dotenvFile, - }) - ); - } -}); - -// We support resolving modules according to `NODE_PATH`. -// This lets you use absolute paths in imports inside large monorepos: -// https://github.com/facebookincubator/create-react-app/issues/253. -// It works similar to `NODE_PATH` in Node itself: -// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders -// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. -// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims. -// https://github.com/facebookincubator/create-react-app/issues/1023#issuecomment-265344421 -// We also resolve them to make sure all tools using them work consistently. -const appDirectory = fs.realpathSync(process.cwd()); -process.env.NODE_PATH = (process.env.NODE_PATH || "") - .split(path.delimiter) - .filter(folder => folder && !path.isAbsolute(folder)) - .map(folder => path.resolve(appDirectory, folder)) - .join(path.delimiter); - -// Grab NODE_ENV and TALK_* environment variables and prepare them to be -// injected into the application via DefinePlugin in Webpack configuration. -const REACT_APP = /^TALK_/i; - -function getClientEnvironment(publicUrl) { - const raw = Object.keys(process.env) - .filter(key => REACT_APP.test(key)) - .reduce( - (env, key) => { - env[key] = process.env[key]; - return env; - }, - { - // Useful for determining whether we’re running in production mode. - // Most importantly, it switches React into the correct mode. - NODE_ENV: process.env.NODE_ENV || "development", - // Useful for resolving the correct path to static assets in `public`. - // For example, . - // This should only be used as an escape hatch. Normally you would put - // images into the `src` and `import` them in code to get their paths. - PUBLIC_URL: publicUrl, - } - ); - // Stringify all values so we can feed into Webpack DefinePlugin - const stringified = { - "process.env": Object.keys(raw).reduce((env, key) => { - env[key] = JSON.stringify(raw[key]); - return env; - }, {}), - }; - - return { raw, stringified }; -} - -module.exports = getClientEnvironment; diff --git a/config/jest.config.js b/config/jest.config.js index 3e7166a29..3d4bf802b 100644 --- a/config/jest.config.js +++ b/config/jest.config.js @@ -1,6 +1,6 @@ module.exports = { projects: [ - "/jest-client.config.js", - "/jest-server.config.js", + "/jest/client.config.js", + "/jest/server.config.js", ], }; diff --git a/config/jest-client.config.js b/config/jest/client.config.js similarity index 95% rename from config/jest-client.config.js rename to config/jest/client.config.js index 3bfbd195c..d5cc83ce3 100644 --- a/config/jest-client.config.js +++ b/config/jest/client.config.js @@ -2,12 +2,12 @@ const path = require("path"); module.exports = { displayName: "client", - rootDir: "../", + rootDir: "../../", roots: ["/src/core/client"], collectCoverageFrom: ["**/*.{js,jsx,mjs,ts,tsx}"], coveragePathIgnorePatterns: ["/node_modules/"], setupFiles: [ - "/config/polyfills.js", + "/src/core/build/polyfills.js", "/src/core/client/test/setup.ts", ], testMatch: ["**/*.spec.{js,jsx,mjs,ts,tsx}"], diff --git a/config/jest-server.config.js b/config/jest/server.config.js similarity index 96% rename from config/jest-server.config.js rename to config/jest/server.config.js index 6ac9556bf..36376dd06 100644 --- a/config/jest-server.config.js +++ b/config/jest/server.config.js @@ -1,6 +1,6 @@ module.exports = { displayName: "server", - rootDir: "../", + rootDir: "../../", roots: ["/src"], collectCoverageFrom: ["**/*.{js,jsx,mjs,ts,tsx}"], coveragePathIgnorePatterns: ["/node_modules/"], diff --git a/config/tsconfig.jest.json b/config/jest/tsconfig.jest.json similarity index 100% rename from config/tsconfig.jest.json rename to config/jest/tsconfig.jest.json diff --git a/config/paths.js b/config/paths.js deleted file mode 100644 index 7d98ac998..000000000 --- a/config/paths.js +++ /dev/null @@ -1,66 +0,0 @@ -"use strict"; - -// A script from `create-react-app` ejected `25.06.2018`. - -const path = require("path"); -const fs = require("fs"); -const url = require("url"); - -// Make sure any symlinks in the project folder are resolved: -// https://github.com/facebookincubator/create-react-app/issues/637 -const appDirectory = fs.realpathSync(process.cwd()); -const resolveApp = relativePath => path.resolve(appDirectory, relativePath); - -const envPublicUrl = process.env.PUBLIC_URL; - -function ensureSlash(p, needsSlash) { - const hasSlash = p.endsWith("/"); - if (hasSlash && !needsSlash) { - return p.substr(p, p.length - 1); - } else if (!hasSlash && needsSlash) { - return `${p}/`; - } else { - return p; - } -} - -const getPublicUrl = appPackageJson => - envPublicUrl || require(appPackageJson).homepage; - -// We use `PUBLIC_URL` environment variable or "homepage" field to infer -// "public path" at which the app is served. -// Webpack needs to know it to put the right + + + diff --git a/src/core/client/embed/index.spec.ts b/src/core/client/embed/index.spec.ts new file mode 100644 index 000000000..66613f1c7 --- /dev/null +++ b/src/core/client/embed/index.spec.ts @@ -0,0 +1,23 @@ +import * as Talk from "./"; + +describe("Basic integration test", () => { + const container: HTMLElement = document.createElement("div"); + let streamInterface: ReturnType; + beforeAll(() => { + container.id = "basic-integration-test-id"; + document.body.appendChild(container); + }); + afterAll(() => { + document.body.removeChild(container); + }); + it("should render iframe", () => { + streamInterface = Talk.render({ + id: "basic-integration-test-id", + }); + expect(container.innerHTML).toMatchSnapshot(); + }); + it("should remove iframe", () => { + streamInterface.remove(); + expect(container.innerHTML).toBe(""); + }); +}); diff --git a/src/core/client/embed/index.ts b/src/core/client/embed/index.ts new file mode 100644 index 000000000..6c6ebc36d --- /dev/null +++ b/src/core/client/embed/index.ts @@ -0,0 +1,30 @@ +import { EventEmitter2 } from "eventemitter2"; +import qs from "query-string"; + +import createStreamInterface from "./Stream"; + +export interface Config { + assetID?: string; + assetURL?: string; + rootURL?: string; + id?: string; + events?: (eventEmitter: EventEmitter2) => void; +} + +export function render(config: Config = {}) { + // Parse query params + const query = qs.parse(location.search); + const eventEmitter = new EventEmitter2({ wildcard: true }); + + if (config.events) { + config.events(eventEmitter); + } + + return createStreamInterface({ + assetID: config.assetID || query.assetID, + assetURL: config.assetURL || query.assetURL, + id: config.id || "talk-embed-stream", + rootURL: config.rootURL || location.origin, + eventEmitter, + }); +} diff --git a/src/core/client/embed/tsconfig.json b/src/core/client/embed/tsconfig.json new file mode 100644 index 000000000..11f8c14ce --- /dev/null +++ b/src/core/client/embed/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "lib": ["dom", "es5"], + "types": ["jest"], + "paths": {} + }, + "include": [ + "./**/*", + "../../../types/pym.d.ts", + "../../../types/simulant.d.ts" + ], + "exclude": ["node_modules"] +} diff --git a/src/core/client/embed/utils/buildURL.spec.ts b/src/core/client/embed/utils/buildURL.spec.ts new file mode 100644 index 000000000..07e4e2ddc --- /dev/null +++ b/src/core/client/embed/utils/buildURL.spec.ts @@ -0,0 +1,18 @@ +import buildURL from "./buildURL"; + +it("should default to window.location", () => { + const url = buildURL(); + expect(url).toBe("http://localhost/"); +}); + +it("should build from parameters", () => { + const url = buildURL({ + protocol: "https", + hostname: "hostname", + port: "8080", + pathname: "/pathname", + search: "search", + hash: "#hash", + }); + expect(url).toBe("https//hostname:8080/pathname?search#hash"); +}); diff --git a/src/core/client/embed/utils/buildURL.ts b/src/core/client/embed/utils/buildURL.ts new file mode 100644 index 000000000..77353427f --- /dev/null +++ b/src/core/client/embed/utils/buildURL.ts @@ -0,0 +1,17 @@ +export default function buildURL({ + protocol = window.location.protocol, + hostname = window.location.hostname, + port = window.location.port, + pathname = window.location.pathname, + search = window.location.search, + hash = window.location.hash, +} = {}) { + if (search && search[0] !== "?") { + search = `?${search}`; + } else if (search === "?") { + search = ""; + } + return `${protocol}//${hostname}${ + port ? `:${port}` : "" + }${pathname}${search}${hash}`; +} diff --git a/src/core/client/embed/utils/ensureEndSlash.spec.ts b/src/core/client/embed/utils/ensureEndSlash.spec.ts new file mode 100644 index 000000000..9ab3f8a94 --- /dev/null +++ b/src/core/client/embed/utils/ensureEndSlash.spec.ts @@ -0,0 +1,11 @@ +import ensureEndSlash from "./ensureEndSlash"; + +it("should add slash to the end", () => { + const path = ensureEndSlash("/test"); + expect(path).toBe("/test/"); +}); + +it("should not add slash to the end if it's already there", () => { + const path = ensureEndSlash("/test/"); + expect(path).toBe("/test/"); +}); diff --git a/src/core/client/embed/utils/ensureEndSlash.ts b/src/core/client/embed/utils/ensureEndSlash.ts new file mode 100644 index 000000000..4adf9a451 --- /dev/null +++ b/src/core/client/embed/utils/ensureEndSlash.ts @@ -0,0 +1,3 @@ +export default function ensureEndSlash(p: string) { + return p.match(/\/$/) ? p : `${p}/`; +} diff --git a/src/core/client/embed/utils/index.ts b/src/core/client/embed/utils/index.ts new file mode 100644 index 000000000..0cf1aaa7f --- /dev/null +++ b/src/core/client/embed/utils/index.ts @@ -0,0 +1,2 @@ +export { default as buildURL } from "./buildURL"; +export { default as ensureEndSlash } from "./ensureEndSlash"; diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx index d14c245f5..90ed10530 100644 --- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx +++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx @@ -1,19 +1,31 @@ import { LocalizationProvider } from "fluent-react/compat"; import { MessageContext } from "fluent/compat"; +import { Child as PymChild } from "pym.js"; import React, { StatelessComponent } from "react"; import { Formatter } from "react-timeago"; import { Environment } from "relay-runtime"; + import { UIContext } from "talk-ui/components"; +import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; export interface TalkContext { - // relayEnvironment for our relay framework. + /** relayEnvironment for our relay framework. */ relayEnvironment: Environment; - // localMessages for our i18n framework. + /** localMessages for our i18n framework. */ localeMessages: MessageContext[]; - // formatter for timeago. + /** formatter for timeago. */ timeagoFormatter?: Formatter; + + /** + * A way to listen for clicks that are e.g. outside of the + * current frame for `ClickOutside` + */ + registerClickFarAway?: ClickFarAwayRegister; + + /** A pym child that interacts with the pym parent. */ + pym?: PymChild; } const { Provider, Consumer } = React.createContext({} as any); @@ -32,7 +44,12 @@ export const TalkContextProvider: StatelessComponent<{ }> = ({ value, children }) => ( - + {children} diff --git a/src/core/client/framework/lib/bootstrap/createContext.tsx b/src/core/client/framework/lib/bootstrap/createContext.tsx index 6043eacb9..2e6bd2aff 100644 --- a/src/core/client/framework/lib/bootstrap/createContext.tsx +++ b/src/core/client/framework/lib/bootstrap/createContext.tsx @@ -1,22 +1,32 @@ +import { EventEmitter2 } from "eventemitter2"; import { Localized } from "fluent-react/compat"; import { noop } from "lodash"; +import { Child as PymChild } from "pym.js"; import React from "react"; import { Formatter } from "react-timeago"; import { Environment, Network, RecordSource, Store } from "relay-runtime"; +import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; + import { generateMessages, LocalesData, negotiateLanguages } from "../i18n"; import { fetchQuery } from "../network"; import { TalkContext } from "./TalkContext"; interface CreateContextArguments { - // Locales that the user accepts, usually `navigator.languages`. + /** Locales that the user accepts, usually `navigator.languages`. */ userLocales: ReadonlyArray; - // Locales data that is returned by our `locales-loader`. + /** Locales data that is returned by our `locales-loader`. */ localesData: LocalesData; - // Init will be called after the context has been created. + /** Init will be called after the context has been created. */ init?: ((context: TalkContext) => void | Promise); + + /** A pym child that interacts with the pym parent. */ + pym?: PymChild; + + /** Supports emitting and listening to events. */ + eventEmitter?: EventEmitter2; } /** @@ -47,6 +57,8 @@ export default async function createContext({ init = noop, userLocales, localesData, + pym, + eventEmitter = new EventEmitter2({ wildcard: true }), }: CreateContextArguments): Promise { // Initialize Relay. const relayEnvironment = new Environment({ @@ -54,6 +66,21 @@ export default async function createContext({ store: new Store(new RecordSource()), }); + // Listen for outside clicks. + let registerClickFarAway: ClickFarAwayRegister | undefined; + if (pym) { + registerClickFarAway = cb => { + pym.onMessage("click", cb); + // Return unlisten callback. + return () => { + const index = pym.messageHandlers.click.indexOf(cb); + if (index > -1) { + pym.messageHandlers.click.splice(index, 1); + } + }; + }; + } + // Initialize i18n. const locales = negotiateLanguages(userLocales, localesData); @@ -69,6 +96,9 @@ export default async function createContext({ relayEnvironment, localeMessages, timeagoFormatter, + pym, + eventEmitter, + registerClickFarAway, }; // Run custom initializations. diff --git a/src/core/client/stream/components/Comment/Comment.tsx b/src/core/client/stream/components/Comment/Comment.tsx index f923e4149..03a947a5a 100644 --- a/src/core/client/stream/components/Comment/Comment.tsx +++ b/src/core/client/stream/components/Comment/Comment.tsx @@ -9,7 +9,7 @@ import Username from "./Username"; export interface CommentProps { author: { - username: string; + username: string | null; } | null; body: string | null; createdAt: string; @@ -19,7 +19,8 @@ const Comment: StatelessComponent = props => { return (
- {props.author && {props.author.username}} + {props.author && + props.author.username && {props.author.username}} {props.createdAt} {props.body} diff --git a/src/core/client/stream/containers/CommentContainer.spec.tsx b/src/core/client/stream/containers/CommentContainer.spec.tsx index 98612be38..4836991b8 100644 --- a/src/core/client/stream/containers/CommentContainer.spec.tsx +++ b/src/core/client/stream/containers/CommentContainer.spec.tsx @@ -19,3 +19,18 @@ it("renders username and body", () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); + +it("renders body only", () => { + const props: PropTypesOf = { + data: { + author: { + username: null, + }, + body: "Woof", + createdAt: "1995-12-17T03:24:00.000Z", + }, + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap index 9e2476f37..f6ec9754b 100644 --- a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap +++ b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap @@ -1,5 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`renders body only 1`] = ` + +`; + exports[`renders username and body 1`] = ` - + - Relay Experiments + Talk - Stream -
+
diff --git a/src/core/client/stream/index.tsx b/src/core/client/stream/index.tsx index 584e5e230..c6f9059e0 100644 --- a/src/core/client/stream/index.tsx +++ b/src/core/client/stream/index.tsx @@ -1,3 +1,4 @@ +import pym from "pym.js"; import React from "react"; import { StatelessComponent } from "react"; import ReactDOM from "react-dom"; @@ -23,6 +24,7 @@ async function main() { init, localesData, userLocales: navigator.languages, + pym: new pym.Child({ polling: 100 }), }); const Index: StatelessComponent = () => ( diff --git a/src/core/client/tsconfig.json b/src/core/client/tsconfig.json index a62cea6c5..847c9f1c5 100644 --- a/src/core/client/tsconfig.json +++ b/src/core/client/tsconfig.json @@ -16,5 +16,5 @@ } }, "include": ["./**/*", "../../types/**/*.d.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "./embed"] } diff --git a/src/core/client/ui/components/ClickOutside/ClickOutside.spec.tsx b/src/core/client/ui/components/ClickOutside/ClickOutside.spec.tsx index fd5ba04b8..e7da49bda 100644 --- a/src/core/client/ui/components/ClickOutside/ClickOutside.spec.tsx +++ b/src/core/client/ui/components/ClickOutside/ClickOutside.spec.tsx @@ -3,7 +3,14 @@ import React from "react"; import simulant from "simulant"; import sinon from "sinon"; -import ClickOutside from "./ClickOutside"; +import UIContext from "../UIContext"; + +import { + ClickFarAwayCallback, + ClickFarAwayRegister, + ClickOutside, + default as ClickOutsideWithContext, +} from "./ClickOutside"; let container: HTMLElement; @@ -60,3 +67,57 @@ it("should ignore click inside", () => { expect(onClickOutside.calledOnce).toEqual(false); wrapper.unmount(); }); + +it("should detect click far away", () => { + let emitFarAwayClick: ClickFarAwayCallback = Function; + const unlisten = sinon.spy(); + const registerClickFarAway: ClickFarAwayRegister = cb => { + emitFarAwayClick = cb; + return unlisten; + }; + const onClickOutside = sinon.spy(); + const wrapper = mount( + + + , + { + attachTo: container, + } + ); + + expect(onClickOutside.calledOnce).toEqual(false); + emitFarAwayClick(); + expect(onClickOutside.calledOnce).toEqual(true); + expect(unlisten.calledOnce).toEqual(false); + wrapper.unmount(); + expect(unlisten.calledOnce).toEqual(true); +}); + +it("should get registerClickFarAway from context", () => { + const registerClickFarAway: ClickFarAwayRegister = sinon.spy(); + const onClickOutside = sinon.spy(); + const context: any = { + registerClickFarAway, + }; + const wrapper = mount( + + + + + , + { + attachTo: container, + } + ); + + expect(wrapper.find(ClickOutside).prop("registerClickFarAway")).toEqual( + registerClickFarAway + ); + wrapper.unmount(); +}); diff --git a/src/core/client/ui/components/ClickOutside/ClickOutside.tsx b/src/core/client/ui/components/ClickOutside/ClickOutside.tsx index cf13762f0..2d79994ad 100644 --- a/src/core/client/ui/components/ClickOutside/ClickOutside.tsx +++ b/src/core/client/ui/components/ClickOutside/ClickOutside.tsx @@ -1,13 +1,30 @@ -import React from "react"; +import React, { StatelessComponent } from "react"; import { findDOMNode } from "react-dom"; +import UIContext from "../UIContext"; + +export type ClickFarAwayCallback = () => void; +export type ClickFarAwayUnlistenCallback = () => void; + +export type ClickFarAwayRegister = ( + callback: ClickFarAwayCallback +) => ClickFarAwayUnlistenCallback; + interface Props { onClickOutside: () => void; + + /** + * A way to listen for clicks that are e.g. outside of the + * current frame for `ClickOutside` + */ + registerClickFarAway?: ClickFarAwayRegister; + children: React.ReactNode; } -class ClickOutside extends React.Component { +export class ClickOutside extends React.Component { public domNode: Element | null = null; + private unlisten?: ClickFarAwayUnlistenCallback; public handleClick = (e: MouseEvent) => { const { onClickOutside } = this.props; @@ -17,17 +34,43 @@ class ClickOutside extends React.Component { } }; + public handleClickFarAway = () => { + const { onClickOutside } = this.props; + // tslint:disable-next-line:no-unused-expression + onClickOutside && onClickOutside(); + }; + public componentDidMount() { this.domNode = findDOMNode(this) as Element; document.addEventListener("click", this.handleClick, true); + + // Listen to far away clicks. + if (this.props.registerClickFarAway) { + this.unlisten = this.props.registerClickFarAway(this.handleClickFarAway); + } } public componentWillUnmount() { document.removeEventListener("click", this.handleClick, true); + + // Unlisten to far away clicks. + if (this.unlisten) { + this.unlisten(); + this.unlisten = undefined; + } } public render() { return this.props.children; } } -export default ClickOutside; + +const ClickOutsideWithContext: StatelessComponent = props => ( + + {({ registerClickFarAway }) => ( + + )} + +); + +export default ClickOutsideWithContext; diff --git a/src/core/client/ui/components/ClickOutside/index.ts b/src/core/client/ui/components/ClickOutside/index.ts new file mode 100644 index 000000000..b70132125 --- /dev/null +++ b/src/core/client/ui/components/ClickOutside/index.ts @@ -0,0 +1 @@ +export { default as ClickOutside, ClickFarAwayRegister } from "./ClickOutside"; diff --git a/src/core/client/ui/components/MatchMedia/MatchMedia.spec.tsx b/src/core/client/ui/components/MatchMedia/MatchMedia.spec.tsx index 1e600dd76..6f53df511 100644 --- a/src/core/client/ui/components/MatchMedia/MatchMedia.spec.tsx +++ b/src/core/client/ui/components/MatchMedia/MatchMedia.spec.tsx @@ -1,9 +1,11 @@ -import { shallow } from "enzyme"; +import { mount, shallow } from "enzyme"; import React from "react"; +import { MediaQueryMatchers } from "react-responsive"; import { PropTypesOf } from "talk-ui/types"; -import { MatchMedia } from "./MatchMedia"; +import UIContext from "../UIContext"; +import { default as MatchMediaWithContext, MatchMedia } from "./MatchMedia"; it("renders correctly", () => { const props: PropTypesOf = { @@ -25,3 +27,20 @@ it("map new speech prop to older aural prop", () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); + +it("should get mediaQueryValues from context", () => { + const mediaQueryValues: Partial = { + width: 100, + }; + const context: any = { + mediaQueryValues, + }; + const wrapper = mount( + + + Hello World + + + ); + expect(wrapper.find(MatchMedia).prop("values")).toEqual(mediaQueryValues); +}); diff --git a/src/core/client/ui/components/UIContext/UIContext.ts b/src/core/client/ui/components/UIContext/UIContext.ts index b8372d1f3..6e734d277 100644 --- a/src/core/client/ui/components/UIContext/UIContext.ts +++ b/src/core/client/ui/components/UIContext/UIContext.ts @@ -2,9 +2,18 @@ import React from "react"; import { MediaQueryMatchers } from "react-responsive"; import { Formatter } from "react-timeago"; +import { ClickFarAwayRegister } from "../ClickOutside"; + export interface UIContextProps { + /** Allows to integrate translated strings into `RelativeTime` Component */ timeagoFormatter?: Formatter | null; + /** Allows testing `MatchMedia` by setting media query values */ mediaQueryValues?: Partial; + /** + * A way to listen for clicks that are e.g. outside of the + * current frame for `ClickOutside` + */ + registerClickFarAway?: ClickFarAwayRegister; } const UIContext = React.createContext({} as any); diff --git a/src/core/server/config.ts b/src/core/common/config.ts similarity index 74% rename from src/core/server/config.ts rename to src/core/common/config.ts index f82bb46b8..099bcc4b2 100644 --- a/src/core/server/config.ts +++ b/src/core/common/config.ts @@ -1,11 +1,6 @@ import convict from "convict"; -import dotenv from "dotenv"; import Joi from "joi"; -// Apply all the configuration provided in the .env file if it isn't already in -// the environment. -dotenv.config(); - // Add custom format for the mongo uri scheme. convict.addFormat({ name: "mongo-uri", @@ -60,12 +55,29 @@ const config = convict({ env: "REDIS", arg: "redis", }, - secret: { - doc: "The secret used to sign and verify JWTs", + signing_secret: { + doc: "", format: "*", - default: null, - env: "SECRET", - arg: "secret", + default: "keyboard cat", // TODO: (wyattjoh) evaluate best solution + env: "SIGNING_SECRET", + arg: "signingSecret", + }, + signing_algorithm: { + doc: "", + format: [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + ], + default: "HS256", + env: "SIGNING_ALGORITHM", + arg: "signingAlgorithm", }, logging_level: { doc: "The logging level to print to the console", @@ -78,5 +90,9 @@ const config = convict({ export type Config = typeof config; +export const createClientEnv = (c: Config) => ({ + NODE_ENV: c.get("env"), +}); + // Setup the base configuration. export default config; diff --git a/src/core/common/types.ts b/src/core/common/types.ts index 2d322e1d3..deb1c57dd 100644 --- a/src/core/common/types.ts +++ b/src/core/common/types.ts @@ -11,3 +11,5 @@ export type Sub = Pick>; * Make all properties in T writeable */ export type Writeable = { -readonly [P in keyof T]: T[P] }; + +export type Promiseable = Promise | T; diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts new file mode 100644 index 000000000..0c438ccf3 --- /dev/null +++ b/src/core/server/app/handlers/auth/local.ts @@ -0,0 +1,77 @@ +import { RequestHandler } from "express"; +import Joi from "joi"; +import { Db } from "mongodb"; + +import { handleSuccessfulLogin } from "talk-server/app/middleware/passport"; +import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; +import { validate } from "talk-server/app/request/body"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { LocalProfile } from "talk-server/models/user"; +import { upsert } from "talk-server/services/users"; +import { Request } from "talk-server/types/express"; + +export interface SignupBody { + username: string; + password: string; + email: string; + displayName?: string; +} + +const SignupBodySchema = Joi.object().keys({ + username: Joi.string().trim(), + password: Joi.string().trim(), + email: Joi.string().trim(), +}); + +export interface SignupOptions { + db: Db; + signingConfig: JWTSigningConfig; +} + +export const signupHandler = (options: SignupOptions): RequestHandler => async ( + req: Request, + res, + next +) => { + try { + // TODO: rate limit based on the IP address and user agent. + + // Tenant is guaranteed at this point. + const tenant = req.tenant!; + + // Check to ensure that the local integration has been enabled. + if (!tenant.auth.integrations.local.enabled) { + // TODO: replace with better error. + return next(new Error("integration is disabled")); + } + + // Get the fields from the body. Validate will throw an error if the body + // does not conform to the specification. + const { username, password, email }: SignupBody = validate( + SignupBodySchema, + req.body + ); + + // Configure with profile. + const profile: LocalProfile = { + id: email, + type: "local", + }; + + // Create the new user. + const user = await upsert(options.db, tenant, { + email, + username, + password, + profiles: [profile], + // New users signing up via local auth will have the commenter role to + // start with. + role: GQLUSER_ROLE.COMMENTER, + }); + + // Send off to the passport handler. + return handleSuccessfulLogin(user, options.signingConfig, req, res, next); + } catch (err) { + return next(err); + } +}; diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 3711234e6..3be153164 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -3,14 +3,15 @@ import http from "http"; import { Redis } from "ioredis"; import { Db } from "mongodb"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; +import { notFoundMiddleware } from "talk-server/app/middleware/notFound"; +import { createPassport } from "talk-server/app/middleware/passport"; +import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware"; import { Schemas } from "talk-server/graph/schemas"; +import TenantCache from "talk-server/services/tenant/cache"; -import { - access as accessLogger, - error as errorLogger, -} from "./middleware/logging"; +import { accessLogger, errorLogger } from "./middleware/logging"; import serveStatic from "./middleware/serveStatic"; import { createRouter } from "./router"; @@ -20,6 +21,8 @@ export interface AppOptions { mongo: Db; redis: Redis; schemas: Schemas; + signingConfig: JWTSigningConfig; + tenantCache: TenantCache; } /** @@ -32,13 +35,21 @@ export async function createApp(options: AppOptions): Promise { // Logging parent.use(accessLogger); + // Create some services for the router. + const passport = createPassport(options); + + // Mount the router. + parent.use( + await createRouter(options, { + passport, + }) + ); + // Static Files parent.use(serveStatic); - // Mount the router. - parent.use(await createRouter(options)); - // Error Handling + parent.use(notFoundMiddleware); parent.use(errorLogger); return parent; @@ -64,7 +75,7 @@ export const listenAndServe = ( * handle websocket traffic by upgrading their http connections to websocket. * * @param schemas schemas for every schema this application handles - * @param server the http.Server to attach the websocket upgraders to + * @param server the http.Server to attach the websocket upgrader to */ export async function attachSubscriptionHandlers( schemas: Schemas, diff --git a/src/core/server/app/middleware/error.ts b/src/core/server/app/middleware/error.ts new file mode 100644 index 000000000..2f666260b --- /dev/null +++ b/src/core/server/app/middleware/error.ts @@ -0,0 +1,6 @@ +import { ErrorRequestHandler } from "express"; + +export const apiErrorHandler: ErrorRequestHandler = (err, req, res, next) => { + // TODO: handle better when we improve errors. + res.status(500).json({ error: err.message }); +}; diff --git a/src/core/server/app/middleware/logging.ts b/src/core/server/app/middleware/logging.ts index 9db48fa31..134c19df1 100644 --- a/src/core/server/app/middleware/logging.ts +++ b/src/core/server/app/middleware/logging.ts @@ -1,8 +1,9 @@ import { ErrorRequestHandler, RequestHandler } from "express"; import now from "performance-now"; -import logger from "../../logger"; -export const access: RequestHandler = (req, res, next) => { +import logger from "talk-server/logger"; + +export const accessLogger: RequestHandler = (req, res, next) => { const startTime = now(); const end = res.end; res.end = (chunk: any, encodingOrCb?: any, cb?: any) => { @@ -37,7 +38,7 @@ export const access: RequestHandler = (req, res, next) => { next(); }; -export const error: ErrorRequestHandler = (err, req, res, next) => { - logger.error({ err }, "http error"); +export const errorLogger: ErrorRequestHandler = (err, req, res, next) => { + logger.error(err, "http error"); next(err); }; diff --git a/src/core/server/app/middleware/notFound.ts b/src/core/server/app/middleware/notFound.ts new file mode 100644 index 000000000..1d5dbde5c --- /dev/null +++ b/src/core/server/app/middleware/notFound.ts @@ -0,0 +1,5 @@ +import { RequestHandler } from "express"; + +export const notFoundMiddleware: RequestHandler = (req, res, next) => { + next(new Error("not found")); +}; diff --git a/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap b/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap new file mode 100644 index 000000000..ada6ed29d --- /dev/null +++ b/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createJWTSigningConfig parses a RSA certificate 1`] = ` +"-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV +EnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+ +MHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U +C9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2 +nQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV +1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS +2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw +fdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD +KrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB +GdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7 +0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ +kPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi +55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN +oLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v +lbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6 +9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK +8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY +SvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1 +rFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr +xqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb +za9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7 +1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0 +pYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f +RaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt +ySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA= +-----END RSA PRIVATE KEY-----" +`; diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index a22e11144..a6bd0032b 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -1,13 +1,116 @@ +import { NextFunction, RequestHandler, Response } from "express"; import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; +import { + createJWTStrategy, + JWTSigningConfig, + SigningTokenOptions, + signTokenString, +} from "talk-server/app/middleware/passport/jwt"; +import { createLocalStrategy } from "talk-server/app/middleware/passport/local"; +import { createOIDCStrategy } from "talk-server/app/middleware/passport/oidc"; +import { createSSOStrategy } from "talk-server/app/middleware/passport/sso"; +import { User } from "talk-server/models/user"; +import TenantCache from "talk-server/services/tenant/cache"; +import { Request } from "talk-server/types/express"; + +export type VerifyCallback = ( + err?: Error | null, + user?: User | null, + info?: { message: string } +) => void; + export interface PassportOptions { - db: Db; + mongo: Db; + signingConfig: JWTSigningConfig; + tenantCache: TenantCache; } -export function createPassport(opts: PassportOptions): passport.Authenticator { +export function createPassport( + options: PassportOptions +): passport.Authenticator { // Create the authenticator. const auth = new Authenticator(); + // Use the OIDC Strategy. + auth.use(createOIDCStrategy(options)); + + // Use the LocalStrategy. + auth.use(createLocalStrategy(options)); + + // Use the SSOStrategy. + auth.use(createSSOStrategy(options)); + + // Use the JWTStrategy. + auth.use(createJWTStrategy(options)); + return auth; } + +export async function handleSuccessfulLogin( + user: User, + signingConfig: JWTSigningConfig, + req: Request, + res: Response, + next: NextFunction +) { + try { + // Grab the tenant from the request. + const { tenant } = req; + + const options: SigningTokenOptions = {}; + + if (tenant) { + // Attach the tenant's id to the issued token as a `iss` claim. + options.issuer = tenant.id; + + // TODO: (wyattjoh) evaluate the possibility when we have multiple + // integrations per type to use the integration id as the audience. + } + + // Grab the token. + const token = await signTokenString(signingConfig, user, options); + + // Set the cache control headers. + res.header("Cache-Control", "private, no-cache, no-store, must-revalidate"); + res.header("Expires", "-1"); + res.header("Pragma", "no-cache"); + + // Send back the details! + res.json({ token }); + } catch (err) { + return next(err); + } +} + +/** + * wrapAuthn will wrap a authenticators authenticate method with one that + * will return a valid login token for a valid login by a compatible strategy. + * + * @param authenticator the base authenticator instance + * @param signingConfig used to sign the tokens that are issued. + * @param name the name of the authenticator to use + * @param options any options to be passed to the authenticate call + */ +export const wrapAuthn = ( + authenticator: passport.Authenticator, + signingConfig: JWTSigningConfig, + name: string, + options?: any +): RequestHandler => (req: Request, res, next) => + authenticator.authenticate( + name, + { ...options, session: false }, + (err: Error | null, user: User | null) => { + if (err) { + return next(err); + } + if (!user) { + // TODO: (wyattjoh) replace with better error. + return next(new Error("no user on request")); + } + + handleSuccessfulLogin(user, signingConfig, req, res, next); + } + )(req, res, next); diff --git a/src/core/server/app/middleware/passport/jwt.spec.ts b/src/core/server/app/middleware/passport/jwt.spec.ts new file mode 100644 index 000000000..3c25db7a4 --- /dev/null +++ b/src/core/server/app/middleware/passport/jwt.spec.ts @@ -0,0 +1,84 @@ +import sinon from "sinon"; + +import { Config } from "talk-common/config"; +import { + createJWTSigningConfig, + extractJWTFromRequest, + parseAuthHeader, +} from "talk-server/app/middleware/passport/jwt"; +import { Request } from "talk-server/types/express"; + +describe("parseAuthHeader", () => { + it("parses valid headers", () => { + const parsed = { + scheme: "bearer", + value: "token", + }; + + expect(parseAuthHeader("Bearer token")).toEqual(parsed); + + expect(parseAuthHeader("bearer token")).toEqual(parsed); + + expect(parseAuthHeader("bearer token")).toEqual(parsed); + }); + + it("parses invalid headers", () => { + expect(parseAuthHeader("this-is-a-wrong-header")).toEqual(null); + expect(parseAuthHeader("bearerthis-is-a-wrong-header")).toEqual(null); + }); +}); + +describe("extractJWTFromRequest", () => { + it("extracts the token from header", () => { + const req = { + get: sinon + .stub() + .withArgs("authorization") + .returns("Bearer token"), + }; + + expect(extractJWTFromRequest((req as any) as Request)).toEqual("token"); + expect(req.get.calledOnce).toBeTruthy(); + + req.get.reset(); + req.get.returns(null); + expect(extractJWTFromRequest((req as any) as Request)).toEqual(null); + expect(req.get.calledOnce).toBeTruthy(); + }); + + it("extracts the token from query string", () => { + const req = { + get: sinon + .stub() + .withArgs("authorization") + .returns(null), + query: { access_token: "token" }, + }; + + expect(extractJWTFromRequest((req as any) as Request)).toEqual("token"); + expect(req.get.calledOnce).toBeTruthy(); + + delete req.query.access_token; + + req.get.reset(); + expect(extractJWTFromRequest((req as any) as Request)).toEqual(null); + expect(req.get.calledOnce).toBeTruthy(); + }); +}); + +describe("createJWTSigningConfig", () => { + it("parses a RSA certificate", () => { + const input = `-----BEGIN RSA PRIVATE KEY-----\\nMIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV\\nEnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+\\nMHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U\\nC9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2\\nnQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV\\n1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS\\n2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw\\nfdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD\\nKrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB\\nGdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7\\n0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ\\nkPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi\\n55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN\\noLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v\\nlbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6\\n9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK\\n8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY\\nSvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1\\nrFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr\\nxqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb\\nza9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7\\n1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0\\npYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f\\nRaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt\\nySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=\\n-----END RSA PRIVATE KEY-----`; + const config = { + get: sinon.stub(), + }; + + config.get.withArgs("signing_secret").returns(input); + config.get.withArgs("signing_algorithm").returns("RS256"); + + const signingConfig = createJWTSigningConfig((config as any) as Config); + + expect(signingConfig.algorithm).toEqual("RS256"); + expect(signingConfig.secret.toString()).toMatchSnapshot(); + }); +}); diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts new file mode 100644 index 000000000..da4d0be11 --- /dev/null +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -0,0 +1,203 @@ +import jwt, { SignOptions } from "jsonwebtoken"; +import uuid from "uuid"; + +import { Db } from "mongodb"; +import { Strategy } from "passport-strategy"; +import { Config } from "talk-common/config"; +import { retrieveUser, User } from "talk-server/models/user"; +import { Request } from "talk-server/types/express"; + +const authHeaderRegex = /(\S+)\s+(\S+)/; + +export function parseAuthHeader(header: string) { + const matches = header.match(authHeaderRegex); + if (!matches || matches.length < 3) { + return null; + } + + return { + scheme: matches[1].toLowerCase(), + value: matches[2], + }; +} + +export function extractJWTFromRequest(req: Request) { + const header = req.get("authorization"); + if (header) { + const parts = parseAuthHeader(header); + if (parts && parts.scheme === "bearer") { + return parts.value; + } + } + + const token: string | undefined | false = req.query && req.query.access_token; + if (token) { + return token; + } + + return null; +} + +export enum AsymmetricSigningAlgorithm { + RS256 = "RS256", + RS384 = "RS384", + RS512 = "RS512", + ES256 = "ES256", + ES384 = "ES384", + ES512 = "ES512", +} + +export enum SymmetricSigningAlgorithm { + HS256 = "HS256", + HS384 = "HS384", + HS512 = "HS512", +} + +export type JWTSigningAlgorithm = + | AsymmetricSigningAlgorithm + | SymmetricSigningAlgorithm; + +export interface JWTSigningConfig { + secret: Buffer; + algorithm: JWTSigningAlgorithm; +} + +export function createAsymmetricSigningConfig( + algorithm: AsymmetricSigningAlgorithm, + secret: string +): JWTSigningConfig { + return { + // Secrets have their newlines encoded with newline literals. + secret: Buffer.from(secret.replace(/\\n/g, "\n")), + algorithm, + }; +} + +export function createSymmetricSigningConfig( + algorithm: SymmetricSigningAlgorithm, + secret: string +): JWTSigningConfig { + return { + secret: new Buffer(secret), + algorithm, + }; +} + +function isSymmetricSigningAlgorithm( + algorithm: string | SymmetricSigningAlgorithm +): algorithm is SymmetricSigningAlgorithm { + return algorithm in SymmetricSigningAlgorithm; +} + +function isAsymmetricSigningAlgorithm( + algorithm: string | AsymmetricSigningAlgorithm +): algorithm is AsymmetricSigningAlgorithm { + return algorithm in AsymmetricSigningAlgorithm; +} + +/** + * Parses the config and provides the signing config. + * + * @param config the server configuration + */ +export function createJWTSigningConfig(config: Config): JWTSigningConfig { + const secret = config.get("signing_secret"); + const algorithm = config.get("signing_algorithm"); + if (isSymmetricSigningAlgorithm(algorithm)) { + return createSymmetricSigningConfig(algorithm, secret); + } else if (isAsymmetricSigningAlgorithm(algorithm)) { + return createAsymmetricSigningConfig(algorithm, secret); + } + + // TODO: (wyattjoh) return better error. + throw new Error("invalid algorithm specified"); +} + +export type SigningTokenOptions = Pick; + +export async function signTokenString( + { algorithm, secret }: JWTSigningConfig, + user: User, + options: SigningTokenOptions +) { + return jwt.sign({}, secret, { + ...options, + jwtid: uuid.v4(), + algorithm, + expiresIn: "1 day", // TODO: (wyattjoh) evaluate allowing configuration? + subject: user.id, + }); +} + +export interface JWTToken { + jti: string; + sub: string; + exp: number; + iss?: string; +} + +export interface JWTStrategyOptions { + signingConfig: JWTSigningConfig; + mongo: Db; +} + +export class JWTStrategy extends Strategy { + public name = "jwt"; + + private signingConfig: JWTSigningConfig; + private mongo: Db; + + constructor({ signingConfig, mongo }: JWTStrategyOptions) { + super(); + + this.signingConfig = signingConfig; + this.mongo = mongo; + } + + public authenticate(req: Request) { + // Lookup the token. + const token = extractJWTFromRequest(req); + if (!token) { + // There was no token on the request, so there was no user, so let's mark + // that the strategy was successful. + return this.success(null, null); + } + + const { tenant } = req; + if (!tenant) { + // TODO: (wyattjoh) return a better error. + return this.error(new Error("tenant not found")); + } + + jwt.verify( + token, + // Use the secret specified in the configuration. + this.signingConfig.secret, + { + // We need to verify that the token is for the specified tenant. + issuer: tenant.id, + // Use the algorithm specified in the configuration. + algorithms: [this.signingConfig.algorithm], + }, + async (err: Error | undefined, { sub }: JWTToken) => { + if (err) { + return this.fail(err, 401); + } + + try { + // Find the user. + const user = await retrieveUser(this.mongo, tenant.id, sub); + + // Return them! The user may be null, but that's ok here. + this.success(user, null); + } catch (err) { + return this.error(err); + } + } + ); + } +} + +export function createJWTStrategy(options: JWTStrategyOptions) { + return new JWTStrategy(options); +} diff --git a/src/core/server/app/middleware/passport/local.ts b/src/core/server/app/middleware/passport/local.ts new file mode 100644 index 000000000..588bcfa03 --- /dev/null +++ b/src/core/server/app/middleware/passport/local.ts @@ -0,0 +1,60 @@ +import { Db } from "mongodb"; +import { Strategy as LocalStrategy } from "passport-local"; + +import { VerifyCallback } from "talk-server/app/middleware/passport"; +import { + retrieveUserWithProfile, + verifyUserPassword, +} from "talk-server/models/user"; +import { Request } from "talk-server/types/express"; + +const verifyFactory = (mongo: Db) => async ( + req: Request, + email: string, + password: string, + done: VerifyCallback +) => { + try { + // TODO: rate limit based on the IP address and user agent. + + // The tenant is guaranteed at this point. + const tenant = req.tenant!; + + // Get the user from the database. + const user = await retrieveUserWithProfile(mongo, tenant.id, { + id: email, + type: "local", + }); + if (!user) { + // The user didn't exist. + return done(null, null); + } + + // Verify the password. + const passwordVerified = await verifyUserPassword(user, password); + if (!passwordVerified) { + // TODO: return better error + return done(new Error("invalid password")); + } + + return done(null, user); + } catch (err) { + return done(err); + } +}; + +export interface LocalStrategyOptions { + mongo: Db; +} + +export function createLocalStrategy({ mongo }: LocalStrategyOptions) { + return new LocalStrategy( + { + usernameField: "email", + passwordField: "password", + session: false, + passReqToCallback: true, + }, + verifyFactory(mongo) + ); +} diff --git a/src/core/server/app/middleware/passport/oidc.spec.ts b/src/core/server/app/middleware/passport/oidc.spec.ts new file mode 100644 index 000000000..6f10b1140 --- /dev/null +++ b/src/core/server/app/middleware/passport/oidc.spec.ts @@ -0,0 +1,87 @@ +import { + OIDCDisplayNameIDTokenSchema, + OIDCIDTokenSchema, +} from "talk-server/app/middleware/passport/oidc"; +import { validate } from "talk-server/app/request/body"; + +describe("OIDCIDTokenSchema", () => { + it("allows a valid payload", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: true, + }; + + expect(validate(OIDCIDTokenSchema, token)).toEqual(token); + }); + + it("allows an empty email_verified", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + }; + + expect(validate(OIDCIDTokenSchema, token)).toEqual({ + ...token, + email_verified: false, + }); + }); + + it("allows an empty picture", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: true, + }; + + expect(validate(OIDCIDTokenSchema, token)).toEqual(token); + }); +}); + +describe("OIDCDisplayNameIDTokenSchema", () => { + it("allows a valid payload", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: true, + name: "name", + nickname: "nickname", + }; + + expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + }); + + it("allows an empty name", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: false, + nickname: "nickname", + }; + + expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + }); + + it("allows an empty nickname", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: false, + name: "name", + }; + + expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + }); +}); diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts new file mode 100644 index 000000000..a835bd042 --- /dev/null +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -0,0 +1,381 @@ +import Joi from "joi"; +import jwt from "jsonwebtoken"; +import jwks, { JwksClient } from "jwks-rsa"; +import { Db } from "mongodb"; +import { Strategy as OAuth2Strategy, VerifyCallback } from "passport-oauth2"; +import { Strategy } from "passport-strategy"; + +import { validate } from "talk-server/app/request/body"; +import { reconstructURL } from "talk-server/app/url"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { OIDCAuthIntegration } from "talk-server/models/settings"; +import { Tenant } from "talk-server/models/tenant"; +import { OIDCProfile, retrieveUserWithProfile } from "talk-server/models/user"; +import TenantCache from "talk-server/services/tenant/cache"; +import { upsert } from "talk-server/services/users"; +import { Request } from "talk-server/types/express"; + +export interface Params { + id_token?: string; +} + +/** + * OIDCIDToken describes the set of claims that are present in a ID Token. This + * interface confirms with the ID Token specification as defined: + * https://openid.net/specs/openid-connect-core-1_0.html#IDToken + */ +export interface OIDCIDToken { + aud: string; + iss: string; + sub: string; + exp: number; // TODO: use this as the source for how long an OIDC user can be logged in for + email?: string; + email_verified?: boolean; + picture?: string; + name?: string; + nickname?: string; +} + +export interface StrategyItem { + strategy: OAuth2Strategy; + jwksClient?: JwksClient; +} + +export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken { + if ( + (token as OIDCIDToken).iss && + (token as OIDCIDToken).sub && + (token as OIDCIDToken).email + ) { + return true; + } + + return false; +} + +/** + * keyFunc will provide the secret based on the given jwkw client. + * + * @param client the jwks client for the specific request being made + */ +const signingKeyFactory = (client: jwks.JwksClient): jwt.KeyFunction => ( + { kid }, + callback +) => { + if (!kid) { + // TODO: return better error. + return callback(new Error("no kid in id_token")); + } + + // Get the signing key from the jwks provider. + client.getSigningKey(kid, (err, key) => { + if (err) { + // TODO: wrap error? + return callback(err); + } + + // Grab the signingKey out of the provided key. + const signingKey = key.publicKey || key.rsaPublicKey; + + callback(null, signingKey); + }); +}; + +function getEnabledIntegration(tenant: Tenant) { + const integration = tenant.auth.integrations.oidc; + if (!integration) { + // TODO: return a better error. + throw new Error("integration not found"); + } + + // Handle when the integration is enabled/disabled. + if (!integration.enabled) { + // TODO: return a better error. + throw new Error("integration not enabled"); + } + + return integration; +} + +export const OIDCIDTokenSchema = Joi.object() + .keys({ + sub: Joi.string(), + iss: Joi.string(), + aud: Joi.string(), + email: Joi.string(), + email_verified: Joi.boolean().default(false), + picture: Joi.string().default(undefined), + }) + .optionalKeys(["picture", "email_verified"]); + +export const OIDCDisplayNameIDTokenSchema = OIDCIDTokenSchema.keys({ + name: Joi.string().default(undefined), + nickname: Joi.string().default(undefined), +}).optionalKeys(["name", "nickname"]); + +export async function findOrCreateOIDCUser( + db: Db, + tenant: Tenant, + token: OIDCIDToken +) { + // Unpack/validate the token content. + const { + sub, + iss, + aud, + email, + email_verified, + picture, + name, + nickname, + }: OIDCIDToken = validate( + tenant.auth.integrations.oidc!.displayNameEnable + ? OIDCDisplayNameIDTokenSchema + : OIDCIDTokenSchema, + token + ); + + // Construct the profile that will be used to query for the user. + const profile: OIDCProfile = { + type: "oidc", + id: sub, + issuer: iss, + audience: aud, + }; + + // Try to lookup user given their id provided in the `sub` claim. + let user = await retrieveUserWithProfile(db, tenant.id, profile); + if (!user) { + // FIXME: implement rules. + + // Default the displayName. When it is disabled, Joi will strip the + // displayName fields from the token, so it will fallback to undefined. + const displayName = nickname || name || undefined; + + // Create the new user, as one didn't exist before! + user = await upsert(db, tenant, { + username: null, + displayName, + role: GQLUSER_ROLE.COMMENTER, + email, + email_verified, + avatar: picture, + profiles: [profile], + }); + } + + // TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch? + + return user; +} + +/** + * OIDC_SCOPE is the set of scopes requested for users signing up via OIDC. + */ +const OIDC_SCOPE = "openid email profile"; + +export interface OIDCStrategyOptions { + mongo: Db; + tenantCache: TenantCache; +} + +export default class OIDCStrategy extends Strategy { + public name = "oidc"; + + private mongo: Db; + private cache = new Map(); + + constructor({ mongo, tenantCache }: OIDCStrategyOptions) { + super(); + + this.mongo = mongo; + + // Subscribe to updates with Tenants. + tenantCache.subscribe(tenant => { + // Delete the tenant cache item when the tenant changes. The refreshed + // Tenant will come in with the request. + this.cache.delete(tenant.id); + }); + } + + private lookupJWKSClient( + req: Request, + tenantID: string, + oidc: OIDCAuthIntegration + ) { + let entry = this.cache.get(tenantID); + if (!entry) { + const strategy = this.createStrategy(req, oidc); + + // Create the entry. + entry = { + strategy, + }; + + // We don't reset the entry in the cache here because if we just created + // it, we'll be creating the jwksClient anyways, so we'll update it there. + } + + if (!entry.jwksClient) { + // Create the new JWKS client. + const jwksClient = jwks({ + jwksUri: oidc.jwksURI, + }); + + // Set the jwksClient on the entry. + entry.jwksClient = jwksClient; + + // Update the cached entry. + this.cache.set(tenantID, entry); + } + + return entry.jwksClient; + } + + private userAuthenticatedCallback = ( + req: Request, + accessToken: string, // ignore the access token, we don't use it. + refreshToken: string, // ignore the refresh token, we don't use it. + params: Params, + profile: any, // we don't look inside the profile (yet). + done: VerifyCallback + ) => { + // Try to lookup user given their id provided in the `sub` claim of the + // `id_token`. + const { id_token } = params; + if (!id_token) { + // TODO: return better error. + return done(new Error("no id_token in params")); + } + + // Grab the tenant out of the request, as we need some more details. + const { tenant } = req; + if (!tenant) { + // TODO: return a better error. + return done(new Error("tenant not found")); + } + + // Get the integration from the tenant. If needed, it will be used to create + // a new strategy. + let integration: OIDCAuthIntegration; + try { + integration = getEnabledIntegration(tenant); + } catch (err) { + // TODO: wrap error? + return done(err); + } + + // Grab the JWKSClient. + const client = this.lookupJWKSClient(req, tenant.id, integration); + + // Verify that the id_token is valid or not. + jwt.verify( + id_token, + signingKeyFactory(client), + { + issuer: integration.issuer, + }, + async (err, decoded) => { + if (err) { + // TODO: wrap error? + return done(err); + } + + try { + const user = await findOrCreateOIDCUser( + this.mongo, + tenant, + decoded as OIDCIDToken + ); + return done(null, user); + } catch (err) { + return done(err); + } + } + ); + }; + + private createStrategy( + req: Request, + integration: OIDCAuthIntegration + ): OAuth2Strategy { + const { clientID, clientSecret, authorizationURL, tokenURL } = integration; + + // Construct the callbackURL from the request. + const callbackURL = reconstructURL(req, "/api/tenant/auth/oidc/callback"); + + // Create a new OAuth2Strategy, where we pass the verify callback bound to + // this OIDCStrategy instance. + return new OAuth2Strategy( + { + passReqToCallback: true, + clientID, + clientSecret, + authorizationURL, + tokenURL, + callbackURL, + }, + this.userAuthenticatedCallback + ); + } + + private async lookupStrategy(req: Request) { + const { tenant } = req; + if (!tenant) { + // TODO: return a better error. + throw new Error("tenant not found"); + } + + // Get the integration from the tenant. If needed, it will be used to create + // a new strategy. + const integration = getEnabledIntegration(tenant); + + // Try to get the Tenant's cached integrations. + let entry = this.cache.get(tenant.id); + if (!entry) { + // Create the strategy. + const strategy = this.createStrategy(req, integration); + + // Reset the entry. + entry = { + strategy, + }; + + // Update the cached integrations value. + this.cache.set(tenant.id, entry); + } + + return entry.strategy; + } + + public async authenticate(req: Request) { + try { + // Lookup the strategy. + const strategy = await this.lookupStrategy(req); + if (!strategy) { + throw new Error("strategy not found"); + } + + // Augment the strategy with the request method bindings. + strategy.error = this.error.bind(this); + strategy.fail = this.fail.bind(this); + strategy.pass = this.pass.bind(this); + strategy.redirect = this.redirect.bind(this); + strategy.success = this.success.bind(this); + + // Authenticate with the strategy, binding the current context to the method + // to provide it with the augmented passport handlers. We also request the + // 'openid' scope so we can get an id_token back. + strategy.authenticate(req, { + scope: OIDC_SCOPE, + session: false, + }); + } catch (err) { + return this.error(err); + } + } +} + +export function createOIDCStrategy(options: OIDCStrategyOptions) { + return new OIDCStrategy(options); +} diff --git a/src/core/server/app/middleware/passport/sso.spec.ts b/src/core/server/app/middleware/passport/sso.spec.ts new file mode 100644 index 000000000..f973f45b7 --- /dev/null +++ b/src/core/server/app/middleware/passport/sso.spec.ts @@ -0,0 +1,83 @@ +import { + isSSOToken, + SSODisplayNameUserProfileSchema, + SSOUserProfileSchema, +} from "talk-server/app/middleware/passport/sso"; +import { validate } from "talk-server/app/request/body"; + +describe("isSSOToken", () => { + it("understands valid sso tokens", () => { + const token = { user: { id: "id", email: "email", username: "username" } }; + expect(isSSOToken(token)).toBeTruthy(); + }); + + it("understands invalid sso tokens", () => { + expect(isSSOToken({ user: { id: "id", email: "email" } })).toBeFalsy(); + expect( + isSSOToken({ user: { id: "id", username: "username" } }) + ).toBeFalsy(); + expect( + isSSOToken({ user: { email: "email", username: "username" } }) + ).toBeFalsy(); + expect(isSSOToken({})).toBeFalsy(); + }); +}); + +describe("SSOUserProfileSchema", () => { + it("allows a valid payload", () => { + const profile = { + id: "id", + email: "email", + username: "username", + avatar: "avatar", + }; + + expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); + }); + + it("allows an empty avatar", () => { + const profile = { + id: "id", + email: "email", + username: "username", + }; + + expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); + }); +}); + +describe("SSODisplayNameUserProfileSchema", () => { + it("allows a valid payload", () => { + const profile = { + id: "id", + email: "email", + username: "username", + avatar: "avatar", + displayName: "displayName", + }; + + expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + }); + + it("allows an empty avatar", () => { + const profile = { + id: "id", + email: "email", + username: "username", + displayName: "displayName", + }; + + expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + }); + + it("allows an empty displayName", () => { + const profile = { + id: "id", + email: "email", + username: "username", + avatar: "avatar", + }; + + expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + }); +}); diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts new file mode 100644 index 000000000..066f2d139 --- /dev/null +++ b/src/core/server/app/middleware/passport/sso.ts @@ -0,0 +1,224 @@ +import Joi from "joi"; +import jwt, { KeyFunctionCallback } from "jsonwebtoken"; +import { Db } from "mongodb"; +import { Strategy } from "passport-strategy"; + +import { extractJWTFromRequest } from "talk-server/app/middleware/passport/jwt"; +import { + findOrCreateOIDCUser, + isOIDCToken, + OIDCIDToken, +} from "talk-server/app/middleware/passport/oidc"; +import { validate } from "talk-server/app/request/body"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Tenant } from "talk-server/models/tenant"; +import { retrieveUserWithProfile, SSOProfile } from "talk-server/models/user"; +import { upsert } from "talk-server/services/users"; +import { Request } from "talk-server/types/express"; + +export interface SSOStrategyOptions { + mongo: Db; +} + +export interface SSOUserProfile { + id: string; + email: string; + username: string; + avatar?: string; + displayName?: string; +} + +export interface SSOToken { + user: SSOUserProfile; +} + +export const SSOUserProfileSchema = Joi.object() + .keys({ + id: Joi.string(), + email: Joi.string(), + username: Joi.string(), + avatar: Joi.string().default(undefined), + }) + .optionalKeys(["avatar"]); + +export const SSODisplayNameUserProfileSchema = SSOUserProfileSchema.keys({ + displayName: Joi.string().default(undefined), +}).optionalKeys(["displayName"]); + +export async function findOrCreateSSOUser( + db: Db, + tenant: Tenant, + token: SSOToken +) { + if (!token.user) { + // TODO: (wyattjoh) replace with better error. + throw new Error("token is malformed, missing user claim"); + } + + // Unpack/validate the token content. + const { id, email, username, displayName, avatar }: SSOUserProfile = validate( + tenant.auth.integrations.sso!.displayNameEnable + ? SSODisplayNameUserProfileSchema + : SSOUserProfileSchema, + token.user + ); + + const profile: SSOProfile = { + type: "sso", + id, + }; + + // Try to lookup user given their id provided in the `sub` claim. + let user = await retrieveUserWithProfile(db, tenant.id, profile); + if (!user) { + // FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method. + + // Create the new user, as one didn't exist before! + user = await upsert(db, tenant, { + username, + // When the displayName is disabled on the tenant, the displayName will + // never be set (or even stored in the database). + displayName, + role: GQLUSER_ROLE.COMMENTER, + email, + avatar, + profiles: [profile], + }); + } + + // TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch? + + return user; +} + +/** + * isSSOUserProfile will check if the given profile is a SSOUserProfile. + * + * @param profile the profile to check for the type + */ +export function isSSOUserProfile( + profile: SSOUserProfile | object +): profile is SSOUserProfile { + return ( + typeof (profile as SSOUserProfile).id !== "undefined" && + typeof (profile as SSOUserProfile).email !== "undefined" && + typeof (profile as SSOUserProfile).username !== "undefined" + ); +} + +export function isSSOToken(token: SSOToken | object): token is SSOToken { + return ( + typeof (token as SSOToken).user === "object" && + isSSOUserProfile((token as SSOToken).user) + ); +} + +export default class SSOStrategy extends Strategy { + public name = "sso"; + + private mongo: Db; + + constructor({ mongo }: SSOStrategyOptions) { + super(); + + this.mongo = mongo; + } + + /** + * retrieves the integration's secret to be used to verify the token. + */ + private getSigningSecretGetter = (tenant: Tenant) => async ( + headers: { kid?: string }, + done: KeyFunctionCallback + ) => { + const integration = tenant.auth.integrations.sso; + if (!integration) { + // TODO: (wyattjoh) return a better error. + return done(new Error("integration not found")); + } + + if (!integration.enabled) { + // TODO: (wyattjoh) return a better error. + return done(new Error("integration not enabled")); + } + + // TODO: (wyattjoh) do something with the kid... Lookup the secret or verify it matches what we have? + + return done(null, integration.key); + }; + + /** + * findOrCreateUser will interpret the token and use the correct strategy for + * retrieving/creating the user. + * + * @param tenant the tenant for the new/returning user + * @param token the token that was unpacked and validated from the sso strategy + */ + private async findOrCreateUser( + tenant: Tenant, + token: OIDCIDToken | SSOToken + ) { + if (isOIDCToken(token)) { + // The token provided for SSO contains an issuer claim. We're assuming + // that this request is associated with an OpenID Connect provider. + return findOrCreateOIDCUser(this.mongo, tenant, token); + } + + // Check to see if this token is a SSO Token or not, if it isn't error out. + if (!isSSOToken(token)) { + // TODO: (wyattjoh) return a better error. + throw new Error("token is invalid"); + } + + // The token provided does not confirm to the OpenID Connect provider + // spec, but id does conform to a SSOToken so we should expect the token to + // contain the user profile. + return findOrCreateSSOUser(this.mongo, tenant, token); + } + + public authenticate(req: Request) { + const { tenant } = req; + if (!tenant) { + // TODO: (wyattjoh) return a better error. + return this.error(new Error("tenant not found")); + } + + // Lookup the token. + const token = extractJWTFromRequest(req); + if (!token) { + // TODO: (wyattjoh) return a better error. + return this.fail(new Error("no token on request"), 400); + } + + // Perform the JWT validation. + jwt.verify( + token, + this.getSigningSecretGetter(tenant), + { + // Force the use of the HS256 algorithm. We can explore switching this + // out in the future.. + algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm. + }, + async (err: Error | undefined, decoded: OIDCIDToken | SSOToken) => { + if (err) { + // TODO: (wyattjoh) wrap error? + return this.error(err); + } + + try { + // Find or create the user based on the decoded token. + const user = await this.findOrCreateUser(tenant, decoded); + + // The user was found or created! + return this.success(user, null); + } catch (err) { + return this.error(err); + } + } + ); + } +} + +export function createSSOStrategy(options: SSOStrategyOptions) { + return new SSOStrategy(options); +} diff --git a/src/core/server/app/middleware/tenant.ts b/src/core/server/app/middleware/tenant.ts index b299b902a..00ff11c07 100644 --- a/src/core/server/app/middleware/tenant.ts +++ b/src/core/server/app/middleware/tenant.ts @@ -1,11 +1,10 @@ import { NextFunction, Response } from "express"; -import { Db } from "mongodb"; -import { retrieveTenantByDomain } from "talk-server/models/tenant"; +import TenantCache from "talk-server/services/tenant/cache"; import { Request } from "talk-server/types/express"; export interface MiddlewareOptions { - db: Db; + cache: TenantCache; } export default (options: MiddlewareOptions) => async ( @@ -14,13 +13,18 @@ export default (options: MiddlewareOptions) => async ( next: NextFunction ) => { try { - // TODO: replace with shared synced cache instead of direct db access. - const tenant = await retrieveTenantByDomain(options.db, req.hostname); + const { cache } = options; + + // Attach the tenant to the request. + const tenant = await cache.retrieveByDomain(req.hostname); if (!tenant) { // TODO: send a http.StatusNotFound? return next(new Error("tenant not found")); } + // Attach the tenant cache to the request. + req.tenantCache = cache; + // Attach the tenant to the request. req.tenant = tenant; diff --git a/src/core/server/app/request/__snapshots__/body.spec.ts.snap b/src/core/server/app/request/__snapshots__/body.spec.ts.snap new file mode 100644 index 000000000..823d9cb68 --- /dev/null +++ b/src/core/server/app/request/__snapshots__/body.spec.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`throws an error for missing fields 1`] = `"child \\"d\\" fails because [\\"d\\" is required]"`; diff --git a/src/core/server/app/request/body.spec.ts b/src/core/server/app/request/body.spec.ts new file mode 100644 index 000000000..98fd1376d --- /dev/null +++ b/src/core/server/app/request/body.spec.ts @@ -0,0 +1,32 @@ +import Joi from "joi"; + +import { validate } from "talk-server/app/request/body"; + +it("strips out unknown fields", () => { + const payload = { a: 1, b: 2, c: 3 }; + const schema = Joi.object().keys({}); + + expect(validate(schema, payload)).toEqual({}); +}); + +it("allows valid fields", () => { + const payload = { a: 1, b: 2, c: 3 }; + const schema = Joi.object().keys({ a: Joi.number() }); + + expect(validate(schema, payload)).toEqual({ a: 1 }); +}); + +it("allows valid fields from extended schema", () => { + const payload = { a: 1, b: 2, c: 3 }; + const schema = Joi.object().keys({ a: Joi.number() }); + const extendedSchema = schema.keys({ b: Joi.number() }); + + expect(validate(extendedSchema, payload)).toEqual({ a: 1, b: 2 }); +}); + +it("throws an error for missing fields", () => { + const payload = { a: 1, b: 2, c: 3 }; + const schema = Joi.object().keys({ d: Joi.number() }); + + expect(() => validate(schema, payload)).toThrowErrorMatchingSnapshot(); +}); diff --git a/src/core/server/app/request/body.ts b/src/core/server/app/request/body.ts new file mode 100644 index 000000000..a8e34412c --- /dev/null +++ b/src/core/server/app/request/body.ts @@ -0,0 +1,24 @@ +import Joi from "joi"; + +/** + * validate will strip unknown fields and perform validation against it. It will + * throw any error encountered. + * + * @param schema the Joi schema to validate against + * @param body the body to parse and strip of unknown fields + */ +export const validate = (schema: Joi.SchemaLike, body: any) => { + // Extract the schema from the request. + const { value, error: err } = Joi.validate(body, schema, { + stripUnknown: true, + presence: "required", + abortEarly: false, + }); + + if (err) { + // TODO: wrap error? + throw err; + } + + return value; +}; diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index fd481ee5c..059a1c39a 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -1,5 +1,10 @@ import express from "express"; +import passport from "passport"; +import { signupHandler } from "talk-server/app/handlers/auth/local"; +import { apiErrorHandler } from "talk-server/app/middleware/error"; +import { errorLogger } from "talk-server/app/middleware/logging"; +import { wrapAuthn } from "talk-server/app/middleware/passport"; import tenantMiddleware from "talk-server/app/middleware/tenant"; import managementGraphMiddleware from "talk-server/graph/management/middleware"; import tenantGraphMiddleware from "talk-server/graph/tenant/middleware"; @@ -7,7 +12,7 @@ import tenantGraphMiddleware from "talk-server/graph/tenant/middleware"; import { AppOptions } from "./index"; import playground from "./middleware/playground"; -async function createManagementRouter(opts: AppOptions) { +async function createManagementRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); // Management API @@ -15,51 +20,101 @@ async function createManagementRouter(opts: AppOptions) { "/graphql", express.json(), await managementGraphMiddleware( - opts.schemas.management, - opts.config, - opts.mongo + app.schemas.management, + app.config, + app.mongo ) ); return router; } -async function createTenantRouter(opts: AppOptions) { +async function createTenantRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); // Tenant identification middleware. - router.use(tenantMiddleware({ db: opts.mongo })); + router.use(tenantMiddleware({ cache: app.tenantCache })); + + // Setup Passport middleware. + router.use(options.passport.initialize()); + + // Setup auth routes. + router.use("/auth", createNewAuthRouter(app, options)); // Tenant API router.use( "/graphql", express.json(), - await tenantGraphMiddleware(opts.schemas.tenant, opts.config, opts.mongo) + // Any users may submit their GraphQL requests with authentication, this + // middleware will unpack their user into the request. + options.passport.authenticate("jwt", { session: false }), + await tenantGraphMiddleware({ + schema: app.schemas.tenant, + config: app.config, + mongo: app.mongo, + redis: app.redis, + }) ); return router; } -async function createAPIRouter(opts: AppOptions) { - // Create a router. +function createNewAuthRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); - // Configure the tenant routes. - router.use("/tenant", await createTenantRouter(opts)); - - // Configure the management routes. - router.use("/management", await createManagementRouter(opts)); + // Mount the passport routes. + router.post( + "/local", + express.json(), + wrapAuthn(options.passport, app.signingConfig, "local") + ); + router.post( + "/local/signup", + express.json(), + signupHandler({ db: app.mongo, signingConfig: app.signingConfig }) + ); + router.post("/sso", wrapAuthn(options.passport, app.signingConfig, "sso")); + router.get("/oidc", wrapAuthn(options.passport, app.signingConfig, "oidc")); + router.get( + "/oidc/callback", + wrapAuthn(options.passport, app.signingConfig, "oidc") + ); return router; } -export async function createRouter(opts: AppOptions) { +async function createAPIRouter(app: AppOptions, options: RouterOptions) { // Create a router. const router = express.Router(); - router.use("/api", await createAPIRouter(opts)); + // Configure the tenant routes. + router.use("/tenant", await createTenantRouter(app, options)); - if (opts.config.get("env") === "development") { + // Configure the management routes. + router.use("/management", await createManagementRouter(app, options)); + + // General API error handler. + router.use(errorLogger); + router.use(apiErrorHandler); + + return router; +} + +export interface RouterOptions { + /** + * passport is the instance of the Authenticator that can be used to create + * and mount new authentication middleware. + */ + passport: passport.Authenticator; +} + +export async function createRouter(app: AppOptions, options: RouterOptions) { + // Create a router. + const router = express.Router(); + + router.use("/api", await createAPIRouter(app, options)); + + if (app.config.get("env") === "development") { // Tenant GraphiQL router.get( "/tenant/graphiql", diff --git a/src/core/server/app/url.ts b/src/core/server/app/url.ts new file mode 100644 index 000000000..b656e4421 --- /dev/null +++ b/src/core/server/app/url.ts @@ -0,0 +1,12 @@ +import { Request } from "talk-server/types/express"; +import { URL } from "url"; + +export function reconstructURL(req: Request, path: string = "/"): string { + const scheme = req.secure ? "https" : "http"; + const host = req.get("host"); + const base = `${scheme}://${host}`; + + const url = new URL(path, base); + + return url.href; +} diff --git a/src/core/server/graph/common/context.ts b/src/core/server/graph/common/context.ts new file mode 100644 index 000000000..7e191e3f8 --- /dev/null +++ b/src/core/server/graph/common/context.ts @@ -0,0 +1,17 @@ +import { User } from "talk-server/models/user"; +import { Request } from "talk-server/types/express"; + +export interface CommonContextOptions { + user?: User; + req?: Request; +} + +export default class CommonContext { + public user?: User; + public req?: Request; + + constructor({ user, req }: CommonContextOptions) { + this.user = user; + this.req = req; + } +} diff --git a/src/core/server/graph/common/directives/auth.ts b/src/core/server/graph/common/directives/auth.ts new file mode 100644 index 000000000..2b21a6366 --- /dev/null +++ b/src/core/server/graph/common/directives/auth.ts @@ -0,0 +1,39 @@ +import { DirectiveResolverFn } from "graphql-tools"; + +import CommonContext from "talk-server/graph/common/context"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; + +export interface AuthDirectiveArgs { + roles?: GQLUSER_ROLE[]; + userIDField?: string; +} + +const auth: DirectiveResolverFn< + Record, + CommonContext +> = (next, src, { roles, userIDField }: AuthDirectiveArgs, { user }) => { + // If there is a user on the request. + if (user) { + // If the role and user owner checks are disabled, then allow them based on + // their authenticated status. + if (!roles && !userIDField) { + return next(); + } + + // And the user has the expected role. + if (roles && roles.includes(user.role)) { + // Let the request continue. + return next(); + } + + // Or the item is owned by the specific user. + if (userIDField && src[userIDField] && src[userIDField] === user.id) { + return next(); + } + } + + // TODO: return better error. + throw new Error("not authorized"); +}; + +export default auth; diff --git a/src/core/server/graph/common/middleware/index.ts b/src/core/server/graph/common/middleware/index.ts index 361bc7073..4d094f59a 100644 --- a/src/core/server/graph/common/middleware/index.ts +++ b/src/core/server/graph/common/middleware/index.ts @@ -5,7 +5,7 @@ import { GraphQLOptions, } from "apollo-server-express"; import { FieldDefinitionNode, GraphQLError, ValidationContext } from "graphql"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; // Sourced from: https://github.com/apollographql/apollo-server/blob/958846887598491fadea57b3f9373d129300f250/packages/apollo-server-core/src/ApolloServer.ts#L46-L57 const NoIntrospection = (context: ValidationContext) => ({ diff --git a/src/core/server/graph/common/scalars/cursor.spec.ts b/src/core/server/graph/common/scalars/cursor.spec.ts new file mode 100644 index 000000000..6d3b807f2 --- /dev/null +++ b/src/core/server/graph/common/scalars/cursor.spec.ts @@ -0,0 +1,136 @@ +import { Kind } from "graphql"; +import { DateTime } from "luxon"; + +import Cursor from "./cursor"; + +describe("parseLiteral", () => { + it("parses a date from a string", () => { + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "2018-07-16T18:34:26.744Z", + }) + ).toBeInstanceOf(Date); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "this-should-fail", + }) + ).toEqual(null); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "", + }) + ).toEqual(null); + }); + + it("parses a number from a string", () => { + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "20", + }) + ).toEqual(20); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "0", + }) + ).toEqual(0); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "null", + }) + ).toEqual(null); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "0", + }) + ).toEqual(0); + }); + + it("parses a number from a number", () => { + expect( + Cursor.parseLiteral({ + kind: Kind.INT, + value: "20", + }) + ).toEqual(20); + + expect( + Cursor.parseLiteral({ + kind: Kind.INT, + value: "0", + }) + ).toEqual(0); + + expect( + Cursor.parseLiteral({ + kind: Kind.INT, + value: "", + }) + ).toEqual(null); + }); + + it("does not parse unknown kinds", () => { + expect( + Cursor.parseLiteral({ + kind: Kind.FLOAT, + value: "0.0", + }) + ).toEqual(null); + }); +}); + +describe("serialize", () => { + it("renders native dates correctly", () => { + const date = new Date(); + const expected = date.toISOString(); + expect(Cursor.serialize(date)).toEqual(expected); + + expect(Cursor.serialize({})).toEqual(null); + }); + + it("renders luxon dates correctly", () => { + const date = DateTime.fromJSDate(new Date()); + const expected = date.toISO(); + expect(Cursor.serialize(date)).toEqual(expected); + }); + + it("renders numbers correctly", () => { + let value = 50; + let expected = "50"; + expect(Cursor.serialize(value)).toEqual(expected); + + value = 0; + expected = "0"; + expect(Cursor.serialize(value)).toEqual(expected); + + expect(Cursor.serialize(null)).toEqual(null); + }); +}); + +describe("parseValue", () => { + it("parses the string value of a Date", () => { + const date = new Date(); + const expected = date.toISOString(); + expect(Cursor.parseValue(expected)).toBeInstanceOf(Date); + }); + + it("parses the string value of a number", () => { + expect(Cursor.parseValue("0")).toEqual(0); + }); + + it("handles invalid properties", () => { + expect(Cursor.parseValue(null)).toEqual(null); + expect(Cursor.parseValue(2)).toEqual(null); + }); +}); diff --git a/src/core/server/graph/common/scalars/cursor.ts b/src/core/server/graph/common/scalars/cursor.ts index 23649e10b..e4aeaab74 100644 --- a/src/core/server/graph/common/scalars/cursor.ts +++ b/src/core/server/graph/common/scalars/cursor.ts @@ -1,11 +1,15 @@ import { GraphQLScalarType } from "graphql"; import { Kind } from "graphql/language"; import { DateTime } from "luxon"; + import { Cursor } from "talk-server/models/connection"; function parseIntegerCursor(value: string): number | null { try { const cursor = parseInt(value, 10); + if (isNaN(cursor)) { + return null; + } return cursor; } catch (err) { diff --git a/src/core/server/graph/common/subscriptions/pubsub.ts b/src/core/server/graph/common/subscriptions/pubsub.ts index 89ede4771..ae716a556 100644 --- a/src/core/server/graph/common/subscriptions/pubsub.ts +++ b/src/core/server/graph/common/subscriptions/pubsub.ts @@ -1,5 +1,5 @@ import { RedisPubSub } from "graphql-redis-subscriptions"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; import { createRedisClient } from "talk-server/services/redis"; export async function createPubSub(config: Config): Promise { diff --git a/src/core/server/graph/management/context.ts b/src/core/server/graph/management/context.ts index dda52cb5b..cdb25dbe3 100644 --- a/src/core/server/graph/management/context.ts +++ b/src/core/server/graph/management/context.ts @@ -1,13 +1,19 @@ import { Db } from "mongodb"; +import CommonContext from "talk-server/graph/common/context"; +import { Request } from "talk-server/types/express"; + export interface ManagementContextOptions { - db: Db; + mongo: Db; + req?: Request; } -export default class ManagementContext { - public db: Db; +export default class ManagementContext extends CommonContext { + public mongo: Db; - constructor({ db }: ManagementContextOptions) { - this.db = db; + constructor({ req, mongo }: ManagementContextOptions) { + super({ req }); + + this.mongo = mongo; } } diff --git a/src/core/server/graph/management/middleware.ts b/src/core/server/graph/management/middleware.ts index def73c2e9..6dd15e5da 100644 --- a/src/core/server/graph/management/middleware.ts +++ b/src/core/server/graph/management/middleware.ts @@ -1,13 +1,14 @@ import { GraphQLSchema } from "graphql"; import { Db } from "mongodb"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; import { graphqlMiddleware } from "talk-server/graph/common/middleware"; +import { Request } from "talk-server/types/express"; -import Context from "./context"; +import ManagementContext from "./context"; -export default (schema: GraphQLSchema, config: Config, db: Db) => - graphqlMiddleware(config, async () => ({ +export default (schema: GraphQLSchema, config: Config, mongo: Db) => + graphqlMiddleware(config, async (req: Request) => ({ schema, - context: new Context({ db }), + context: new ManagementContext({ req, mongo }), })); diff --git a/src/core/server/graph/management/resolvers/index.ts b/src/core/server/graph/management/resolvers/index.ts index 42b27b820..b1bcbcef8 100644 --- a/src/core/server/graph/management/resolvers/index.ts +++ b/src/core/server/graph/management/resolvers/index.ts @@ -1,5 +1,5 @@ -import Cursor from "../../common/scalars/cursor"; +import { GQLResolver } from "talk-server/graph/management/schema/__generated__/types"; -export default { - Cursor, -}; +const Resolvers: GQLResolver = {}; + +export default Resolvers; diff --git a/src/core/server/graph/management/schema/index.ts b/src/core/server/graph/management/schema/index.ts index 6dccc1c9c..41c6fe4ef 100644 --- a/src/core/server/graph/management/schema/index.ts +++ b/src/core/server/graph/management/schema/index.ts @@ -1,6 +1,8 @@ +import { IResolvers } from "graphql-tools"; + import { loadSchema } from "talk-common/graphql"; import resolvers from "talk-server/graph/management/resolvers"; export default function getManagementSchema() { - return loadSchema("management", resolvers); + return loadSchema("management", resolvers as IResolvers); } diff --git a/src/core/server/graph/management/schema/schema.graphql b/src/core/server/graph/management/schema/schema.graphql index 67655516d..e07ccbdd9 100644 --- a/src/core/server/graph/management/schema/schema.graphql +++ b/src/core/server/graph/management/schema/schema.graphql @@ -7,27 +7,22 @@ Time represented as an ISO8601 string. """ scalar Time -""" -Cursor represents a paginating cursor. -""" -scalar Cursor - ################################################################################ ## Tenant ################################################################################ type Tenant { - id: ID! + id: ID! - """ - organizationName is the name of the organization. - """ - organizationName: String + """ + organizationName is the name of the organization. + """ + organizationName: String - """ - organizationContactEmail is the email of the organization. - """ - organizationContactEmail: String + """ + organizationContactEmail is the email of the organization. + """ + organizationContactEmail: String } ################################################################################ @@ -35,5 +30,5 @@ type Tenant { ################################################################################ type Query { - tenant(id: ID!): Tenant + tenant(id: ID!): Tenant } diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index c49b3d49a..3eaf8e1ca 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -1,27 +1,49 @@ +import { Redis } from "ioredis"; import { Db } from "mongodb"; + +import CommonContext from "talk-server/graph/common/context"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; +import TenantCache from "talk-server/services/tenant/cache"; +import { Request } from "talk-server/types/express"; + import loaders from "./loaders"; import mutators from "./mutators"; export interface TenantContextOptions { - db: Db; + mongo: Db; + redis: Redis; tenant: Tenant; + tenantCache: TenantCache; + req?: Request; user?: User; } -export default class TenantContext { +export default class TenantContext extends CommonContext { public loaders: ReturnType; public mutators: ReturnType; - public db: Db; - public tenant: Tenant; + public mongo: Db; + public redis: Redis; public user?: User; + public tenant: Tenant; + public tenantCache: TenantCache; + + constructor({ + req, + user, + tenant, + mongo, + redis, + tenantCache, + }: TenantContextOptions) { + super({ user, req }); - constructor({ user, tenant, db }: TenantContextOptions) { this.tenant = tenant; + this.tenantCache = tenantCache; this.user = user; + this.mongo = mongo; + this.redis = redis; this.loaders = loaders(this); this.mutators = mutators(this); - this.db = db; } } diff --git a/src/core/server/graph/tenant/loaders/assets.ts b/src/core/server/graph/tenant/loaders/assets.ts index 55c50a0dc..79acdeda9 100644 --- a/src/core/server/graph/tenant/loaders/assets.ts +++ b/src/core/server/graph/tenant/loaders/assets.ts @@ -1,9 +1,17 @@ import DataLoader from "dataloader"; + import TenantContext from "talk-server/graph/tenant/context"; -import { Asset, retrieveManyAssets } from "talk-server/models/asset"; +import { + Asset, + FindOrCreateAssetInput, + retrieveManyAssets, +} from "talk-server/models/asset"; +import { findOrCreate } from "talk-server/services/assets"; export default (ctx: TenantContext) => ({ + findOrCreate: (input: FindOrCreateAssetInput) => + findOrCreate(ctx.mongo, ctx.tenant, input), asset: new DataLoader(ids => - retrieveManyAssets(ctx.db, ctx.tenant.id, ids) + retrieveManyAssets(ctx.mongo, ctx.tenant.id, ids) ), }); diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/comments.ts index 6649a50ab..111a40cd4 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -1,18 +1,54 @@ import DataLoader from "dataloader"; + import Context from "talk-server/graph/tenant/context"; import { - ConnectionInput, - retrieveAssetConnection, - retrieveMany, - retrieveRepliesConnection, + AssetToCommentsArgs, + CommentToRepliesArgs, + GQLCOMMENT_SORT, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { + retrieveCommentAssetConnection, + retrieveCommentRepliesConnection, + retrieveManyComments, } from "talk-server/models/comment"; export default (ctx: Context) => ({ comment: new DataLoader((ids: string[]) => - retrieveMany(ctx.db, ctx.tenant.id, ids) + retrieveManyComments(ctx.mongo, ctx.tenant.id, ids) ), - forAsset: (assetID: string, input: ConnectionInput) => - retrieveAssetConnection(ctx.db, ctx.tenant.id, assetID, input), - forParent: (assetID: string, parentID: string, input: ConnectionInput) => - retrieveRepliesConnection(ctx.db, ctx.tenant.id, assetID, parentID, input), + forAsset: ( + assetID: string, + // Apply the graph schema defaults at the loader. + { + first = 10, + orderBy = GQLCOMMENT_SORT.CREATED_AT_DESC, + after, + }: AssetToCommentsArgs + ) => + retrieveCommentAssetConnection(ctx.mongo, ctx.tenant.id, assetID, { + first, + orderBy, + after, + }), + forParent: ( + assetID: string, + parentID: string, + // Apply the graph schema defaults at the loader. + { + first = 10, + orderBy = GQLCOMMENT_SORT.CREATED_AT_DESC, + after, + }: CommentToRepliesArgs + ) => + retrieveCommentRepliesConnection( + ctx.mongo, + ctx.tenant.id, + assetID, + parentID, + { + first, + orderBy, + after, + } + ), }); diff --git a/src/core/server/graph/tenant/loaders/users.ts b/src/core/server/graph/tenant/loaders/users.ts index d875bc6b4..c684a9d23 100644 --- a/src/core/server/graph/tenant/loaders/users.ts +++ b/src/core/server/graph/tenant/loaders/users.ts @@ -1,9 +1,9 @@ import DataLoader from "dataloader"; import Context from "talk-server/graph/tenant/context"; -import { retrieveMany, User } from "talk-server/models/user"; +import { retrieveManyUsers, User } from "talk-server/models/user"; export default (ctx: Context) => ({ user: new DataLoader(ids => - retrieveMany(ctx.db, ctx.tenant.id, ids) + retrieveManyUsers(ctx.mongo, ctx.tenant.id, ids) ), }); diff --git a/src/core/server/graph/tenant/middleware.ts b/src/core/server/graph/tenant/middleware.ts index f583c6d7a..3be331d25 100644 --- a/src/core/server/graph/tenant/middleware.ts +++ b/src/core/server/graph/tenant/middleware.ts @@ -1,21 +1,41 @@ import { GraphQLSchema } from "graphql"; +import { Redis } from "ioredis"; import { Db } from "mongodb"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; import { graphqlMiddleware } from "talk-server/graph/common/middleware"; import { Request } from "talk-server/types/express"; import TenantContext from "./context"; -export default async (schema: GraphQLSchema, config: Config, db: Db) => { +export interface TenantGraphQLMiddlewareOptions { + schema: GraphQLSchema; + config: Config; + mongo: Db; + redis: Redis; +} + +export default async ({ + schema, + config, + mongo, + redis, +}: TenantGraphQLMiddlewareOptions) => { return graphqlMiddleware(config, async (req: Request) => { // Load the tenant and user from the request. - const { tenant, user } = req; + const { tenant, user, tenantCache } = req; // Return the graph options. return { schema, - context: new TenantContext({ db, tenant: tenant!, user }), + context: new TenantContext({ + req, + mongo, + redis, + tenant: tenant!, + user, + tenantCache, + }), }; }); }; diff --git a/src/core/server/graph/tenant/mutators/comment.ts b/src/core/server/graph/tenant/mutators/comment.ts index 1046930b9..1560b0095 100644 --- a/src/core/server/graph/tenant/mutators/comment.ts +++ b/src/core/server/graph/tenant/mutators/comment.ts @@ -1,16 +1,21 @@ import TenantContext from "talk-server/graph/tenant/context"; -import { CreateCommentInput } from "talk-server/graph/tenant/resolvers/mutation"; +import { GQLCreateCommentInput } from "talk-server/graph/tenant/schema/__generated__/types"; import { Comment } from "talk-server/models/comment"; import { create } from "talk-server/services/comments"; export default (ctx: TenantContext) => ({ - create: (input: CreateCommentInput): Promise => { - // FIXME: remove tenant + user ! - return create(ctx.db, ctx.tenant!.id, { - author_id: ctx.user!.id, - asset_id: input.assetID, - body: input.body, - parent_id: input.parentID, - }); + create: (input: GQLCreateCommentInput): Promise => { + return create( + ctx.mongo, + ctx.tenant, + ctx.user!, + { + author_id: ctx.user!.id, + asset_id: input.assetID, + body: input.body, + parent_id: input.parentID, + }, + ctx.req + ); }, }); diff --git a/src/core/server/graph/tenant/mutators/index.ts b/src/core/server/graph/tenant/mutators/index.ts index 25433bfbe..8ec9d1d02 100644 --- a/src/core/server/graph/tenant/mutators/index.ts +++ b/src/core/server/graph/tenant/mutators/index.ts @@ -1,6 +1,9 @@ import TenantContext from "talk-server/graph/tenant/context"; + import Comment from "./comment"; +import Settings from "./settings"; export default (ctx: TenantContext) => ({ Comment: Comment(ctx), + Settings: Settings(ctx), }); diff --git a/src/core/server/graph/tenant/mutators/settings.ts b/src/core/server/graph/tenant/mutators/settings.ts new file mode 100644 index 000000000..19a7c04e0 --- /dev/null +++ b/src/core/server/graph/tenant/mutators/settings.ts @@ -0,0 +1,11 @@ +import { isNull, omitBy } from "lodash"; + +import TenantContext from "talk-server/graph/tenant/context"; +import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Tenant } from "talk-server/models/tenant"; +import { update } from "talk-server/services/tenant"; + +export default ({ mongo, redis, tenantCache, tenant }: TenantContext) => ({ + update: (input: GQLSettingsInput): Promise => + update(mongo, redis, tenantCache, tenant, omitBy(input, isNull)), +}); diff --git a/src/core/server/graph/tenant/resolvers/asset.ts b/src/core/server/graph/tenant/resolvers/asset.ts index 7f17e8861..e5487ba08 100644 --- a/src/core/server/graph/tenant/resolvers/asset.ts +++ b/src/core/server/graph/tenant/resolvers/asset.ts @@ -1,10 +1,11 @@ -import Context from "talk-server/graph/tenant/context"; +import { GQLAssetTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; import { Asset } from "talk-server/models/asset"; -import { ConnectionInput } from "talk-server/models/comment"; -export default { - comments: async (asset: Asset, input: ConnectionInput, ctx: Context) => +const Asset: GQLAssetTypeResolver = { + comments: (asset, input, ctx) => ctx.loaders.Comments.forAsset(asset.id, input), // TODO: implement this. isClosed: () => false, }; + +export default Asset; diff --git a/src/core/server/graph/tenant/resolvers/auth_integrations.ts b/src/core/server/graph/tenant/resolvers/auth_integrations.ts new file mode 100644 index 000000000..164e15bfe --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/auth_integrations.ts @@ -0,0 +1,14 @@ +import { GQLAuthIntegrationsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { AuthIntegration, AuthIntegrations } from "talk-server/models/settings"; + +const disabled: AuthIntegration = { enabled: false }; + +const AuthIntegrations: GQLAuthIntegrationsTypeResolver = { + local: auth => auth.local || disabled, + sso: auth => auth.sso || disabled, + oidc: auth => auth.oidc || disabled, + google: auth => auth.google || disabled, + facebook: auth => auth.facebook || disabled, +}; + +export default AuthIntegrations; diff --git a/src/core/server/graph/tenant/resolvers/auth_settings.ts b/src/core/server/graph/tenant/resolvers/auth_settings.ts new file mode 100644 index 000000000..6847244a9 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/auth_settings.ts @@ -0,0 +1,8 @@ +import { GQLAuthSettingsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Auth } from "talk-server/models/settings"; + +const AuthSettings: GQLAuthSettingsTypeResolver = { + integrations: auth => auth.integrations, +}; + +export default AuthSettings; diff --git a/src/core/server/graph/tenant/resolvers/comment.ts b/src/core/server/graph/tenant/resolvers/comment.ts index d4033e0cb..3ecf35f41 100644 --- a/src/core/server/graph/tenant/resolvers/comment.ts +++ b/src/core/server/graph/tenant/resolvers/comment.ts @@ -1,11 +1,12 @@ -import Context from "talk-server/graph/tenant/context"; -import { Comment, ConnectionInput } from "talk-server/models/comment"; +import { GQLCommentTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Comment } from "talk-server/models/comment"; -export default { - createdAt: async (comment: Comment, _: any, ctx: Context) => - comment.created_at, - author: async (comment: Comment, _: any, ctx: Context) => +const Comment: GQLCommentTypeResolver = { + createdAt: comment => comment.created_at, + author: (comment, input, ctx) => ctx.loaders.Users.user.load(comment.author_id), - replies: async (comment: Comment, input: ConnectionInput, ctx: Context) => + replies: (comment, input, ctx) => ctx.loaders.Comments.forParent(comment.asset_id, comment.id, input), }; + +export default Comment; diff --git a/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts b/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts new file mode 100644 index 000000000..ddccd91ef --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts @@ -0,0 +1,10 @@ +import { GQLFacebookAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { FacebookAuthIntegration } from "talk-server/models/settings"; + +const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver< + FacebookAuthIntegration +> = { + config: auth => auth, +}; + +export default FacebookAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/google_auth_integration.ts b/src/core/server/graph/tenant/resolvers/google_auth_integration.ts new file mode 100644 index 000000000..6ca00f87a --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/google_auth_integration.ts @@ -0,0 +1,10 @@ +import { GQLGoogleAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { GoogleAuthIntegration } from "talk-server/models/settings"; + +const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver< + GoogleAuthIntegration +> = { + config: auth => auth, +}; + +export default GoogleAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index d1143f1ff..dd0a09082 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -1,13 +1,33 @@ -import Cursor from "../../common/scalars/cursor"; -import Asset from "./asset"; -import Comment from "./comment"; -import Mutation from "./mutation"; -import Query from "./query"; +import Cursor from "talk-server/graph/common/scalars/cursor"; +import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -export default { +import Asset from "./asset"; +import AuthIntegrations from "./auth_integrations"; +import AuthSettings from "./auth_settings"; +import Comment from "./comment"; +import FacebookAuthIntegration from "./facebook_auth_integration"; +import GoogleAuthIntegration from "./google_auth_integration"; +import LocalAuthIntegration from "./local_auth_integration"; +import Mutation from "./mutation"; +import OIDCAuthIntegration from "./oidc_auth_integration"; +import Profile from "./profile"; +import Query from "./query"; +import SSOAuthIntegration from "./sso_auth_integration"; + +const Resolvers: GQLResolver = { Asset, + AuthIntegrations, + AuthSettings, Comment, + FacebookAuthIntegration, + GoogleAuthIntegration, + LocalAuthIntegration, + OIDCAuthIntegration, + SSOAuthIntegration, Cursor, - Query, Mutation, + Profile, + Query, }; + +export default Resolvers; diff --git a/src/core/server/graph/tenant/resolvers/local_auth_integration.ts b/src/core/server/graph/tenant/resolvers/local_auth_integration.ts new file mode 100644 index 000000000..3b99eec6a --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/local_auth_integration.ts @@ -0,0 +1,8 @@ +import { GQLLocalAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { LocalAuthIntegration } from "talk-server/models/settings"; + +const LocalAuthIntegration: GQLLocalAuthIntegrationTypeResolver< + LocalAuthIntegration +> = {}; + +export default LocalAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/mutation.ts b/src/core/server/graph/tenant/resolvers/mutation.ts index 4d3b82a76..8f15117c9 100644 --- a/src/core/server/graph/tenant/resolvers/mutation.ts +++ b/src/core/server/graph/tenant/resolvers/mutation.ts @@ -1,26 +1,14 @@ -import { ClientMutationProps } from "talk-server/graph/common/resolvers/mutation"; -import TenantContext from "talk-server/graph/tenant/context"; -import { Comment } from "talk-server/models/comment"; +import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -export interface CreateCommentInput extends ClientMutationProps { - assetID: string; - parentID?: string; - body: string; -} - -export interface CreateCommentPayload extends ClientMutationProps { - comment: Comment; -} - -const Mutation = { - createComment: async ( - source: void, - input: CreateCommentInput, - ctx: TenantContext - ): Promise => ({ +const Mutation: GQLMutationTypeResolver = { + createComment: async (source, { input }, ctx) => ({ comment: await ctx.mutators.Comment.create(input), clientMutationId: input.clientMutationId, }), + updateSettings: async (source, { input }, ctx) => ({ + settings: await ctx.mutators.Settings.update(input.settings), + clientMutationId: input.clientMutationId, + }), }; export default Mutation; diff --git a/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts b/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts new file mode 100644 index 000000000..8595a39f4 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts @@ -0,0 +1,10 @@ +import { GQLOIDCAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { OIDCAuthIntegration } from "talk-server/models/settings"; + +const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver< + OIDCAuthIntegration +> = { + config: auth => auth, +}; + +export default OIDCAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/profile.ts b/src/core/server/graph/tenant/resolvers/profile.ts new file mode 100644 index 000000000..c1c6ad13b --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/profile.ts @@ -0,0 +1,21 @@ +import { GQLProfileTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; + +import { Profile } from "talk-server/models/user"; + +const resolveType: GQLProfileTypeResolver = profile => { + switch (profile.type) { + case "local": + return "LocalProfile"; + case "oidc": + return "OIDCProfile"; + case "sso": + return "SSOProfile"; + default: + // TODO: replace with better error. + throw new Error("invalid profile type"); + } +}; + +export default { + __resolveType: resolveType, +}; diff --git a/src/core/server/graph/tenant/resolvers/query.ts b/src/core/server/graph/tenant/resolvers/query.ts index ae76679a9..5dbd4bccc 100644 --- a/src/core/server/graph/tenant/resolvers/query.ts +++ b/src/core/server/graph/tenant/resolvers/query.ts @@ -1,10 +1,9 @@ -import TenantContext from "talk-server/graph/tenant/context"; +import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -export default { - asset: async ( - source: void, - { id }: { id: string; url: string }, - ctx: TenantContext - ) => ctx.loaders.Assets.asset.load(id), - settings: async (parent: any, args: any, ctx: TenantContext) => ctx.tenant, +const Query: GQLQueryTypeResolver = { + asset: (source, args, ctx) => ctx.loaders.Assets.findOrCreate(args), + settings: (source, args, ctx) => ctx.tenant, + me: (source, args, ctx) => ctx.user, }; + +export default Query; diff --git a/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts b/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts new file mode 100644 index 000000000..14b8e2b82 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts @@ -0,0 +1,10 @@ +import { GQLSSOAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { SSOAuthIntegration } from "talk-server/models/settings"; + +const SSOAuthIntegration: GQLSSOAuthIntegrationTypeResolver< + SSOAuthIntegration +> = { + config: auth => auth, +}; + +export default SSOAuthIntegration; diff --git a/src/core/server/graph/tenant/schema/index.ts b/src/core/server/graph/tenant/schema/index.ts index 844e9a4b4..251298ee0 100644 --- a/src/core/server/graph/tenant/schema/index.ts +++ b/src/core/server/graph/tenant/schema/index.ts @@ -1,6 +1,14 @@ +import { attachDirectiveResolvers, IResolvers } from "graphql-tools"; + import { loadSchema } from "talk-common/graphql"; +import auth from "talk-server/graph/common/directives/auth"; import resolvers from "talk-server/graph/tenant/resolvers"; export default function getTenantSchema() { - return loadSchema("tenant", resolvers); + const schema = loadSchema("tenant", resolvers as IResolvers); + + // Attach the directive resolvers. + attachDirectiveResolvers(schema, { auth }); + + return schema; } diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 5270f26fd..b045c572f 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -1,3 +1,16 @@ +################################################################################ +## Custom Directives +################################################################################ + +""" +auth is a directive that will enforce authorization rules on the schema +definition. It will restrict the viewer of the field based on roles or if the +`userIDField` is specified, it will see if the current users ID equals the field +specified. This allows users that own a specific resource (like a comment, or a +flag) see their own content, but restrict it to everyone else. +""" +directive @auth(roles: [USER_ROLE!], userIDField: String) on FIELD_DEFINITION + ################################################################################ ## Custom Scalar Types ################################################################################ @@ -12,6 +25,19 @@ Cursor represents a paginating cursor. """ scalar Cursor +################################################################################ +## Actions +################################################################################ + +enum ACTION_TYPE { + FLAG + DONTAGREE +} + +enum ACTION_ITEM_TYPE { + COMMENTS +} + ################################################################################ ## Settings ################################################################################ @@ -31,9 +57,9 @@ enum MODERATION_MODE { } """ -Wordlist describes all the available wordlists. +WordlistSettings describes all the available wordlists. """ -type Wordlist { +type WordlistSettings { """ banned words will by default reject the comment if it is found. """ @@ -45,12 +71,129 @@ type Wordlist { suspect: [String!]! } -# Settings stores the global settings for a given installation. +################################################################################ +## AuthSettings +################################################################################ + +########################## +## LocalAuthIntegration +########################## + +type LocalAuthIntegration { + enabled: Boolean! +} + +########################## +## SSOAuthIntegration +########################## + +type SSOAuthIntegrationConfig { + key: String! + + """ + displayNameEnable when enabled, will allow Users to set and view their + displayName's. + """ + displayNameEnable: Boolean! +} + +type SSOAuthIntegration { + enabled: Boolean! + config: SSOAuthIntegrationConfig @auth(roles: [ADMIN]) +} + +########################## +## OIDCAuthIntegration +########################## + +type OIDCAuthIntegrationConfig { + clientID: String! + clientSecret: String! + authorizationURL: String! + tokenURL: String! + + """ + displayNameEnable when enabled, will allow Users to set and view their + displayName's. + """ + displayNameEnable: Boolean! +} + +type OIDCAuthIntegrationOptions { + name: String! +} + +type OIDCAuthIntegration { + enabled: Boolean! + options: OIDCAuthIntegrationOptions + config: SSOAuthIntegrationConfig @auth(roles: [ADMIN]) +} + +########################## +## GoogleAuthIntegration +########################## + +type GoogleAuthIntegrationConfig { + clientID: String! + clientSecret: String! +} + +type GoogleAuthIntegration { + enabled: Boolean! + config: GoogleAuthIntegrationConfig @auth(roles: [ADMIN]) +} + +########################## +## FacebookAuthIntegration +########################## + +type FacebookAuthIntegrationConfig { + clientID: String! + clientSecret: String! +} + +type FacebookAuthIntegration { + enabled: Boolean! + config: FacebookAuthIntegrationConfig @auth(roles: [ADMIN]) +} + +type AuthIntegrations { + local: LocalAuthIntegration! + sso: SSOAuthIntegration! + oidc: OIDCAuthIntegration! + google: GoogleAuthIntegration! + facebook: FacebookAuthIntegration! +} + +""" +AuthSettings contains all the settings related to authentication and +authorization. +""" +type AuthSettings { + """ + integrations are the set of configurations for the variations of + authentication solutions. + """ + integrations: AuthIntegrations! +} + +################################################################################ +## Settings +################################################################################ + +""" +Settings stores the global settings for a given Tenant. +""" type Settings { + """ + domain is the domain that is associated with this Tenant. + """ + domain: String @auth(roles: [ADMIN]) + """ moderation is the moderation mode for all Asset's on the site. """ - moderation: MODERATION_MODE! + moderation: MODERATION_MODE @auth(roles: [ADMIN]) """ Enables a requirement for email confirmation before a user can login. @@ -87,13 +230,13 @@ type Settings { """ premodLinksEnable will put all comments that contain links into premod. """ - premodLinksEnable: Boolean! + premodLinksEnable: Boolean @auth(roles: [ADMIN]) """ autoCloseStream when true will auto close the stream when the `closeTimeout` amount of seconds have been reached. """ - autoCloseStream: Boolean! + autoCloseStream: Boolean! @auth(roles: [ADMIN]) """ customCssUrl is the URL of the custom CSS used to display on the frontend. @@ -152,18 +295,80 @@ type Settings { """ wordlist will return a given list of words. """ - wordlist: Wordlist! + wordlist: WordlistSettings @auth(roles: [ADMIN, MODERATOR]) """ domains will return a given list of whitelisted domains. """ - domains: [String!]! + domains: [String!] @auth(roles: [ADMIN]) + + """ + auth contains all the settings related to authentication and authorization. + """ + auth: AuthSettings! } ################################################################################ ## User ################################################################################ +enum USER_ROLE { + COMMENTER + STAFF + MODERATOR + ADMIN +} + +enum USER_USERNAME_STATUS { + """ + UNSET is used when the username can be changed, and does not necessarily + require moderator action to become active. This can be used when the user + signs up with a social login and has the option of setting their own + username. + """ + UNSET + + """ + SET is used when the username has been set for the first time, but cannot + change without the username being rejected by a moderator and that moderator + agreeing that the username should be allowed to change. + """ + SET + + """ + APPROVED is used when the username was changed, and subsequently approved by + said moderator. + """ + APPROVED + + """ + REJECTED is used when the username was changed, and subsequently rejected by + said moderator. + """ + REJECTED + + """ + CHANGED is used after a user has changed their username after it was + rejected. + """ + CHANGED +} + +type LocalProfile { + id: String! +} + +type OIDCProfile { + id: String! + provider: String! +} + +type SSOProfile { + id: String! +} + +union Profile = LocalProfile | OIDCProfile | SSOProfile + """ User is someone that leaves Comments, and logs in. """ @@ -176,7 +381,22 @@ type User { """ username is the name of the User visible to other Users. """ - username: String! + username: String + + """ + displayName is provided optionally when enabled and available. + """ + displayName: String + + """ + profiles is the array of profiles assigned to the user. + """ + profiles: [Profile!] @auth(roles: [ADMIN, MODERATOR], userIDField: "id") + + """ + role is the current role of the User. + """ + role: USER_ROLE! @auth(roles: [ADMIN, MODERATOR], userIDField: "id") } ################################################################################ @@ -184,8 +404,34 @@ type User { ################################################################################ enum COMMENT_STATUS { + """ + The comment is not PREMOD, but was not applied a moderation status by a + moderator. + """ NONE + + """ + The comment has been accepted by a moderator. + """ ACCEPTED + + """ + The comment has been rejected by a moderator. + """ + REJECTED + + """ + The comment was created while the asset's premoderation option was on, and + new comments that haven't been moderated yet are referred to as + "premoderated" or "premod" comments. + """ + PREMOD + + """ + SYSTEM_WITHHELD represents a comment that was withheld by the system because + it was flagged by an internal process for further review. + """ + SYSTEM_WITHHELD } """ @@ -391,9 +637,9 @@ type Query { assets(cursor: Cursor, limit: Int = 10): AssetsConnection """ - asset is the Asset specified by its ID. + asset is the Asset specified by its ID/URL. """ - asset(id: ID!): Asset + asset(id: ID, url: String): Asset """ me is the current logged in User. @@ -455,6 +701,66 @@ type CreateCommentPayload { clientMutationId: String! } +################## +## updateSettings +################## + +""" +SettingsInput is the partial type of the Settings type for performing mutations. +""" +input SettingsInput { + moderation: MODERATION_MODE + requireEmailConfirmation: Boolean + infoBoxEnable: Boolean + infoBoxContent: String + questionBoxEnable: Boolean + questionBoxContent: String + questionBoxIcon: String + premodLinksEnable: Boolean + autoCloseStream: Boolean + customCssUrl: String + closedTimeout: Int + closedMessage: String + disableCommenting: Boolean + disableCommentingMessage: String + editCommentWindowLength: Int + charCountEnable: Boolean + charCount: Int + organizationName: String + organizationContactEmail: String + # wordlist: WordlistSettings @auth(roles: [ADMIN, MODERATOR]) + domains: [String!] + # auth: AuthSettings! +} + +""" +UpdateSettingsInput provides the input for the updateSettings Mutation. +""" +input UpdateSettingsInput { + settings: SettingsInput! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +""" +UpdateSettingsPayload contains the updated Settings after the updateSettings +mutation. +""" +type UpdateSettingsPayload { + """ + settings is the updated Settings. + """ + settings: Settings + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + ################## ## Mutation ################## @@ -463,7 +769,12 @@ type Mutation { """ createComment will create a Comment as the current logged in User. """ - createComment(input: CreateCommentInput!): CreateCommentPayload + createComment(input: CreateCommentInput!): CreateCommentPayload @auth + + """ + updateSettings will update the Settings for the given Tenant. + """ + updateSettings(input: UpdateSettingsInput!): UpdateSettingsPayload @auth(roles: [ADMIN]) } ################################################################################ diff --git a/src/core/server/index.ts b/src/core/server/index.ts index fdf4f3aea..47a0c2db4 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -1,11 +1,14 @@ import express, { Express } from "express"; import http from "http"; +import config, { Config } from "talk-common/config"; +import { createJWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import getManagementSchema from "talk-server/graph/management/schema"; import { Schemas } from "talk-server/graph/schemas"; import getTenantSchema from "talk-server/graph/tenant/schema"; + +import TenantCache from "talk-server/services/tenant/cache"; import { attachSubscriptionHandlers, createApp, listenAndServe } from "./app"; -import config, { Config } from "./config"; import logger from "./logger"; import { createMongoDB } from "./services/mongodb"; import { createRedisClient } from "./services/redis"; @@ -63,6 +66,15 @@ class Server { // Setup Redis. const redis = await createRedisClient(config); + // Create the signing config. + const signingConfig = createJWTSigningConfig(this.config); + + // Create the TenantCache. + const tenantCache = new TenantCache(mongo, await createRedisClient(config)); + + // Prime the tenant cache so it'll be ready to serve now. + await tenantCache.primeAll(); + // Create the Talk App, branching off from the parent app. const app: Express = await createApp({ parent, @@ -70,6 +82,8 @@ class Server { redis, config: this.config, schemas: this.schemas, + signingConfig, + tenantCache, }); // Start the application and store the resulting http.Server. diff --git a/src/core/server/logger.ts b/src/core/server/logger.ts index c3b50c355..5332d9778 100644 --- a/src/core/server/logger.ts +++ b/src/core/server/logger.ts @@ -1,5 +1,12 @@ -import bunyan from "bunyan"; +import bunyan, { LogLevelString } from "bunyan"; -const logger = bunyan.createLogger({ name: "talk" }); +import config from "talk-common/config"; + +const logger = bunyan.createLogger({ + name: "talk", + serializers: bunyan.stdSerializers, + // TODO: (wyattjoh) move this into some managed instance? + level: config.get("logging_level") as LogLevelString, +}); export default logger; diff --git a/src/core/server/models/actions.ts b/src/core/server/models/actions.ts index ead2033eb..f304da550 100644 --- a/src/core/server/models/actions.ts +++ b/src/core/server/models/actions.ts @@ -1,3 +1,17 @@ -export interface ActionCounts { - [_: string]: number; +import { + GQLACTION_ITEM_TYPE, + GQLACTION_TYPE, +} from "talk-server/graph/tenant/schema/__generated__/types"; + +export type ActionCounts = Record; + +export interface Action { + readonly id: string; + action_type: GQLACTION_TYPE; + item_type: GQLACTION_ITEM_TYPE; + item_id: string; + group_id?: string; + user_id?: string; + created_at: Date; + metadata?: Record; } diff --git a/src/core/server/models/asset.ts b/src/core/server/models/asset.ts index e00dd2a56..40fbedf0a 100644 --- a/src/core/server/models/asset.ts +++ b/src/core/server/models/asset.ts @@ -1,10 +1,11 @@ import dotize from "dotize"; import { defaults } from "lodash"; import { Db } from "mongodb"; -import { Omit } from "talk-common/types"; -import { TenantResource } from "talk-server/models/tenant"; import uuid from "uuid"; -import Query from "./query"; + +import { Omit } from "talk-common/types"; +import { ModerationSettings } from "talk-server/models/settings"; +import { TenantResource } from "talk-server/models/tenant"; function collection(db: Db) { return db.collection>("assets"); @@ -25,50 +26,111 @@ export interface Asset extends TenantResource { publication_date?: Date; modified_date?: Date; created_at: Date; + + /** + * settings provides a point where the settings can be overriden for a + * specific Asset. + */ + settings?: Partial; } -export type CreateAssetInput = Pick; +export interface UpsertAssetInput { + id?: string; + url: string; +} -export async function createAsset( +export async function upsertAsset( db: Db, tenantID: string, - input: CreateAssetInput + { id, url }: UpsertAssetInput ) { const now = new Date(); - // Construct the filter. - const query = new Query(collection(db)).where({ - tenant_id: tenantID, - }); - if (input.id) { - query.where({ id: input.id }); - } else { - query.where({ url: input.url }); - } + // TODO: verify that the url for the given Asset is whitelisted by the tenant. - // Craft the update object. + // Create the asset, optionally sourcing the id from the input, additionally + // porting in the tenant_id. const update: { $setOnInsert: Asset } = { - $setOnInsert: defaults(input, { - id: uuid.v4(), - tenant_id: tenantID, - created_at: now, - }), + $setOnInsert: defaults( + { + url, + tenant_id: tenantID, + created_at: now, + }, + { id }, + { + id: uuid.v4(), + } + ), }; - // Perform the upsert operation. - const result = await collection(db).findOneAndUpdate(query.filter, update, { - // Create the object if it doesn't already exist. - upsert: true, - // False to return the updated document instead of the original - // document. - returnOriginal: false, - }); + // Perform the find and update operation to try and find and or create the + // asset. + const { value: asset } = await collection(db).findOneAndUpdate( + { url }, + update, + { + // Create the object if it doesn't already exist. + upsert: true, - return result.value || null; + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!asset) { + return null; + } + + if (!asset.scraped) { + // TODO: create scrape job to collect asset metadata + } + + return asset; +} + +export interface FindOrCreateAssetInput { + id?: string; + url?: string; +} + +export async function findOrCreateAsset( + db: Db, + tenantID: string, + { id, url }: FindOrCreateAssetInput +) { + if (id) { + if (url) { + // The URL was specified, this is an upsert operation. + return upsertAsset(db, tenantID, { + id, + url, + }); + } + + // The URL was not specified, this is a lookup operation. + return retrieveAsset(db, tenantID, id); + } + + // The ID was not specified, this is an upsert operation. Check to see that + // the URL exists. + if (!url) { + throw new Error("cannot upsert an asset without the url"); + } + + return upsertAsset(db, tenantID, { url }); +} + +export async function retrieveAssetByURL( + db: Db, + tenantID: string, + url: string +) { + return collection(db).findOne({ url, tenant_id: tenantID }); } export async function retrieveAsset(db: Db, tenantID: string, id: string) { - return await collection(db).findOne({ id, tenant_id: tenantID }); + return collection(db).findOne({ id, tenant_id: tenantID }); } export async function retrieveManyAssets( @@ -86,6 +148,21 @@ export async function retrieveManyAssets( return ids.map(id => assets.find(asset => asset.id === id) || null); } +export async function retrieveManyAssetsByURL( + db: Db, + tenantID: string, + urls: string[] +) { + const cursor = await collection(db).find({ + url: { $in: urls }, + tenant_id: tenantID, + }); + + const assets = await cursor.toArray(); + + return urls.map(url => assets.find(asset => asset.url === url) || null); +} + export type UpdateAssetInput = Omit< Partial, "id" | "tenant_id" | "url" | "created_at" @@ -100,7 +177,7 @@ export async function updateAsset( const result = await collection(db).findOneAndUpdate( { id, tenant_id: tenantID }, // Only update fields that have been updated. - { $set: dotize(update) }, + { $set: dotize.convert(update) }, // False to return the updated document instead of the original // document. { returnOriginal: false } diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index 43c588509..430f85225 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -1,6 +1,11 @@ -import { merge } from "lodash"; import { Db } from "mongodb"; +import uuid from "uuid"; + import { Omit, Sub } from "talk-common/types"; +import { + GQLCOMMENT_SORT, + GQLCOMMENT_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; import { ActionCounts } from "talk-server/models/actions"; import { Connection, @@ -11,7 +16,6 @@ import { } from "talk-server/models/connection"; import Query from "talk-server/models/query"; import { TenantResource } from "talk-server/models/tenant"; -import uuid from "uuid"; function collection(db: Db) { return db.collection>("comments"); @@ -23,35 +27,25 @@ export interface BodyHistoryItem { } export interface StatusHistoryItem { - status: CommentStatus; // TODO: migrate field + status: GQLCOMMENT_STATUS; // TODO: migrate field assigned_by?: string; created_at: Date; } -export enum CommentStatus { - ACCEPTED = "ACCEPTED", - REJECTED = "REJECTED", - PREMOD = "PREMOD", - SYSTEM_WITHHELD = "SYSTEM_WITHHELD", - NONE = "NONE", -} - export interface Comment extends TenantResource { readonly id: string; - parent_id?: string; + parent_id: string | null; author_id: string; asset_id: string; body: string; body_history: BodyHistoryItem[]; - status: CommentStatus; + status: GQLCOMMENT_STATUS; status_history: StatusHistoryItem[]; action_counts: ActionCounts; reply_count: number; created_at: Date; deleted_at?: Date; - metadata?: { - [_: string]: any; - }; + metadata?: Record; } export type CreateCommentInput = Omit< @@ -64,7 +58,7 @@ export type CreateCommentInput = Omit< | "status_history" >; -export async function create( +export async function createComment( db: Db, tenantID: string, input: CreateCommentInput @@ -96,25 +90,26 @@ export async function create( }; // Merge the defaults and the input together. - const comment: Readonly = merge({}, defaults, input); - - // TODO: Check for existence of the parent ID before we create the comment. - - // TODO: Check for existence of the asset ID before we create the comment. + const comment: Readonly = { + ...defaults, + ...input, + }; // Insert it into the database. await collection(db).insertOne(comment); - // TODO: update reply count of parent if exists. - return comment; } -export async function retrieve(db: Db, tenantID: string, id: string) { +export async function retrieveComment(db: Db, tenantID: string, id: string) { return collection(db).findOne({ id, tenant_id: tenantID }); } -export async function retrieveMany(db: Db, tenantID: string, ids: string[]) { +export async function retrieveManyComments( + db: Db, + tenantID: string, + ids: string[] +) { const cursor = await collection(db).find({ id: { $in: ids, @@ -127,16 +122,9 @@ export async function retrieveMany(db: Db, tenantID: string, ids: string[]) { return ids.map(id => comments.find(comment => comment.id === id) || null); } -export enum CommentSort { - CREATED_AT_DESC = "CREATED_AT_DESC", - CREATED_AT_ASC = "CREATED_AT_ASC", - REPLIES_DESC = "REPLIES_DESC", - RESPECT_DESC = "RESPECT_DESC", -} - export interface ConnectionInput { first: number; - orderBy: CommentSort; + orderBy: GQLCOMMENT_SORT; after?: Cursor; } @@ -144,11 +132,11 @@ function cursorGetterFactory( input: ConnectionInput ): NodeToCursorTransformer { switch (input.orderBy) { - case CommentSort.CREATED_AT_DESC: - case CommentSort.CREATED_AT_ASC: + case GQLCOMMENT_SORT.CREATED_AT_DESC: + case GQLCOMMENT_SORT.CREATED_AT_ASC: return comment => comment.created_at; - case CommentSort.REPLIES_DESC: - case CommentSort.RESPECT_DESC: + case GQLCOMMENT_SORT.REPLIES_DESC: + case GQLCOMMENT_SORT.RESPECT_DESC: return (_, index) => (input.after ? (input.after as number) : 0) + index + 1; } @@ -162,7 +150,7 @@ function cursorGetterFactory( * @param parentID the parent id for the comment to retrieve * @param input connection configuration */ -export async function retrieveRepliesConnection( +export async function retrieveCommentRepliesConnection( db: Db, tenantID: string, assetID: string, @@ -188,7 +176,7 @@ export async function retrieveRepliesConnection( * @param assetID the Asset id for the comment to retrieve * @param input connection configuration */ -export async function retrieveAssetConnection( +export async function retrieveCommentAssetConnection( db: Db, tenantID: string, assetID: string, @@ -254,25 +242,25 @@ async function retrieveConnection( function applyInputToQuery(input: ConnectionInput, query: Query) { switch (input.orderBy) { - case CommentSort.CREATED_AT_DESC: + case GQLCOMMENT_SORT.CREATED_AT_DESC: query.orderBy({ created_at: -1 }); if (input.after) { query.where({ created_at: { $lt: input.after as Date } }); } break; - case CommentSort.CREATED_AT_ASC: + case GQLCOMMENT_SORT.CREATED_AT_ASC: query.orderBy({ created_at: 1 }); if (input.after) { query.where({ created_at: { $gt: input.after as Date } }); } break; - case CommentSort.REPLIES_DESC: + case GQLCOMMENT_SORT.REPLIES_DESC: query.orderBy({ reply_count: -1, created_at: -1 }); if (input.after) { query.after(input.after as number); } break; - case CommentSort.RESPECT_DESC: + case GQLCOMMENT_SORT.RESPECT_DESC: query.orderBy({ "action_counts.respect": -1, created_at: -1 }); if (input.after) { query.after(input.after as number); diff --git a/src/core/server/models/settings.ts b/src/core/server/models/settings.ts new file mode 100644 index 000000000..217db50b9 --- /dev/null +++ b/src/core/server/models/settings.ts @@ -0,0 +1,246 @@ +import { + GQLMODERATION_MODE, + GQLUSER_ROLE, +} from "talk-server/graph/tenant/schema/__generated__/types"; + +export interface Wordlist { + banned: string[]; + suspect: string[]; +} + +export interface EmailDomainRuleCondition { + /** + * emailDomain is the domain name component of the email addresses that should + * match for this condition. + */ + emailDomain: string; + /** + * emailVerifiedRequired stipulates that this rule only applies when the user + * account has been marked as having their email address already verified. + */ + emailVerifiedRequired: boolean; +} + +/** + * RoleRule describes the role assignment for when a user logs into Talk, how + * they can have their account automatically upgraded to a specific role when + * the domain for their email matches the one provided. + */ +export interface RoleRule extends Partial { + /** + * role is the specific GQLUSER_ROLE that should be assigned to the newly + * created user depending on their email address. + */ + role: GQLUSER_ROLE; +} + +export interface AuthRules { + /** + * roles allow the configuration of automatic role assignment based on the + * user's email address. + */ + roles?: RoleRule[]; + + /** + * restrictTo when populated, will restrict which users can login using this + * integration. If a user successfully logs in using the OIDCStrategy, but + * does not match the following rules, the user will not be created. + */ + restrictTo?: EmailDomainRuleCondition[]; +} + +export interface AuthIntegration { + enabled: boolean; +} + +export interface DisplayNameAuthIntegration { + displayNameEnable: boolean; +} + +/** + * SSOAuthIntegration is an AuthIntegration that provides a secret to the admins + * of a tenant, where they can sign a SSO payload with it to provide to the + * embed to allow single sign on. + */ +export interface SSOAuthIntegration + extends AuthIntegration, + DisplayNameAuthIntegration { + key: string; +} + +/** + * OIDCAuthIntegration provides a way to store Open ID Connect credentials. This + * will be used in the admin to provide staff logins for users. + */ +export interface OIDCAuthIntegration + extends AuthIntegration, + DisplayNameAuthIntegration { + clientID: string; + clientSecret: string; + issuer: string; + authorizationURL: string; + jwksURI: string; + tokenURL: string; +} + +export interface FacebookAuthIntegration extends AuthIntegration { + clientID: string; + clientSecret: string; +} + +export interface GoogleAuthIntegration extends AuthIntegration { + clientID: string; + clientSecret: string; +} + +export type LocalAuthIntegration = AuthIntegration; + +/** + * AuthIntegrations describes all of the possible auth integration + * configurations. + */ +export interface AuthIntegrations { + /** + * local is the auth integration for the email/password based auth. + */ + local: LocalAuthIntegration; + + /** + * sso is the external auth integration for the single sign on auth. + */ + sso?: SSOAuthIntegration; + + /** + * sso is the external auth integration for the OpenID Connect auth. + */ + oidc?: OIDCAuthIntegration; + + /** + * sso is the external auth integration for the Google auth. + */ + google?: GoogleAuthIntegration; + + /** + * sso is the external auth integration for the Facebook auth. + */ + facebook?: FacebookAuthIntegration; +} + +export interface Auth { + integrations: AuthIntegrations; +} + +/** + * Akismet provides integration with the Akismet Spam detection service. + */ +export interface AkismetIntegration { + /** + * When true, it will enable comments to be checked by Akismet. + */ + enabled: boolean; + + /** + * The key for the Akismet integration. + */ + key?: string; + + /** + * The site (blog) for the Akismet integration. + */ + site?: string; +} + +export interface ExternalIntegrations { + /** + * akismet provides integration with the Akismet Spam detection service. + */ + akismet: AkismetIntegration; +} + +export interface ModerationSettings { + moderation: GQLMODERATION_MODE; + requireEmailConfirmation: boolean; + infoBoxEnable: boolean; + infoBoxContent?: string; + questionBoxEnable: boolean; + questionBoxIcon?: string; + questionBoxContent?: string; + premodLinksEnable: boolean; + autoCloseStream: boolean; + closedTimeout: number; + closedMessage?: string; + disableCommenting: boolean; + disableCommentingMessage?: string; + charCountEnable: boolean; + charCount?: number; +} + +/** + * KarmaThreshold defines the bounds for which a User will become unreliable or + * reliable based on their karma score. If the score is equal or less than the + * unreliable value, they are unreliable. If the score is equal or more than the + * reliable value, they are reliable. If they are neither reliable or unreliable + * then they are neutral. + */ +export interface KarmaThreshold { + reliable: number; + unreliable: number; +} + +export interface KarmaThresholds { + /** + * flag represents karma settings in relation to how well a User's flagging + * ability aligns with the moderation decicions made by moderators. + */ + flag: KarmaThreshold; + + /** + * comment represents the karma setting in relation to how well a User's comments are moderated. + */ + comment: KarmaThreshold; +} + +export interface Karma { + /** + * When true, checks will be completed to ensure that the Karma checks are + * completed. + */ + enabled: boolean; + + /** + * karmaThresholds contains the currently set thresholds for triggering Trust + * beheviour. + */ + thresholds: KarmaThresholds; +} + +export interface Settings extends ModerationSettings { + customCssUrl?: string; + + /** + * editCommentWindowLength is the length of time (in milliseconds) after a + * comment is posted that it can still be edited by the author. + */ + editCommentWindowLength: number; + + /** + * karma is the set of settings related to how user Trust and Karma are + * handled. + */ + karma: Karma; + + /** + * wordlist stores all the banned/suspect words. + */ + wordlist: Wordlist; + + /** + * Set of configured authentication integrations. + */ + auth: Auth; + + /** + * Various integrations with external services. + */ + integrations: ExternalIntegrations; +} diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index 8e41a1bf2..bd67c99b4 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -1,9 +1,11 @@ import dotize from "dotize"; -import { merge } from "lodash"; import { Db } from "mongodb"; -import { Sub } from "talk-common/types"; import uuid from "uuid"; +import { Omit, Sub } from "talk-common/types"; +import { GQLMODERATION_MODE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Settings } from "talk-server/models/settings"; + function collection(db: Db) { return db.collection>("tenants"); } @@ -12,51 +14,21 @@ export interface TenantResource { readonly tenant_id: string; } -export interface Wordlist { - banned: string[]; - suspect: string[]; -} - -export enum Moderation { - PRE = "PRE", - POST = "POST", -} - -export interface Tenant { +/** + * Tenant describes a given Tenant on Talk that has Assets, Comments, and Users. + */ +export interface Tenant extends Settings { readonly id: string; // Domain is set when the tenant is created, and is used to retrieve the // specific tenant that the API request pertains to. domain: string; - moderation: Moderation; - requireEmailConfirmation: boolean; - infoBoxEnable: boolean; - infoBoxContent?: string; - questionBoxEnable: boolean; - questionBoxIcon?: string; - questionBoxContent?: string; - premodLinksEnable: boolean; - autoCloseStream: boolean; - closedTimeout: number; - closedMessage?: string; - customCssUrl?: string; - disableCommenting: boolean; - disableCommentingMessage?: string; + // domains is the list of domains that are allowed to have the iframe load on. + domains: string[]; - // editCommentWindowLength is the length of time (in milliseconds) after a - // comment is posted that it can still be edited by the author. - editCommentWindowLength: number; - charCountEnable: boolean; - charCount?: number; organizationName: string; organizationContactEmail: string; - - // wordlist stores all the banned/suspect words. - wordlist: Wordlist; - - // domains is the set of whitelisted domains. - domains: string[]; } /** @@ -81,7 +53,7 @@ export async function createTenant(db: Db, input: CreateTenantInput) { id: uuid.v4(), // Default to post moderation. - moderation: Moderation.POST, + moderation: GQLMODERATION_MODE.POST, // Email confirmation is default off. requireEmailConfirmation: false, @@ -99,10 +71,34 @@ export async function createTenant(db: Db, input: CreateTenantInput) { suspect: [], banned: [], }, + auth: { + integrations: { + local: { + enabled: true, + }, + }, + }, + karma: { + enabled: true, + thresholds: { + // By default, flaggers are reliable after one correct flag, and + // unreliable if there is an incorrect flag. + flag: { reliable: 1, unreliable: -1 }, + comment: { reliable: 1, unreliable: -1 }, + }, + }, + integrations: { + akismet: { + enabled: false, + }, + }, }; // Create the new Tenant by merging it together with the defaults. - const tenant: Readonly = merge({}, input, defaults); + const tenant: Readonly = { + ...defaults, + ...input, + }; // Insert the Tenant into the database. await collection(db).insert(tenant); @@ -114,7 +110,7 @@ export async function retrieveTenantByDomain(db: Db, domain: string) { return collection(db).findOne({ domain }); } -export async function retrieve(db: Db, id: string) { +export async function retrieveTenant(db: Db, id: string) { return collection(db).findOne({ id }); } @@ -150,16 +146,18 @@ export async function retrieveAllTenants(db: Db) { .toArray(); } +export type UpdateTenantInput = Omit, "id" | "domain">; + export async function updateTenant( db: Db, id: string, - update: Partial + update: UpdateTenantInput ) { // Get the tenant from the database. const result = await collection(db).findOneAndUpdate( { id }, // Only update fields that have been updated. - { $set: dotize(update) }, + { $set: dotize.convert(update) }, // False to return the updated document instead of the original // document. { returnOriginal: false } diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 6812fefce..9b1d04343 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -1,112 +1,106 @@ -import { merge } from "lodash"; +import bcrypt from "bcryptjs"; import { Db } from "mongodb"; -import { Omit, Sub } from "talk-common/types"; -import { ActionCounts } from "talk-server/models/actions"; -import { TenantResource } from "talk-server/models/tenant"; import uuid from "uuid"; +import { Omit, Sub } from "talk-common/types"; +import { + GQLUSER_ROLE, + GQLUSER_USERNAME_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { ActionCounts } from "talk-server/models/actions"; +import { FilterQuery } from "talk-server/models/query"; +import { TenantResource } from "talk-server/models/tenant"; + function collection(db: Db) { return db.collection>("users"); } -export interface Profile { - readonly id: string; - provider: string; +export interface LocalProfile { + type: "local"; + id: string; } +export interface OIDCProfile { + type: "oidc"; + id: string; + issuer: string; + audience: string; +} + +export interface SSOProfile { + type: "sso"; + id: string; +} + +export type Profile = LocalProfile | OIDCProfile | SSOProfile; + export interface Token { readonly id: string; name: string; active: boolean; } -export enum UserUsernameStatus { - // UNSET is used when the username can be changed, and does not necessarily - // require moderator action to become active. This can be used when the user - // signs up with a social login and has the option of setting their own - // username. - UNSET = "UNSET", - - // SET is used when the username has been set for the first time, but cannot - // change without the username being rejected by a moderator and that moderator - // agreeing that the username should be allowed to change. - SET = "SET", - - // APPROVED is used when the username was changed, and subsequently approved by - // said moderator. - APPROVED = "APPROVED", - - // REJECTED is used when the username was changed, and subsequently rejected by - // said moderator. - REJECTED = "REJECTED", - - // CHANGED is used after a user has changed their username after it was - // rejected. - CHANGED = "CHANGED", -} - -export enum UserRole { - ADMIN = "ADMIN", - MODERATOR = "MODERATOR", - STAFF = "STAFF", - COMMENTER = "COMMENTER", -} - export interface UserStatusHistory { - status: T; // TODO: migrate field + status: T; assigned_by?: string; - reason?: string; // TODO: migrate field + reason?: string; created_at: Date; } export interface UserStatusItem { - status: T; // TODO: migrate field + status: T; history: Array>; } export interface UserStatus { - username: UserStatusItem; + username: UserStatusItem; banned: UserStatusItem; suspension: UserStatusItem; } export interface User extends TenantResource { readonly id: string; - username: string; + username: string | null; + displayName?: string; password?: string; + avatar?: string; + email?: string; + email_verified?: boolean; profiles: Profile[]; tokens: Token[]; - role: UserRole; + role: GQLUSER_ROLE; status: UserStatus; action_counts: ActionCounts; - ignored_users: string[]; // TODO: migrate field + ignored_users: string[]; created_at: Date; } -export type CreateUserInput = Omit< +export type UpsertUserInput = Omit< User, | "id" | "tenant_id" | "tokens" | "status" - | "role" | "action_counts" | "ignored_users" | "created_at" >; -export async function create(db: Db, tenantID: string, input: CreateUserInput) { +export async function upsertUser( + db: Db, + tenantID: string, + input: UpsertUserInput +) { const now = new Date(); - // // Pull out some useful properties from the input. - // const { body, status } = input; + // Create a new ID for the user. + const id = uuid.v4(); // default are the properties set by the application when a new user is // created. - const defaults: Sub = { - id: uuid.v4(), + const defaults: Sub = { + id, tenant_id: tenantID, - role: UserRole.COMMENTER, tokens: [], action_counts: {}, ignored_users: [], @@ -120,27 +114,87 @@ export async function create(db: Db, tenantID: string, input: CreateUserInput) { history: [], }, username: { - status: UserUsernameStatus.SET, + status: input.username + ? GQLUSER_USERNAME_STATUS.SET + : GQLUSER_USERNAME_STATUS.UNSET, history: [], }, }, created_at: now, }; + let hashedPassword; + if (input.password) { + // Hash the user's password with bcrypt. + hashedPassword = await bcrypt.hash(input.password, 10); + } + // Merge the defaults and the input together. - const user: Readonly = merge({}, defaults, input); + const user: Readonly = { + ...defaults, + ...input, - // Insert it into the database. - await collection(db).insertOne(user); + // Specified last in the merge call, it will override any existing password + // entry if it is defined. + password: hashedPassword, + }; - return user; + // Create a query that will utilize a findOneAndUpdate to facilitate an upsert + // operation to ensure no user has the same profile and/or email address. If + // any user is found to have the same profile as any of the profiles specified + // in the new user object, then we should error here. + const filter = createUpsertUserFilter(user); + + // Create the upsert/update operation. + const update: { $setOnInsert: Readonly } = { + $setOnInsert: user, + }; + + // Insert it into the database. This may throw an error. + const result = await collection(db).findOneAndUpdate(filter, update, { + // We are using this to create a user, so we need to upsert it. + upsert: true, + + // False to return the updated document instead of the original document. + // This lets us detect if the document was updated or not. + returnOriginal: false, + }); + + // Check to see if this was a new user that was upserted, or one was found + // that matched existing records. We are sure here that the record exists + // because we're returning the updated document and performing an upsert + // operation. + if (result.value!.id !== id) { + // TODO: return better error. + throw new Error("user already found"); + } + + return result.value!; } -export async function retrieve(db: Db, tenantID: string, id: string) { +const createUpsertUserFilter = (user: Readonly) => { + const query: FilterQuery = { + // Query by the profiles if the user is being created with one. + $or: user.profiles.map(profile => ({ profiles: { $elemMatch: profile } })), + }; + + if (user.email) { + // Query by the email address if the user is being created with one. + query.$or.push({ email: user.email }); + } + + return query; +}; + +export async function retrieveUser(db: Db, tenantID: string, id: string) { return collection(db).findOne({ id, tenant_id: tenantID }); } -export async function retrieveMany(db: Db, tenantID: string, ids: string[]) { +export async function retrieveManyUsers( + db: Db, + tenantID: string, + ids: string[] +) { const cursor = await collection(db).find({ id: { $in: ids, @@ -153,11 +207,24 @@ export async function retrieveMany(db: Db, tenantID: string, ids: string[]) { return ids.map(id => users.find(comment => comment.id === id) || null); } -export async function updateRole( +export async function retrieveUserWithProfile( + db: Db, + tenantID: string, + profile: Profile +) { + return collection(db).findOne({ + tenant_id: tenantID, + profiles: { + $elemMatch: profile, + }, + }); +} + +export async function updateUserRole( db: Db, tenantID: string, id: string, - role: UserRole + role: GQLUSER_ROLE ) { const result = await collection(db).findOneAndUpdate( { id, tenant_id: tenantID }, @@ -167,3 +234,11 @@ export async function updateRole( return result.value || null; } + +export async function verifyUserPassword(user: User, password: string) { + if (user.password) { + return bcrypt.compare(password, user.password); + } + + return false; +} diff --git a/src/core/server/services/assets/index.ts b/src/core/server/services/assets/index.ts new file mode 100644 index 000000000..8c43269d9 --- /dev/null +++ b/src/core/server/services/assets/index.ts @@ -0,0 +1,21 @@ +import { Db } from "mongodb"; + +import { + findOrCreateAsset, + FindOrCreateAssetInput, +} from "talk-server/models/asset"; +import { Tenant } from "talk-server/models/tenant"; + +export type FindOrCreateAsset = FindOrCreateAssetInput; + +export async function findOrCreate( + db: Db, + tenant: Tenant, + input: FindOrCreateAsset +) { + // TODO: check to see if the tenant has enabled lazy asset creation. + + const asset = await findOrCreateAsset(db, tenant.id, input); + + return asset; +} diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index 95a9001cb..6bacbd415 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -1,12 +1,16 @@ import { Db } from "mongodb"; import { Omit } from "talk-common/types"; +import { retrieveAsset } from "talk-server/models/asset"; import { - Comment, - CommentStatus, - create as createComment, + createComment, CreateCommentInput, + retrieveComment, } from "talk-server/models/comment"; +import { Tenant } from "talk-server/models/tenant"; +import { User } from "talk-server/models/user"; +import { processForModeration } from "talk-server/services/comments/moderation"; +import { Request } from "talk-server/types/express"; export type CreateComment = Omit< CreateCommentInput, @@ -14,16 +18,51 @@ export type CreateComment = Omit< >; export async function create( - db: Db, - tenantID: string, - input: CreateComment -): Promise { - // TODO: run the comment through the moderation phases. - const comment = await createComment(db, tenantID, { - status: CommentStatus.ACCEPTED, + mongo: Db, + tenant: Tenant, + author: User, + input: CreateComment, + req?: Request +) { + const asset = await retrieveAsset(mongo, tenant.id, input.asset_id); + if (!asset) { + // TODO: (wyattjoh) return better error. + throw new Error("asset referenced does not exist"); + } + + // TODO: (wyattjoh) Check that the asset was visable. + + if (input.parent_id) { + // Check to see that the reference parent ID exists. + const parent = await retrieveComment(mongo, tenant.id, input.parent_id); + if (!parent) { + // TODO: (wyattjoh) return better error. + throw new Error("parent comment referenced does not exist"); + } + + // TODO: (wyattjoh) Check that the parent comment was visible. + } + + // Run the comment through the moderation phases. + const { status } = await processForModeration({ + asset, + tenant, + comment: input, + author, + req, + }); + + // TODO: (wyattjoh) use the actions somehow. + + const comment = await createComment(mongo, tenant.id, { + status, action_counts: {}, ...input, }); + if (input.parent_id) { + // TODO: update reply count of parent. + } + return comment; } diff --git a/src/core/server/services/comments/moderation/index.ts b/src/core/server/services/comments/moderation/index.ts new file mode 100644 index 000000000..cfd7f2c80 --- /dev/null +++ b/src/core/server/services/comments/moderation/index.ts @@ -0,0 +1,75 @@ +import { Omit, Promiseable } from "talk-common/types"; +import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Action } from "talk-server/models/actions"; +import { Asset } from "talk-server/models/asset"; +import { Tenant } from "talk-server/models/tenant"; +import { CreateComment } from "talk-server/services/comments"; + +import { User } from "talk-server/models/user"; +import { Request } from "talk-server/types/express"; +import { moderationPhases } from "./phases"; + +// TODO: (wyattjoh) move into actions module. +export type CreateAction = Omit< + Action, + "id" | "item_type" | "item_id" | "created_at" +>; + +export interface PhaseResult { + actions: CreateAction[]; + status: GQLCOMMENT_STATUS; +} + +export interface ModerationPhaseContext { + asset: Asset; + tenant: Tenant; + comment: CreateComment; + author: User; + req?: Request; +} + +export type ModerationPhase = ( + context: ModerationPhaseContext +) => Promiseable; + +export type IntermediatePhaseResult = Partial | void; + +export type IntermediateModerationPhase = ( + context: ModerationPhaseContext +) => Promiseable; + +/** + * compose will create a moderation pipeline for which is executable with the + * passed actions. + */ +const compose = ( + phases: IntermediateModerationPhase[] +): ModerationPhase => async context => { + const actions: CreateAction[] = []; + + // Loop over all the moderation phases and see if we've resolved the status. + for (const phase of phases) { + const result = await phase(context); + if (result) { + if (result.actions) { + actions.push(...result.actions); + } + + // If this result contained a status, then we've finished resolving + // phases! + const { status } = result; + if (status) { + return { status, actions }; + } + } + } + + // If we didn't determine a different comment from a previous itteration, set + // it to 'NONE'. + return { status: GQLCOMMENT_STATUS.NONE, actions }; +}; + +/** + * process the comment and return moderation details. + */ +export const processForModeration: ModerationPhase = compose(moderationPhases); diff --git a/src/core/server/services/comments/moderation/phases/assetClosed.spec.ts b/src/core/server/services/comments/moderation/phases/assetClosed.spec.ts new file mode 100644 index 000000000..31d5a1f2f --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/assetClosed.spec.ts @@ -0,0 +1,42 @@ +import { Asset } from "talk-server/models/asset"; +import { Comment } from "talk-server/models/comment"; +import { Tenant } from "talk-server/models/tenant"; +import { User } from "talk-server/models/user"; +import { assetClosed } from "talk-server/services/comments/moderation/phases/assetClosed"; + +describe("assetClosed", () => { + it("throws an error when the asset is closed", () => { + const asset = { closedAt: new Date() }; + + expect(() => + assetClosed({ + asset: asset as Asset, + tenant: (null as any) as Tenant, + comment: (null as any) as Comment, + author: (null as any) as User, + }) + ).toThrow(); + }); + + it("does not throw an error when the asset is not closed", () => { + const now = new Date(); + + expect( + assetClosed({ + asset: { closedAt: new Date(now.getTime() + 60000) } as Asset, + tenant: (null as any) as Tenant, + comment: (null as any) as Comment, + author: (null as any) as User, + }) + ).toBeUndefined(); + + expect( + assetClosed({ + asset: {} as Asset, + tenant: (null as any) as Tenant, + comment: (null as any) as Comment, + author: (null as any) as User, + }) + ).toBeUndefined(); + }); +}); diff --git a/src/core/server/services/comments/moderation/phases/assetClosed.ts b/src/core/server/services/comments/moderation/phases/assetClosed.ts new file mode 100644 index 000000000..0755949ac --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/assetClosed.ts @@ -0,0 +1,12 @@ +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; + +// This phase checks to see if the asset being processed is closed or not. +export const assetClosed: IntermediateModerationPhase = ({ asset }) => { + // Check to see if the asset has closed commenting... + if (asset.closedAt && asset.closedAt.valueOf() <= Date.now()) { + // TODO: (wyattjoh) return better error. + throw new Error("asset is currently closed for commenting"); + } + + return; +}; diff --git a/src/core/server/services/comments/moderation/phases/commentLength.ts b/src/core/server/services/comments/moderation/phases/commentLength.ts new file mode 100644 index 000000000..d714950b2 --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/commentLength.ts @@ -0,0 +1,45 @@ +import { + GQLACTION_TYPE, + GQLCOMMENT_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { ModerationSettings } from "talk-server/models/settings"; +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; + +const testCharCount = (settings: Partial, length: number) => + settings.charCountEnable && settings.charCount && length > settings.charCount; + +export const commentLength: IntermediateModerationPhase = async ({ + asset, + tenant, + comment, +}) => { + const length = comment.body.length; + + // Check to see if the body is too short, if it is, then complain about it! + if (length < 2) { + // TODO: (wyattjoh) return better error. + throw new Error("comment body too short"); + } + + // Reject if the comment is too long + if ( + testCharCount(tenant, length) || + (asset.settings && testCharCount(asset.settings, length)) + ) { + // Add the flag related to Trust to the comment. + return { + status: GQLCOMMENT_STATUS.REJECTED, + actions: [ + { + action_type: GQLACTION_TYPE.FLAG, + group_id: "BODY_COUNT", + metadata: { + count: length, + }, + }, + ], + }; + } + + return; +}; diff --git a/src/core/server/services/comments/moderation/phases/commentingDisabled.ts b/src/core/server/services/comments/moderation/phases/commentingDisabled.ts new file mode 100644 index 000000000..742d97974 --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/commentingDisabled.ts @@ -0,0 +1,19 @@ +import { ModerationSettings } from "talk-server/models/settings"; +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; + +const testDisabledCommenting = (settings: Partial) => + settings.disableCommenting; + +export const commentingDisabled: IntermediateModerationPhase = ({ + asset, + tenant, +}) => { + // Check to see if the asset has closed commenting. + if ( + testDisabledCommenting(tenant) || + (asset.settings && testDisabledCommenting(asset.settings)) + ) { + // TODO: (wyattjoh) return better error. + throw new Error("commenting has been disabled tenant wide"); + } +}; diff --git a/src/core/server/services/comments/moderation/phases/index.ts b/src/core/server/services/comments/moderation/phases/index.ts new file mode 100644 index 000000000..6cb656903 --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/index.ts @@ -0,0 +1,26 @@ +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; + +import { premod } from "talk-server/services/comments/moderation/phases/premod"; +import { assetClosed } from "./assetClosed"; +import { commentingDisabled } from "./commentingDisabled"; +import { commentLength } from "./commentLength"; +import { karma } from "./karma"; +import { links } from "./links"; +import { spam } from "./spam"; +import { staff } from "./staff"; +import { wordlist } from "./wordlist"; + +/** + * The moderation phases to apply for each comment being processed. + */ +export const moderationPhases: IntermediateModerationPhase[] = [ + commentLength, + assetClosed, + commentingDisabled, + wordlist, + staff, + links, + karma, + spam, + premod, +]; diff --git a/src/core/server/services/comments/moderation/phases/karma.ts b/src/core/server/services/comments/moderation/phases/karma.ts new file mode 100755 index 000000000..3a4d8fa84 --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/karma.ts @@ -0,0 +1,40 @@ +import { + GQLACTION_TYPE, + GQLCOMMENT_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; +import { + getCommentTrustScore, + isReliableCommenter, +} from "talk-server/services/users/karma"; + +// This phase checks to see if the user making the comment is allowed to do so +// considering their reliability (Trust) status. +export const karma: IntermediateModerationPhase = ({ tenant, author }) => { + // If the user is not a reliable commenter (passed the unreliability + // threshold by having too many rejected comments) then we can change the + // status of the comment to `SYSTEM_WITHHELD`, therefore pushing the user's + // comments away from the public eye until a moderator can manage them. This + // of course can only be applied if the comment's current status is `NONE`, + // we don't want to interfere if the comment was rejected. + if ( + tenant.karma.enabled && + isReliableCommenter(tenant.karma.thresholds, author) === false + ) { + // Add the flag related to Trust to the comment. + return { + status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, + actions: [ + { + action_type: GQLACTION_TYPE.FLAG, + group_id: "TRUST", + metadata: { + trust: getCommentTrustScore(author), + }, + }, + ], + }; + } + + return; +}; diff --git a/src/core/server/services/comments/moderation/phases/links.ts b/src/core/server/services/comments/moderation/phases/links.ts new file mode 100755 index 000000000..3db715d5f --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/links.ts @@ -0,0 +1,49 @@ +import linkify from "linkify-it"; +import tlds from "tlds"; + +import { + GQLACTION_TYPE, + GQLCOMMENT_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { ModerationSettings } from "talk-server/models/settings"; +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; + +/** + * The preloaded linkify instance with common tlds. + */ +const testForLinks = linkify().tlds(tlds); + +const testPremodLinksEnable = ( + settings: Partial, + body: string +) => settings.premodLinksEnable && testForLinks.test(body); + +// This phase checks the comment if it has any links in it if the check is +// enabled. +export const links: IntermediateModerationPhase = ({ + asset, + tenant, + comment, + author, +}) => { + if ( + testPremodLinksEnable(tenant, comment.body) || + (asset.settings && testPremodLinksEnable(asset.settings, comment.body)) + ) { + // Add the flag related to Trust to the comment. + return { + status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, + actions: [ + { + action_type: GQLACTION_TYPE.FLAG, + group_id: "LINKS", + metadata: { + links: comment.body, + }, + }, + ], + }; + } + + return; +}; diff --git a/src/core/server/services/comments/moderation/phases/premod.ts b/src/core/server/services/comments/moderation/phases/premod.ts new file mode 100755 index 000000000..c12fb582d --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/premod.ts @@ -0,0 +1,28 @@ +import { + GQLCOMMENT_STATUS, + GQLMODERATION_MODE, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { ModerationSettings } from "talk-server/models/settings"; +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; + +const testModerationMode = (settings: Partial) => + settings.moderation === GQLMODERATION_MODE.PRE; + +// This phase checks to see if the settings have premod enabled, if they do, +// the comment is premod, otherwise, it's just none. +export const premod: IntermediateModerationPhase = ({ asset, tenant }) => { + // If the settings say that we're in premod mode, then the comment is in + // premod status. + + // TODO: (wyattjoh) pull from the asset settings. + if ( + testModerationMode(tenant) || + (asset.settings && testModerationMode(asset.settings)) + ) { + return { + status: GQLCOMMENT_STATUS.PREMOD, + }; + } + + return; +}; diff --git a/src/core/server/services/comments/moderation/phases/spam.ts b/src/core/server/services/comments/moderation/phases/spam.ts new file mode 100644 index 000000000..f44a4e85b --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/spam.ts @@ -0,0 +1,74 @@ +import { Client } from "akismet-api"; + +import { + GQLACTION_TYPE, + GQLCOMMENT_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; + +export const spam: IntermediateModerationPhase = async ({ + asset, + tenant, + comment, + author, + req, +}) => { + const integration = tenant.integrations.akismet; + + // We can only check for spam if this comment originated from a graphql + // request via an HTTP call. + if (!req || !integration.enabled) { + return; + } + + if (!integration.key || !integration.site) { + return; + } + + // Create the Akismet client. + const client = new Client({ + key: integration.key, + blog: integration.site, + }); + + // Grab the properties we need. + const userIP = req.ip; + if (!userIP) { + return; + } + + const userAgent = req.get("User-Agent"); + if (!userAgent || userAgent.length === 0) { + return; + } + + const referrer = req.get("Referrer"); + if (!referrer || referrer.length === 0) { + return; + } + + // Check the comment for spam. + const isSpam = await client.checkSpam({ + user_ip: userIP, // REQUIRED + referrer, // REQUIRED + user_agent: userAgent, // REQUIRED + comment_content: comment.body, + permalink: asset.url, + comment_author: author.displayName || author.username || "", + comment_type: "comment", + is_test: false, + }); + if (isSpam) { + return { + status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, + actions: [ + { + action_type: GQLACTION_TYPE.FLAG, + group_id: "SPAM_COMMENT", + }, + ], + }; + } + + return; +}; diff --git a/src/core/server/services/comments/moderation/phases/staff.ts b/src/core/server/services/comments/moderation/phases/staff.ts new file mode 100755 index 000000000..607e49c82 --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/staff.ts @@ -0,0 +1,21 @@ +import { + GQLCOMMENT_STATUS, + GQLUSER_ROLE, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; + +// If a given user is a staff member, always approve their comment. +export const staff: IntermediateModerationPhase = ({ + asset, + tenant, + comment, + author, +}) => { + if (author.role !== GQLUSER_ROLE.COMMENTER) { + return { + status: GQLCOMMENT_STATUS.ACCEPTED, + }; + } + + return; +}; diff --git a/src/core/server/services/comments/moderation/phases/wordlist.ts b/src/core/server/services/comments/moderation/phases/wordlist.ts new file mode 100755 index 000000000..0c62f3261 --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/wordlist.ts @@ -0,0 +1,50 @@ +import { + GQLACTION_TYPE, + GQLCOMMENT_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; +import { containsMatchingPhrase } from "talk-server/services/comments/moderation/wordlist"; + +// This phase checks the comment against the wordlist. +export const wordlist: IntermediateModerationPhase = ({ + asset, + tenant, + comment, + author, +}) => { + // Decide the status based on whether or not the current asset/settings + // has pre-mod enabled or not. If the comment was rejected based on the + // wordlist, then reject it, otherwise if the moderation setting is + // premod, set it to `premod`. + if (containsMatchingPhrase(tenant.wordlist.banned, comment.body)) { + // Add the flag related to Trust to the comment. + return { + status: GQLCOMMENT_STATUS.REJECTED, + actions: [ + { + action_type: GQLACTION_TYPE.FLAG, + group_id: "BANNED_WORD", + }, + ], + }; + } + + // If the comment has a suspect word or a link, we need to add a + // flag to it to indicate that it needs to be looked at. + // Otherwise just return the new comment. + + // If the wordlist has matched the suspect word filter and we haven't disabled + // auto-flagging suspect words, then we should flag the comment! + if (containsMatchingPhrase(tenant.wordlist.suspect, comment.body)) { + return { + actions: [ + { + action_type: GQLACTION_TYPE.FLAG, + group_id: "SUSPECT_WORD", + }, + ], + }; + } + + return; +}; diff --git a/src/core/server/services/comments/moderation/wordlist.spec.ts b/src/core/server/services/comments/moderation/wordlist.spec.ts new file mode 100644 index 000000000..d23705ecf --- /dev/null +++ b/src/core/server/services/comments/moderation/wordlist.spec.ts @@ -0,0 +1,46 @@ +import { containsMatchingPhrase } from "talk-server/services/comments/moderation/wordlist"; + +const phrases = [ + "cookies", + "how to do bad things", + "how to do really bad things", + "s h i t", + "$hit", + "p**ch", + "p*ch", +]; + +describe("containsMatchingPhrase", () => { + it("does match on a word in the list", () => { + [ + "how to do really bad things", + "what is cookies", + "cookies", + "COOKIES.", + "how to do bad things", + "How To do bad things!", + "This stuff is $hit!", + "That's a p**ch!", + ].forEach(word => { + expect(containsMatchingPhrase(phrases, word)).toEqual(true); + }); + }); + + it("does not match on a word not in the list", () => { + [ + "how to", + "cookie", + "how to be a great person?", + "how to not do really bad things?", + "i have $100 dollars.", + "I have bad $ hit lling", + "That's a p***ch!", + ].forEach(word => { + expect(containsMatchingPhrase(phrases, word)).toEqual(false); + }); + }); + + it("allows an empty list", () => { + expect(containsMatchingPhrase([], "test")).toEqual(false); + }); +}); diff --git a/src/core/server/services/comments/moderation/wordlist.ts b/src/core/server/services/comments/moderation/wordlist.ts new file mode 100644 index 000000000..30cc124ac --- /dev/null +++ b/src/core/server/services/comments/moderation/wordlist.ts @@ -0,0 +1,25 @@ +/** + * Escape string for special regular expression characters. + */ +export function escapeRegExp(str: string) { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string +} + +/** + * Generate a regular expression that catches the `phrases`. + */ +export function generateRegExp(phrases: string[]) { + const inner = phrases + .map(phrase => + phrase + .split(/\s+/) + .map(word => escapeRegExp(word)) + .join('[\\s"?!.]+') + ) + .join("|"); + + return new RegExp(`(^|[^\\w])(${inner})(?=[^\\w]|$)`, "iu"); +} + +export const containsMatchingPhrase = (phrases: string[], testString: string) => + phrases.length > 0 ? generateRegExp(phrases).test(testString) : false; diff --git a/src/core/server/services/mongodb/index.ts b/src/core/server/services/mongodb/index.ts index 03f4d21a6..c5fe7c1df 100644 --- a/src/core/server/services/mongodb/index.ts +++ b/src/core/server/services/mongodb/index.ts @@ -1,5 +1,5 @@ import { Db, MongoClient } from "mongodb"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; /** * create will connect to the MongoDB instance identified in the configuration. diff --git a/src/core/server/services/redis/index.ts b/src/core/server/services/redis/index.ts index 14b977b59..8ec7fbb65 100644 --- a/src/core/server/services/redis/index.ts +++ b/src/core/server/services/redis/index.ts @@ -1,5 +1,5 @@ import RedisClient, { Redis } from "ioredis"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; /** * create will connect to the Redis instance identified in the configuration. diff --git a/src/core/server/services/tenant/cache.ts b/src/core/server/services/tenant/cache.ts index bd3599461..58b1a4a1a 100644 --- a/src/core/server/services/tenant/cache.ts +++ b/src/core/server/services/tenant/cache.ts @@ -1,35 +1,70 @@ import DataLoader from "dataloader"; import { Redis } from "ioredis"; import { Db } from "mongodb"; +import uuid from "uuid"; +import { EventEmitter } from "events"; +import logger from "talk-server/logger"; import { retrieveAllTenants, retrieveManyTenants, + retrieveManyTenantsByDomain, Tenant, } from "talk-server/models/tenant"; -const CacheUpdateChannel = "tenant"; +const TENANT_UPDATE_CHANNEL = "tenant"; -// Cache provides an interface for retrieving tenant stored in local memory -// rather than grabbing it from the database every single call. -export default class Cache { - // private tenants: Map>>; - private tenants: DataLoader | null>; - private db: Db; +const EMITTER_EVENT_NAME = "update"; - constructor(db: Db, subscriber: Redis) { +export type SubscribeCallback = (tenant: Tenant) => void; + +interface TenantUpdateMessage { + tenant: Tenant; + clientApplicationID: string; +} + +// TenantCache provides an interface for retrieving tenant stored in local +// memory rather than grabbing it from the database every single call. +export default class TenantCache { + /** + * tenantsByID reference the tenants that have been cached/retrieved by ID. + */ + private tenantsByID = new DataLoader | null>(ids => { + logger.debug({ ids: ids.length }, "now loading tenants"); + return retrieveManyTenants(this.mongo, ids); + }); + + /** + * tenantsByDomain reference the tenants that have been cached/retrieved by + * Domain. + */ + private tenantsByDomain = new DataLoader | null>( + domains => { + logger.debug({ domains: domains.length }, "now loading tenants"); + return retrieveManyTenantsByDomain(this.mongo, domains); + } + ); + + /** + * Create a new client application ID. This prevents duplicated messages + * generated by this application from being handled as external messages + * as we should have already processed it. + */ + private clientApplicationID = uuid.v4(); + + private mongo: Db; + private emitter = new EventEmitter(); + + constructor(mongo: Db, subscriber: Redis) { // Save the Db reference. - this.db = db; - - // Prepare the list of all tenant's maintained by this instance. - this.tenants = new DataLoader(ids => retrieveManyTenants(db, ids)); - - // Subscribe to tenant notifications. - subscriber.subscribe(CacheUpdateChannel); + this.mongo = mongo; // Attach to messages on this connection so we can receive updates when // the tenant are changed. subscriber.on("message", this.onMessage); + + // Subscribe to tenant notifications. + subscriber.subscribe(TENANT_UPDATE_CHANNEL); } /** @@ -37,13 +72,19 @@ export default class Cache { */ public async primeAll() { // Grab all the tenants for this node. - const tenants = await retrieveAllTenants(this.db); + const tenants = await retrieveAllTenants(this.mongo); // Clear out all the items in the cache. - this.tenants.clearAll(); + this.tenantsByID.clearAll(); + this.tenantsByDomain.clearAll(); // Prime the cache with each of these tenants. - tenants.forEach(tenant => this.tenants.prime(tenant.id, tenant)); + tenants.forEach(tenant => { + this.tenantsByID.prime(tenant.id, tenant); + this.tenantsByDomain.prime(tenant.domain, tenant); + }); + + logger.debug({ tenants: tenants.length }, "primed tenants"); } /** @@ -54,26 +95,61 @@ export default class Cache { message: string ): Promise => { // Only do things when the message is for tenant. - if (channel !== CacheUpdateChannel) { + if (channel !== TENANT_UPDATE_CHANNEL) { return; } try { // Updated tenant come from the messages. - const tenant: Tenant = JSON.parse(message); + const { tenant, clientApplicationID }: TenantUpdateMessage = JSON.parse( + message + ); + + // Check to see if this was the update issued by this instance. + if (clientApplicationID === this.clientApplicationID) { + // It was, so just return here, we already updated/handled it. + return; + } + + logger.debug({ tenant_id: tenant.id }, "received updated tenant"); // Update the tenant cache. - this.tenants.clear(tenant.id).prime(tenant.id, tenant); + this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant); + this.tenantsByDomain.clear(tenant.domain).prime(tenant.domain, tenant); + + // Publish the event for the connected listeners. + this.emitter.emit(EMITTER_EVENT_NAME, tenant); } catch (err) { - // FIXME: handle the error + logger.error( + { err }, + "an error occurred while trying to parse/prime the tenant/tenant cache" + ); } }; + public async retrieveByID(id: string): Promise | null> { + return this.tenantsByID.load(id); + } + + public async retrieveByDomain( + domain: string + ): Promise | null> { + return this.tenantsByDomain.load(domain); + } + /** - * retrieve returns a promise that will resolve to the tenant for Talk. + * This allows you to subscribe to new Tenant updates. This will also return + * a function that when called, unsubscribes you from updates. + * + * @param callback the function to be called when there is an updated Tenant. */ - public async retrieve(id: string): Promise | null> { - return this.tenants.load(id); + public subscribe(callback: SubscribeCallback) { + this.emitter.on(EMITTER_EVENT_NAME, callback); + + // Return the unsubscribe function. + return () => { + this.emitter.removeListener(EMITTER_EVENT_NAME, callback); + }; } /** @@ -85,9 +161,23 @@ export default class Cache { */ public async update(conn: Redis, tenant: Tenant): Promise { // Update the tenant in the local cache. - this.tenants.clear(tenant.id).prime(tenant.id, tenant); + this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant); + this.tenantsByDomain.clear(tenant.domain).prime(tenant.domain, tenant); // Notify the other nodes about the tenant change. - await conn.publish(CacheUpdateChannel, JSON.stringify(tenant)); + const message: TenantUpdateMessage = { + tenant, + clientApplicationID: this.clientApplicationID, + }; + + const subscribers = await conn.publish( + TENANT_UPDATE_CHANNEL, + JSON.stringify(message) + ); + + logger.debug({ tenant_id: tenant.id, subscribers }, "updated tenant"); + + // Publish the event for the connected listeners. + this.emitter.emit(EMITTER_EVENT_NAME, tenant); } } diff --git a/src/core/server/services/tenant/index.ts b/src/core/server/services/tenant/index.ts new file mode 100644 index 000000000..4107e7051 --- /dev/null +++ b/src/core/server/services/tenant/index.ts @@ -0,0 +1,30 @@ +import { Redis } from "ioredis"; +import { Db } from "mongodb"; + +import { + Tenant, + updateTenant, + UpdateTenantInput, +} from "talk-server/models/tenant"; + +import TenantCache from "./cache"; + +export type UpdateTenant = UpdateTenantInput; + +export async function update( + db: Db, + conn: Redis, + cache: TenantCache, + tenant: Tenant, + input: UpdateTenant +): Promise { + const updatedTenant = await updateTenant(db, tenant.id, input); + if (!updatedTenant) { + return null; + } + + // Update the tenant cache. + await cache.update(conn, updatedTenant); + + return updatedTenant; +} diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts new file mode 100644 index 000000000..1a0a62b4f --- /dev/null +++ b/src/core/server/services/users/index.ts @@ -0,0 +1,12 @@ +import { Db } from "mongodb"; + +import { Tenant } from "talk-server/models/tenant"; +import { upsertUser, UpsertUserInput } from "talk-server/models/user"; + +export type UpsertUser = UpsertUserInput; + +export async function upsert(db: Db, tenant: Tenant, input: UpsertUser) { + const user = await upsertUser(db, tenant.id, input); + + return user; +} diff --git a/src/core/server/services/users/karma.ts b/src/core/server/services/users/karma.ts new file mode 100644 index 000000000..4a17538b0 --- /dev/null +++ b/src/core/server/services/users/karma.ts @@ -0,0 +1,22 @@ +import { get } from "lodash"; + +import { KarmaThresholds } from "talk-server/models/settings"; +import { User } from "talk-server/models/user"; + +export const getCommentTrustScore = (user: User): number => + get(user, "metadata.trust.comment.karma", 0); + +export const isReliableCommenter = ( + thresholds: KarmaThresholds, + user: User +): boolean | null => { + const score = getCommentTrustScore(user); + + if (score >= thresholds.comment.reliable) { + return true; + } else if (score <= thresholds.comment.unreliable) { + return false; + } + + return null; +}; diff --git a/src/core/server/types/express.ts b/src/core/server/types/express.ts index d18c0fdfb..595bc904c 100644 --- a/src/core/server/types/express.ts +++ b/src/core/server/types/express.ts @@ -2,8 +2,10 @@ import { Request } from "express"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; +import TenantCache from "talk-server/services/tenant/cache"; export interface Request extends Request { user?: User; tenant?: Tenant; + tenantCache: TenantCache; } diff --git a/src/index.ts b/src/index.ts index ec3479156..985b3476e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,9 @@ +import dotenv from "dotenv"; + +// Apply all the configuration provided in the .env file if it isn't already in +// the environment. +dotenv.config(); + import express from "express"; import logger from "talk-server/logger"; diff --git a/src/tsconfig.json b/src/tsconfig.json index 857892f4d..ac216dbaa 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -9,7 +9,7 @@ "outDir": "../dist", // See https://github.com/prismagraphql/graphql-request/issues/26 for why we // have to include "dom" here. - "lib": ["es6", "esnext.asynciterable", "dom"], + "lib": ["es2017", "es6", "esnext.asynciterable", "dom"], "baseUrl": "./", "paths": { "talk-server/*": ["./core/server/*"], diff --git a/src/types/akismet-api.d.ts b/src/types/akismet-api.d.ts new file mode 100644 index 000000000..a6d6a4475 --- /dev/null +++ b/src/types/akismet-api.d.ts @@ -0,0 +1,33 @@ +declare module "akismet-api" { + export interface ClientOptions { + key: string; + blog: string; + } + + export interface CheckSpamOptions { + user_ip: string; + user_agent: string; + referrer: string; + permalink?: string; + comment_type?: string; + comment_author?: string; + comment_content?: string; + comment_author_url?: string; + comment_author_email?: string; + comment_date_gmt?: string; + comment_post_modified_gmt?: string; + user_role?: string; + is_test?: boolean; + } + + export class Client { + constructor(options: ClientOptions) + + /** + * checkSpam will check the given comment payload for spam. + * + * @param options used to provide the input for checking spam + */ + checkSpam(options: CheckSpamOptions): Promise + } +} \ No newline at end of file diff --git a/src/types/dotize.d.ts b/src/types/dotize.d.ts index f8b3bca5f..0b6e1c5f1 100644 --- a/src/types/dotize.d.ts +++ b/src/types/dotize.d.ts @@ -1,5 +1,3 @@ declare module "dotize" { - export = dotize; - - function dotize(obj: any): { [_: string]: any }; + export function convert(obj: Object): Record } diff --git a/src/types/jsonwebtoken.d.ts b/src/types/jsonwebtoken.d.ts new file mode 100644 index 000000000..de8c9f82f --- /dev/null +++ b/src/types/jsonwebtoken.d.ts @@ -0,0 +1,19 @@ +import { VerifyOptions, VerifyCallback } from "jsonwebtoken"; + +declare module "jsonwebtoken" { + export type KeyFunctionCallback = ( + err: Error | null, + secretOrPublicKey?: string | Buffer + ) => void; + export type KeyFunction = ( + headers: { kid?: string }, + callback: KeyFunctionCallback + ) => void; + + export function verify( + token: string, + secretOrPublicKey: string | Buffer | KeyFunction, + options?: VerifyOptions, + callback?: VerifyCallback + ): void; +} diff --git a/src/types/pym.d.ts b/src/types/pym.d.ts new file mode 100644 index 000000000..4c3285192 --- /dev/null +++ b/src/types/pym.d.ts @@ -0,0 +1,174 @@ +declare module "pym.js" { + export interface ChildSettings { + /** + * Callback invoked after receiving a resize event from the parent, + * sets module:pym.Child#settings.renderCallback + */ + renderCallback?: Function; + /** xdomain to validate messages received */ + xdomain?: string; + /** polling frequency in milliseconds to send height to parent */ + polling?: number; + /** + * parent container id used when navigating the child + * iframe to a new page but we want to keep it responsive. + */ + id?: string; + /** + * if passed it will be override the default parentUrl query string + * parameter name expected on the iframe src + */ + parenturlparam?: string; + } + + /** The Child half of a responsive iframe. */ + export class Child { + /** The id of the parent container */ + id: string; + + /** The timerId in order to be able to stop when polling is enabled */ + timerId: string; + + /** The initial width of the parent page */ + parentWidth: string; + + /** The URL of the parent page from window.location.href. */ + parentUrl: string; + + /** The title of the parent page from document.title. */ + parentTitle: string; + + /** Stores the registered messageHandlers for each messageType */ + messageHandlers: Record void>>; + + /** RegularExpression to validate the received messages */ + messageRegex: RegExp; + + constructor(config: ChildSettings); + + /** Navigate parent to a given url. */ + navigateParentTo(url: string): void; + + /** + * Bind a callback to a given messageType from the child. + * Reserved message names are: "width". + */ + onMessage(messageType: string, callback: (message: string) => void): void; + + /** Unbind child event handlers and timers. */ + remove(): void; + + /** Scroll parent to a given element id. */ + scrollParentTo(hash: string): void; + + /** Scroll parent to a given child element id. */ + scrollParentToChildEl(id: string): void; + + /** Scroll parent to a particular child offset. */ + scrollParentToChildPos(pos: number): void; + + /** Transmit the current iframe height to the parent. */ + sendHeight(): void; + + /** Send a message to the the Parent. */ + sendMessage(messageType: string, message: string): void; + } + + export interface ParentSettings { + /** xdomain to validate messages received */ + xdomain?: string; + /** if passed it will be assigned to the iframe title attribute */ + title?: string; + /** if passed it will be assigned to the iframe name attribute */ + name?: string; + /** if passed it will be assigned to the iframe id attribute */ + id?: string; + /** if passed it will be assigned to the iframe allowfullscreen attribute */ + allowfullscreen?: boolean; + /** + * if passed it will be assigned to the iframe sandbox attribute + * (we do not validate the syntax so be careful!!) + */ + sandbox?: boolean; + /** + * if passed it will be override the default parentUrl query string + * parameter name passed to the iframe src + */ + parenturlparam?: string; + /** + * if passed it will be override the default parentUrl query string + * parameter value passed to the iframe src + */ + parenturlvalue?: string; + /** + * if passed and different than false it will strip the querystring + * params parentUrl and parentTitle passed to the iframe src + */ + optionalparams?: string; + /** if passed it will activate scroll tracking on the parent */ + trackscroll?: boolean; + /** + * if passed it will set the throttle wait in order to fire + * scroll messaging. Defaults to 100 ms. + */ + scrollwait?: number; + } + + /** The Parent half of a response iframe. */ + export class Parent { + /** The container DOM object */ + el: HTMLElement; + + /** The id of the container element */ + id: string; + + /** The contained child iframe */ + iframe: HTMLElement; + + /** Stores the registered messageHandlers for each messageType */ + messageHandlers: Record void>>; + + /** RegularExpression to validate the received messages */ + messageRegex: RegExp; + + /** + * The parent instance settings, updated by the values + * passed in the config object + */ + settings: ParentSettings; + + /** The url that will be set as the iframe's src */ + url: string; + + constructor(id: string, url: string, config: ParentSettings); + + /** + * Bind a callback to a given messageType from the child. + * Reserved message names are: "height", "scrollTo" and "navigateTo". + */ + onMessage(messageType: string, callback: (message: string) => void): void; + + /** Remove this parent from the page and unbind it's event handlers. */ + remove(): void; + + /** Send a message to the the child. */ + sendMessage(messageType: string, message: string): void; + + /** + * Transmit the current viewport and iframe position to the child. + * Sends viewport width, viewport height + * and iframe bounding rect top-left-bottom-right + * all separated by spaces + * + * You shouldn't need to call this directly. + */ + sendViewportAndIFramePosition(): void; + + /** + * Transmit the current iframe width to the child. + * You shouldn't need to call this directly. + */ + sendWidth(): void; + } + export function autoInit(doNotRaiseEvents: boolean): void; +} diff --git a/src/types/react-dev-utils.d.ts b/src/types/react-dev-utils.d.ts new file mode 100644 index 000000000..946e07665 --- /dev/null +++ b/src/types/react-dev-utils.d.ts @@ -0,0 +1,28 @@ +declare module "react-dev-utils/InterpolateHtmlPlugin" { + import { Plugin } from "webpack"; + export default class InterpolateHtmlPlugin extends Plugin { + constructor(env: Record); + } +} + +declare module "react-dev-utils/ModuleScopePlugin" { + import { Plugin } from "webpack"; + export default class ModuleScopePlugin extends Plugin { + constructor(rootPath: string, ignore: ReadonlyArray); + } +} + +declare module "react-dev-utils/WatchMissingNodeModulesPlugin" { + import { Plugin } from "webpack"; + export default class ModuleScopePlugin extends Plugin { + constructor(nodeModulesPath: string); + } +} + +declare module "react-dev-utils/errorOverlayMiddleware"; +declare module "react-dev-utils/ignoredFiles"; +declare module "react-dev-utils/noopServiceWorkerMiddleware"; +declare module "react-dev-utils/WebpackDevServerUtils"; +declare module "react-dev-utils/FileSizeReporter"; +declare module "react-dev-utils/formatWebpackMessages"; +declare module "react-dev-utils/printBuildError"; diff --git a/src/types/webfinger.d.ts b/src/types/webfinger.d.ts new file mode 100644 index 000000000..a13bfb998 --- /dev/null +++ b/src/types/webfinger.d.ts @@ -0,0 +1,16 @@ +declare module "webfinger" { + export interface WebfingerOptions { + webfingerOnly?: boolean; + } + + export interface WebfingerCallback { + (err: Error, jrd: { [key: string]: any }): void; + } + + export function webfinger( + resource: string, + res: string, + options: WebfingerOptions, + callback: WebfingerCallback + ): void; +} diff --git a/tsconfig.json b/tsconfig.json index dbb50ebf7..3ddce16aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,13 +13,13 @@ "noImplicitAny": true, "strictNullChecks": true, "noErrorTruncation": true, - "lib": ["es6", "esnext.asynciterable"] + "lib": ["dom", "es6", "esnext.asynciterable"] }, "include": [ "./src/**/.*.js", + "./src/types/**/*.d.ts", "./scripts/**/*", "./config/**/*", - "./test/**/*", "*.js" ], "exclude": ["node_modules"] diff --git a/tslint.json b/tslint.json index 68001f8c6..b87117294 100644 --- a/tslint.json +++ b/tslint.json @@ -7,10 +7,7 @@ "rules": { "prettier": true, "object-literal-sort-keys": false, - "interface-name": [ - true, - "never-prefix" - ], + "interface-name": [true, "never-prefix"], "no-switch-case-fall-through": true, "member-ordering": false, "variable-name": [ @@ -35,8 +32,6 @@ ] }, "linterOptions": { - "exclude": [ - "**/node_modules/**/*" - ] + "exclude": ["**/node_modules/**/*", "./config/coverage/**"] } -} \ No newline at end of file +}