Merge branch 'ui-components' of github.com:coralproject/talk into ui-components

This commit is contained in:
Belen Curcio
2018-08-03 09:25:11 -03:00
159 changed files with 6379 additions and 1540 deletions
+447
View File
@@ -0,0 +1,447 @@
import CaseSensitivePathsPlugin from "case-sensitive-paths-webpack-plugin";
import ExtractTextPlugin from "extract-text-webpack-plugin";
import HtmlWebpackPlugin, { Options } from "html-webpack-plugin";
import path from "path";
import InterpolateHtmlPlugin from "react-dev-utils/InterpolateHtmlPlugin";
import WatchMissingNodeModulesPlugin from "react-dev-utils/WatchMissingNodeModulesPlugin";
import TsconfigPathsPlugin from "tsconfig-paths-webpack-plugin";
import UglifyJsPlugin from "uglifyjs-webpack-plugin";
import webpack, { Configuration } from "webpack";
import ManifestPlugin from "webpack-manifest-plugin";
import paths from "./paths";
interface CreateWebpackConfig {
publicPath?: string;
publicURL?: string;
env?: Record<string, string>;
disableSourcemaps?: boolean;
appendPlugins?: any[];
}
export default function createWebpackConfig({
publicPath = "/",
publicURL = "",
env = process.env as Record<string, string>,
appendPlugins = [],
disableSourcemaps,
}: CreateWebpackConfig = {}): Configuration[] {
const envStringified = {
"process.env": Object.keys(env).reduce<Record<string, string>>(
(result, key) => {
result[key] = JSON.stringify(env[key]);
return result;
},
{}
),
};
const isProduction = env.NODE_ENV === "production";
const htmlWebpackConfig: Options = {
minify: isProduction && {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
};
const styleLoader = {
loader: require.resolve("style-loader"),
options: {
hmr: !isProduction,
},
};
const cssLoaders = [
{
loader: require.resolve("css-loader"),
options: {
modules: true,
importLoaders: 1,
localIdentName: "[name]-[local]-[hash:base64:5]",
minimize: isProduction,
sourceMap: isProduction && !disableSourcemaps,
},
},
{
loader: require.resolve("postcss-loader"),
options: {
config: {
path: paths.appPostCssConfig,
},
},
},
];
const additionalPlugins = isProduction
? [
// Minify the code.
new UglifyJsPlugin({
uglifyOptions: {
compress: {
warnings: false,
// Disabled because of an issue with Uglify breaking seemingly valid code:
// https://github.com/facebookincubator/create-react-app/issues/2376
// Pending further investigation:
// https://github.com/mishoo/UglifyJS2/issues/2011
comparisons: false,
},
mangle: {
safari10: true,
},
output: {
comments: false,
// Turned on because emoji and regex is not minified properly using default
// https://github.com/facebookincubator/create-react-app/issues/2488
ascii_only: true,
},
},
sourceMap: !disableSourcemaps,
}),
// Note: this won't work without ExtractTextPlugin.extract(..) in `loaders`.
new ExtractTextPlugin({
// We use [md5:contenthash:hex:20] instead of [contenthash:8]
// because of this bug https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/763.
// TODO: Repalce with mini-css-extract-plugin once it supports HMR.
// https://github.com/webpack-contrib/mini-css-extract-plugin
filename: "assets/css/[name].[md5:contenthash:hex:20].css",
}),
]
: [
// Add module names to factory functions so they appear in browser profiler.
new webpack.NamedModulesPlugin(),
// This is necessary to emit hot updates (currently CSS only):
new webpack.HotModuleReplacementPlugin(),
// Watcher doesn't work well if you mistype casing in a path so we use
// a plugin that prints an error when you attempt to do this.
// See https://github.com/facebookincubator/create-react-app/issues/240
new CaseSensitivePathsPlugin(),
// If you require a missing module and then `npm install` it, you still have
// to restart the development server for Webpack to discover it. This plugin
// makes the discovery automatic so you don't have to restart.
// See https://github.com/facebookincubator/create-react-app/issues/186
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
];
const baseConfig: Configuration = {
// Set webpack mode.
mode: isProduction ? "production" : "development",
devtool:
!disableSourcemaps && isProduction
? // We generate sourcemaps in production. This is slow but gives good results.
// You can exclude the *.map files from the build during deployment.
"source-map"
: // You may want 'eval' instead if you prefer to see the compiled output in DevTools.
// See the discussion in https://github.com/facebookincubator/create-react-app/issues/343.
"cheap-module-source-map",
// These are the "entry points" to our application.
// This means they will be the "root" imports that are included in JS bundle.
// The first two entry points enable "hot" CSS and auto-refreshes for JS.
output: {
// Add /* filename */ comments to generated require()s in the output.
pathinfo: !isProduction,
// The dist folder.
path: paths.appDistStatic,
// Generated JS file names (with nested folders).
// There will be one main bundle, and one file per asynchronous chunk.
filename: isProduction
? "assets/js/[name].[chunkhash:8].js"
: "assets/js/[name].js",
chunkFilename: isProduction
? "assets/js/[name].[chunkhash:8].chunk.js"
: "assets/js/[name].chunk.js",
// We inferred the "public path" (such as / or /my-project) from homepage.
publicPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: (info: any) =>
path
.relative(paths.appSrc, info.absoluteResourcePath)
.replace(/\\/g, "/"),
},
resolve: {
extensions: [".js", ".json", ".ts", ".tsx"],
plugins: [
// Support `tsconfig.json` `path` setting.
new TsconfigPathsPlugin({
configFile: paths.appTsconfig,
extensions: [".js", ".ts", ".tsx"],
}),
],
},
resolveLoader: {
// Add path to our own loaders.
modules: ["node_modules", paths.appLoaders],
},
module: {
strictExportPresence: true,
rules: [
// Disable require.ensure as it's not a standard language feature.
{ parser: { requireEnsure: false } },
// First, run the linter.
// It's important to do this before Babel processes the JS.
{
test: /\.(js|ts|tsx)$/,
enforce: "pre",
use: [
{
options: {
tsConfigFile: paths.appTsconfig,
},
loader: require.resolve("tslint-loader"),
},
],
include: paths.appSrc,
},
{
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
// back to the "file" loader at the end of the loader list.
oneOf: [
{
test: paths.appStreamLocalesTemplate,
use: [
// This is the locales loader that loads available locales
// from a particular target.
{
loader: "locales-loader",
options: {
pathToLocales: paths.appLocales,
// Default locale if non could be negotiated.
defaultLocale: "en-US",
// Fallback locale if a translation was not found.
// If not set, will use the text that is already
// in the code base.
fallbackLocale: "en-US",
// Common fluent files are always included in the locale bundles.
commonFiles: ["framework.ftl", "common.ftl"],
// Locales that come with the main bundle. Others are loaded on demand.
bundled: ["en-US"],
// Target specifies the prefix for fluent files to be loaded.
// ${target}-xyz.ftl and ${†arget}.ftl are loaded into the locales.
target: "stream",
// All available locales can be loadable on demand.
// To restrict available locales set:
// availableLocales: ["en-US"],
},
},
],
},
// Loader for our fluent files.
{
test: /\.ftl$/,
use: ["raw-loader"],
},
// "url" loader works like "file" loader except that it embeds assets
// smaller than specified limit in bytes as data URLs to avoid requests.
// A missing `test` is equivalent to a match.
{
test: [/\.gif$/, /\.jpe?g$/, /\.png$/],
loader: require.resolve("url-loader"),
options: {
limit: 10000,
name: "assets/media/[name].[hash:8].[ext]",
},
},
// Process JS with Babel.
{
test: /\.(ts|tsx)$/,
include: paths.appSrc,
use: [
{
loader: require.resolve("babel-loader"),
options: {
// This is a feature of `babel-loader` for webpack (not Babel itself).
// It enables caching results in ./node_modules/.cache/babel-loader/
// directory for faster rebuilds.
cacheDirectory: true,
},
},
{
loader: "ts-loader",
options: {
configFile: paths.appTsconfig,
compilerOptions: {
target: "es2015",
module: "esnext",
jsx: "preserve",
noEmit: false,
},
// Overwrites the behavior of `include` and `exclude` to only
// include files that are actually being imported and which
// are necessary to compile the bundle.
onlyCompileBundledFiles: true,
},
},
],
},
// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader turns CSS into JS modules that inject <style> tags.
// In production, we use a plugin to extract that CSS to a file, and
// in development "style" loader enables hot editing of CSS.
{
test: /\.css$/,
loader:
(isProduction &&
ExtractTextPlugin.extract({
fallback: styleLoader,
use: cssLoaders,
})) ||
undefined,
use:
(!isProduction && [
require.resolve("style-loader"),
...cssLoaders,
]) ||
undefined,
},
// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename.
// In production, they would get copied to the `build` folder.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
// Exclude `js` files to keep "css" loader working as it injects
// its runtime that would otherwise processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [/\.(js|ts|tsx)$/, /\.html$/, /\.json$/],
loader: require.resolve("file-loader"),
options: {
name: "assets/media/[name].[hash:8].[ext]",
},
},
],
},
// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.
],
},
plugins: [
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'development') { ... }. See `./env.js`.
new webpack.DefinePlugin(envStringified),
...additionalPlugins,
...appendPlugins,
],
// Some libraries import Node modules but don't use them in the browser.
// Tell Webpack to provide empty mocks for them so importing them works.
node: {
dgram: "empty",
fs: "empty",
net: "empty",
tls: "empty",
child_process: "empty",
},
// Turn off performance hints during development because we don't do any
// splitting or minification in interest of speed. These warnings become
// cumbersome.
performance: {
hints: isProduction && "warning",
},
};
return [
/* Webpack config for our different target, e.g. stream, admin... */
{
...baseConfig,
entry: {
stream: [
// We ship polyfills by default
paths.appPolyfill,
// Include an alternative client for WebpackDevServer. A client's job is to
// connect to WebpackDevServer by a socket and get notified about changes.
// When you save a file, the client will either apply hot updates (in case
// of CSS changes), or refresh the page (in case of JS changes). When you
// make a syntax error, this client will display a syntax error overlay.
// Note: instead of the default WebpackDevServer client, we use a custom one
// to bring better experience for Create React App users. You can replace
// the line below with these two lines if you prefer the stock client:
// require.resolve('webpack-dev-server/client') + '?/',
// require.resolve('webpack/hot/dev-server'),
(isProduction && "") ||
require.resolve("react-dev-utils/webpackHotDevClient"),
paths.appStreamIndex,
// Remove deactivated entries.
].filter(s => s),
},
plugins: [
...baseConfig.plugins!,
// Generates an `stream.html` file with the <script> injected.
new HtmlWebpackPlugin({
filename: "stream.html",
template: paths.appStreamHTML,
chunks: ["stream"],
inject: "body",
...htmlWebpackConfig,
}),
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
// In development, this will be an empty string.
new InterpolateHtmlPlugin(env),
// Generate a manifest file which contains a mapping of all asset filenames
// to their corresponding output file so that tools can pick it up without
// having to parse `index.html`.
new ManifestPlugin({
fileName: "asset-manifest.json",
}),
],
},
/* Webpack config for our embed */
{
...baseConfig,
entry: [
// No polyfills for the embed.
(isProduction && "") ||
require.resolve("react-dev-utils/webpackHotDevClient"),
paths.appEmbedIndex,
// Remove deactivated entries.
].filter(s => s),
output: {
...baseConfig.output,
library: "Talk",
// don't hash the embed, cache-busting must be completed by the requester
// as this lives in a static template on the embed site.
filename: "assets/js/embed.js",
},
plugins: [
...baseConfig.plugins!,
// Generates an `stream.html` file with the <script> injected.
new HtmlWebpackPlugin({
filename: "embed.html",
template: paths.appEmbedHTML,
inject: "head",
...htmlWebpackConfig,
}),
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
// In development, this will be an empty string.
new InterpolateHtmlPlugin(env),
// Generate a manifest file which contains a mapping of all asset filenames
// to their corresponding output file so that tools can pick it up without
// having to parse `index.html`.
new ManifestPlugin({
fileName: "embed-manifest.json",
}),
],
},
];
}
+206
View File
@@ -0,0 +1,206 @@
const loaderUtils = require("loader-utils");
const fs = require("fs");
const path = require("path");
const memoize = require("lodash/memoize");
/**
* Default values for every param that can be passed in the loader query.
*/
const DEFAULT_QUERY_VALUES = {
// Path to locales.
pathToLocales: null,
// Default locale if non could be negotiated.
defaultLocale: "en-US",
// Fallback locale if a translation was not found.
// If not set, will use the text that is already
// in the code base.
fallbackLocale: "",
// If set, restrict to this list of available locales.
availableLocales: null,
// Common fluent files are always included in the locale bundles.
commonFiles: [],
// Locales that come with the main bundle. Others are loaded on demand.
bundled: [],
// Target specifies the prefix for fluent files to be loaded. ${target}-xyz.ftl and ${†arget}.ftl are
// loaded into the locales.
target: "",
};
function getFiles(target, pathToLocale, context) {
const { commonFiles } = context;
const common = [];
const suffixes = [];
const files = fs.readdirSync(pathToLocale);
files.forEach(f => {
if (commonFiles.includes(f)) {
common.push(f);
return;
}
if (f.startsWith(target)) {
suffixes.push(f.substr(target.length));
return;
}
});
return { common, suffixes };
}
function generateTarget(target, context) {
const {
defaultLocale,
fallbackLocale,
pathToLocales,
resourcePath,
locales,
bundled,
} = context;
const getLocalePath = locale => path.join(pathToLocales, locale);
const getLocaleFiles = memoize(locale =>
getFiles(target, getLocalePath(locale), context)
);
const loadables = locales.filter(locale => !bundled.includes(locale));
return `
var ret = {
defaultLocale: ${JSON.stringify(defaultLocale)},
fallbackLocale: ${JSON.stringify(fallbackLocale)},
availableLocales: ${JSON.stringify(locales)},
bundled: {},
loadables: {},
};
// Bundled locales are directly available in the main bundle.
${bundled
.map(
locale => `
{
var suffixes = ${JSON.stringify(getLocaleFiles(locale).suffixes)};
var contents = [];
${getLocaleFiles(locale)
.common.map(
file => `
contents.push(require(${JSON.stringify(
path.join(getLocalePath(locale), file).replace(/\\/g, "/")
)}));
`
)
.join("\n")}
contents = contents.concat(suffixes.map(function(suffix) { return require(\`${path
.join(getLocalePath(locale), target)
.replace(/\\/g, "/")}\${suffix}\`); }));
ret.bundled[${JSON.stringify(locale)}] = contents.join("\\n");
}
`
)
.join("\n")}
// Loadables are in a separate bundle, that can be easily loaded.
${loadables
.map(
locale => `
ret.loadables[${JSON.stringify(locale)}] = function() {
var suffixes = ${JSON.stringify(getLocaleFiles(locale).suffixes)};
var promises = [];
${getLocaleFiles(locale)
.common.map(
file => `
promises.push(
import(
/* webpackChunkName: ${JSON.stringify(
`${target}-locale-${locale}`
)}, webpackMode: "lazy" */
${JSON.stringify(
path.join(getLocalePath(locale), file).replace(/\\/g, "/")
)}
)
);
`
)
.join("\n")}
promises = promises.concat(suffixes.map(function(suffix) {
return import(
/* webpackChunkName: ${JSON.stringify(
`${target}-locale-${locale}`
)}, webpackMode: "lazy-once" */
\`${path
.join(getLocalePath(locale), target)
.replace(/\\/g, "/")}\${suffix}\`
)
}));
return Promise.all(promises).then(function(modules) {
return modules.map(function(m){return m.default}).join("\\n");
});
};
`
)
.join("\n")}
module.exports = ret;
`;
}
module.exports = function(source) {
const options = Object.assign(
{},
DEFAULT_QUERY_VALUES,
loaderUtils.getOptions(this)
);
const {
pathToLocales,
defaultLocale,
fallbackLocale,
availableLocales,
target,
bundled,
commonFiles,
} = options;
let locales = fs.readdirSync(pathToLocales);
if (availableLocales) {
availableLocales.forEach(locale => {
if (!locales.includes(locale)) {
throw new Error(`locale ${fallbackLocale} not available`);
}
});
locales = availableLocales;
}
if (fallbackLocale && !locales.includes(fallbackLocale)) {
throw new Error(
`fallbackLocale ${fallbackLocale} not in available locales`
);
}
if (!pathToLocales) {
throw new Error(`pathToLocales is required`);
}
if (!defaultLocale) {
throw new Error(`defaultLocale is required`);
}
if (!locales.includes(defaultLocale)) {
throw new Error(`defaultLocale ${defaultLocale} not in available locales`);
}
const context = {
// Use relative paths because it fails on Windows.
pathToLocales,
resourcePath: this.resourcePath,
defaultLocale,
fallbackLocale,
commonFiles,
locales,
bundled,
};
this.cacheable();
return generateTarget(target, context);
};
+33
View File
@@ -0,0 +1,33 @@
import fs from "fs";
import path from "path";
// 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: string) =>
path.resolve(appDirectory, relativePath);
const resolveSrc = (relativePath: string) =>
path.resolve(__dirname, "../../", relativePath);
export default {
appPostCssConfig: resolveSrc("core/build/postcss.config.js"),
appLoaders: resolveSrc("core/build/loaders"),
appSrc: resolveSrc("."),
appTsconfig: resolveSrc("core/client/tsconfig.json"),
appPolyfill: resolveSrc("core/build/polyfills.js"),
appLocales: resolveSrc("locales"),
appThemeVariables: resolveSrc("core/client/ui/theme/variables.ts"),
appThemeVariablesCSS: resolveSrc("core/client/ui/theme/variables.css"),
appStreamHTML: resolveSrc("core/client/stream/index.html"),
appStreamLocalesTemplate: resolveSrc("core/client/stream/locales.ts"),
appStreamIndex: resolveSrc("core/client/stream/index.tsx"),
appEmbedIndex: resolveSrc("core/client/embed/index.ts"),
appEmbedHTML: resolveSrc("core/client/embed/index.html"),
appDistStatic: resolveApp("dist/static"),
appPublic: resolveApp("public"),
appPackageJson: resolveApp("package.json"),
appNodeModules: resolveApp("node_modules"),
};
+1
View File
@@ -0,0 +1 @@
require("@babel/polyfill");
+78
View File
@@ -0,0 +1,78 @@
const kebabCase = require("lodash/kebabCase");
const mapKeys = require("lodash/mapKeys");
const mapValues = require("lodash/mapValues");
const pickBy = require("lodash/pickBy");
const flat = require("flat");
const paths = require("./paths").default;
const autoprefixer = require("autoprefixer");
const postcssFontMagician = require("postcss-font-magician");
const postcssFlexbugsFixes = require("postcss-flexbugs-fixes");
const postcssVariables = require("postcss-css-variables");
const postcssPresetEnv = require("postcss-preset-env");
const postcssNested = require("postcss-nested");
const postcssImport = require("postcss-import");
const postcssPrependImports = require("postcss-prepend-imports");
const postcssAdvancedVariables = require("postcss-advanced-variables");
delete require.cache[paths.appThemeVariables];
const variables = require(paths.appThemeVariables).default;
const flatKebabVariables = mapKeys(
mapValues(flat(variables, { delimiter: "-" }), v => v.toString()),
(_, k) => kebabCase(k)
);
// These are the default css standard variables.
const cssVariables = pickBy(
flatKebabVariables,
(v, k) => !k.startsWith("breakpoints-")
);
// These are sass style variables used in media queries.
const mediaQueryVariables = mapValues(
pickBy(flatKebabVariables, (v, k) => k.startsWith("breakpoints-")),
// Add unit to breakpoints.
// Add 1 to support mobile first approach where we start
// with the smallest screen and gradually add styling for the
// next bigger screen. This is realized using `min-width` without
// ever using `max-width`.
v => `${Number.parseInt(v) + 1}px`
);
module.exports = {
// Necessary for external CSS imports to work
// https://github.com/facebookincubator/create-react-app/issues/2677
ident: "postcss",
plugins: [
// This allows us to define dynamic css variables.
postcssPrependImports({
path: "",
files: [paths.appThemeVariablesCSS],
}),
// Needed by above plugin.
postcssImport(),
// Support nesting.
postcssNested(),
// Sass style variables to be used in media queries.
postcssAdvancedVariables({ variables: mediaQueryVariables }),
// CSS standard variables for everything else.
postcssVariables({
variables: cssVariables,
}),
// Provides a modern CSS environment.
postcssPresetEnv(),
// Does all the font handling logic.
postcssFontMagician(),
// Fix known flexbox bugs.
postcssFlexbugsFixes,
// Vendor prefixing.
autoprefixer({
browsers: [
">1%",
"last 4 versions",
"Firefox ESR",
"not ie < 9", // React doesn't support IE8 anyway
],
flexbox: "no-2009",
}),
],
};
+55
View File
@@ -0,0 +1,55 @@
import sinon from "sinon";
import { Decorator } from "./decorators";
import PymControl from "./PymControl";
describe("PymControl", () => {
const container: HTMLElement = document.createElement("div");
const cleanupDecorator = sinon.mock().once();
const withMockDecorator: Decorator = sinon
.mock()
.once()
.withArgs(sinon.match.object)
.returns(cleanupDecorator);
let control: PymControl;
beforeAll(() => {
container.id = "pymcontrol-test-id";
document.body.appendChild(container);
});
afterAll(() => {
document.body.removeChild(container);
});
it("should create iframe", () => {
control = new PymControl({
decorators: [withMockDecorator],
id: container.id,
url: "http://coralproject.net",
title: "iFrame title",
});
expect(container.innerHTML).toMatchSnapshot();
});
it("should send message", done => {
const messages: MessageEvent[] = [];
const messageRecorder = (e: MessageEvent) => messages.push(e);
const contentWindow = (container.firstChild as HTMLIFrameElement)
.contentWindow!;
contentWindow.addEventListener("message", messageRecorder, false);
control.sendMessage("test", "hello world");
setTimeout(() => {
contentWindow.removeEventListener("message", messageRecorder, false);
expect(messages).toHaveLength(1);
expect(messages[0].data).toMatchSnapshot();
done();
});
});
it("should remove iframe", () => {
control.remove();
expect(container.innerHTML).toBe("");
});
it("should cleanup decorators", () => {
cleanupDecorator.verify();
});
});
+41
View File
@@ -0,0 +1,41 @@
import pym from "pym.js";
import { CleanupCallback, Decorator } from "./decorators";
interface PymControlConfig {
id: string;
url: string;
title: string;
decorators?: ReadonlyArray<Decorator>;
}
export default class PymControl {
private pym: pym.Parent;
private cleanups: CleanupCallback[];
constructor(config: PymControlConfig) {
const decorators = config.decorators || [];
this.pym = new pym.Parent(config.id, config.url, {
title: config.title,
id: `${config.id}_iframe`,
name: `${config.id}_iframe`,
});
this.cleanups = decorators
.map(enhance => enhance(this.pym))
.filter(cb => cb) as CleanupCallback[];
}
public sendMessage(id: string, raw?: string) {
this.pym.sendMessage(id, raw || "");
}
public remove() {
this.cleanups.forEach(cb => cb());
this.cleanups = [];
// Remove the pym parent.
this.pym.remove();
}
}
+70
View File
@@ -0,0 +1,70 @@
import sinon from "sinon";
import { createStreamInterface } from "./Stream";
it("should call eventEmitter.on", () => {
const control = {};
const cb = () => "";
const eventEmitter = {
on: sinon
.mock()
.once()
.withArgs("eventName", cb),
};
const stream = createStreamInterface(control as any, eventEmitter as any);
stream.on("eventName", cb);
eventEmitter.on.verify();
});
it("should call eventEmitter.off", () => {
const control = {};
const cb = () => "";
const eventEmitter = {
off: sinon
.mock()
.once()
.withArgs("eventName", cb),
};
const stream = createStreamInterface(control as any, eventEmitter as any);
stream.off("eventName", cb);
eventEmitter.off.verify();
});
it("should call control.login", () => {
const control = {
sendMessage: sinon
.mock()
.once()
.withArgs("login", "token"),
};
const eventEmitter = {};
const stream = createStreamInterface(control as any, eventEmitter as any);
stream.login("token");
control.sendMessage.verify();
});
it("should call control.logout", () => {
const control = {
sendMessage: sinon
.mock()
.once()
.withArgs("logout"),
};
const eventEmitter = {};
const stream = createStreamInterface(control as any, eventEmitter as any);
stream.logout();
control.sendMessage.verify();
});
it("should call control.remove", () => {
const control = {
remove: sinon
.mock()
.once()
.withArgs(),
};
const eventEmitter = {};
const stream = createStreamInterface(control as any, eventEmitter as any);
stream.remove();
control.remove.verify();
});
+83
View File
@@ -0,0 +1,83 @@
import { EventEmitter2 } from "eventemitter2";
import qs from "query-string";
import {
Decorator,
withAutoHeight,
withClickEvent,
withCommentID,
withEventEmitter,
withIOSSafariWidthWorkaround,
} from "./decorators";
import PymControl from "./PymControl";
import { ensureEndSlash } from "./utils";
interface CreatePymControlConfig {
assetID?: string;
assetURL?: string;
title?: string;
eventEmitter: EventEmitter2;
id: string;
rootURL: string;
}
export function createPymControl(config: CreatePymControlConfig) {
const streamDecorators: ReadonlyArray<Decorator> = [
withIOSSafariWidthWorkaround,
withAutoHeight,
withClickEvent,
withCommentID,
withEventEmitter(config.eventEmitter),
];
const query = qs.stringify({
assetID: config.assetID,
assetURL: config.assetURL,
});
const url = `${ensureEndSlash(config.rootURL)}stream.html?${query}`;
return new PymControl({
id: config.id,
title: config.title || "Talk Embed Stream",
decorators: streamDecorators,
url,
});
}
type EventCallback = (data: any) => void;
export function createStreamInterface(
control: PymControl,
eventEmitter: EventEmitter2
) {
return {
on(eventName: string, callback: EventCallback) {
return eventEmitter.on(eventName, callback);
},
off(eventName: string, callback: EventCallback) {
return eventEmitter.off(eventName, callback);
},
login(token: string) {
control.sendMessage("login", token);
},
logout() {
control.sendMessage("logout");
},
remove() {
return control.remove();
},
};
}
export type StreamInterface = ReturnType<typeof createStreamInterface>;
export interface CreateConfig {
assetID?: string;
assetURL?: string;
title?: string;
eventEmitter: EventEmitter2;
id: string;
rootURL: string;
}
export default function create(config: CreateConfig) {
return createStreamInterface(createPymControl(config), config.eventEmitter);
}
@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PymControl should create iframe 1`] = `"<iframe src=\\"http://coralproject.net/?initialWidth=0&amp;childId=pymcontrol-test-id&amp;parentTitle=&amp;parentUrl=http%3A%2F%2Flocalhost%2F\\" width=\\"100%\\" scrolling=\\"no\\" marginheight=\\"0\\" frameborder=\\"0\\" title=\\"iFrame title\\" id=\\"pymcontrol-test-id_iframe\\" name=\\"pymcontrol-test-id_iframe\\"></iframe>"`;
exports[`PymControl should send message 1`] = `"pymxPYMxpymcontrol-test-idxPYMxtestxPYMxhello world"`;
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Basic integration test should render iframe 1`] = `"<iframe src=\\"http://localhost/stream.html?&amp;initialWidth=0&amp;childId=basic-integration-test-id&amp;parentTitle=&amp;parentUrl=http%3A%2F%2Flocalhost%2F\\" width=\\"100%\\" scrolling=\\"no\\" marginheight=\\"0\\" frameborder=\\"0\\" title=\\"Talk Embed Stream\\" id=\\"basic-integration-test-id_iframe\\" name=\\"basic-integration-test-id_iframe\\" style=\\"width: 1px; min-width: 100%;\\"></iframe>"`;
+11
View File
@@ -0,0 +1,11 @@
import pym from "pym.js";
export type CleanupCallback = () => void;
export type Decorator = (pym: pym.Parent) => CleanupCallback | void;
export { default as withAutoHeight } from "./withAutoHeight";
export { default as withClickEvent } from "./withClickEvent";
export { default as withCommentID } from "./withCommentID";
export { default as withEventEmitter } from "./withEventEmitter";
export {
default as withIOSSafariWidthWorkaround,
} from "./withIOSSafariWidthWorkaround";
@@ -0,0 +1,16 @@
import withAutoHeight from "./withAutoHeight";
it("should set height", () => {
const fakePym = {
onMessage: (type: string, callback: (height: string) => void) => {
expect(type).toBe("height");
callback("100");
},
el: document.createElement("div"),
};
fakePym.el.innerHTML = "<span>Hello World </span>";
withAutoHeight(fakePym as any);
expect(fakePym.el.innerHTML).toBe(
'<span style="height: 100px;">Hello World </span>'
);
});
@@ -0,0 +1,14 @@
import { Decorator } from "./";
const withAutoHeight: Decorator = pym => {
// Resize parent iframe height when child height changes
let cachedHeight: string;
pym.onMessage("height", (height: string) => {
if (height !== cachedHeight) {
(pym.el.firstChild! as HTMLElement).style.height = `${height}px`;
cachedHeight = height;
}
});
};
export default withAutoHeight;
@@ -0,0 +1,19 @@
import simulant from "simulant";
import sinon from "sinon";
import { CleanupCallback } from ".";
import withClickEvent from "./withClickEvent";
it("should send click events", () => {
const pymMock = {
sendMessage: sinon
.mock()
.once()
.withArgs("click", ""),
};
const cleanup = withClickEvent(pymMock as any) as CleanupCallback;
simulant.fire(document.body, "click");
cleanup();
simulant.fire(document.body, "click");
pymMock.sendMessage.verify();
});
@@ -0,0 +1,16 @@
import { Decorator } from "./";
const withClickEvent: Decorator = pym => {
const handleClick = () => pym.sendMessage("click", "");
// If the user clicks outside the embed, then tell the embed.
document.addEventListener("click", handleClick, true);
// Return cleanup callback.
return () => {
// Remove the event listeners.
document.removeEventListener("click", handleClick, true);
};
};
export default withClickEvent;
@@ -0,0 +1,36 @@
import withCommentID from "./withCommentID";
it("should add commentID", () => {
const previousLocation = location.toString();
const previousState = window.history.state;
const fakePym = {
onMessage: (type: string, callback: (id: string) => void) => {
if (type === "view-comment") {
callback("comment-id");
}
},
};
withCommentID(fakePym as any);
expect(location.toString()).toBe("http://localhost/?commentId=comment-id");
window.history.replaceState(previousState, document.title, previousLocation);
});
it("should remove commentID", () => {
const previousLocation = location.toString();
const previousState = window.history.state;
window.history.replaceState(
previousState,
document.title,
"http://localhost/?commentId=comment-id"
);
const fakePym = {
onMessage: (type: string, callback: () => void) => {
if (type === "view-all-comments") {
callback();
}
},
};
withCommentID(fakePym as any);
expect(location.toString()).toBe("http://localhost/");
window.history.replaceState(previousState, document.title, previousLocation);
});
@@ -0,0 +1,36 @@
import qs from "query-string";
import { buildURL } from "../utils";
import { Decorator } from "./";
const withCommentID: Decorator = pym => {
// Remove the comment id from the query.
pym.onMessage("view-all-comments", () => {
const search = qs.stringify({
...qs.parse(location.search),
commentId: undefined,
});
// Remove the commentId url param.
const url = buildURL({ search });
// Change the url.
window.history.replaceState({}, document.title, url);
});
// Add the permalink comment id to the query.
pym.onMessage("view-comment", (id: string) => {
const search = qs.stringify({
...qs.parse(location.search),
commentId: id,
});
// Remove the commentId url param.
const url = buildURL({ search });
// Change the url.
window.history.replaceState({}, document.title, url);
});
};
export default withCommentID;
@@ -0,0 +1,21 @@
import sinon from "sinon";
import withEventEmitter from "./withEventEmitter";
it("should emit events from pym to eventEmitter", () => {
const eventEmitterMock = {
emit: sinon
.mock()
.once()
.withArgs("eventName", "value"),
};
const fakePym = {
onMessage: (type: string, callback: (raw: string) => void) => {
expect(type).toBe("event");
callback(JSON.stringify({ eventName: "eventName", value: "value" }));
},
el: document.createElement("div"),
};
withEventEmitter(eventEmitterMock as any)(fakePym as any);
eventEmitterMock.emit.verify();
});
@@ -0,0 +1,13 @@
import { EventEmitter2 } from "eventemitter2";
import { Decorator } from "./";
const withEventEmitter = (eventEmitter: EventEmitter2): Decorator => pym => {
// Pass events from iframe to the event emitter.
pym.onMessage("event", (raw: string) => {
const { eventName, value } = JSON.parse(raw);
eventEmitter.emit(eventName, value);
});
};
export default withEventEmitter;
@@ -0,0 +1,12 @@
import withIOSSafariWidthWorkaround from "./withIOSSafariWidthWorkaround";
it("should set width workaround", () => {
const fakePym = {
el: document.createElement("div"),
};
fakePym.el.innerHTML = "<span>Hello World</span>";
withIOSSafariWidthWorkaround(fakePym as any);
expect(fakePym.el.innerHTML).toBe(
'<span style="width: 1px; min-width: 100%;">Hello World</span>'
);
});
@@ -0,0 +1,9 @@
import { Decorator } from "./";
const withIOSSafariWidthWorkaround: Decorator = pym => {
// Workaround: IOS Safari ignores `width` but respects `min-width` value.
(pym.el.firstChild! as HTMLElement).style.width = "1px";
(pym.el.firstChild! as HTMLElement).style.minWidth = "100%";
};
export default withIOSSafariWidthWorkaround;
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>Talk 5.0 Embed Stream</title>
<meta charset="utf-8">
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no">
</head>
<body>
<h1 style="text-align: center" }>Talk 5.0 Embed Stream</h1>
<div id="coralStreamEmbed"></div>
<script>
window.TalkEmbed = Talk.render(document.getElementById('coralStreamEmbed'));
</script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
import * as Talk from "./";
describe("Basic integration test", () => {
const container: HTMLElement = document.createElement("div");
let streamInterface: ReturnType<typeof Talk.render>;
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("");
});
});
+30
View File
@@ -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,
});
}
+14
View File
@@ -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"]
}
@@ -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");
});
+17
View File
@@ -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}`;
}
@@ -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/");
});
@@ -0,0 +1,3 @@
export default function ensureEndSlash(p: string) {
return p.match(/\/$/) ? p : `${p}/`;
}
+2
View File
@@ -0,0 +1,2 @@
export { default as buildURL } from "./buildURL";
export { default as ensureEndSlash } from "./ensureEndSlash";
@@ -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<TalkContext>({} as any);
@@ -32,7 +44,12 @@ export const TalkContextProvider: StatelessComponent<{
}> = ({ value, children }) => (
<Provider value={value}>
<LocalizationProvider messages={value.localeMessages}>
<UIContext.Provider value={{ timeagoFormatter: value.timeagoFormatter }}>
<UIContext.Provider
value={{
timeagoFormatter: value.timeagoFormatter,
registerClickFarAway: value.registerClickFarAway,
}}
>
{children}
</UIContext.Provider>
</LocalizationProvider>
@@ -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<string>;
// 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<void>);
/** 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<TalkContext> {
// 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.
@@ -9,7 +9,7 @@ import Username from "./Username";
export interface CommentProps {
author: {
username: string;
username: string | null;
} | null;
body: string | null;
createdAt: string;
@@ -19,7 +19,8 @@ const Comment: StatelessComponent<CommentProps> = props => {
return (
<div role="article">
<TopBar>
{props.author && <Username>{props.author.username}</Username>}
{props.author &&
props.author.username && <Username>{props.author.username}</Username>}
<Timestamp>{props.createdAt}</Timestamp>
</TopBar>
<Typography>{props.body}</Typography>
@@ -19,3 +19,18 @@ it("renders username and body", () => {
const wrapper = shallow(<CommentContainer {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("renders body only", () => {
const props: PropTypesOf<typeof CommentContainer> = {
data: {
author: {
username: null,
},
body: "Woof",
createdAt: "1995-12-17T03:24:00.000Z",
},
};
const wrapper = shallow(<CommentContainer {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -1,5 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders body only 1`] = `
<Comment
author={
Object {
"username": null,
}
}
body="Woof"
createdAt="1995-12-17T03:24:00.000Z"
/>
`;
exports[`renders username and body 1`] = `
<Comment
author={
+3 -3
View File
@@ -1,15 +1,15 @@
<!DOCTYPE html>
<html prefix="og: http://ogp.me/ns#">
<html>
<head>
<title>Relay Experiments</title>
<title>Talk - Stream</title>
<meta charset="utf-8">
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no">
</head>
<body>
<div id="app" aria-role="application" onclick="void(0)"></div>
<div id="app"></div>
</body>
</html>
+2
View File
@@ -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 = () => (
+1 -1
View File
@@ -16,5 +16,5 @@
}
},
"include": ["./**/*", "../../types/**/*.d.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "./embed"]
}
@@ -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(
<ClickOutside
onClickOutside={onClickOutside}
registerClickFarAway={registerClickFarAway}
>
<button id="click-outside-test-button">Push Me</button>
</ClickOutside>,
{
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(
<UIContext.Provider value={context}>
<ClickOutsideWithContext
onClickOutside={onClickOutside}
registerClickFarAway={registerClickFarAway}
>
<button id="click-outside-test-button">Push Me</button>
</ClickOutsideWithContext>
</UIContext.Provider>,
{
attachTo: container,
}
);
expect(wrapper.find(ClickOutside).prop("registerClickFarAway")).toEqual(
registerClickFarAway
);
wrapper.unmount();
});
@@ -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<Props> {
export class ClickOutside extends React.Component<Props> {
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<Props> {
}
};
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> = props => (
<UIContext.Consumer>
{({ registerClickFarAway }) => (
<ClickOutside {...props} registerClickFarAway={registerClickFarAway} />
)}
</UIContext.Consumer>
);
export default ClickOutsideWithContext;
@@ -0,0 +1 @@
export { default as ClickOutside, ClickFarAwayRegister } from "./ClickOutside";
@@ -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<typeof MatchMedia> = {
@@ -25,3 +27,20 @@ it("map new speech prop to older aural prop", () => {
const wrapper = shallow(<MatchMedia {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("should get mediaQueryValues from context", () => {
const mediaQueryValues: Partial<MediaQueryMatchers> = {
width: 100,
};
const context: any = {
mediaQueryValues,
};
const wrapper = mount(
<UIContext.Provider value={context}>
<MatchMediaWithContext maxWidth="xs">
<span>Hello World</span>
</MatchMediaWithContext>
</UIContext.Provider>
);
expect(wrapper.find(MatchMedia).prop("values")).toEqual(mediaQueryValues);
});
@@ -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<MediaQueryMatchers>;
/**
* A way to listen for clicks that are e.g. outside of the
* current frame for `ClickOutside`
*/
registerClickFarAway?: ClickFarAwayRegister;
}
const UIContext = React.createContext<UIContextProps>({} as any);
@@ -1,11 +1,6 @@
import convict from "convict";
import dotenv from "dotenv";
import Joi from "joi";
// Apply all the configuration provided in the .env file if it isn't already in
// the environment.
dotenv.config();
// Add custom format for the mongo uri scheme.
convict.addFormat({
name: "mongo-uri",
@@ -60,12 +55,29 @@ const config = convict({
env: "REDIS",
arg: "redis",
},
secret: {
doc: "The secret used to sign and verify JWTs",
signing_secret: {
doc: "",
format: "*",
default: null,
env: "SECRET",
arg: "secret",
default: "keyboard cat", // TODO: (wyattjoh) evaluate best solution
env: "SIGNING_SECRET",
arg: "signingSecret",
},
signing_algorithm: {
doc: "",
format: [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"ES512",
],
default: "HS256",
env: "SIGNING_ALGORITHM",
arg: "signingAlgorithm",
},
logging_level: {
doc: "The logging level to print to the console",
@@ -78,5 +90,9 @@ const config = convict({
export type Config = typeof config;
export const createClientEnv = (c: Config) => ({
NODE_ENV: c.get("env"),
});
// Setup the base configuration.
export default config;
+2
View File
@@ -11,3 +11,5 @@ export type Sub<T, U> = Pick<T, Diff<keyof T, keyof U>>;
* Make all properties in T writeable
*/
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
export type Promiseable<T> = Promise<T> | T;
@@ -0,0 +1,77 @@
import { RequestHandler } from "express";
import Joi from "joi";
import { Db } from "mongodb";
import { handleSuccessfulLogin } from "talk-server/app/middleware/passport";
import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt";
import { validate } from "talk-server/app/request/body";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { LocalProfile } from "talk-server/models/user";
import { upsert } from "talk-server/services/users";
import { Request } from "talk-server/types/express";
export interface SignupBody {
username: string;
password: string;
email: string;
displayName?: string;
}
const SignupBodySchema = Joi.object().keys({
username: Joi.string().trim(),
password: Joi.string().trim(),
email: Joi.string().trim(),
});
export interface SignupOptions {
db: Db;
signingConfig: JWTSigningConfig;
}
export const signupHandler = (options: SignupOptions): RequestHandler => async (
req: Request,
res,
next
) => {
try {
// TODO: rate limit based on the IP address and user agent.
// Tenant is guaranteed at this point.
const tenant = req.tenant!;
// Check to ensure that the local integration has been enabled.
if (!tenant.auth.integrations.local.enabled) {
// TODO: replace with better error.
return next(new Error("integration is disabled"));
}
// Get the fields from the body. Validate will throw an error if the body
// does not conform to the specification.
const { username, password, email }: SignupBody = validate(
SignupBodySchema,
req.body
);
// Configure with profile.
const profile: LocalProfile = {
id: email,
type: "local",
};
// Create the new user.
const user = await upsert(options.db, tenant, {
email,
username,
password,
profiles: [profile],
// New users signing up via local auth will have the commenter role to
// start with.
role: GQLUSER_ROLE.COMMENTER,
});
// Send off to the passport handler.
return handleSuccessfulLogin(user, options.signingConfig, req, res, next);
} catch (err) {
return next(err);
}
};
+20 -9
View File
@@ -3,14 +3,15 @@ import http from "http";
import { Redis } from "ioredis";
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import { Config } from "talk-common/config";
import { notFoundMiddleware } from "talk-server/app/middleware/notFound";
import { createPassport } from "talk-server/app/middleware/passport";
import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt";
import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware";
import { Schemas } from "talk-server/graph/schemas";
import TenantCache from "talk-server/services/tenant/cache";
import {
access as accessLogger,
error as errorLogger,
} from "./middleware/logging";
import { accessLogger, errorLogger } from "./middleware/logging";
import serveStatic from "./middleware/serveStatic";
import { createRouter } from "./router";
@@ -20,6 +21,8 @@ export interface AppOptions {
mongo: Db;
redis: Redis;
schemas: Schemas;
signingConfig: JWTSigningConfig;
tenantCache: TenantCache;
}
/**
@@ -32,13 +35,21 @@ export async function createApp(options: AppOptions): Promise<Express> {
// Logging
parent.use(accessLogger);
// Create some services for the router.
const passport = createPassport(options);
// Mount the router.
parent.use(
await createRouter(options, {
passport,
})
);
// Static Files
parent.use(serveStatic);
// Mount the router.
parent.use(await createRouter(options));
// Error Handling
parent.use(notFoundMiddleware);
parent.use(errorLogger);
return parent;
@@ -64,7 +75,7 @@ export const listenAndServe = (
* handle websocket traffic by upgrading their http connections to websocket.
*
* @param schemas schemas for every schema this application handles
* @param server the http.Server to attach the websocket upgraders to
* @param server the http.Server to attach the websocket upgrader to
*/
export async function attachSubscriptionHandlers(
schemas: Schemas,
+6
View File
@@ -0,0 +1,6 @@
import { ErrorRequestHandler } from "express";
export const apiErrorHandler: ErrorRequestHandler = (err, req, res, next) => {
// TODO: handle better when we improve errors.
res.status(500).json({ error: err.message });
};
+5 -4
View File
@@ -1,8 +1,9 @@
import { ErrorRequestHandler, RequestHandler } from "express";
import now from "performance-now";
import logger from "../../logger";
export const access: RequestHandler = (req, res, next) => {
import logger from "talk-server/logger";
export const accessLogger: RequestHandler = (req, res, next) => {
const startTime = now();
const end = res.end;
res.end = (chunk: any, encodingOrCb?: any, cb?: any) => {
@@ -37,7 +38,7 @@ export const access: RequestHandler = (req, res, next) => {
next();
};
export const error: ErrorRequestHandler = (err, req, res, next) => {
logger.error({ err }, "http error");
export const errorLogger: ErrorRequestHandler = (err, req, res, next) => {
logger.error(err, "http error");
next(err);
};
@@ -0,0 +1,5 @@
import { RequestHandler } from "express";
export const notFoundMiddleware: RequestHandler = (req, res, next) => {
next(new Error("not found"));
};
@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`createJWTSigningConfig parses a RSA certificate 1`] = `
"-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV
EnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+
MHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U
C9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2
nQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV
1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS
2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw
fdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD
KrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB
GdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7
0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ
kPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi
55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN
oLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v
lbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6
9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK
8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY
SvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1
rFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr
xqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb
za9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7
1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0
pYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f
RaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt
ySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=
-----END RSA PRIVATE KEY-----"
`;
@@ -1,13 +1,116 @@
import { NextFunction, RequestHandler, Response } from "express";
import { Db } from "mongodb";
import passport, { Authenticator } from "passport";
import {
createJWTStrategy,
JWTSigningConfig,
SigningTokenOptions,
signTokenString,
} from "talk-server/app/middleware/passport/jwt";
import { createLocalStrategy } from "talk-server/app/middleware/passport/local";
import { createOIDCStrategy } from "talk-server/app/middleware/passport/oidc";
import { createSSOStrategy } from "talk-server/app/middleware/passport/sso";
import { User } from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
import { Request } from "talk-server/types/express";
export type VerifyCallback = (
err?: Error | null,
user?: User | null,
info?: { message: string }
) => void;
export interface PassportOptions {
db: Db;
mongo: Db;
signingConfig: JWTSigningConfig;
tenantCache: TenantCache;
}
export function createPassport(opts: PassportOptions): passport.Authenticator {
export function createPassport(
options: PassportOptions
): passport.Authenticator {
// Create the authenticator.
const auth = new Authenticator();
// Use the OIDC Strategy.
auth.use(createOIDCStrategy(options));
// Use the LocalStrategy.
auth.use(createLocalStrategy(options));
// Use the SSOStrategy.
auth.use(createSSOStrategy(options));
// Use the JWTStrategy.
auth.use(createJWTStrategy(options));
return auth;
}
export async function handleSuccessfulLogin(
user: User,
signingConfig: JWTSigningConfig,
req: Request,
res: Response,
next: NextFunction
) {
try {
// Grab the tenant from the request.
const { tenant } = req;
const options: SigningTokenOptions = {};
if (tenant) {
// Attach the tenant's id to the issued token as a `iss` claim.
options.issuer = tenant.id;
// TODO: (wyattjoh) evaluate the possibility when we have multiple
// integrations per type to use the integration id as the audience.
}
// Grab the token.
const token = await signTokenString(signingConfig, user, options);
// Set the cache control headers.
res.header("Cache-Control", "private, no-cache, no-store, must-revalidate");
res.header("Expires", "-1");
res.header("Pragma", "no-cache");
// Send back the details!
res.json({ token });
} catch (err) {
return next(err);
}
}
/**
* wrapAuthn will wrap a authenticators authenticate method with one that
* will return a valid login token for a valid login by a compatible strategy.
*
* @param authenticator the base authenticator instance
* @param signingConfig used to sign the tokens that are issued.
* @param name the name of the authenticator to use
* @param options any options to be passed to the authenticate call
*/
export const wrapAuthn = (
authenticator: passport.Authenticator,
signingConfig: JWTSigningConfig,
name: string,
options?: any
): RequestHandler => (req: Request, res, next) =>
authenticator.authenticate(
name,
{ ...options, session: false },
(err: Error | null, user: User | null) => {
if (err) {
return next(err);
}
if (!user) {
// TODO: (wyattjoh) replace with better error.
return next(new Error("no user on request"));
}
handleSuccessfulLogin(user, signingConfig, req, res, next);
}
)(req, res, next);
@@ -0,0 +1,84 @@
import sinon from "sinon";
import { Config } from "talk-common/config";
import {
createJWTSigningConfig,
extractJWTFromRequest,
parseAuthHeader,
} from "talk-server/app/middleware/passport/jwt";
import { Request } from "talk-server/types/express";
describe("parseAuthHeader", () => {
it("parses valid headers", () => {
const parsed = {
scheme: "bearer",
value: "token",
};
expect(parseAuthHeader("Bearer token")).toEqual(parsed);
expect(parseAuthHeader("bearer token")).toEqual(parsed);
expect(parseAuthHeader("bearer token")).toEqual(parsed);
});
it("parses invalid headers", () => {
expect(parseAuthHeader("this-is-a-wrong-header")).toEqual(null);
expect(parseAuthHeader("bearerthis-is-a-wrong-header")).toEqual(null);
});
});
describe("extractJWTFromRequest", () => {
it("extracts the token from header", () => {
const req = {
get: sinon
.stub()
.withArgs("authorization")
.returns("Bearer token"),
};
expect(extractJWTFromRequest((req as any) as Request)).toEqual("token");
expect(req.get.calledOnce).toBeTruthy();
req.get.reset();
req.get.returns(null);
expect(extractJWTFromRequest((req as any) as Request)).toEqual(null);
expect(req.get.calledOnce).toBeTruthy();
});
it("extracts the token from query string", () => {
const req = {
get: sinon
.stub()
.withArgs("authorization")
.returns(null),
query: { access_token: "token" },
};
expect(extractJWTFromRequest((req as any) as Request)).toEqual("token");
expect(req.get.calledOnce).toBeTruthy();
delete req.query.access_token;
req.get.reset();
expect(extractJWTFromRequest((req as any) as Request)).toEqual(null);
expect(req.get.calledOnce).toBeTruthy();
});
});
describe("createJWTSigningConfig", () => {
it("parses a RSA certificate", () => {
const input = `-----BEGIN RSA PRIVATE KEY-----\\nMIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV\\nEnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+\\nMHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U\\nC9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2\\nnQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV\\n1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS\\n2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw\\nfdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD\\nKrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB\\nGdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7\\n0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ\\nkPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi\\n55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN\\noLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v\\nlbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6\\n9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK\\n8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY\\nSvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1\\nrFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr\\nxqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb\\nza9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7\\n1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0\\npYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f\\nRaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt\\nySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=\\n-----END RSA PRIVATE KEY-----`;
const config = {
get: sinon.stub(),
};
config.get.withArgs("signing_secret").returns(input);
config.get.withArgs("signing_algorithm").returns("RS256");
const signingConfig = createJWTSigningConfig((config as any) as Config);
expect(signingConfig.algorithm).toEqual("RS256");
expect(signingConfig.secret.toString()).toMatchSnapshot();
});
});
@@ -0,0 +1,203 @@
import jwt, { SignOptions } from "jsonwebtoken";
import uuid from "uuid";
import { Db } from "mongodb";
import { Strategy } from "passport-strategy";
import { Config } from "talk-common/config";
import { retrieveUser, User } from "talk-server/models/user";
import { Request } from "talk-server/types/express";
const authHeaderRegex = /(\S+)\s+(\S+)/;
export function parseAuthHeader(header: string) {
const matches = header.match(authHeaderRegex);
if (!matches || matches.length < 3) {
return null;
}
return {
scheme: matches[1].toLowerCase(),
value: matches[2],
};
}
export function extractJWTFromRequest(req: Request) {
const header = req.get("authorization");
if (header) {
const parts = parseAuthHeader(header);
if (parts && parts.scheme === "bearer") {
return parts.value;
}
}
const token: string | undefined | false = req.query && req.query.access_token;
if (token) {
return token;
}
return null;
}
export enum AsymmetricSigningAlgorithm {
RS256 = "RS256",
RS384 = "RS384",
RS512 = "RS512",
ES256 = "ES256",
ES384 = "ES384",
ES512 = "ES512",
}
export enum SymmetricSigningAlgorithm {
HS256 = "HS256",
HS384 = "HS384",
HS512 = "HS512",
}
export type JWTSigningAlgorithm =
| AsymmetricSigningAlgorithm
| SymmetricSigningAlgorithm;
export interface JWTSigningConfig {
secret: Buffer;
algorithm: JWTSigningAlgorithm;
}
export function createAsymmetricSigningConfig(
algorithm: AsymmetricSigningAlgorithm,
secret: string
): JWTSigningConfig {
return {
// Secrets have their newlines encoded with newline literals.
secret: Buffer.from(secret.replace(/\\n/g, "\n")),
algorithm,
};
}
export function createSymmetricSigningConfig(
algorithm: SymmetricSigningAlgorithm,
secret: string
): JWTSigningConfig {
return {
secret: new Buffer(secret),
algorithm,
};
}
function isSymmetricSigningAlgorithm(
algorithm: string | SymmetricSigningAlgorithm
): algorithm is SymmetricSigningAlgorithm {
return algorithm in SymmetricSigningAlgorithm;
}
function isAsymmetricSigningAlgorithm(
algorithm: string | AsymmetricSigningAlgorithm
): algorithm is AsymmetricSigningAlgorithm {
return algorithm in AsymmetricSigningAlgorithm;
}
/**
* Parses the config and provides the signing config.
*
* @param config the server configuration
*/
export function createJWTSigningConfig(config: Config): JWTSigningConfig {
const secret = config.get("signing_secret");
const algorithm = config.get("signing_algorithm");
if (isSymmetricSigningAlgorithm(algorithm)) {
return createSymmetricSigningConfig(algorithm, secret);
} else if (isAsymmetricSigningAlgorithm(algorithm)) {
return createAsymmetricSigningConfig(algorithm, secret);
}
// TODO: (wyattjoh) return better error.
throw new Error("invalid algorithm specified");
}
export type SigningTokenOptions = Pick<SignOptions, "audience" | "issuer">;
export async function signTokenString(
{ algorithm, secret }: JWTSigningConfig,
user: User,
options: SigningTokenOptions
) {
return jwt.sign({}, secret, {
...options,
jwtid: uuid.v4(),
algorithm,
expiresIn: "1 day", // TODO: (wyattjoh) evaluate allowing configuration?
subject: user.id,
});
}
export interface JWTToken {
jti: string;
sub: string;
exp: number;
iss?: string;
}
export interface JWTStrategyOptions {
signingConfig: JWTSigningConfig;
mongo: Db;
}
export class JWTStrategy extends Strategy {
public name = "jwt";
private signingConfig: JWTSigningConfig;
private mongo: Db;
constructor({ signingConfig, mongo }: JWTStrategyOptions) {
super();
this.signingConfig = signingConfig;
this.mongo = mongo;
}
public authenticate(req: Request) {
// Lookup the token.
const token = extractJWTFromRequest(req);
if (!token) {
// There was no token on the request, so there was no user, so let's mark
// that the strategy was successful.
return this.success(null, null);
}
const { tenant } = req;
if (!tenant) {
// TODO: (wyattjoh) return a better error.
return this.error(new Error("tenant not found"));
}
jwt.verify(
token,
// Use the secret specified in the configuration.
this.signingConfig.secret,
{
// We need to verify that the token is for the specified tenant.
issuer: tenant.id,
// Use the algorithm specified in the configuration.
algorithms: [this.signingConfig.algorithm],
},
async (err: Error | undefined, { sub }: JWTToken) => {
if (err) {
return this.fail(err, 401);
}
try {
// Find the user.
const user = await retrieveUser(this.mongo, tenant.id, sub);
// Return them! The user may be null, but that's ok here.
this.success(user, null);
} catch (err) {
return this.error(err);
}
}
);
}
}
export function createJWTStrategy(options: JWTStrategyOptions) {
return new JWTStrategy(options);
}
@@ -0,0 +1,60 @@
import { Db } from "mongodb";
import { Strategy as LocalStrategy } from "passport-local";
import { VerifyCallback } from "talk-server/app/middleware/passport";
import {
retrieveUserWithProfile,
verifyUserPassword,
} from "talk-server/models/user";
import { Request } from "talk-server/types/express";
const verifyFactory = (mongo: Db) => async (
req: Request,
email: string,
password: string,
done: VerifyCallback
) => {
try {
// TODO: rate limit based on the IP address and user agent.
// The tenant is guaranteed at this point.
const tenant = req.tenant!;
// Get the user from the database.
const user = await retrieveUserWithProfile(mongo, tenant.id, {
id: email,
type: "local",
});
if (!user) {
// The user didn't exist.
return done(null, null);
}
// Verify the password.
const passwordVerified = await verifyUserPassword(user, password);
if (!passwordVerified) {
// TODO: return better error
return done(new Error("invalid password"));
}
return done(null, user);
} catch (err) {
return done(err);
}
};
export interface LocalStrategyOptions {
mongo: Db;
}
export function createLocalStrategy({ mongo }: LocalStrategyOptions) {
return new LocalStrategy(
{
usernameField: "email",
passwordField: "password",
session: false,
passReqToCallback: true,
},
verifyFactory(mongo)
);
}
@@ -0,0 +1,87 @@
import {
OIDCDisplayNameIDTokenSchema,
OIDCIDTokenSchema,
} from "talk-server/app/middleware/passport/oidc";
import { validate } from "talk-server/app/request/body";
describe("OIDCIDTokenSchema", () => {
it("allows a valid payload", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
email_verified: true,
};
expect(validate(OIDCIDTokenSchema, token)).toEqual(token);
});
it("allows an empty email_verified", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
};
expect(validate(OIDCIDTokenSchema, token)).toEqual({
...token,
email_verified: false,
});
});
it("allows an empty picture", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
email_verified: true,
};
expect(validate(OIDCIDTokenSchema, token)).toEqual(token);
});
});
describe("OIDCDisplayNameIDTokenSchema", () => {
it("allows a valid payload", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
email_verified: true,
name: "name",
nickname: "nickname",
};
expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token);
});
it("allows an empty name", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
email_verified: false,
nickname: "nickname",
};
expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token);
});
it("allows an empty nickname", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
email_verified: false,
name: "name",
};
expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token);
});
});
@@ -0,0 +1,381 @@
import Joi from "joi";
import jwt from "jsonwebtoken";
import jwks, { JwksClient } from "jwks-rsa";
import { Db } from "mongodb";
import { Strategy as OAuth2Strategy, VerifyCallback } from "passport-oauth2";
import { Strategy } from "passport-strategy";
import { validate } from "talk-server/app/request/body";
import { reconstructURL } from "talk-server/app/url";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { OIDCAuthIntegration } from "talk-server/models/settings";
import { Tenant } from "talk-server/models/tenant";
import { OIDCProfile, retrieveUserWithProfile } from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
import { upsert } from "talk-server/services/users";
import { Request } from "talk-server/types/express";
export interface Params {
id_token?: string;
}
/**
* OIDCIDToken describes the set of claims that are present in a ID Token. This
* interface confirms with the ID Token specification as defined:
* https://openid.net/specs/openid-connect-core-1_0.html#IDToken
*/
export interface OIDCIDToken {
aud: string;
iss: string;
sub: string;
exp: number; // TODO: use this as the source for how long an OIDC user can be logged in for
email?: string;
email_verified?: boolean;
picture?: string;
name?: string;
nickname?: string;
}
export interface StrategyItem {
strategy: OAuth2Strategy;
jwksClient?: JwksClient;
}
export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken {
if (
(token as OIDCIDToken).iss &&
(token as OIDCIDToken).sub &&
(token as OIDCIDToken).email
) {
return true;
}
return false;
}
/**
* keyFunc will provide the secret based on the given jwkw client.
*
* @param client the jwks client for the specific request being made
*/
const signingKeyFactory = (client: jwks.JwksClient): jwt.KeyFunction => (
{ kid },
callback
) => {
if (!kid) {
// TODO: return better error.
return callback(new Error("no kid in id_token"));
}
// Get the signing key from the jwks provider.
client.getSigningKey(kid, (err, key) => {
if (err) {
// TODO: wrap error?
return callback(err);
}
// Grab the signingKey out of the provided key.
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
};
function getEnabledIntegration(tenant: Tenant) {
const integration = tenant.auth.integrations.oidc;
if (!integration) {
// TODO: return a better error.
throw new Error("integration not found");
}
// Handle when the integration is enabled/disabled.
if (!integration.enabled) {
// TODO: return a better error.
throw new Error("integration not enabled");
}
return integration;
}
export const OIDCIDTokenSchema = Joi.object()
.keys({
sub: Joi.string(),
iss: Joi.string(),
aud: Joi.string(),
email: Joi.string(),
email_verified: Joi.boolean().default(false),
picture: Joi.string().default(undefined),
})
.optionalKeys(["picture", "email_verified"]);
export const OIDCDisplayNameIDTokenSchema = OIDCIDTokenSchema.keys({
name: Joi.string().default(undefined),
nickname: Joi.string().default(undefined),
}).optionalKeys(["name", "nickname"]);
export async function findOrCreateOIDCUser(
db: Db,
tenant: Tenant,
token: OIDCIDToken
) {
// Unpack/validate the token content.
const {
sub,
iss,
aud,
email,
email_verified,
picture,
name,
nickname,
}: OIDCIDToken = validate(
tenant.auth.integrations.oidc!.displayNameEnable
? OIDCDisplayNameIDTokenSchema
: OIDCIDTokenSchema,
token
);
// Construct the profile that will be used to query for the user.
const profile: OIDCProfile = {
type: "oidc",
id: sub,
issuer: iss,
audience: aud,
};
// Try to lookup user given their id provided in the `sub` claim.
let user = await retrieveUserWithProfile(db, tenant.id, profile);
if (!user) {
// FIXME: implement rules.
// Default the displayName. When it is disabled, Joi will strip the
// displayName fields from the token, so it will fallback to undefined.
const displayName = nickname || name || undefined;
// Create the new user, as one didn't exist before!
user = await upsert(db, tenant, {
username: null,
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
email_verified,
avatar: picture,
profiles: [profile],
});
}
// TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch?
return user;
}
/**
* OIDC_SCOPE is the set of scopes requested for users signing up via OIDC.
*/
const OIDC_SCOPE = "openid email profile";
export interface OIDCStrategyOptions {
mongo: Db;
tenantCache: TenantCache;
}
export default class OIDCStrategy extends Strategy {
public name = "oidc";
private mongo: Db;
private cache = new Map<string, StrategyItem>();
constructor({ mongo, tenantCache }: OIDCStrategyOptions) {
super();
this.mongo = mongo;
// Subscribe to updates with Tenants.
tenantCache.subscribe(tenant => {
// Delete the tenant cache item when the tenant changes. The refreshed
// Tenant will come in with the request.
this.cache.delete(tenant.id);
});
}
private lookupJWKSClient(
req: Request,
tenantID: string,
oidc: OIDCAuthIntegration
) {
let entry = this.cache.get(tenantID);
if (!entry) {
const strategy = this.createStrategy(req, oidc);
// Create the entry.
entry = {
strategy,
};
// We don't reset the entry in the cache here because if we just created
// it, we'll be creating the jwksClient anyways, so we'll update it there.
}
if (!entry.jwksClient) {
// Create the new JWKS client.
const jwksClient = jwks({
jwksUri: oidc.jwksURI,
});
// Set the jwksClient on the entry.
entry.jwksClient = jwksClient;
// Update the cached entry.
this.cache.set(tenantID, entry);
}
return entry.jwksClient;
}
private userAuthenticatedCallback = (
req: Request,
accessToken: string, // ignore the access token, we don't use it.
refreshToken: string, // ignore the refresh token, we don't use it.
params: Params,
profile: any, // we don't look inside the profile (yet).
done: VerifyCallback
) => {
// Try to lookup user given their id provided in the `sub` claim of the
// `id_token`.
const { id_token } = params;
if (!id_token) {
// TODO: return better error.
return done(new Error("no id_token in params"));
}
// Grab the tenant out of the request, as we need some more details.
const { tenant } = req;
if (!tenant) {
// TODO: return a better error.
return done(new Error("tenant not found"));
}
// Get the integration from the tenant. If needed, it will be used to create
// a new strategy.
let integration: OIDCAuthIntegration;
try {
integration = getEnabledIntegration(tenant);
} catch (err) {
// TODO: wrap error?
return done(err);
}
// Grab the JWKSClient.
const client = this.lookupJWKSClient(req, tenant.id, integration);
// Verify that the id_token is valid or not.
jwt.verify(
id_token,
signingKeyFactory(client),
{
issuer: integration.issuer,
},
async (err, decoded) => {
if (err) {
// TODO: wrap error?
return done(err);
}
try {
const user = await findOrCreateOIDCUser(
this.mongo,
tenant,
decoded as OIDCIDToken
);
return done(null, user);
} catch (err) {
return done(err);
}
}
);
};
private createStrategy(
req: Request,
integration: OIDCAuthIntegration
): OAuth2Strategy {
const { clientID, clientSecret, authorizationURL, tokenURL } = integration;
// Construct the callbackURL from the request.
const callbackURL = reconstructURL(req, "/api/tenant/auth/oidc/callback");
// Create a new OAuth2Strategy, where we pass the verify callback bound to
// this OIDCStrategy instance.
return new OAuth2Strategy(
{
passReqToCallback: true,
clientID,
clientSecret,
authorizationURL,
tokenURL,
callbackURL,
},
this.userAuthenticatedCallback
);
}
private async lookupStrategy(req: Request) {
const { tenant } = req;
if (!tenant) {
// TODO: return a better error.
throw new Error("tenant not found");
}
// Get the integration from the tenant. If needed, it will be used to create
// a new strategy.
const integration = getEnabledIntegration(tenant);
// Try to get the Tenant's cached integrations.
let entry = this.cache.get(tenant.id);
if (!entry) {
// Create the strategy.
const strategy = this.createStrategy(req, integration);
// Reset the entry.
entry = {
strategy,
};
// Update the cached integrations value.
this.cache.set(tenant.id, entry);
}
return entry.strategy;
}
public async authenticate(req: Request) {
try {
// Lookup the strategy.
const strategy = await this.lookupStrategy(req);
if (!strategy) {
throw new Error("strategy not found");
}
// Augment the strategy with the request method bindings.
strategy.error = this.error.bind(this);
strategy.fail = this.fail.bind(this);
strategy.pass = this.pass.bind(this);
strategy.redirect = this.redirect.bind(this);
strategy.success = this.success.bind(this);
// Authenticate with the strategy, binding the current context to the method
// to provide it with the augmented passport handlers. We also request the
// 'openid' scope so we can get an id_token back.
strategy.authenticate(req, {
scope: OIDC_SCOPE,
session: false,
});
} catch (err) {
return this.error(err);
}
}
}
export function createOIDCStrategy(options: OIDCStrategyOptions) {
return new OIDCStrategy(options);
}
@@ -0,0 +1,83 @@
import {
isSSOToken,
SSODisplayNameUserProfileSchema,
SSOUserProfileSchema,
} from "talk-server/app/middleware/passport/sso";
import { validate } from "talk-server/app/request/body";
describe("isSSOToken", () => {
it("understands valid sso tokens", () => {
const token = { user: { id: "id", email: "email", username: "username" } };
expect(isSSOToken(token)).toBeTruthy();
});
it("understands invalid sso tokens", () => {
expect(isSSOToken({ user: { id: "id", email: "email" } })).toBeFalsy();
expect(
isSSOToken({ user: { id: "id", username: "username" } })
).toBeFalsy();
expect(
isSSOToken({ user: { email: "email", username: "username" } })
).toBeFalsy();
expect(isSSOToken({})).toBeFalsy();
});
});
describe("SSOUserProfileSchema", () => {
it("allows a valid payload", () => {
const profile = {
id: "id",
email: "email",
username: "username",
avatar: "avatar",
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
});
it("allows an empty avatar", () => {
const profile = {
id: "id",
email: "email",
username: "username",
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
});
});
describe("SSODisplayNameUserProfileSchema", () => {
it("allows a valid payload", () => {
const profile = {
id: "id",
email: "email",
username: "username",
avatar: "avatar",
displayName: "displayName",
};
expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile);
});
it("allows an empty avatar", () => {
const profile = {
id: "id",
email: "email",
username: "username",
displayName: "displayName",
};
expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile);
});
it("allows an empty displayName", () => {
const profile = {
id: "id",
email: "email",
username: "username",
avatar: "avatar",
};
expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile);
});
});
@@ -0,0 +1,224 @@
import Joi from "joi";
import jwt, { KeyFunctionCallback } from "jsonwebtoken";
import { Db } from "mongodb";
import { Strategy } from "passport-strategy";
import { extractJWTFromRequest } from "talk-server/app/middleware/passport/jwt";
import {
findOrCreateOIDCUser,
isOIDCToken,
OIDCIDToken,
} from "talk-server/app/middleware/passport/oidc";
import { validate } from "talk-server/app/request/body";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { Tenant } from "talk-server/models/tenant";
import { retrieveUserWithProfile, SSOProfile } from "talk-server/models/user";
import { upsert } from "talk-server/services/users";
import { Request } from "talk-server/types/express";
export interface SSOStrategyOptions {
mongo: Db;
}
export interface SSOUserProfile {
id: string;
email: string;
username: string;
avatar?: string;
displayName?: string;
}
export interface SSOToken {
user: SSOUserProfile;
}
export const SSOUserProfileSchema = Joi.object()
.keys({
id: Joi.string(),
email: Joi.string(),
username: Joi.string(),
avatar: Joi.string().default(undefined),
})
.optionalKeys(["avatar"]);
export const SSODisplayNameUserProfileSchema = SSOUserProfileSchema.keys({
displayName: Joi.string().default(undefined),
}).optionalKeys(["displayName"]);
export async function findOrCreateSSOUser(
db: Db,
tenant: Tenant,
token: SSOToken
) {
if (!token.user) {
// TODO: (wyattjoh) replace with better error.
throw new Error("token is malformed, missing user claim");
}
// Unpack/validate the token content.
const { id, email, username, displayName, avatar }: SSOUserProfile = validate(
tenant.auth.integrations.sso!.displayNameEnable
? SSODisplayNameUserProfileSchema
: SSOUserProfileSchema,
token.user
);
const profile: SSOProfile = {
type: "sso",
id,
};
// Try to lookup user given their id provided in the `sub` claim.
let user = await retrieveUserWithProfile(db, tenant.id, profile);
if (!user) {
// FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method.
// Create the new user, as one didn't exist before!
user = await upsert(db, tenant, {
username,
// When the displayName is disabled on the tenant, the displayName will
// never be set (or even stored in the database).
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
avatar,
profiles: [profile],
});
}
// TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch?
return user;
}
/**
* isSSOUserProfile will check if the given profile is a SSOUserProfile.
*
* @param profile the profile to check for the type
*/
export function isSSOUserProfile(
profile: SSOUserProfile | object
): profile is SSOUserProfile {
return (
typeof (profile as SSOUserProfile).id !== "undefined" &&
typeof (profile as SSOUserProfile).email !== "undefined" &&
typeof (profile as SSOUserProfile).username !== "undefined"
);
}
export function isSSOToken(token: SSOToken | object): token is SSOToken {
return (
typeof (token as SSOToken).user === "object" &&
isSSOUserProfile((token as SSOToken).user)
);
}
export default class SSOStrategy extends Strategy {
public name = "sso";
private mongo: Db;
constructor({ mongo }: SSOStrategyOptions) {
super();
this.mongo = mongo;
}
/**
* retrieves the integration's secret to be used to verify the token.
*/
private getSigningSecretGetter = (tenant: Tenant) => async (
headers: { kid?: string },
done: KeyFunctionCallback
) => {
const integration = tenant.auth.integrations.sso;
if (!integration) {
// TODO: (wyattjoh) return a better error.
return done(new Error("integration not found"));
}
if (!integration.enabled) {
// TODO: (wyattjoh) return a better error.
return done(new Error("integration not enabled"));
}
// TODO: (wyattjoh) do something with the kid... Lookup the secret or verify it matches what we have?
return done(null, integration.key);
};
/**
* findOrCreateUser will interpret the token and use the correct strategy for
* retrieving/creating the user.
*
* @param tenant the tenant for the new/returning user
* @param token the token that was unpacked and validated from the sso strategy
*/
private async findOrCreateUser(
tenant: Tenant,
token: OIDCIDToken | SSOToken
) {
if (isOIDCToken(token)) {
// The token provided for SSO contains an issuer claim. We're assuming
// that this request is associated with an OpenID Connect provider.
return findOrCreateOIDCUser(this.mongo, tenant, token);
}
// Check to see if this token is a SSO Token or not, if it isn't error out.
if (!isSSOToken(token)) {
// TODO: (wyattjoh) return a better error.
throw new Error("token is invalid");
}
// The token provided does not confirm to the OpenID Connect provider
// spec, but id does conform to a SSOToken so we should expect the token to
// contain the user profile.
return findOrCreateSSOUser(this.mongo, tenant, token);
}
public authenticate(req: Request) {
const { tenant } = req;
if (!tenant) {
// TODO: (wyattjoh) return a better error.
return this.error(new Error("tenant not found"));
}
// Lookup the token.
const token = extractJWTFromRequest(req);
if (!token) {
// TODO: (wyattjoh) return a better error.
return this.fail(new Error("no token on request"), 400);
}
// Perform the JWT validation.
jwt.verify(
token,
this.getSigningSecretGetter(tenant),
{
// Force the use of the HS256 algorithm. We can explore switching this
// out in the future..
algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm.
},
async (err: Error | undefined, decoded: OIDCIDToken | SSOToken) => {
if (err) {
// TODO: (wyattjoh) wrap error?
return this.error(err);
}
try {
// Find or create the user based on the decoded token.
const user = await this.findOrCreateUser(tenant, decoded);
// The user was found or created!
return this.success(user, null);
} catch (err) {
return this.error(err);
}
}
);
}
}
export function createSSOStrategy(options: SSOStrategyOptions) {
return new SSOStrategy(options);
}
+9 -5
View File
@@ -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;
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`throws an error for missing fields 1`] = `"child \\"d\\" fails because [\\"d\\" is required]"`;
+32
View File
@@ -0,0 +1,32 @@
import Joi from "joi";
import { validate } from "talk-server/app/request/body";
it("strips out unknown fields", () => {
const payload = { a: 1, b: 2, c: 3 };
const schema = Joi.object().keys({});
expect(validate(schema, payload)).toEqual({});
});
it("allows valid fields", () => {
const payload = { a: 1, b: 2, c: 3 };
const schema = Joi.object().keys({ a: Joi.number() });
expect(validate(schema, payload)).toEqual({ a: 1 });
});
it("allows valid fields from extended schema", () => {
const payload = { a: 1, b: 2, c: 3 };
const schema = Joi.object().keys({ a: Joi.number() });
const extendedSchema = schema.keys({ b: Joi.number() });
expect(validate(extendedSchema, payload)).toEqual({ a: 1, b: 2 });
});
it("throws an error for missing fields", () => {
const payload = { a: 1, b: 2, c: 3 };
const schema = Joi.object().keys({ d: Joi.number() });
expect(() => validate(schema, payload)).toThrowErrorMatchingSnapshot();
});
+24
View File
@@ -0,0 +1,24 @@
import Joi from "joi";
/**
* validate will strip unknown fields and perform validation against it. It will
* throw any error encountered.
*
* @param schema the Joi schema to validate against
* @param body the body to parse and strip of unknown fields
*/
export const validate = (schema: Joi.SchemaLike, body: any) => {
// Extract the schema from the request.
const { value, error: err } = Joi.validate(body, schema, {
stripUnknown: true,
presence: "required",
abortEarly: false,
});
if (err) {
// TODO: wrap error?
throw err;
}
return value;
};
+72 -17
View File
@@ -1,5 +1,10 @@
import express from "express";
import passport from "passport";
import { signupHandler } from "talk-server/app/handlers/auth/local";
import { apiErrorHandler } from "talk-server/app/middleware/error";
import { errorLogger } from "talk-server/app/middleware/logging";
import { wrapAuthn } from "talk-server/app/middleware/passport";
import tenantMiddleware from "talk-server/app/middleware/tenant";
import managementGraphMiddleware from "talk-server/graph/management/middleware";
import tenantGraphMiddleware from "talk-server/graph/tenant/middleware";
@@ -7,7 +12,7 @@ import tenantGraphMiddleware from "talk-server/graph/tenant/middleware";
import { AppOptions } from "./index";
import playground from "./middleware/playground";
async function createManagementRouter(opts: AppOptions) {
async function createManagementRouter(app: AppOptions, options: RouterOptions) {
const router = express.Router();
// Management API
@@ -15,51 +20,101 @@ async function createManagementRouter(opts: AppOptions) {
"/graphql",
express.json(),
await managementGraphMiddleware(
opts.schemas.management,
opts.config,
opts.mongo
app.schemas.management,
app.config,
app.mongo
)
);
return router;
}
async function createTenantRouter(opts: AppOptions) {
async function createTenantRouter(app: AppOptions, options: RouterOptions) {
const router = express.Router();
// Tenant identification middleware.
router.use(tenantMiddleware({ db: opts.mongo }));
router.use(tenantMiddleware({ cache: app.tenantCache }));
// Setup Passport middleware.
router.use(options.passport.initialize());
// Setup auth routes.
router.use("/auth", createNewAuthRouter(app, options));
// Tenant API
router.use(
"/graphql",
express.json(),
await tenantGraphMiddleware(opts.schemas.tenant, opts.config, opts.mongo)
// Any users may submit their GraphQL requests with authentication, this
// middleware will unpack their user into the request.
options.passport.authenticate("jwt", { session: false }),
await tenantGraphMiddleware({
schema: app.schemas.tenant,
config: app.config,
mongo: app.mongo,
redis: app.redis,
})
);
return router;
}
async function createAPIRouter(opts: AppOptions) {
// Create a router.
function createNewAuthRouter(app: AppOptions, options: RouterOptions) {
const router = express.Router();
// Configure the tenant routes.
router.use("/tenant", await createTenantRouter(opts));
// Configure the management routes.
router.use("/management", await createManagementRouter(opts));
// Mount the passport routes.
router.post(
"/local",
express.json(),
wrapAuthn(options.passport, app.signingConfig, "local")
);
router.post(
"/local/signup",
express.json(),
signupHandler({ db: app.mongo, signingConfig: app.signingConfig })
);
router.post("/sso", wrapAuthn(options.passport, app.signingConfig, "sso"));
router.get("/oidc", wrapAuthn(options.passport, app.signingConfig, "oidc"));
router.get(
"/oidc/callback",
wrapAuthn(options.passport, app.signingConfig, "oidc")
);
return router;
}
export async function createRouter(opts: AppOptions) {
async function createAPIRouter(app: AppOptions, options: RouterOptions) {
// Create a router.
const router = express.Router();
router.use("/api", await createAPIRouter(opts));
// Configure the tenant routes.
router.use("/tenant", await createTenantRouter(app, options));
if (opts.config.get("env") === "development") {
// Configure the management routes.
router.use("/management", await createManagementRouter(app, options));
// General API error handler.
router.use(errorLogger);
router.use(apiErrorHandler);
return router;
}
export interface RouterOptions {
/**
* passport is the instance of the Authenticator that can be used to create
* and mount new authentication middleware.
*/
passport: passport.Authenticator;
}
export async function createRouter(app: AppOptions, options: RouterOptions) {
// Create a router.
const router = express.Router();
router.use("/api", await createAPIRouter(app, options));
if (app.config.get("env") === "development") {
// Tenant GraphiQL
router.get(
"/tenant/graphiql",
+12
View File
@@ -0,0 +1,12 @@
import { Request } from "talk-server/types/express";
import { URL } from "url";
export function reconstructURL(req: Request, path: string = "/"): string {
const scheme = req.secure ? "https" : "http";
const host = req.get("host");
const base = `${scheme}://${host}`;
const url = new URL(path, base);
return url.href;
}
+17
View File
@@ -0,0 +1,17 @@
import { User } from "talk-server/models/user";
import { Request } from "talk-server/types/express";
export interface CommonContextOptions {
user?: User;
req?: Request;
}
export default class CommonContext {
public user?: User;
public req?: Request;
constructor({ user, req }: CommonContextOptions) {
this.user = user;
this.req = req;
}
}
@@ -0,0 +1,39 @@
import { DirectiveResolverFn } from "graphql-tools";
import CommonContext from "talk-server/graph/common/context";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
export interface AuthDirectiveArgs {
roles?: GQLUSER_ROLE[];
userIDField?: string;
}
const auth: DirectiveResolverFn<
Record<string, string | undefined>,
CommonContext
> = (next, src, { roles, userIDField }: AuthDirectiveArgs, { user }) => {
// If there is a user on the request.
if (user) {
// If the role and user owner checks are disabled, then allow them based on
// their authenticated status.
if (!roles && !userIDField) {
return next();
}
// And the user has the expected role.
if (roles && roles.includes(user.role)) {
// Let the request continue.
return next();
}
// Or the item is owned by the specific user.
if (userIDField && src[userIDField] && src[userIDField] === user.id) {
return next();
}
}
// TODO: return better error.
throw new Error("not authorized");
};
export default auth;
@@ -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) => ({
@@ -0,0 +1,136 @@
import { Kind } from "graphql";
import { DateTime } from "luxon";
import Cursor from "./cursor";
describe("parseLiteral", () => {
it("parses a date from a string", () => {
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "2018-07-16T18:34:26.744Z",
})
).toBeInstanceOf(Date);
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "this-should-fail",
})
).toEqual(null);
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "",
})
).toEqual(null);
});
it("parses a number from a string", () => {
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "20",
})
).toEqual(20);
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "0",
})
).toEqual(0);
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "null",
})
).toEqual(null);
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "0",
})
).toEqual(0);
});
it("parses a number from a number", () => {
expect(
Cursor.parseLiteral({
kind: Kind.INT,
value: "20",
})
).toEqual(20);
expect(
Cursor.parseLiteral({
kind: Kind.INT,
value: "0",
})
).toEqual(0);
expect(
Cursor.parseLiteral({
kind: Kind.INT,
value: "",
})
).toEqual(null);
});
it("does not parse unknown kinds", () => {
expect(
Cursor.parseLiteral({
kind: Kind.FLOAT,
value: "0.0",
})
).toEqual(null);
});
});
describe("serialize", () => {
it("renders native dates correctly", () => {
const date = new Date();
const expected = date.toISOString();
expect(Cursor.serialize(date)).toEqual(expected);
expect(Cursor.serialize({})).toEqual(null);
});
it("renders luxon dates correctly", () => {
const date = DateTime.fromJSDate(new Date());
const expected = date.toISO();
expect(Cursor.serialize(date)).toEqual(expected);
});
it("renders numbers correctly", () => {
let value = 50;
let expected = "50";
expect(Cursor.serialize(value)).toEqual(expected);
value = 0;
expected = "0";
expect(Cursor.serialize(value)).toEqual(expected);
expect(Cursor.serialize(null)).toEqual(null);
});
});
describe("parseValue", () => {
it("parses the string value of a Date", () => {
const date = new Date();
const expected = date.toISOString();
expect(Cursor.parseValue(expected)).toBeInstanceOf(Date);
});
it("parses the string value of a number", () => {
expect(Cursor.parseValue("0")).toEqual(0);
});
it("handles invalid properties", () => {
expect(Cursor.parseValue(null)).toEqual(null);
expect(Cursor.parseValue(2)).toEqual(null);
});
});
@@ -1,11 +1,15 @@
import { GraphQLScalarType } from "graphql";
import { Kind } from "graphql/language";
import { DateTime } from "luxon";
import { Cursor } from "talk-server/models/connection";
function parseIntegerCursor(value: string): number | null {
try {
const cursor = parseInt(value, 10);
if (isNaN(cursor)) {
return null;
}
return cursor;
} catch (err) {
@@ -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<RedisPubSub> {
+11 -5
View File
@@ -1,13 +1,19 @@
import { Db } from "mongodb";
import CommonContext from "talk-server/graph/common/context";
import { Request } from "talk-server/types/express";
export interface ManagementContextOptions {
db: Db;
mongo: Db;
req?: Request;
}
export default class ManagementContext {
public db: Db;
export default class ManagementContext extends CommonContext {
public mongo: Db;
constructor({ db }: ManagementContextOptions) {
this.db = db;
constructor({ req, mongo }: ManagementContextOptions) {
super({ req });
this.mongo = mongo;
}
}
@@ -1,13 +1,14 @@
import { GraphQLSchema } from "graphql";
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import { Config } from "talk-common/config";
import { graphqlMiddleware } from "talk-server/graph/common/middleware";
import { Request } from "talk-server/types/express";
import Context from "./context";
import ManagementContext from "./context";
export default (schema: GraphQLSchema, config: Config, db: Db) =>
graphqlMiddleware(config, async () => ({
export default (schema: GraphQLSchema, config: Config, mongo: Db) =>
graphqlMiddleware(config, async (req: Request) => ({
schema,
context: new Context({ db }),
context: new ManagementContext({ req, mongo }),
}));
@@ -1,5 +1,5 @@
import Cursor from "../../common/scalars/cursor";
import { GQLResolver } from "talk-server/graph/management/schema/__generated__/types";
export default {
Cursor,
};
const Resolvers: GQLResolver = {};
export default Resolvers;
@@ -1,6 +1,8 @@
import { IResolvers } from "graphql-tools";
import { loadSchema } from "talk-common/graphql";
import resolvers from "talk-server/graph/management/resolvers";
export default function getManagementSchema() {
return loadSchema("management", resolvers);
return loadSchema("management", resolvers as IResolvers);
}
@@ -7,27 +7,22 @@ Time represented as an ISO8601 string.
"""
scalar Time
"""
Cursor represents a paginating cursor.
"""
scalar Cursor
################################################################################
## Tenant
################################################################################
type Tenant {
id: ID!
id: ID!
"""
organizationName is the name of the organization.
"""
organizationName: String
"""
organizationName is the name of the organization.
"""
organizationName: String
"""
organizationContactEmail is the email of the organization.
"""
organizationContactEmail: String
"""
organizationContactEmail is the email of the organization.
"""
organizationContactEmail: String
}
################################################################################
@@ -35,5 +30,5 @@ type Tenant {
################################################################################
type Query {
tenant(id: ID!): Tenant
tenant(id: ID!): Tenant
}
+28 -6
View File
@@ -1,27 +1,49 @@
import { Redis } from "ioredis";
import { Db } from "mongodb";
import CommonContext from "talk-server/graph/common/context";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
import { Request } from "talk-server/types/express";
import loaders from "./loaders";
import mutators from "./mutators";
export interface TenantContextOptions {
db: Db;
mongo: Db;
redis: Redis;
tenant: Tenant;
tenantCache: TenantCache;
req?: Request;
user?: User;
}
export default class TenantContext {
export default class TenantContext extends CommonContext {
public loaders: ReturnType<typeof loaders>;
public mutators: ReturnType<typeof mutators>;
public db: Db;
public tenant: Tenant;
public mongo: Db;
public redis: Redis;
public user?: User;
public tenant: Tenant;
public tenantCache: TenantCache;
constructor({
req,
user,
tenant,
mongo,
redis,
tenantCache,
}: TenantContextOptions) {
super({ user, req });
constructor({ user, tenant, db }: TenantContextOptions) {
this.tenant = tenant;
this.tenantCache = tenantCache;
this.user = user;
this.mongo = mongo;
this.redis = redis;
this.loaders = loaders(this);
this.mutators = mutators(this);
this.db = db;
}
}
+10 -2
View File
@@ -1,9 +1,17 @@
import DataLoader from "dataloader";
import TenantContext from "talk-server/graph/tenant/context";
import { Asset, retrieveManyAssets } from "talk-server/models/asset";
import {
Asset,
FindOrCreateAssetInput,
retrieveManyAssets,
} from "talk-server/models/asset";
import { findOrCreate } from "talk-server/services/assets";
export default (ctx: TenantContext) => ({
findOrCreate: (input: FindOrCreateAssetInput) =>
findOrCreate(ctx.mongo, ctx.tenant, input),
asset: new DataLoader<string, Asset | null>(ids =>
retrieveManyAssets(ctx.db, ctx.tenant.id, ids)
retrieveManyAssets(ctx.mongo, ctx.tenant.id, ids)
),
});
@@ -1,18 +1,54 @@
import DataLoader from "dataloader";
import Context from "talk-server/graph/tenant/context";
import {
ConnectionInput,
retrieveAssetConnection,
retrieveMany,
retrieveRepliesConnection,
AssetToCommentsArgs,
CommentToRepliesArgs,
GQLCOMMENT_SORT,
} from "talk-server/graph/tenant/schema/__generated__/types";
import {
retrieveCommentAssetConnection,
retrieveCommentRepliesConnection,
retrieveManyComments,
} from "talk-server/models/comment";
export default (ctx: Context) => ({
comment: new DataLoader((ids: string[]) =>
retrieveMany(ctx.db, ctx.tenant.id, ids)
retrieveManyComments(ctx.mongo, ctx.tenant.id, ids)
),
forAsset: (assetID: string, input: ConnectionInput) =>
retrieveAssetConnection(ctx.db, ctx.tenant.id, assetID, input),
forParent: (assetID: string, parentID: string, input: ConnectionInput) =>
retrieveRepliesConnection(ctx.db, ctx.tenant.id, assetID, parentID, input),
forAsset: (
assetID: string,
// Apply the graph schema defaults at the loader.
{
first = 10,
orderBy = GQLCOMMENT_SORT.CREATED_AT_DESC,
after,
}: AssetToCommentsArgs
) =>
retrieveCommentAssetConnection(ctx.mongo, ctx.tenant.id, assetID, {
first,
orderBy,
after,
}),
forParent: (
assetID: string,
parentID: string,
// Apply the graph schema defaults at the loader.
{
first = 10,
orderBy = GQLCOMMENT_SORT.CREATED_AT_DESC,
after,
}: CommentToRepliesArgs
) =>
retrieveCommentRepliesConnection(
ctx.mongo,
ctx.tenant.id,
assetID,
parentID,
{
first,
orderBy,
after,
}
),
});
@@ -1,9 +1,9 @@
import DataLoader from "dataloader";
import Context from "talk-server/graph/tenant/context";
import { retrieveMany, User } from "talk-server/models/user";
import { retrieveManyUsers, User } from "talk-server/models/user";
export default (ctx: Context) => ({
user: new DataLoader<string, User | null>(ids =>
retrieveMany(ctx.db, ctx.tenant.id, ids)
retrieveManyUsers(ctx.mongo, ctx.tenant.id, ids)
),
});
+24 -4
View File
@@ -1,21 +1,41 @@
import { GraphQLSchema } from "graphql";
import { Redis } from "ioredis";
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import { Config } from "talk-common/config";
import { graphqlMiddleware } from "talk-server/graph/common/middleware";
import { Request } from "talk-server/types/express";
import TenantContext from "./context";
export default async (schema: GraphQLSchema, config: Config, db: Db) => {
export interface TenantGraphQLMiddlewareOptions {
schema: GraphQLSchema;
config: Config;
mongo: Db;
redis: Redis;
}
export default async ({
schema,
config,
mongo,
redis,
}: TenantGraphQLMiddlewareOptions) => {
return graphqlMiddleware(config, async (req: Request) => {
// Load the tenant and user from the request.
const { tenant, user } = req;
const { tenant, user, tenantCache } = req;
// Return the graph options.
return {
schema,
context: new TenantContext({ db, tenant: tenant!, user }),
context: new TenantContext({
req,
mongo,
redis,
tenant: tenant!,
user,
tenantCache,
}),
};
});
};
@@ -1,16 +1,21 @@
import TenantContext from "talk-server/graph/tenant/context";
import { CreateCommentInput } from "talk-server/graph/tenant/resolvers/mutation";
import { GQLCreateCommentInput } from "talk-server/graph/tenant/schema/__generated__/types";
import { Comment } from "talk-server/models/comment";
import { create } from "talk-server/services/comments";
export default (ctx: TenantContext) => ({
create: (input: CreateCommentInput): Promise<Comment> => {
// FIXME: remove tenant + user !
return create(ctx.db, ctx.tenant!.id, {
author_id: ctx.user!.id,
asset_id: input.assetID,
body: input.body,
parent_id: input.parentID,
});
create: (input: GQLCreateCommentInput): Promise<Comment> => {
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
);
},
});
@@ -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),
});
@@ -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<Tenant | null> =>
update(mongo, redis, tenantCache, tenant, omitBy(input, isNull)),
});
@@ -1,10 +1,11 @@
import Context from "talk-server/graph/tenant/context";
import { GQLAssetTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { Asset } from "talk-server/models/asset";
import { ConnectionInput } from "talk-server/models/comment";
export default {
comments: async (asset: Asset, input: ConnectionInput, ctx: Context) =>
const Asset: GQLAssetTypeResolver<Asset> = {
comments: (asset, input, ctx) =>
ctx.loaders.Comments.forAsset(asset.id, input),
// TODO: implement this.
isClosed: () => false,
};
export default Asset;
@@ -0,0 +1,14 @@
import { GQLAuthIntegrationsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { AuthIntegration, AuthIntegrations } from "talk-server/models/settings";
const disabled: AuthIntegration = { enabled: false };
const AuthIntegrations: GQLAuthIntegrationsTypeResolver<AuthIntegrations> = {
local: auth => auth.local || disabled,
sso: auth => auth.sso || disabled,
oidc: auth => auth.oidc || disabled,
google: auth => auth.google || disabled,
facebook: auth => auth.facebook || disabled,
};
export default AuthIntegrations;
@@ -0,0 +1,8 @@
import { GQLAuthSettingsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { Auth } from "talk-server/models/settings";
const AuthSettings: GQLAuthSettingsTypeResolver<Auth> = {
integrations: auth => auth.integrations,
};
export default AuthSettings;
@@ -1,11 +1,12 @@
import Context from "talk-server/graph/tenant/context";
import { Comment, ConnectionInput } from "talk-server/models/comment";
import { GQLCommentTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { Comment } from "talk-server/models/comment";
export default {
createdAt: async (comment: Comment, _: any, ctx: Context) =>
comment.created_at,
author: async (comment: Comment, _: any, ctx: Context) =>
const Comment: GQLCommentTypeResolver<Comment> = {
createdAt: comment => comment.created_at,
author: (comment, input, ctx) =>
ctx.loaders.Users.user.load(comment.author_id),
replies: async (comment: Comment, input: ConnectionInput, ctx: Context) =>
replies: (comment, input, ctx) =>
ctx.loaders.Comments.forParent(comment.asset_id, comment.id, input),
};
export default Comment;
@@ -0,0 +1,10 @@
import { GQLFacebookAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { FacebookAuthIntegration } from "talk-server/models/settings";
const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver<
FacebookAuthIntegration
> = {
config: auth => auth,
};
export default FacebookAuthIntegration;
@@ -0,0 +1,10 @@
import { GQLGoogleAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { GoogleAuthIntegration } from "talk-server/models/settings";
const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver<
GoogleAuthIntegration
> = {
config: auth => auth,
};
export default GoogleAuthIntegration;
@@ -1,13 +1,33 @@
import Cursor from "../../common/scalars/cursor";
import Asset from "./asset";
import Comment from "./comment";
import Mutation from "./mutation";
import Query from "./query";
import Cursor from "talk-server/graph/common/scalars/cursor";
import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types";
export default {
import Asset from "./asset";
import AuthIntegrations from "./auth_integrations";
import AuthSettings from "./auth_settings";
import Comment from "./comment";
import FacebookAuthIntegration from "./facebook_auth_integration";
import GoogleAuthIntegration from "./google_auth_integration";
import LocalAuthIntegration from "./local_auth_integration";
import Mutation from "./mutation";
import OIDCAuthIntegration from "./oidc_auth_integration";
import Profile from "./profile";
import Query from "./query";
import SSOAuthIntegration from "./sso_auth_integration";
const Resolvers: GQLResolver = {
Asset,
AuthIntegrations,
AuthSettings,
Comment,
FacebookAuthIntegration,
GoogleAuthIntegration,
LocalAuthIntegration,
OIDCAuthIntegration,
SSOAuthIntegration,
Cursor,
Query,
Mutation,
Profile,
Query,
};
export default Resolvers;
@@ -0,0 +1,8 @@
import { GQLLocalAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { LocalAuthIntegration } from "talk-server/models/settings";
const LocalAuthIntegration: GQLLocalAuthIntegrationTypeResolver<
LocalAuthIntegration
> = {};
export default LocalAuthIntegration;
@@ -1,26 +1,14 @@
import { ClientMutationProps } from "talk-server/graph/common/resolvers/mutation";
import TenantContext from "talk-server/graph/tenant/context";
import { Comment } from "talk-server/models/comment";
import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
export interface CreateCommentInput extends ClientMutationProps {
assetID: string;
parentID?: string;
body: string;
}
export interface CreateCommentPayload extends ClientMutationProps {
comment: Comment;
}
const Mutation = {
createComment: async (
source: void,
input: CreateCommentInput,
ctx: TenantContext
): Promise<CreateCommentPayload> => ({
const Mutation: GQLMutationTypeResolver<void> = {
createComment: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.create(input),
clientMutationId: input.clientMutationId,
}),
updateSettings: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.update(input.settings),
clientMutationId: input.clientMutationId,
}),
};
export default Mutation;
@@ -0,0 +1,10 @@
import { GQLOIDCAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { OIDCAuthIntegration } from "talk-server/models/settings";
const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver<
OIDCAuthIntegration
> = {
config: auth => auth,
};
export default OIDCAuthIntegration;
@@ -0,0 +1,21 @@
import { GQLProfileTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { Profile } from "talk-server/models/user";
const resolveType: GQLProfileTypeResolver<Profile> = profile => {
switch (profile.type) {
case "local":
return "LocalProfile";
case "oidc":
return "OIDCProfile";
case "sso":
return "SSOProfile";
default:
// TODO: replace with better error.
throw new Error("invalid profile type");
}
};
export default {
__resolveType: resolveType,
};
@@ -1,10 +1,9 @@
import TenantContext from "talk-server/graph/tenant/context";
import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
export default {
asset: async (
source: void,
{ id }: { id: string; url: string },
ctx: TenantContext
) => ctx.loaders.Assets.asset.load(id),
settings: async (parent: any, args: any, ctx: TenantContext) => ctx.tenant,
const Query: GQLQueryTypeResolver<void> = {
asset: (source, args, ctx) => ctx.loaders.Assets.findOrCreate(args),
settings: (source, args, ctx) => ctx.tenant,
me: (source, args, ctx) => ctx.user,
};
export default Query;
@@ -0,0 +1,10 @@
import { GQLSSOAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { SSOAuthIntegration } from "talk-server/models/settings";
const SSOAuthIntegration: GQLSSOAuthIntegrationTypeResolver<
SSOAuthIntegration
> = {
config: auth => auth,
};
export default SSOAuthIntegration;
+9 -1
View File
@@ -1,6 +1,14 @@
import { attachDirectiveResolvers, IResolvers } from "graphql-tools";
import { loadSchema } from "talk-common/graphql";
import auth from "talk-server/graph/common/directives/auth";
import resolvers from "talk-server/graph/tenant/resolvers";
export default function getTenantSchema() {
return loadSchema("tenant", resolvers);
const schema = loadSchema("tenant", resolvers as IResolvers);
// Attach the directive resolvers.
attachDirectiveResolvers(schema, { auth });
return schema;
}
@@ -1,3 +1,16 @@
################################################################################
## Custom Directives
################################################################################
"""
auth is a directive that will enforce authorization rules on the schema
definition. It will restrict the viewer of the field based on roles or if the
`userIDField` is specified, it will see if the current users ID equals the field
specified. This allows users that own a specific resource (like a comment, or a
flag) see their own content, but restrict it to everyone else.
"""
directive @auth(roles: [USER_ROLE!], userIDField: String) on FIELD_DEFINITION
################################################################################
## Custom Scalar Types
################################################################################
@@ -12,6 +25,19 @@ Cursor represents a paginating cursor.
"""
scalar Cursor
################################################################################
## Actions
################################################################################
enum ACTION_TYPE {
FLAG
DONTAGREE
}
enum ACTION_ITEM_TYPE {
COMMENTS
}
################################################################################
## Settings
################################################################################
@@ -31,9 +57,9 @@ enum MODERATION_MODE {
}
"""
Wordlist describes all the available wordlists.
WordlistSettings describes all the available wordlists.
"""
type Wordlist {
type WordlistSettings {
"""
banned words will by default reject the comment if it is found.
"""
@@ -45,12 +71,129 @@ type Wordlist {
suspect: [String!]!
}
# Settings stores the global settings for a given installation.
################################################################################
## AuthSettings
################################################################################
##########################
## LocalAuthIntegration
##########################
type LocalAuthIntegration {
enabled: Boolean!
}
##########################
## SSOAuthIntegration
##########################
type SSOAuthIntegrationConfig {
key: String!
"""
displayNameEnable when enabled, will allow Users to set and view their
displayName's.
"""
displayNameEnable: Boolean!
}
type SSOAuthIntegration {
enabled: Boolean!
config: SSOAuthIntegrationConfig @auth(roles: [ADMIN])
}
##########################
## OIDCAuthIntegration
##########################
type OIDCAuthIntegrationConfig {
clientID: String!
clientSecret: String!
authorizationURL: String!
tokenURL: String!
"""
displayNameEnable when enabled, will allow Users to set and view their
displayName's.
"""
displayNameEnable: Boolean!
}
type OIDCAuthIntegrationOptions {
name: String!
}
type OIDCAuthIntegration {
enabled: Boolean!
options: OIDCAuthIntegrationOptions
config: SSOAuthIntegrationConfig @auth(roles: [ADMIN])
}
##########################
## GoogleAuthIntegration
##########################
type GoogleAuthIntegrationConfig {
clientID: String!
clientSecret: String!
}
type GoogleAuthIntegration {
enabled: Boolean!
config: GoogleAuthIntegrationConfig @auth(roles: [ADMIN])
}
##########################
## FacebookAuthIntegration
##########################
type FacebookAuthIntegrationConfig {
clientID: String!
clientSecret: String!
}
type FacebookAuthIntegration {
enabled: Boolean!
config: FacebookAuthIntegrationConfig @auth(roles: [ADMIN])
}
type AuthIntegrations {
local: LocalAuthIntegration!
sso: SSOAuthIntegration!
oidc: OIDCAuthIntegration!
google: GoogleAuthIntegration!
facebook: FacebookAuthIntegration!
}
"""
AuthSettings contains all the settings related to authentication and
authorization.
"""
type AuthSettings {
"""
integrations are the set of configurations for the variations of
authentication solutions.
"""
integrations: AuthIntegrations!
}
################################################################################
## Settings
################################################################################
"""
Settings stores the global settings for a given Tenant.
"""
type Settings {
"""
domain is the domain that is associated with this Tenant.
"""
domain: String @auth(roles: [ADMIN])
"""
moderation is the moderation mode for all Asset's on the site.
"""
moderation: MODERATION_MODE!
moderation: MODERATION_MODE @auth(roles: [ADMIN])
"""
Enables a requirement for email confirmation before a user can login.
@@ -87,13 +230,13 @@ type Settings {
"""
premodLinksEnable will put all comments that contain links into premod.
"""
premodLinksEnable: Boolean!
premodLinksEnable: Boolean @auth(roles: [ADMIN])
"""
autoCloseStream when true will auto close the stream when the `closeTimeout`
amount of seconds have been reached.
"""
autoCloseStream: Boolean!
autoCloseStream: Boolean! @auth(roles: [ADMIN])
"""
customCssUrl is the URL of the custom CSS used to display on the frontend.
@@ -152,18 +295,80 @@ type Settings {
"""
wordlist will return a given list of words.
"""
wordlist: Wordlist!
wordlist: WordlistSettings @auth(roles: [ADMIN, MODERATOR])
"""
domains will return a given list of whitelisted domains.
"""
domains: [String!]!
domains: [String!] @auth(roles: [ADMIN])
"""
auth contains all the settings related to authentication and authorization.
"""
auth: AuthSettings!
}
################################################################################
## User
################################################################################
enum USER_ROLE {
COMMENTER
STAFF
MODERATOR
ADMIN
}
enum USER_USERNAME_STATUS {
"""
UNSET is used when the username can be changed, and does not necessarily
require moderator action to become active. This can be used when the user
signs up with a social login and has the option of setting their own
username.
"""
UNSET
"""
SET is used when the username has been set for the first time, but cannot
change without the username being rejected by a moderator and that moderator
agreeing that the username should be allowed to change.
"""
SET
"""
APPROVED is used when the username was changed, and subsequently approved by
said moderator.
"""
APPROVED
"""
REJECTED is used when the username was changed, and subsequently rejected by
said moderator.
"""
REJECTED
"""
CHANGED is used after a user has changed their username after it was
rejected.
"""
CHANGED
}
type LocalProfile {
id: String!
}
type OIDCProfile {
id: String!
provider: String!
}
type SSOProfile {
id: String!
}
union Profile = LocalProfile | OIDCProfile | SSOProfile
"""
User is someone that leaves Comments, and logs in.
"""
@@ -176,7 +381,22 @@ type User {
"""
username is the name of the User visible to other Users.
"""
username: String!
username: String
"""
displayName is provided optionally when enabled and available.
"""
displayName: String
"""
profiles is the array of profiles assigned to the user.
"""
profiles: [Profile!] @auth(roles: [ADMIN, MODERATOR], userIDField: "id")
"""
role is the current role of the User.
"""
role: USER_ROLE! @auth(roles: [ADMIN, MODERATOR], userIDField: "id")
}
################################################################################
@@ -184,8 +404,34 @@ type User {
################################################################################
enum COMMENT_STATUS {
"""
The comment is not PREMOD, but was not applied a moderation status by a
moderator.
"""
NONE
"""
The comment has been accepted by a moderator.
"""
ACCEPTED
"""
The comment has been rejected by a moderator.
"""
REJECTED
"""
The comment was created while the asset's premoderation option was on, and
new comments that haven't been moderated yet are referred to as
"premoderated" or "premod" comments.
"""
PREMOD
"""
SYSTEM_WITHHELD represents a comment that was withheld by the system because
it was flagged by an internal process for further review.
"""
SYSTEM_WITHHELD
}
"""
@@ -391,9 +637,9 @@ type Query {
assets(cursor: Cursor, limit: Int = 10): AssetsConnection
"""
asset is the Asset specified by its ID.
asset is the Asset specified by its ID/URL.
"""
asset(id: ID!): Asset
asset(id: ID, url: String): Asset
"""
me is the current logged in User.
@@ -455,6 +701,66 @@ type CreateCommentPayload {
clientMutationId: String!
}
##################
## updateSettings
##################
"""
SettingsInput is the partial type of the Settings type for performing mutations.
"""
input SettingsInput {
moderation: MODERATION_MODE
requireEmailConfirmation: Boolean
infoBoxEnable: Boolean
infoBoxContent: String
questionBoxEnable: Boolean
questionBoxContent: String
questionBoxIcon: String
premodLinksEnable: Boolean
autoCloseStream: Boolean
customCssUrl: String
closedTimeout: Int
closedMessage: String
disableCommenting: Boolean
disableCommentingMessage: String
editCommentWindowLength: Int
charCountEnable: Boolean
charCount: Int
organizationName: String
organizationContactEmail: String
# wordlist: WordlistSettings @auth(roles: [ADMIN, MODERATOR])
domains: [String!]
# auth: AuthSettings!
}
"""
UpdateSettingsInput provides the input for the updateSettings Mutation.
"""
input UpdateSettingsInput {
settings: SettingsInput!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
"""
UpdateSettingsPayload contains the updated Settings after the updateSettings
mutation.
"""
type UpdateSettingsPayload {
"""
settings is the updated Settings.
"""
settings: Settings
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## Mutation
##################
@@ -463,7 +769,12 @@ type Mutation {
"""
createComment will create a Comment as the current logged in User.
"""
createComment(input: CreateCommentInput!): CreateCommentPayload
createComment(input: CreateCommentInput!): CreateCommentPayload @auth
"""
updateSettings will update the Settings for the given Tenant.
"""
updateSettings(input: UpdateSettingsInput!): UpdateSettingsPayload @auth(roles: [ADMIN])
}
################################################################################

Some files were not shown because too many files have changed in this diff Show More