mirror of
https://github.com/wassname/talk.git
synced 2026-08-06 13:41:02 +08:00
Merge branch 'ui-components' of github.com:coralproject/talk into ui-components
This commit is contained in:
@@ -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",
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
require("@babel/polyfill");
|
||||
@@ -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",
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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&childId=pymcontrol-test-id&parentTitle=&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?&initialWidth=0&childId=basic-integration-test-id&parentTitle=&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>"`;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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}/`;
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = () => (
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]"`;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user