From 6d7056d831a29510599ce3cccf19ffcc01829475 Mon Sep 17 00:00:00 2001 From: Kiwi Date: Thu, 2 Aug 2018 17:29:18 +0200 Subject: [PATCH 1/2] [next] Add support for embed (#1762) * Move talk-server/config to talk-common/config * Refactor build into /src/core/build and use common config * Add embed webpack config * Start implementing embed * Implement embed * Add pym types * Add event emitter to Talk Context * Add MatchMedia test for passing values from the context * Add support for click far away * Integrate pym click events to registerClickFarAway * Add tests * Resolve merge issues * Apply PR review --- config/env.js | 93 ---- config/jest.config.js | 4 +- .../client.config.js} | 4 +- .../server.config.js} | 2 +- config/{ => jest}/tsconfig.jest.json | 0 config/paths.js | 66 --- config/paths.ts | 15 + config/watcher.ts | 2 - config/webpack.config.dev.js | 334 ------------- config/webpack.config.prod.js | 388 --------------- ...r.config.js => webpackDevServer.config.ts} | 57 +-- doczrc.js | 8 +- package-lock.json | 354 +++++++++++++- package.json | 38 +- scripts/{build.js => build.ts} | 56 +-- scripts/{start.js => start.ts} | 72 ++- scripts/test.js | 14 +- src/core/build/createWebpackConfig.ts | 447 ++++++++++++++++++ .../core/build/loaders}/locales-loader.js | 0 src/core/build/paths.ts | 33 ++ {config => src/core/build}/polyfills.js | 0 {config => src/core/build}/postcss.config.js | 5 +- src/core/client/embed/PymControl.spec.ts | 55 +++ src/core/client/embed/PymControl.ts | 41 ++ src/core/client/embed/Stream.spec.ts | 70 +++ src/core/client/embed/Stream.ts | 83 ++++ .../__snapshots__/PymControl.spec.ts.snap | 5 + .../embed/__snapshots__/index.spec.ts.snap | 3 + src/core/client/embed/decorators/index.ts | 11 + .../embed/decorators/withAutoHeight.spec.ts | 16 + .../client/embed/decorators/withAutoHeight.ts | 14 + .../embed/decorators/withClickEvent.spec.ts | 19 + .../client/embed/decorators/withClickEvent.ts | 16 + .../embed/decorators/withCommentID.spec.ts | 36 ++ .../client/embed/decorators/withCommentID.ts | 36 ++ .../embed/decorators/withEventEmitter.spec.ts | 21 + .../embed/decorators/withEventEmitter.ts | 13 + .../withIOSSafariWidthWorkaround.spec.ts | 12 + .../withIOSSafariWidthWorkaround.ts | 9 + src/core/client/embed/index.html | 19 + src/core/client/embed/index.spec.ts | 23 + src/core/client/embed/index.ts | 30 ++ src/core/client/embed/tsconfig.json | 14 + src/core/client/embed/utils/buildURL.spec.ts | 18 + src/core/client/embed/utils/buildURL.ts | 17 + .../client/embed/utils/ensureEndSlash.spec.ts | 11 + src/core/client/embed/utils/ensureEndSlash.ts | 3 + src/core/client/embed/utils/index.ts | 2 + .../framework/lib/bootstrap/TalkContext.tsx | 25 +- .../framework/lib/bootstrap/createContext.tsx | 36 +- src/core/client/stream/index.html | 6 +- src/core/client/stream/index.tsx | 2 + src/core/client/tsconfig.json | 2 +- .../ClickOutside/ClickOutside.spec.tsx | 63 ++- .../components/ClickOutside/ClickOutside.tsx | 49 +- .../ui/components/ClickOutside/index.ts | 1 + .../components/MatchMedia/MatchMedia.spec.tsx | 23 +- .../ui/components/UIContext/UIContext.ts | 9 + src/core/{server => common}/config.ts | 4 + src/core/server/app/index.ts | 2 +- .../app/middleware/passport/jwt.spec.ts | 2 +- .../server/app/middleware/passport/jwt.ts | 2 +- .../server/graph/common/middleware/index.ts | 2 +- .../graph/common/subscriptions/pubsub.ts | 2 +- .../server/graph/management/middleware.ts | 2 +- src/core/server/graph/tenant/middleware.ts | 2 +- src/core/server/index.ts | 2 +- src/core/server/services/mongodb/index.ts | 2 +- src/core/server/services/redis/index.ts | 2 +- src/types/pym.d.ts | 174 +++++++ src/types/react-dev-utils.d.ts | 28 ++ tsconfig.json | 10 +- 72 files changed, 1995 insertions(+), 1046 deletions(-) delete mode 100644 config/env.js rename config/{jest-client.config.js => jest/client.config.js} (95%) rename config/{jest-server.config.js => jest/server.config.js} (96%) rename config/{ => jest}/tsconfig.jest.json (100%) delete mode 100644 config/paths.js create mode 100644 config/paths.ts delete mode 100644 config/webpack.config.dev.js delete mode 100644 config/webpack.config.prod.js rename config/{webpackDevServer.config.js => webpackDevServer.config.ts} (61%) rename scripts/{build.js => build.ts} (74%) rename scripts/{start.js => start.ts} (55%) create mode 100644 src/core/build/createWebpackConfig.ts rename {loaders => src/core/build/loaders}/locales-loader.js (100%) create mode 100644 src/core/build/paths.ts rename {config => src/core/build}/polyfills.js (100%) rename {config => src/core/build}/postcss.config.js (96%) create mode 100644 src/core/client/embed/PymControl.spec.ts create mode 100644 src/core/client/embed/PymControl.ts create mode 100644 src/core/client/embed/Stream.spec.ts create mode 100644 src/core/client/embed/Stream.ts create mode 100644 src/core/client/embed/__snapshots__/PymControl.spec.ts.snap create mode 100644 src/core/client/embed/__snapshots__/index.spec.ts.snap create mode 100644 src/core/client/embed/decorators/index.ts create mode 100644 src/core/client/embed/decorators/withAutoHeight.spec.ts create mode 100644 src/core/client/embed/decorators/withAutoHeight.ts create mode 100644 src/core/client/embed/decorators/withClickEvent.spec.ts create mode 100644 src/core/client/embed/decorators/withClickEvent.ts create mode 100644 src/core/client/embed/decorators/withCommentID.spec.ts create mode 100644 src/core/client/embed/decorators/withCommentID.ts create mode 100644 src/core/client/embed/decorators/withEventEmitter.spec.ts create mode 100644 src/core/client/embed/decorators/withEventEmitter.ts create mode 100644 src/core/client/embed/decorators/withIOSSafariWidthWorkaround.spec.ts create mode 100644 src/core/client/embed/decorators/withIOSSafariWidthWorkaround.ts create mode 100644 src/core/client/embed/index.html create mode 100644 src/core/client/embed/index.spec.ts create mode 100644 src/core/client/embed/index.ts create mode 100644 src/core/client/embed/tsconfig.json create mode 100644 src/core/client/embed/utils/buildURL.spec.ts create mode 100644 src/core/client/embed/utils/buildURL.ts create mode 100644 src/core/client/embed/utils/ensureEndSlash.spec.ts create mode 100644 src/core/client/embed/utils/ensureEndSlash.ts create mode 100644 src/core/client/embed/utils/index.ts create mode 100644 src/core/client/ui/components/ClickOutside/index.ts rename src/core/{server => common}/config.ts (95%) create mode 100644 src/types/pym.d.ts create mode 100644 src/types/react-dev-utils.d.ts 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 73cc43370..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/index.html b/src/core/client/stream/index.html index 7578f176f..5c2f7b60e 100644 --- a/src/core/client/stream/index.html +++ b/src/core/client/stream/index.html @@ -1,15 +1,15 @@ - + - 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 95% rename from src/core/server/config.ts rename to src/core/common/config.ts index d4b5ca3fc..099bcc4b2 100644 --- a/src/core/server/config.ts +++ b/src/core/common/config.ts @@ -90,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/server/app/index.ts b/src/core/server/app/index.ts index 4594c9e81..d1310eb41 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -3,10 +3,10 @@ import http from "http"; import { Redis } from "ioredis"; import { Db } from "mongodb"; +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 { Config } from "talk-server/config"; import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware"; import { Schemas } from "talk-server/graph/schemas"; diff --git a/src/core/server/app/middleware/passport/jwt.spec.ts b/src/core/server/app/middleware/passport/jwt.spec.ts index fa7b87d9d..bede65426 100644 --- a/src/core/server/app/middleware/passport/jwt.spec.ts +++ b/src/core/server/app/middleware/passport/jwt.spec.ts @@ -1,11 +1,11 @@ import sinon from "sinon"; +import { Config } from "talk-common/config"; import { createJWTSigningConfig, extractJWTFromRequest, parseAuthHeader, } from "talk-server/app/middleware/passport/jwt"; -import { Config } from "talk-server/config"; import { Request } from "talk-server/types/express"; describe("parseAuthHeader", () => { diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts index b47bd06dd..569c005b5 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -3,7 +3,7 @@ import uuid from "uuid"; import { Db } from "mongodb"; import { Strategy } from "passport-strategy"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; import { retrieveUser, User } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; 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/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/middleware.ts b/src/core/server/graph/management/middleware.ts index def73c2e9..8a62eed63 100644 --- a/src/core/server/graph/management/middleware.ts +++ b/src/core/server/graph/management/middleware.ts @@ -1,7 +1,7 @@ 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 Context from "./context"; diff --git a/src/core/server/graph/tenant/middleware.ts b/src/core/server/graph/tenant/middleware.ts index f583c6d7a..f27e2430e 100644 --- a/src/core/server/graph/tenant/middleware.ts +++ b/src/core/server/graph/tenant/middleware.ts @@ -1,7 +1,7 @@ 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"; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 56e92f7f7..3ef89bde4 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -1,13 +1,13 @@ 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 { attachSubscriptionHandlers, createApp, listenAndServe } from "./app"; -import config, { Config } from "./config"; import logger from "./logger"; import { createMongoDB } from "./services/mongodb"; import { createRedisClient } from "./services/redis"; 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/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/tsconfig.json b/tsconfig.json index bee79d2d0..3ddce16aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,8 +13,14 @@ "noImplicitAny": true, "strictNullChecks": true, "noErrorTruncation": true, - "lib": ["es6", "esnext.asynciterable"] + "lib": ["dom", "es6", "esnext.asynciterable"] }, - "include": ["./src/**/.*.js", "./scripts/**/*", "./config/**/*", "*.js"], + "include": [ + "./src/**/.*.js", + "./src/types/**/*.d.ts", + "./scripts/**/*", + "./config/**/*", + "*.js" + ], "exclude": ["node_modules"] } From 4606626ec4b0202f1e5b0aca9207ac9c905ec5fc Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 2 Aug 2018 20:09:55 +0000 Subject: [PATCH 2/2] [next] Settings/Tenant (#1758) * feat: initial support for synced tenants * fix: cleanup * fix: logger now respects logging level * fix: cache now ignores updates issued from itself * feat: print subscriber count * fix: support tenant cache for oidc strategy * fix: replace some constructor initializers with property initializers * fix: audit * [next] Comments and Moderation (#1759) * feat: initial moderation + validation for new comments * fix: added Promiseable type * feat: initial actions impl * feat: more moderation phases * fix: handle settings inheritence * fix: moved settings into new file * fix: defaults and documentation * fix: replace merge with object spread * feat: added integration with akismet * fix: fixed compile * fix: import ordering * fix: merge issue causing build to fail --- package-lock.json | 96 ++++++- package.json | 13 +- src/core/common/types.ts | 2 + src/core/server/app/index.ts | 9 +- .../passport/__snapshots__/jwt.spec.ts.snap | 2 +- .../server/app/middleware/passport/index.ts | 19 +- .../app/middleware/passport/jwt.spec.ts | 2 +- .../server/app/middleware/passport/jwt.ts | 19 +- .../server/app/middleware/passport/local.ts | 10 +- .../server/app/middleware/passport/oidc.ts | 38 +-- .../server/app/middleware/passport/sso.ts | 15 +- src/core/server/app/middleware/tenant.ts | 14 +- src/core/server/app/router.ts | 9 +- src/core/server/graph/common/context.ts | 6 +- src/core/server/graph/management/context.ts | 13 +- .../server/graph/management/middleware.ts | 9 +- src/core/server/graph/tenant/context.ts | 29 ++- .../server/graph/tenant/loaders/assets.ts | 4 +- .../server/graph/tenant/loaders/comments.ts | 20 +- src/core/server/graph/tenant/loaders/users.ts | 2 +- src/core/server/graph/tenant/middleware.ts | 26 +- .../server/graph/tenant/mutators/comment.ts | 19 +- .../server/graph/tenant/mutators/index.ts | 3 + .../server/graph/tenant/mutators/settings.ts | 11 + .../tenant/resolvers/auth_integrations.ts | 2 +- .../graph/tenant/resolvers/auth_settings.ts | 2 +- .../resolvers/facebook_auth_integration.ts | 2 +- .../resolvers/google_auth_integration.ts | 2 +- .../resolvers/local_auth_integration.ts | 2 +- .../server/graph/tenant/resolvers/mutation.ts | 4 + .../tenant/resolvers/oidc_auth_integration.ts | 2 +- .../tenant/resolvers/sso_auth_integration.ts | 2 +- .../server/graph/tenant/schema/schema.graphql | 107 +++++++- src/core/server/index.ts | 8 + src/core/server/logger.ts | 6 +- src/core/server/models/actions.ts | 18 +- src/core/server/models/asset.ts | 9 +- src/core/server/models/comment.ts | 12 +- src/core/server/models/settings.ts | 246 ++++++++++++++++++ src/core/server/models/tenant.ts | 174 +++---------- src/core/server/models/user.ts | 8 +- src/core/server/services/comments/index.ts | 58 ++++- .../services/comments/moderation/index.ts | 75 ++++++ .../moderation/phases/assetClosed.spec.ts | 42 +++ .../comments/moderation/phases/assetClosed.ts | 12 + .../moderation/phases/commentLength.ts | 45 ++++ .../moderation/phases/commentingDisabled.ts | 19 ++ .../comments/moderation/phases/index.ts | 26 ++ .../comments/moderation/phases/karma.ts | 40 +++ .../comments/moderation/phases/links.ts | 49 ++++ .../comments/moderation/phases/premod.ts | 28 ++ .../comments/moderation/phases/spam.ts | 74 ++++++ .../comments/moderation/phases/staff.ts | 21 ++ .../comments/moderation/phases/wordlist.ts | 50 ++++ .../comments/moderation/wordlist.spec.ts | 46 ++++ .../services/comments/moderation/wordlist.ts | 25 ++ src/core/server/services/tenant/cache.ts | 144 ++++++++-- src/core/server/services/tenant/index.ts | 30 +++ src/core/server/services/users/karma.ts | 22 ++ src/core/server/types/express.ts | 2 + src/types/akismet-api.d.ts | 33 +++ src/types/dotize.d.ts | 4 +- tslint.json | 11 +- 63 files changed, 1529 insertions(+), 323 deletions(-) create mode 100644 src/core/server/graph/tenant/mutators/settings.ts create mode 100644 src/core/server/models/settings.ts create mode 100644 src/core/server/services/comments/moderation/index.ts create mode 100644 src/core/server/services/comments/moderation/phases/assetClosed.spec.ts create mode 100644 src/core/server/services/comments/moderation/phases/assetClosed.ts create mode 100644 src/core/server/services/comments/moderation/phases/commentLength.ts create mode 100644 src/core/server/services/comments/moderation/phases/commentingDisabled.ts create mode 100644 src/core/server/services/comments/moderation/phases/index.ts create mode 100755 src/core/server/services/comments/moderation/phases/karma.ts create mode 100755 src/core/server/services/comments/moderation/phases/links.ts create mode 100755 src/core/server/services/comments/moderation/phases/premod.ts create mode 100644 src/core/server/services/comments/moderation/phases/spam.ts create mode 100755 src/core/server/services/comments/moderation/phases/staff.ts create mode 100755 src/core/server/services/comments/moderation/phases/wordlist.ts create mode 100644 src/core/server/services/comments/moderation/wordlist.spec.ts create mode 100644 src/core/server/services/comments/moderation/wordlist.ts create mode 100644 src/core/server/services/tenant/index.ts create mode 100644 src/core/server/services/users/karma.ts create mode 100644 src/types/akismet-api.d.ts diff --git a/package-lock.json b/package-lock.json index 9f175a484..9cafac515 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1836,6 +1836,12 @@ "@types/node": "*" } }, + "@types/linkify-it": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-2.0.3.tgz", + "integrity": "sha512-WRj1rJPkj3fyDME63xUkWvcWzq0XjpiqjJGNCH4Y6Fbiv2fVMDCqeIA6ch/UUG3VYrfGq1VNFLsz6Sat6FjsPw==", + "dev": true + }, "@types/lodash": { "version": "4.14.111", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.111.tgz", @@ -2022,6 +2028,12 @@ "integrity": "sha512-78AdXtlhpCHT0K3EytMpn4JNxaf5tbqbLcbIRoQIHzpTIyjpxLQKRoxU55ujBXAtg3Nl2h/XWvfDa9dsMOd0pQ==", "dev": true }, + "@types/tlds": { + "version": "1.199.0", + "resolved": "https://registry.npmjs.org/@types/tlds/-/tlds-1.199.0.tgz", + "integrity": "sha512-wDAuzclsEH+fzHZouSHKB5qHWx2ABHwDfcF1X8Aslu20iHrMbCwOeTPINp9qfymlwnSsH5cMD3Ps7WbgmOcXUg==", + "dev": true + }, "@types/tough-cookie": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-2.3.3.tgz", @@ -2548,6 +2560,15 @@ "integrity": "sha1-6GuBnGAs+IIa1jdBNpjx3sAhhHo=", "dev": true }, + "akismet-api": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/akismet-api/-/akismet-api-4.2.0.tgz", + "integrity": "sha512-HJcF3QYWmiQb3TkcXLxGvc0L0HyGA7edm3GRCXvFWzGKFfsqgCjoehx6Lzmpb5sCUAJQmxu1HpWjv8n7u0GstQ==", + "requires": { + "bluebird": "^3.1.1", + "superagent": "^3.8.0" + } + }, "align-text": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", @@ -5907,8 +5928,7 @@ "component-emitter": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", - "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=", - "dev": true + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" }, "compressible": { "version": "2.0.14", @@ -6045,6 +6065,11 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" }, + "cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==" + }, "cookies": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.7.1.tgz", @@ -9351,6 +9376,11 @@ "integrity": "sha1-1hcBB+nv3E7TDJ3DkBbflCtctYs=", "dev": true }, + "formidable": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz", + "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==" + }, "forwarded": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", @@ -11867,8 +11897,7 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "isemail": { "version": "3.1.2", @@ -14239,6 +14268,14 @@ "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.3.tgz", "integrity": "sha512-zrycnIMsLw/3ZxTbW7HCez56rcFGecWTx5OZNplzcXUUmJLmoYArC6qdJzmAN5BWiNXGcpjhF9RQ1HSv5zebEw==" }, + "linkify-it": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.0.3.tgz", + "integrity": "sha1-2UpGSPmxwXnWT6lykSaL22zpQ08=", + "requires": { + "uc.micro": "^1.0.1" + } + }, "load-cfg": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/load-cfg/-/load-cfg-0.5.6.tgz", @@ -18615,8 +18652,7 @@ "process-nextick-args": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", - "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", - "dev": true + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" }, "promise": { "version": "7.3.1", @@ -19441,7 +19477,6 @@ "version": "2.3.6", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", - "dev": true, "requires": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -21283,7 +21318,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "requires": { "safe-buffer": "~5.1.0" } @@ -21376,6 +21410,33 @@ "ws": "^5.2.0" } }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "requires": { + "component-emitter": "^1.2.0", + "cookiejar": "^2.1.0", + "debug": "^3.1.0", + "extend": "^3.0.0", + "form-data": "^2.3.1", + "formidable": "^1.2.0", + "methods": "^1.1.1", + "mime": "^1.4.1", + "qs": "^6.5.1", + "readable-stream": "^2.3.5" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + } + } + }, "supports-color": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", @@ -21656,6 +21717,11 @@ "integrity": "sha512-rUwGDruKq1gX+FFHbTl5qjI7teVO7eOe+C8IcQ7QT+1BK3eEUXJqbZcBOeaRP4FwSC/C1A5jDoIVta0nIQ9yew==", "dev": true }, + "tlds": { + "version": "1.203.1", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.203.1.tgz", + "integrity": "sha512-7MUlYyGJ6rSitEZ3r1Q1QNV8uSIzapS8SmmhSusBuIc7uIxPPwsKllEP0GRp1NS6Ik6F+fRZvnjDWm3ecv2hDw==" + }, "tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -22351,6 +22417,11 @@ "integrity": "sha512-LtzwHlVHwFGTptfNSgezHp7WUlwiqb0gA9AALRbKaERfxwJoiX0A73QbTToxteIAuIaFshhgIZfqK8s7clqgnA==", "dev": true }, + "uc.micro": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.5.tgz", + "integrity": "sha512-JoLI4g5zv5qNyT09f4YAvEZIIV1oOjqnewYg5D38dkQljIzpPT296dbIGvKro3digYI1bkb7W6EP1y4uDlmzLg==" + }, "uglify-js": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.2.tgz", @@ -22810,9 +22881,9 @@ } }, "url-parse": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.1.tgz", - "integrity": "sha512-x95Td74QcvICAA0+qERaVkRpTGKyBHHYdwL2LXZm5t/gBtCB9KQSO/0zQgSTYEV1p0WcvSg79TLNPSvd5IDJMQ==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.3.tgz", + "integrity": "sha512-rh+KuAW36YKo0vClhQzLLveoj8FwPJNu65xLb7Mrt+eZht0IPT0IXgSv8gcMegZ6NvjJUALf6Mf25POlMwD1Fw==", "dev": true, "requires": { "querystringify": "^2.0.0", @@ -22849,8 +22920,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "util.promisify": { "version": "1.0.0", diff --git a/package.json b/package.json index f2f981469..749fefffe 100644 --- a/package.json +++ b/package.json @@ -31,34 +31,37 @@ "author": "", "license": "Apache-2.0", "dependencies": { + "akismet-api": "^4.2.0", "apollo-server-express": "^1.3.6", "bcryptjs": "^2.4.3", "bunyan": "^1.8.12", "convict": "^4.3.1", "dataloader": "^1.4.0", - "dotenv-expand": "^4.2.0", "dotenv": "^6.0.0", + "dotenv-expand": "^4.2.0", "dotize": "^0.2.0", - "express-static-gzip": "^0.3.2", "express": "^4.16.3", + "express-static-gzip": "^0.3.2", "fs-extra": "^6.0.1", + "graphql": "^0.13.2", "graphql-config": "^2.0.1", "graphql-redis-subscriptions": "^1.5.0", "graphql-tools": "^3.0.5", - "graphql": "^0.13.2", "ioredis": "^3.2.2", "joi": "^13.4.0", "jsonwebtoken": "^8.3.0", "jwks-rsa": "^1.3.0", + "linkify-it": "^2.0.3", "lodash": "^4.17.10", "luxon": "^1.3.1", "mongodb": "^3.1.1", + "passport": "^0.4.0", "passport-local": "^1.0.0", "passport-oauth2": "^1.4.0", "passport-strategy": "^1.0.0", - "passport": "^0.4.0", "performance-now": "^2.1.0", "subscriptions-transport-ws": "^0.9.12", + "tlds": "^1.203.1", "uuid": "^3.3.2" }, "devDependencies": { @@ -90,6 +93,7 @@ "@types/joi": "^13.0.8", "@types/jsdom": "^11.0.6", "@types/jsonwebtoken": "^7.2.7", + "@types/linkify-it": "^2.0.3", "@types/lodash": "^4.14.111", "@types/luxon": "^0.5.3", "@types/mongodb": "^3.1.1", @@ -107,6 +111,7 @@ "@types/relay-runtime": "github:coralproject/patched#types/relay-runtime", "@types/sane": "^2.0.0", "@types/sinon": "^5.0.1", + "@types/tlds": "^1.199.0", "@types/uglifyjs-webpack-plugin": "^1.1.0", "@types/uuid": "^3.4.3", "@types/webpack": "^4.4.7", 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/index.ts b/src/core/server/app/index.ts index d1310eb41..3be153164 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -9,6 +9,7 @@ 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 { accessLogger, errorLogger } from "./middleware/logging"; import serveStatic from "./middleware/serveStatic"; @@ -21,6 +22,7 @@ export interface AppOptions { redis: Redis; schemas: Schemas; signingConfig: JWTSigningConfig; + tenantCache: TenantCache; } /** @@ -34,10 +36,7 @@ export async function createApp(options: AppOptions): Promise { parent.use(accessLogger); // Create some services for the router. - const passport = createPassport({ - db: options.mongo, - signingConfig: options.signingConfig, - }); + const passport = createPassport(options); // Mount the router. parent.use( @@ -76,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/passport/__snapshots__/jwt.spec.ts.snap b/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap index 1e61492d7..ada6ed29d 100644 --- a/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap +++ b/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`createJWTSigningConfig parses a RSA certiciate 1`] = ` +exports[`createJWTSigningConfig parses a RSA certificate 1`] = ` "-----BEGIN RSA PRIVATE KEY----- MIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV EnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+ diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index d9d6768f3..a6bd0032b 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -12,6 +12,7 @@ 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 = ( @@ -21,28 +22,28 @@ export type VerifyCallback = ( ) => void; export interface PassportOptions { - db: Db; + mongo: Db; signingConfig: JWTSigningConfig; + tenantCache: TenantCache; } -export function createPassport({ - db, - signingConfig, -}: PassportOptions): passport.Authenticator { +export function createPassport( + options: PassportOptions +): passport.Authenticator { // Create the authenticator. const auth = new Authenticator(); // Use the OIDC Strategy. - auth.use(createOIDCStrategy({ db })); + auth.use(createOIDCStrategy(options)); // Use the LocalStrategy. - auth.use(createLocalStrategy({ db })); + auth.use(createLocalStrategy(options)); // Use the SSOStrategy. - auth.use(createSSOStrategy({ db })); + auth.use(createSSOStrategy(options)); // Use the JWTStrategy. - auth.use(createJWTStrategy({ db, signingConfig })); + auth.use(createJWTStrategy(options)); return auth; } diff --git a/src/core/server/app/middleware/passport/jwt.spec.ts b/src/core/server/app/middleware/passport/jwt.spec.ts index bede65426..3c25db7a4 100644 --- a/src/core/server/app/middleware/passport/jwt.spec.ts +++ b/src/core/server/app/middleware/passport/jwt.spec.ts @@ -67,7 +67,7 @@ describe("extractJWTFromRequest", () => { }); describe("createJWTSigningConfig", () => { - it("parses a RSA certiciate", () => { + 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(), diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts index 569c005b5..da4d0be11 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -67,7 +67,7 @@ export function createAsymmetricSigningConfig( secret: string ): JWTSigningConfig { return { - // Secrets have their newlines encoded with newline litterals. + // Secrets have their newlines encoded with newline literals. secret: Buffer.from(secret.replace(/\\n/g, "\n")), algorithm, }; @@ -138,21 +138,20 @@ export interface JWTToken { export interface JWTStrategyOptions { signingConfig: JWTSigningConfig; - db: Db; + mongo: Db; } export class JWTStrategy extends Strategy { + public name = "jwt"; + private signingConfig: JWTSigningConfig; - private db: Db; + private mongo: Db; - public name: string; - - constructor({ signingConfig, db }: JWTStrategyOptions) { + constructor({ signingConfig, mongo }: JWTStrategyOptions) { super(); - this.name = "jwt"; this.signingConfig = signingConfig; - this.db = db; + this.mongo = mongo; } public authenticate(req: Request) { @@ -160,7 +159,7 @@ export class JWTStrategy extends Strategy { 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 succesfull. + // that the strategy was successful. return this.success(null, null); } @@ -187,7 +186,7 @@ export class JWTStrategy extends Strategy { try { // Find the user. - const user = await retrieveUser(this.db, tenant.id, sub); + 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); diff --git a/src/core/server/app/middleware/passport/local.ts b/src/core/server/app/middleware/passport/local.ts index b57c4402e..588bcfa03 100644 --- a/src/core/server/app/middleware/passport/local.ts +++ b/src/core/server/app/middleware/passport/local.ts @@ -8,7 +8,7 @@ import { } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; -const verifyFactory = (db: Db) => async ( +const verifyFactory = (mongo: Db) => async ( req: Request, email: string, password: string, @@ -21,7 +21,7 @@ const verifyFactory = (db: Db) => async ( const tenant = req.tenant!; // Get the user from the database. - const user = await retrieveUserWithProfile(db, tenant.id, { + const user = await retrieveUserWithProfile(mongo, tenant.id, { id: email, type: "local", }); @@ -44,10 +44,10 @@ const verifyFactory = (db: Db) => async ( }; export interface LocalStrategyOptions { - db: Db; + mongo: Db; } -export function createLocalStrategy({ db }: LocalStrategyOptions) { +export function createLocalStrategy({ mongo }: LocalStrategyOptions) { return new LocalStrategy( { usernameField: "email", @@ -55,6 +55,6 @@ export function createLocalStrategy({ db }: LocalStrategyOptions) { session: false, passReqToCallback: true, }, - verifyFactory(db) + verifyFactory(mongo) ); } diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index 2bbe16a30..a835bd042 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -8,8 +8,10 @@ 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, Tenant } from "talk-server/models/tenant"; +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"; @@ -39,10 +41,6 @@ export interface StrategyItem { jwksClient?: JwksClient; } -export interface OIDCStrategyOptions { - db: Db; -} - export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken { if ( (token as OIDCIDToken).iss && @@ -176,20 +174,28 @@ export async function findOrCreateOIDCUser( */ const OIDC_SCOPE = "openid email profile"; -// FIXME: attach strategy to cache updates of the tenants +export interface OIDCStrategyOptions { + mongo: Db; + tenantCache: TenantCache; +} export default class OIDCStrategy extends Strategy { - public name: string; + public name = "oidc"; - private db: Db; - private cache: Map; + private mongo: Db; + private cache = new Map(); - constructor({ db }: OIDCStrategyOptions) { + constructor({ mongo, tenantCache }: OIDCStrategyOptions) { super(); - this.name = "oidc"; - this.cache = new Map(); - this.db = db; + 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( @@ -277,7 +283,7 @@ export default class OIDCStrategy extends Strategy { try { const user = await findOrCreateOIDCUser( - this.db, + this.mongo, tenant, decoded as OIDCIDToken ); @@ -370,6 +376,6 @@ export default class OIDCStrategy extends Strategy { } } -export function createOIDCStrategy({ db }: OIDCStrategyOptions) { - return new OIDCStrategy({ db }); +export function createOIDCStrategy(options: OIDCStrategyOptions) { + return new OIDCStrategy(options); } diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts index 4e6903139..066f2d139 100644 --- a/src/core/server/app/middleware/passport/sso.ts +++ b/src/core/server/app/middleware/passport/sso.ts @@ -17,7 +17,7 @@ import { upsert } from "talk-server/services/users"; import { Request } from "talk-server/types/express"; export interface SSOStrategyOptions { - db: Db; + mongo: Db; } export interface SSOUserProfile { @@ -114,15 +114,14 @@ export function isSSOToken(token: SSOToken | object): token is SSOToken { } export default class SSOStrategy extends Strategy { - public name: string; + public name = "sso"; - private db: Db; + private mongo: Db; - constructor({ db }: SSOStrategyOptions) { + constructor({ mongo }: SSOStrategyOptions) { super(); - this.name = "sso"; - this.db = db; + this.mongo = mongo; } /** @@ -162,7 +161,7 @@ export default class SSOStrategy extends Strategy { 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.db, tenant, token); + return findOrCreateOIDCUser(this.mongo, tenant, token); } // Check to see if this token is a SSO Token or not, if it isn't error out. @@ -174,7 +173,7 @@ export default class SSOStrategy extends Strategy { // 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.db, tenant, token); + return findOrCreateSSOUser(this.mongo, tenant, token); } public authenticate(req: Request) { 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/router.ts b/src/core/server/app/router.ts index 085b9e4f9..059a1c39a 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -33,7 +33,7 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); // Tenant identification middleware. - router.use(tenantMiddleware({ db: app.mongo })); + router.use(tenantMiddleware({ cache: app.tenantCache })); // Setup Passport middleware. router.use(options.passport.initialize()); @@ -48,7 +48,12 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { // 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(app.schemas.tenant, app.config, app.mongo) + await tenantGraphMiddleware({ + schema: app.schemas.tenant, + config: app.config, + mongo: app.mongo, + redis: app.redis, + }) ); return router; diff --git a/src/core/server/graph/common/context.ts b/src/core/server/graph/common/context.ts index d939d7b64..7e191e3f8 100644 --- a/src/core/server/graph/common/context.ts +++ b/src/core/server/graph/common/context.ts @@ -1,13 +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 }: CommonContextOptions) { + constructor({ user, req }: CommonContextOptions) { this.user = user; + this.req = req; } } diff --git a/src/core/server/graph/management/context.ts b/src/core/server/graph/management/context.ts index c77f1a207..cdb25dbe3 100644 --- a/src/core/server/graph/management/context.ts +++ b/src/core/server/graph/management/context.ts @@ -1,16 +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 extends CommonContext { - public db: Db; + public mongo: Db; - constructor({ db }: ManagementContextOptions) { - super({}); + constructor({ req, mongo }: ManagementContextOptions) { + super({ req }); - this.db = db; + this.mongo = mongo; } } diff --git a/src/core/server/graph/management/middleware.ts b/src/core/server/graph/management/middleware.ts index 8a62eed63..6dd15e5da 100644 --- a/src/core/server/graph/management/middleware.ts +++ b/src/core/server/graph/management/middleware.ts @@ -3,11 +3,12 @@ import { Db } from "mongodb"; 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/tenant/context.ts b/src/core/server/graph/tenant/context.ts index 0f51eb529..3eaf8e1ca 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -1,30 +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 extends CommonContext { public loaders: ReturnType; public mutators: ReturnType; - public db: Db; + public mongo: Db; + public redis: Redis; public user?: User; public tenant: Tenant; + public tenantCache: TenantCache; - constructor({ user, tenant, db }: TenantContextOptions) { - super({ user }); + constructor({ + req, + user, + tenant, + mongo, + redis, + tenantCache, + }: TenantContextOptions) { + super({ user, req }); 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 b747a1be2..79acdeda9 100644 --- a/src/core/server/graph/tenant/loaders/assets.ts +++ b/src/core/server/graph/tenant/loaders/assets.ts @@ -10,8 +10,8 @@ import { findOrCreate } from "talk-server/services/assets"; export default (ctx: TenantContext) => ({ findOrCreate: (input: FindOrCreateAssetInput) => - findOrCreate(ctx.db, ctx.tenant, input), + 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 719046a83..111a40cd4 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -14,7 +14,7 @@ import { export default (ctx: Context) => ({ comment: new DataLoader((ids: string[]) => - retrieveManyComments(ctx.db, ctx.tenant.id, ids) + retrieveManyComments(ctx.mongo, ctx.tenant.id, ids) ), forAsset: ( assetID: string, @@ -25,7 +25,7 @@ export default (ctx: Context) => ({ after, }: AssetToCommentsArgs ) => - retrieveCommentAssetConnection(ctx.db, ctx.tenant.id, assetID, { + retrieveCommentAssetConnection(ctx.mongo, ctx.tenant.id, assetID, { first, orderBy, after, @@ -40,9 +40,15 @@ export default (ctx: Context) => ({ after, }: CommentToRepliesArgs ) => - retrieveCommentRepliesConnection(ctx.db, ctx.tenant.id, assetID, parentID, { - first, - orderBy, - after, - }), + 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 34bbebc10..c684a9d23 100644 --- a/src/core/server/graph/tenant/loaders/users.ts +++ b/src/core/server/graph/tenant/loaders/users.ts @@ -4,6 +4,6 @@ import { retrieveManyUsers, User } from "talk-server/models/user"; export default (ctx: Context) => ({ user: new DataLoader(ids => - retrieveManyUsers(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 f27e2430e..3be331d25 100644 --- a/src/core/server/graph/tenant/middleware.ts +++ b/src/core/server/graph/tenant/middleware.ts @@ -1,4 +1,5 @@ import { GraphQLSchema } from "graphql"; +import { Redis } from "ioredis"; import { Db } from "mongodb"; import { Config } from "talk-common/config"; @@ -7,15 +8,34 @@ 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 1ad380a78..1560b0095 100644 --- a/src/core/server/graph/tenant/mutators/comment.ts +++ b/src/core/server/graph/tenant/mutators/comment.ts @@ -5,12 +5,17 @@ import { create } from "talk-server/services/comments"; export default (ctx: TenantContext) => ({ create: (input: GQLCreateCommentInput): Promise => { - // FIXME: remove tenant + user ! - return create(ctx.db, ctx.tenant, { - author_id: ctx.user!.id, - asset_id: input.assetID, - body: input.body, - parent_id: input.parentID, - }); + 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/auth_integrations.ts b/src/core/server/graph/tenant/resolvers/auth_integrations.ts index 6842b891b..164e15bfe 100644 --- a/src/core/server/graph/tenant/resolvers/auth_integrations.ts +++ b/src/core/server/graph/tenant/resolvers/auth_integrations.ts @@ -1,5 +1,5 @@ import { GQLAuthIntegrationsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { AuthIntegration, AuthIntegrations } from "talk-server/models/tenant"; +import { AuthIntegration, AuthIntegrations } from "talk-server/models/settings"; const disabled: AuthIntegration = { enabled: false }; diff --git a/src/core/server/graph/tenant/resolvers/auth_settings.ts b/src/core/server/graph/tenant/resolvers/auth_settings.ts index ed8ec8659..6847244a9 100644 --- a/src/core/server/graph/tenant/resolvers/auth_settings.ts +++ b/src/core/server/graph/tenant/resolvers/auth_settings.ts @@ -1,5 +1,5 @@ import { GQLAuthSettingsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { Auth } from "talk-server/models/tenant"; +import { Auth } from "talk-server/models/settings"; const AuthSettings: GQLAuthSettingsTypeResolver = { integrations: auth => auth.integrations, diff --git a/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts b/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts index c0a127d82..ddccd91ef 100644 --- a/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts +++ b/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts @@ -1,5 +1,5 @@ import { GQLFacebookAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { FacebookAuthIntegration } from "talk-server/models/tenant"; +import { FacebookAuthIntegration } from "talk-server/models/settings"; const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver< 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 index 8edbabcf6..6ca00f87a 100644 --- a/src/core/server/graph/tenant/resolvers/google_auth_integration.ts +++ b/src/core/server/graph/tenant/resolvers/google_auth_integration.ts @@ -1,5 +1,5 @@ import { GQLGoogleAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { GoogleAuthIntegration } from "talk-server/models/tenant"; +import { GoogleAuthIntegration } from "talk-server/models/settings"; const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver< GoogleAuthIntegration diff --git a/src/core/server/graph/tenant/resolvers/local_auth_integration.ts b/src/core/server/graph/tenant/resolvers/local_auth_integration.ts index fe14c7b92..3b99eec6a 100644 --- a/src/core/server/graph/tenant/resolvers/local_auth_integration.ts +++ b/src/core/server/graph/tenant/resolvers/local_auth_integration.ts @@ -1,5 +1,5 @@ import { GQLLocalAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { LocalAuthIntegration } from "talk-server/models/tenant"; +import { LocalAuthIntegration } from "talk-server/models/settings"; const LocalAuthIntegration: GQLLocalAuthIntegrationTypeResolver< LocalAuthIntegration diff --git a/src/core/server/graph/tenant/resolvers/mutation.ts b/src/core/server/graph/tenant/resolvers/mutation.ts index f51f0fc1b..8f15117c9 100644 --- a/src/core/server/graph/tenant/resolvers/mutation.ts +++ b/src/core/server/graph/tenant/resolvers/mutation.ts @@ -5,6 +5,10 @@ const Mutation: GQLMutationTypeResolver = { 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 index 966001ab9..8595a39f4 100644 --- a/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts +++ b/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts @@ -1,5 +1,5 @@ import { GQLOIDCAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { OIDCAuthIntegration } from "talk-server/models/tenant"; +import { OIDCAuthIntegration } from "talk-server/models/settings"; const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver< OIDCAuthIntegration diff --git a/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts b/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts index 562cad894..14b8e2b82 100644 --- a/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts +++ b/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts @@ -1,5 +1,5 @@ import { GQLSSOAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { SSOAuthIntegration } from "talk-server/models/tenant"; +import { SSOAuthIntegration } from "talk-server/models/settings"; const SSOAuthIntegration: GQLSSOAuthIntegrationTypeResolver< SSOAuthIntegration diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index d8f082ff7..b045c572f 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -25,6 +25,19 @@ Cursor represents a paginating cursor. """ scalar Cursor +################################################################################ +## Actions +################################################################################ + +enum ACTION_TYPE { + FLAG + DONTAGREE +} + +enum ACTION_ITEM_TYPE { + COMMENTS +} + ################################################################################ ## Settings ################################################################################ @@ -287,7 +300,7 @@ type Settings { """ domains will return a given list of whitelisted domains. """ - domains: [String!] @auth(roles: [ADMIN]) @auth(roles: [ADMIN]) + domains: [String!] @auth(roles: [ADMIN]) """ auth contains all the settings related to authentication and authorization. @@ -301,6 +314,7 @@ type Settings { enum USER_ROLE { COMMENTER + STAFF MODERATOR ADMIN } @@ -390,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 } """ @@ -661,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 ################## @@ -670,6 +770,11 @@ type Mutation { createComment will create a Comment as the current logged in User. """ 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 3ef89bde4..47a0c2db4 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -7,6 +7,7 @@ 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 logger from "./logger"; import { createMongoDB } from "./services/mongodb"; @@ -68,6 +69,12 @@ class Server { // 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, @@ -76,6 +83,7 @@ class Server { 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 7a9ee886e..5332d9778 100644 --- a/src/core/server/logger.ts +++ b/src/core/server/logger.ts @@ -1,8 +1,12 @@ -import bunyan from "bunyan"; +import bunyan, { LogLevelString } from "bunyan"; + +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 b09583d9c..40fbedf0a 100644 --- a/src/core/server/models/asset.ts +++ b/src/core/server/models/asset.ts @@ -4,6 +4,7 @@ import { Db } from "mongodb"; import uuid from "uuid"; import { Omit } from "talk-common/types"; +import { ModerationSettings } from "talk-server/models/settings"; import { TenantResource } from "talk-server/models/tenant"; function collection(db: Db) { @@ -25,6 +26,12 @@ 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 interface UpsertAssetInput { @@ -170,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 875317b8d..430f85225 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -1,4 +1,3 @@ -import { merge } from "lodash"; import { Db } from "mongodb"; import uuid from "uuid"; @@ -91,17 +90,14 @@ export async function createComment( }; // 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; } 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 bc19c6553..bd67c99b4 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -1,13 +1,10 @@ import dotize from "dotize"; -import { merge } from "lodash"; import { Db } from "mongodb"; import uuid from "uuid"; -import { Sub } from "talk-common/types"; -import { - GQLMODERATION_MODE, - GQLUSER_ROLE, -} from "talk-server/graph/tenant/schema/__generated__/types"; +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"); @@ -17,147 +14,21 @@ export interface TenantResource { readonly tenant_id: string; } -export interface Wordlist { - banned: string[]; - suspect: string[]; -} - -// AuthIntegrations. - -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 local 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; -} - -// Tenant definition. - -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: GQLMODERATION_MODE; - 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; - - // 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 is the list of domains that are allowed to have the iframe load on. domains: string[]; - // Set of configured authentication integrations. - auth: Auth; + organizationName: string; + organizationContactEmail: string; } /** @@ -207,10 +78,27 @@ export async function createTenant(db: Db, input: CreateTenantInput) { }, }, }, + 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); @@ -258,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 5adc4bc53..9b1d04343 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -1,5 +1,4 @@ import bcrypt from "bcryptjs"; -import { merge } from "lodash"; import { Db } from "mongodb"; import uuid from "uuid"; @@ -131,11 +130,14 @@ export async function upsertUser( } // Merge the defaults and the input together. - const user: Readonly = merge({}, defaults, input, { + const user: Readonly = { + ...defaults, + ...input, + // Specified last in the merge call, it will override any existing password // entry if it is defined. password: hashedPassword, - }); + }; // 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 diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index dfba035d4..6bacbd415 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -1,22 +1,68 @@ import { Db } from "mongodb"; import { Omit } from "talk-common/types"; -import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types"; -import { createComment, CreateCommentInput } from "talk-server/models/comment"; +import { retrieveAsset } from "talk-server/models/asset"; +import { + 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, "status" | "action_counts" >; -export async function create(db: Db, tenant: Tenant, input: CreateComment) { - // TODO: run the comment through the moderation phases. - const comment = await createComment(db, tenant.id, { - status: GQLCOMMENT_STATUS.ACCEPTED, +export async function create( + 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/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/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/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/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 +}