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

This commit is contained in:
Belen Curcio
2018-08-03 09:25:11 -03:00
159 changed files with 6379 additions and 1540 deletions
-93
View File
@@ -1,93 +0,0 @@
"use strict";
const fs = require("fs");
const path = require("path");
const paths = require("./paths");
// Make sure that including paths.js after env.js will read .env variables.
delete require.cache[require.resolve("./paths")];
const NODE_ENV = process.env.NODE_ENV;
if (!NODE_ENV) {
throw new Error(
"The NODE_ENV environment variable is required but was not specified."
);
}
// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
var dotenvFiles = [
`${paths.dotenv}.${NODE_ENV}.local`,
`${paths.dotenv}.${NODE_ENV}`,
// Don't include `.env.local` for `test` environment
// since normally you expect tests to produce the same
// results for everyone
NODE_ENV !== "test" && `${paths.dotenv}.local`,
paths.dotenv,
].filter(Boolean);
// Load environment variables from .env* files. Suppress warnings using silent
// if this file is missing. dotenv will never modify any environment variables
// that have already been set. Variable expansion is supported in .env files.
// https://github.com/motdotla/dotenv
// https://github.com/motdotla/dotenv-expand
dotenvFiles.forEach(dotenvFile => {
if (fs.existsSync(dotenvFile)) {
require("dotenv-expand")(
require("dotenv").config({
path: dotenvFile,
})
);
}
});
// We support resolving modules according to `NODE_PATH`.
// This lets you use absolute paths in imports inside large monorepos:
// https://github.com/facebookincubator/create-react-app/issues/253.
// It works similar to `NODE_PATH` in Node itself:
// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders
// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored.
// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims.
// https://github.com/facebookincubator/create-react-app/issues/1023#issuecomment-265344421
// We also resolve them to make sure all tools using them work consistently.
const appDirectory = fs.realpathSync(process.cwd());
process.env.NODE_PATH = (process.env.NODE_PATH || "")
.split(path.delimiter)
.filter(folder => folder && !path.isAbsolute(folder))
.map(folder => path.resolve(appDirectory, folder))
.join(path.delimiter);
// Grab NODE_ENV and TALK_* environment variables and prepare them to be
// injected into the application via DefinePlugin in Webpack configuration.
const REACT_APP = /^TALK_/i;
function getClientEnvironment(publicUrl) {
const raw = Object.keys(process.env)
.filter(key => REACT_APP.test(key))
.reduce(
(env, key) => {
env[key] = process.env[key];
return env;
},
{
// Useful for determining whether were running in production mode.
// Most importantly, it switches React into the correct mode.
NODE_ENV: process.env.NODE_ENV || "development",
// Useful for resolving the correct path to static assets in `public`.
// For example, <img src={process.env.PUBLIC_URL + '/img/logo.png'} />.
// This should only be used as an escape hatch. Normally you would put
// images into the `src` and `import` them in code to get their paths.
PUBLIC_URL: publicUrl,
}
);
// Stringify all values so we can feed into Webpack DefinePlugin
const stringified = {
"process.env": Object.keys(raw).reduce((env, key) => {
env[key] = JSON.stringify(raw[key]);
return env;
}, {}),
};
return { raw, stringified };
}
module.exports = getClientEnvironment;
+2 -2
View File
@@ -1,6 +1,6 @@
module.exports = {
projects: [
"<rootDir>/jest-client.config.js",
"<rootDir>/jest-server.config.js",
"<rootDir>/jest/client.config.js",
"<rootDir>/jest/server.config.js",
],
};
@@ -2,12 +2,12 @@ const path = require("path");
module.exports = {
displayName: "client",
rootDir: "../",
rootDir: "../../",
roots: ["<rootDir>/src/core/client"],
collectCoverageFrom: ["**/*.{js,jsx,mjs,ts,tsx}"],
coveragePathIgnorePatterns: ["/node_modules/"],
setupFiles: [
"<rootDir>/config/polyfills.js",
"<rootDir>/src/core/build/polyfills.js",
"<rootDir>/src/core/client/test/setup.ts",
],
testMatch: ["**/*.spec.{js,jsx,mjs,ts,tsx}"],
@@ -1,6 +1,6 @@
module.exports = {
displayName: "server",
rootDir: "../",
rootDir: "../../",
roots: ["<rootDir>/src"],
collectCoverageFrom: ["**/*.{js,jsx,mjs,ts,tsx}"],
coveragePathIgnorePatterns: ["/node_modules/"],
-66
View File
@@ -1,66 +0,0 @@
"use strict";
// A script from `create-react-app` ejected `25.06.2018`.
const path = require("path");
const fs = require("fs");
const url = require("url");
// Make sure any symlinks in the project folder are resolved:
// https://github.com/facebookincubator/create-react-app/issues/637
const appDirectory = fs.realpathSync(process.cwd());
const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
const envPublicUrl = process.env.PUBLIC_URL;
function ensureSlash(p, needsSlash) {
const hasSlash = p.endsWith("/");
if (hasSlash && !needsSlash) {
return p.substr(p, p.length - 1);
} else if (!hasSlash && needsSlash) {
return `${p}/`;
} else {
return p;
}
}
const getPublicUrl = appPackageJson =>
envPublicUrl || require(appPackageJson).homepage;
// We use `PUBLIC_URL` environment variable or "homepage" field to infer
// "public path" at which the app is served.
// Webpack needs to know it to put the right <script> hrefs into HTML even in
// single-page apps that may serve index.html for nested URLs like /todos/42.
// We can't use a relative path in HTML because we don't want to load something
// like /todos/42/static/js/bundle.7289d.js. We have to know the root.
function getServedPath(appPackageJson) {
const publicUrl = getPublicUrl(appPackageJson);
const servedUrl =
envPublicUrl || (publicUrl ? url.parse(publicUrl).pathname : "/");
return ensureSlash(servedUrl, true);
}
// config after eject: we're in ./config/
module.exports = {
dotenv: resolveApp(".env"),
appPostCssConfig: resolveApp("config/postcss.config.js"),
appJestConfig: resolveApp("config/jest.config.js"),
appLoaders: resolveApp("loaders"),
appDist: resolveApp("dist"),
appPublic: resolveApp("public"),
appPackageJson: resolveApp("package.json"),
appSrc: resolveApp("src"),
appTsconfig: resolveApp("src/core/client/tsconfig.json"),
appLocales: resolveApp("src/locales"),
appThemeVariables: resolveApp("src/core/client/ui/theme/variables.ts"),
appThemeVariablesCSS: resolveApp("src/core/client/ui/theme/variables.css"),
testsSetup: resolveApp("src/setupTests.js"),
appNodeModules: resolveApp("node_modules"),
publicUrl: getPublicUrl(resolveApp("package.json")),
servedPath: getServedPath(resolveApp("package.json")),
appStreamHtml: resolveApp("src/core/client/stream/index.html"),
appStreamLocalesTemplate: resolveApp("src/core/client/stream/locales.ts"),
appStreamIndex: resolveApp("src/core/client/stream/index.tsx"),
};
+15
View File
@@ -0,0 +1,15 @@
import fs from "fs";
import path from "path";
import appPaths from "../src/core/build/paths";
// 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);
// config after eject: we're in ./config/
export default {
...appPaths,
appJestConfig: resolveApp("config/jest.config.js"),
};
+8 -4
View File
@@ -4,12 +4,16 @@ import {
Config,
LongRunningExecutor,
} from "../scripts/watcher";
// Ensure environment variables are read.
import "./env";
const config: Config = {
rootDir: path.resolve(__dirname, "../src"),
watchers: {
compileSchema: {
paths: ["core/server/**/*.graphql"],
executor: new CommandExecutor("npm run compile:schema", {
runOnInit: true,
}),
},
compileRelayStream: {
paths: [
"core/client/stream/**/*.ts",
@@ -49,7 +53,7 @@ const config: Config = {
},
defaultSet: "client",
sets: {
server: ["runServer"],
server: ["compileSchema", "runServer"],
client: [
"runServer",
"runWebpackDevServer",
@@ -57,7 +61,7 @@ const config: Config = {
"compileRelayStream",
],
docz: ["runDocz", "compileCSSTypes"],
compile: ["compileCSSTypes", "compileRelayStream"],
compile: ["compileSchema", "compileCSSTypes", "compileRelayStream"],
},
};
-334
View File
@@ -1,334 +0,0 @@
"use strict";
const autoprefixer = require("autoprefixer");
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const CaseSensitivePathsPlugin = require("case-sensitive-paths-webpack-plugin");
const InterpolateHtmlPlugin = require("react-dev-utils/InterpolateHtmlPlugin");
const WatchMissingNodeModulesPlugin = require("react-dev-utils/WatchMissingNodeModulesPlugin");
const ModuleScopePlugin = require("react-dev-utils/ModuleScopePlugin");
const getClientEnvironment = require("./env");
const paths = require("./paths");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
// Webpack uses `publicPath` to determine where the app is being served from.
// In development, we always serve from the root. This makes config easier.
const publicPath = "/";
// `publicUrl` is just like `publicPath`, but we will provide it to our app
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
// Omit trailing slash as %PUBLIC_PATH%/xyz looks better than %PUBLIC_PATH%xyz.
const publicUrl = "";
// Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl);
// This is the development configuration.
// It is focused on developer experience and fast rebuilds.
// The production configuration is different and lives in a separate file.
module.exports = {
// Set webpack mode.
mode: process.env.NODE_ENV === "production" ? "production" : "development",
// 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.
devtool: "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.
entry: [
// We ship polyfills by default:
require.resolve("./polyfills"),
// 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'),
require.resolve("react-dev-utils/webpackHotDevClient"),
// Finally, this is your app's code:
paths.appStreamIndex,
// We include the app code last so that if there is a runtime error during
// initialization, it doesn't blow up the WebpackDevServer client, and
// changing JS code would still trigger a refresh.
],
output: {
// Add /* filename */ comments to generated require()s in the output.
pathinfo: true,
// This does not produce a real file. It's just the virtual path that is
// served by WebpackDevServer in development. This is the JS bundle
// containing code from all our entry points, and the Webpack runtime.
filename: "static/js/bundle.js",
// There are also additional JS chunk files if you use code splitting.
chunkFilename: "static/js/[name].chunk.js",
// This is the URL that app is served from. We use "/" in development.
publicPath: publicPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: info =>
path.resolve(info.absoluteResourcePath).replace(/\\/g, "/"),
},
resolve: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebookincubator/create-react-app/issues/253
modules: ["node_modules", paths.appNodeModules].concat(
// It is guaranteed to exist because we tweak it in `env.js`
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
),
// These are the reasonable defaults supported by the Node ecosystem.
// We also include JSX as a common component filename extension to support
// some tools, although we do not recommend using it, see:
// https://github.com/facebookincubator/create-react-app/issues/290
// `web` extension prefixes have been added for better support
// for React Native Web.
extensions: [
".web.js",
".mjs",
".js",
".json",
".web.jsx",
".jsx",
".ts",
".tsx",
],
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
"react-native": "react-native-web",
},
plugins: [
// Prevents users from importing files from outside of src/ (or node_modules/).
// This often causes confusion because we only process files within src/ with babel.
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// please link the files into your node_modules/ and let module-resolution kick in.
// Make sure your source files are compiled, as they will not be processed in any way.
new ModuleScopePlugin(paths.appSrc, [paths.appPackageJson]),
// Support `tsconfig.json` `path` setting.
new TsconfigPathsPlugin({
configFile: paths.appTsconfig,
extensions: [".js", ".jsx", ".mjs", ".ts", ".tsx"],
}),
],
},
resolveLoader: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebookincubator/create-react-app/issues/253
modules: ["node_modules", paths.appNodeModules, paths.appLoaders].concat(
// It is guaranteed to exist because we tweak it in `env.js`
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
),
},
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|jsx|mjs|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: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
loader: require.resolve("url-loader"),
options: {
limit: 10000,
name: "static/media/[name].[hash:8].[ext]",
},
},
// Process JS with Babel.
{
test: /\.(js|jsx|mjs|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: {
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, but
// in development "style" loader enables hot editing of CSS.
{
test: /\.css$/,
use: [
require.resolve("style-loader"),
{
loader: require.resolve("css-loader"),
options: {
modules: true,
importLoaders: 1,
localIdentName: "[name]-[local]-[hash:base64:5]",
},
},
{
loader: require.resolve("postcss-loader"),
options: {
config: {
path: paths.appPostCssConfig,
},
},
},
],
},
// "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|jsx|mjs|ts|tsx)$/, /\.html$/, /\.json$/],
loader: require.resolve("file-loader"),
options: {
name: "static/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: [
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin({
inject: true,
template: paths.appStreamHtml,
}),
// 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.raw),
// Add module names to factory functions so they appear in browser profiler.
new webpack.NamedModulesPlugin(),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'development') { ... }. See `./env.js`.
new webpack.DefinePlugin(env.stringified),
// 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),
// Moment.js is an extremely popular library that bundles large locale files
// by default due to how Webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales.
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
// You can remove this if you don't use Moment.js:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
],
// 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: false,
},
};
-388
View File
@@ -1,388 +0,0 @@
"use strict";
const autoprefixer = require("autoprefixer");
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const ManifestPlugin = require("webpack-manifest-plugin");
const InterpolateHtmlPlugin = require("react-dev-utils/InterpolateHtmlPlugin");
const ModuleScopePlugin = require("react-dev-utils/ModuleScopePlugin");
const getClientEnvironment = require("./env");
const paths = require("./paths");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
const UglifyJsPlugin = require("uglifyjs-webpack-plugin");
// Webpack uses `publicPath` to determine where the app is being served from.
// It requires a trailing slash, or the file assets will get an incorrect path.
const publicPath = paths.servedPath;
// Some apps do not use client-side routing with pushState.
// For these, "homepage" can be set to "." to enable relative asset paths.
const shouldUseRelativeAssetPaths = publicPath === "./";
// Source maps are resource heavy and can cause out of memory issue for large source files.
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== "false";
// `publicUrl` is just like `publicPath`, but we will provide it to our app
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
const publicUrl = publicPath.slice(0, -1);
// Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl);
// Assert this just to be safe.
// Development builds of React are slow and not intended for production.
if (env.stringified["process.env"].NODE_ENV !== '"production"') {
throw new Error("Production builds must have NODE_ENV=production.");
}
// Note: defined here because it will be used more than once.
// 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
const cssFilename = "static/css/[name].[md5:contenthash:hex:20].css";
// ExtractTextPlugin expects the build output to be flat.
// (See https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/27)
// However, our output is structured with css, js and media folders.
// To have this structure working with relative paths, we have to use custom options.
const extractTextPluginOptions = shouldUseRelativeAssetPaths
? // Making sure that the publicPath goes back to to build folder.
{ publicPath: Array(cssFilename.split("/").length).join("../") }
: {};
// This is the production configuration.
// It compiles slowly and is focused on producing a fast and minimal bundle.
// The development configuration is different and lives in a separate file.
module.exports = {
// Don't attempt to continue if there are any errors.
bail: true,
// Set webpack mode.
mode: "production",
// We generate sourcemaps in production. This is slow but gives good results.
// You can exclude the *.map files from the build during deployment.
devtool: shouldUseSourceMap ? "source-map" : false,
// In production, we only want to load the polyfills and the app code.
entry: [require.resolve("./polyfills"), paths.appStreamIndex],
output: {
// The dist folder.
path: paths.appDist,
// Generated JS file names (with nested folders).
// There will be one main bundle, and one file per asynchronous chunk.
// We don't currently advertise code splitting but Webpack supports it.
filename: "static/js/[name].[chunkhash:8].js",
chunkFilename: "static/js/[name].[chunkhash:8].chunk.js",
// We inferred the "public path" (such as / or /my-project) from homepage.
publicPath: publicPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: info =>
path
.relative(paths.appSrc, info.absoluteResourcePath)
.replace(/\\/g, "/"),
},
resolve: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebookincubator/create-react-app/issues/253
modules: ["node_modules", paths.appNodeModules].concat(
// It is guaranteed to exist because we tweak it in `env.js`
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
),
// These are the reasonable defaults supported by the Node ecosystem.
// We also include JSX as a common component filename extension to support
// some tools, although we do not recommend using it, see:
// https://github.com/facebookincubator/create-react-app/issues/290
// `web` extension prefixes have been added for better support
// for React Native Web.
extensions: [
".web.js",
".mjs",
".js",
".json",
".web.jsx",
".jsx",
".ts",
".tsx",
],
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
"react-native": "react-native-web",
},
plugins: [
// Prevents users from importing files from outside of src/ (or node_modules/).
// This often causes confusion because we only process files within src/ with babel.
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// please link the files into your node_modules/ and let module-resolution kick in.
// Make sure your source files are compiled, as they will not be processed in any way.
new ModuleScopePlugin(paths.appSrc, [paths.appPackageJson]),
// Support `tsconfig.json` `path` setting.
new TsconfigPathsPlugin({
configFile: paths.appTsconfig,
extensions: [".js", ".jsx", ".mjs", ".ts", ".tsx"],
}),
],
},
resolveLoader: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebookincubator/create-react-app/issues/253
modules: ["node_modules", paths.appNodeModules, paths.appLoaders].concat(
// It is guaranteed to exist because we tweak it in `env.js`
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
),
},
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|jsx|mjs|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: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
loader: require.resolve("url-loader"),
options: {
limit: 10000,
name: "static/media/[name].[hash:8].[ext]",
},
},
// Process JS with Babel.
{
test: /\.(js|jsx|mjs|ts|tsx)$/,
include: paths.appSrc,
use: [
{
loader: require.resolve("babel-loader"),
options: {
compact: true,
},
},
{
loader: "ts-loader",
options: {
compilerOptions: {
target: "es2015",
module: "esnext",
jsx: "preserve",
noEmit: false,
},
},
},
],
},
// The notation here is somewhat confusing.
// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader normally turns CSS into JS modules injecting <style>,
// but unlike in development configuration, we do something different.
// `ExtractTextPlugin` first applies the "postcss" and "css" loaders
// (second argument), then grabs the result CSS and puts it into a
// separate file in our build process. This way we actually ship
// a single CSS file in production instead of JS code injecting <style>
// tags. If you use code splitting, however, any async bundles will still
// use the "style" loader inside the async code so CSS from them won't be
// in the main CSS file.
{
test: /\.css$/,
loader: ExtractTextPlugin.extract(
Object.assign(
{
fallback: {
loader: require.resolve("style-loader"),
options: {
hmr: false,
},
},
use: [
{
loader: require.resolve("css-loader"),
options: {
modules: true,
importLoaders: 1,
minimize: true,
sourceMap: shouldUseSourceMap,
},
},
{
loader: require.resolve("postcss-loader"),
options: {
config: {
path: paths.appPostCssConfig,
},
},
},
],
},
extractTextPluginOptions
)
),
// Note: this won't work without `new ExtractTextPlugin()` in `plugins`.
},
// "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|jsx|mjs|ts|tsx)$/, /\.html$/, /\.json$/],
loader: require.resolve("file-loader"),
options: {
name: "static/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: [
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin({
inject: true,
template: paths.appStreamHtml,
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
}),
// 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.raw),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV was set to production here.
// Otherwise React will be compiled in the very slow development mode.
new webpack.DefinePlugin(env.stringified),
// 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: shouldUseSourceMap,
},
}),
// Note: this won't work without ExtractTextPlugin.extract(..) in `loaders`.
new ExtractTextPlugin({
filename: cssFilename,
}),
// 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",
}),
// Moment.js is an extremely popular library that bundles large locale files
// by default due to how Webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales.
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
// You can remove this if you don't use Moment.js:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
],
// 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",
},
};
@@ -1,36 +1,21 @@
"use strict";
import errorOverlayMiddleware from "react-dev-utils/errorOverlayMiddleware";
import ignoredFiles from "react-dev-utils/ignoredFiles";
import noopServiceWorkerMiddleware from "react-dev-utils/noopServiceWorkerMiddleware";
import { Configuration } from "webpack-dev-server";
import paths from "./paths";
const errorOverlayMiddleware = require("react-dev-utils/errorOverlayMiddleware");
const noopServiceWorkerMiddleware = require("react-dev-utils/noopServiceWorkerMiddleware");
const ignoredFiles = require("react-dev-utils/ignoredFiles");
const config = require("./webpack.config.dev");
const paths = require("./paths");
interface WebpackDevServerConfig {
allowedHost: any;
serverPort: number;
publicPath: string;
}
const protocol = process.env.HTTPS === "true" ? "https" : "http";
const host = process.env.HOST || "0.0.0.0";
const serverPort = process.env.PORT || 3000;
const doczPort = process.env.DOCZ_PORT || 3030;
module.exports = function(proxy, allowedHost) {
export default function({
allowedHost,
serverPort,
publicPath,
}: WebpackDevServerConfig): Configuration {
return {
// WebpackDevServer 2.4.3 introduced a security fix that prevents remote
// websites from potentially accessing local content through DNS rebinding:
// https://github.com/webpack/webpack-dev-server/issues/887
// https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a
// However, it made several existing use cases such as development in cloud
// environment or subdomains in development significantly more complicated:
// https://github.com/facebookincubator/create-react-app/issues/2271
// https://github.com/facebookincubator/create-react-app/issues/2233
// While we're investigating better solutions, for now we will take a
// compromise. Since our WDS configuration only serves files in the `public`
// folder we won't consider accessing them a vulnerability. However, if you
// use the `proxy` feature, it gets more dangerous because it can expose
// remote code execution vulnerabilities in backends like Django and Rails.
// So we will disable the host check normally, but enable it if you have
// specified the `proxy` setting. Finally, we let you override it if you
// really know what you're doing with a special environment variable.
disableHostCheck:
!proxy || process.env.DANGEROUSLY_DISABLE_HOST_CHECK === "true",
// Enable gzip compression of generated files.
compress: true,
// Silence WebpackDevServer's own logs since they're generally not useful.
@@ -61,7 +46,7 @@ module.exports = function(proxy, allowedHost) {
hot: true,
// It is important to tell WebpackDevServer to use the same "root" path
// as we specified in the config. In development, we always serve from /.
publicPath: config.output.publicPath,
publicPath,
// WebpackDevServer is noisy by default so we emit custom message instead
// by listening to the compiler events with `compiler.plugin` calls above.
quiet: true,
@@ -72,9 +57,6 @@ module.exports = function(proxy, allowedHost) {
watchOptions: {
ignored: ignoredFiles(paths.appSrc),
},
// Enable HTTPS if the HTTPS environment variable is set to 'true'
https: protocol === "https",
host: host,
overlay: false,
historyApiFallback: {
// Paths with dots should still use the history fallback.
@@ -82,13 +64,14 @@ module.exports = function(proxy, allowedHost) {
disableDotRule: true,
},
public: allowedHost,
proxy: proxy || {
index: "embed.html",
proxy: {
// Proxy to the graphql server.
"/api": {
target: `http://localhost:${serverPort}`,
},
},
before(app) {
before(app: any) {
// This lets us open files from the runtime error overlay.
app.use(errorOverlayMiddleware());
// This service worker file is effectively a 'no-op' that will reset any
@@ -99,4 +82,4 @@ module.exports = function(proxy, allowedHost) {
app.use(noopServiceWorkerMiddleware());
},
};
};
}
+3 -5
View File
@@ -1,14 +1,12 @@
// Apply all the configuration provided in the .env file.
require("dotenv").config();
const path = require("path");
const fs = require("fs");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
const extensions = [".ts", ".tsx", ".js"];
const paths = require("./config/paths");
// Ensure environment variables are read.
require("./config/env");
// const stringify = require('json-stringify-safe');
export default {
title: "Talk 5.0",
source: "./src",
+721 -97
View File
File diff suppressed because it is too large Load Diff
+45 -9
View File
@@ -3,28 +3,37 @@
"version": "5.0.0",
"description": "A better commenting experience from Mozilla, The Washington Post, and The New York Times.",
"scripts": {
"start": "node dist/index.js",
"test": "node scripts/test.js --env=jsdom",
"build": "npm-run-all compile --parallel build:*",
"build:client": "node ./scripts/build.js",
"build:client": "ts-node ./scripts/build.ts",
"build:server": "tsc -p ./src/tsconfig.json",
"watch": "cross-env NODE_ENV=development ts-node ./scripts/watcher/bin/watcher.ts --config ./config/watcher.ts",
"compile": "npm-run-all --parallel compile:*",
"compile:css-types": "tcm src/core/client/",
"compile:relay-stream": "ts-node ./scripts/compileRelay --src ./src/core/client/stream --schema tenant",
"compile:schema": "node ./scripts/generateSchemaTypes.js",
"docz": "docz",
"start": "node dist/index.js",
"start:development": "cross-env NODE_ENV=development ts-node --project ./src/tsconfig.json -r tsconfig-paths/register ./src/index.ts",
"start:webpackDevServer": "node ./scripts/start.js",
"lint-fix": "npm run lint:server -- --fix && npm run lint:client -- --fix && npm run lint:scripts -- --fix",
"start:webpackDevServer": "ts-node ./scripts/start.ts",
"lint": "npm-run-all --parallel lint:*",
"lint:server": "tslint --project ./src/tsconfig.json",
"lint:client": "tslint --project ./src/core/client/tsconfig.json",
"lint:client-embed": "tslint --project ./src/core/client/embed/tsconfig.json",
"lint:scripts": "tslint --project ./tsconfig.json",
"docz": "docz"
"lint-fix": "npm run lint:server -- --fix && npm run lint:client -- --fix && npm run lint:client-embed -- --fix && npm run lint:scripts -- --fix",
"test": "node scripts/test.js --env=jsdom",
"tscheck": "npm-run-all --parallel tscheck:*",
"tscheck:server": "tsc --project ./src/tsconfig.json --noEmit",
"tscheck:client": "tsc --project ./src/core/client/tsconfig.json --noEmit",
"tscheck:client-embed": "tsc --project ./src/core/client/embed/tsconfig.json --noEmit",
"tscheck:scripts": "tsc --project ./tsconfig.json --noEmit",
"watch": "cross-env NODE_ENV=development ts-node ./scripts/watcher/bin/watcher.ts --config ./config/watcher.ts"
},
"author": "",
"license": "Apache-2.0",
"dependencies": {
"akismet-api": "^4.2.0",
"apollo-server-express": "^1.3.6",
"bcryptjs": "^2.4.3",
"bunyan": "^1.8.12",
"convict": "^4.3.1",
"dataloader": "^1.4.0",
@@ -40,12 +49,19 @@
"graphql-tools": "^3.0.5",
"ioredis": "^3.2.2",
"joi": "^13.4.0",
"jsonwebtoken": "^8.3.0",
"jwks-rsa": "^1.3.0",
"linkify-it": "^2.0.3",
"lodash": "^4.17.10",
"luxon": "^1.3.1",
"mongodb": "^3.1.1",
"passport": "^0.4.0",
"passport-local": "^1.0.0",
"passport-oauth2": "^1.4.0",
"passport-strategy": "^1.0.0",
"performance-now": "^2.1.0",
"subscriptions-transport-ws": "^0.9.12",
"tlds": "^1.203.1",
"uuid": "^3.3.2"
},
"devDependencies": {
@@ -55,7 +71,9 @@
"@babel/polyfill": "7.0.0-beta.49",
"@babel/preset-env": "7.0.0-beta.49",
"@babel/preset-react": "7.0.0-beta.49",
"@types/bcryptjs": "^2.4.1",
"@types/bunyan": "^1.8.4",
"@types/case-sensitive-paths-webpack-plugin": "^2.1.2",
"@types/chokidar": "^1.7.5",
"@types/classnames": "^2.2.4",
"@types/commander": "^2.12.2",
@@ -64,17 +82,26 @@
"@types/dotenv": "^4.0.3",
"@types/enzyme": "^3.1.11",
"@types/enzyme-adapter-react-16": "^1.0.2",
"@types/eventemitter2": "^4.1.0",
"@types/express": "^4.16.0",
"@types/extract-text-webpack-plugin": "^3.0.3",
"@types/fs-extra": "^5.0.4",
"@types/graphql": "^0.13.3",
"@types/html-webpack-plugin": "^2.30.4",
"@types/ioredis": "^3.2.12",
"@types/jest": "^23.1.5",
"@types/joi": "^13.0.8",
"@types/jsdom": "^11.0.6",
"@types/jsonwebtoken": "^7.2.7",
"@types/linkify-it": "^2.0.3",
"@types/lodash": "^4.14.111",
"@types/luxon": "^0.5.3",
"@types/mongodb": "^3.1.1",
"@types/node": "^10.5.2",
"@types/passport": "^0.4.5",
"@types/passport": "^0.4.6",
"@types/passport-local": "^1.0.33",
"@types/passport-oauth2": "^1.4.5",
"@types/passport-strategy": "^0.2.33",
"@types/query-string": "^6.1.0",
"@types/react-dom": "^16.0.6",
"@types/react-relay": "github:coralproject/patched#types/react-relay",
@@ -84,7 +111,12 @@
"@types/relay-runtime": "github:coralproject/patched#types/relay-runtime",
"@types/sane": "^2.0.0",
"@types/sinon": "^5.0.1",
"@types/tlds": "^1.199.0",
"@types/uglifyjs-webpack-plugin": "^1.1.0",
"@types/uuid": "^3.4.3",
"@types/webpack": "^4.4.7",
"@types/webpack-dev-server": "^2.9.5",
"@types/webpack-manifest-plugin": "^1.3.2",
"@types/ws": "^5.1.2",
"autoprefixer": "^8.6.5",
"babel-core": "^7.0.0-bridge.0",
@@ -104,6 +136,7 @@
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"enzyme-to-json": "^3.3.4",
"eventemitter2": "^5.0.1",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"final-form": "^4.8.1",
"flat": "^4.1.0",
@@ -112,6 +145,7 @@
"fluent-langneg": "^0.1.0",
"fluent-react": "^0.7.0",
"graphql-playground-middleware-express": "^1.7.2",
"graphql-schema-typescript": "^1.2.1",
"html-webpack-plugin": "^3.2.0",
"jest": "^23.4.1",
"jest-junit": "^5.1.0",
@@ -130,6 +164,7 @@
"postcss-preset-env": "^5.2.1",
"prettier": "^1.13.7",
"pstree.remy": "^1.1.0",
"pym.js": "^1.3.2",
"query-string": "^6.1.0",
"raw-loader": "^0.5.1",
"react": "^16.4.0",
@@ -169,6 +204,7 @@
"webpack-cli": "^3.0.2",
"webpack-dev-server": "^3.1.4",
"webpack-hot-client": "^4.1.1",
"webpack-manifest-plugin": "^2.0.3"
"webpack-manifest-plugin": "^2.0.3",
"whatwg-fetch": "^2.0.4"
}
}
+28 -28
View File
@@ -1,8 +1,20 @@
"use strict";
#!/usr/bin/env ts-node
import chalk from "chalk";
import fs from "fs-extra";
import FileSizeReporter from "react-dev-utils/FileSizeReporter";
import formatWebpackMessages from "react-dev-utils/formatWebpackMessages";
import printBuildError from "react-dev-utils/printBuildError";
import webpack from "webpack";
import paths from "../config/paths";
import createWebpackConfig from "../src/core/build/createWebpackConfig";
import config, { createClientEnv } from "../src/core/common/config";
// tslint:disable: no-console
// Do this as the first thing so that any code reading it knows the right env.
// Enforce environment to be production.
config.validate().set("env", "production");
process.env.BABEL_ENV = "production";
process.env.NODE_ENV = "production";
@@ -13,20 +25,6 @@ process.on("unhandledRejection", err => {
throw err;
});
// Ensure environment variables are read.
require("../config/env");
const path = require("path");
const chalk = require("chalk");
const fs = require("fs-extra");
const webpack = require("webpack");
const config = require("../config/webpack.config.prod");
const paths = require("../config/paths");
const formatWebpackMessages = require("react-dev-utils/formatWebpackMessages");
const printHostingInstructions = require("react-dev-utils/printHostingInstructions");
const FileSizeReporter = require("react-dev-utils/FileSizeReporter");
const printBuildError = require("react-dev-utils/printBuildError");
const measureFileSizesBeforeBuild =
FileSizeReporter.measureFileSizesBeforeBuild;
const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild;
@@ -42,15 +40,15 @@ const treatWarningsAsErrors =
false &&
process.env.CI &&
(typeof process.env.CI !== "string" ||
process.env.CI.toLowerCase() !== "false");
process.env.CI!.toLowerCase() !== "false");
// First, read the current file sizes in build directory.
// This lets us display how much they changed later.
measureFileSizesBeforeBuild(paths.appDist)
.then(previousFileSizes => {
measureFileSizesBeforeBuild(paths.appDistStatic)
.then((previousFileSizes: any) => {
// Remove all content but keep the directory so that
// if you're in it, you don't end up in Trash
fs.emptyDirSync(paths.appDist);
fs.emptyDirSync(paths.appDistStatic);
// Merge with the public folder
if (fs.pathExistsSync(paths.appPublic)) {
copyPublicFolder();
@@ -59,7 +57,7 @@ measureFileSizesBeforeBuild(paths.appDist)
return build(previousFileSizes);
})
.then(
({ stats, previousFileSizes, warnings }) => {
({ stats, previousFileSizes, warnings }: any) => {
if (warnings.length) {
console.log(chalk.yellow("Compiled with warnings.\n"));
console.log(warnings.join("\n\n"));
@@ -81,13 +79,13 @@ measureFileSizesBeforeBuild(paths.appDist)
printFileSizesAfterBuild(
stats,
previousFileSizes,
paths.appDist,
paths.appDistStatic,
WARN_AFTER_BUNDLE_GZIP_SIZE,
WARN_AFTER_CHUNK_GZIP_SIZE
);
console.log();
},
err => {
(err: Error) => {
console.log(chalk.red("Failed to compile.\n"));
printBuildError(err);
process.exit(1);
@@ -95,16 +93,18 @@ measureFileSizesBeforeBuild(paths.appDist)
);
// Create the production build and print the deployment instructions.
function build(previousFileSizes) {
function build(previousFileSizes: any) {
console.log("Creating an optimized production build...");
let compiler = webpack(config);
const webpackConfig = createWebpackConfig({
env: createClientEnv(config),
});
const compiler = webpack(webpackConfig);
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
if (err) {
return reject(err);
}
const messages = formatWebpackMessages(stats.toJson({}, true));
const messages = formatWebpackMessages(stats.toJson({}));
if (messages.errors.length) {
// Only keep the first error. Others are often indicative
// of the same problem, but confuse the reader with noise.
@@ -132,7 +132,7 @@ function build(previousFileSizes) {
}
function copyPublicFolder() {
fs.copySync(paths.appPublic, paths.appDist, {
fs.copySync(paths.appPublic, paths.appDistStatic, {
dereference: true,
});
}
+90
View File
@@ -0,0 +1,90 @@
const { Linter, Configuration } = require("tslint");
const { generateTSTypesAsString } = require("graphql-schema-typescript");
const { getGraphQLConfig } = require("graphql-config");
const path = require("path");
const fs = require("fs");
function lintAndWrite(files) {
const linter = new Linter({ fix: true });
for (const { fileName, types } of files) {
const configuration = Configuration.findConfiguration(null, fileName)
.results;
linter.lint(fileName, types, configuration);
}
}
function getFileName(name) {
return path.join(
__dirname,
"../src/core/server/graph",
name,
"schema/__generated__/types.ts"
);
}
async function main() {
const config = getGraphQLConfig(__dirname);
const projects = config.getProjects();
const files = [
{
name: "tenant",
fileName: getFileName("tenant"),
config: {
contextType: "TenantContext",
importStatements: [
'import { Cursor } from "talk-server/models/connection";',
'import TenantContext from "talk-server/graph/tenant/context";',
],
customScalarType: { Cursor: "Cursor", Time: "string" },
},
},
{
name: "management",
fileName: getFileName("management"),
config: {
contextType: "ManagementContext",
importStatements: [
'import ManagementContext from "talk-server/graph/management/context";',
],
},
},
];
for (const file of files) {
// Load the graph schema.
const schema = projects[file.name].getSchema();
// Create the generated directory.
const dir = path.dirname(file.fileName);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
// Create the types for this file.
file.types = await generateTSTypesAsString(schema, {
tabSpaces: 2,
typePrefix: "GQL",
strictNulls: true,
...file.config,
});
}
// Send the files off to the linter to be linted and written.
lintAndWrite(files);
return files;
}
main()
.then(files => {
for (const { fileName } of files) {
// tslint:disable-next-line:no-console
console.log(`Generated ${fileName}`);
}
})
.catch(err => {
// tslint:disable-next-line:no-console
console.error(err);
});
+35 -37
View File
@@ -1,8 +1,22 @@
"use strict";
#!/usr/bin/env ts-node
import chalk from "chalk";
import {
choosePort,
createCompiler,
prepareUrls,
} from "react-dev-utils/WebpackDevServerUtils";
import webpack from "webpack";
import WebpackDevServer from "webpack-dev-server";
import createDevServerConfig from "../config/webpackDevServer.config";
import createWebpackConfig from "../src/core/build/createWebpackConfig";
import config, { createClientEnv } from "../src/core/common/config";
// tslint:disable: no-console
// Do this as the first thing so that any code reading it knows the right env.
// Enforce environment to be development.
config.validate().set("env", "development");
process.env.BABEL_ENV = "development";
process.env.NODE_ENV = "development";
@@ -13,25 +27,8 @@ process.on("unhandledRejection", err => {
throw err;
});
// Ensure environment variables are read.
require("../config/env");
const fs = require("fs");
const chalk = require("chalk");
const webpack = require("webpack");
const WebpackDevServer = require("webpack-dev-server");
const {
choosePort,
createCompiler,
prepareProxy,
prepareUrls,
} = require("react-dev-utils/WebpackDevServerUtils");
const paths = require("../config/paths");
const config = require("../config/webpack.config.dev");
const createDevServerConfig = require("../config/webpackDevServer.config");
const PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 8080;
const HOST = process.env.HOST || "0.0.0.0";
const PORT = parseInt(process.env.DEV_SERVER_PORT!, 10) || 8080;
const HOST = "0.0.0.0";
if (process.env.HOST) {
console.log(
@@ -51,42 +48,43 @@ if (process.env.HOST) {
// We attempt to use the default port but if it is busy, we offer the user to
// run on a different port. `choosePort()` Promise resolves to the next free port.
choosePort(HOST, PORT)
.then(port => {
.then((port: number) => {
if (port == null) {
// We have not found a port.
return;
}
const protocol = process.env.HTTPS === "true" ? "https" : "http";
const appName = require(paths.appPackageJson).name;
const protocol = "http";
const appName = "Talk";
const urls = prepareUrls(protocol, HOST, port);
const webpackConfig = createWebpackConfig({
env: createClientEnv(config),
});
// Create a webpack compiler that is configured with custom messages.
const compiler = createCompiler(webpack, config, appName, urls);
// Load proxy config
const proxySetting = require(paths.appPackageJson).proxy;
const proxyConfig = prepareProxy(proxySetting, paths.appPublic);
const compiler = createCompiler(webpack, webpackConfig, appName, urls);
// Serve webpack assets generated by the compiler over a web sever.
const serverConfig = createDevServerConfig(
proxyConfig,
urls.lanUrlForConfig
);
const serverConfig = createDevServerConfig({
allowedHost: urls.lanUrlForConfig,
serverPort: config.get("port"),
publicPath: webpackConfig[0].output!.publicPath!,
});
const devServer = new WebpackDevServer(compiler, serverConfig);
// Launch WebpackDevServer.
devServer.listen(port, HOST, err => {
devServer.listen(port, HOST, (err: Error) => {
if (err) {
return console.log(err);
}
console.log(chalk.cyan("Starting the development server...\n"));
});
["SIGINT", "SIGTERM"].forEach(function(sig) {
process.on(sig, function() {
["SIGINT", "SIGTERM"].forEach((sig: any) => {
process.once(sig, () => {
devServer.close();
process.exit();
});
});
})
.catch(err => {
if (err && err.message) {
.catch((err: Error) => {
if (err.message) {
console.log(err.message);
}
process.exit(1);
+9 -5
View File
@@ -1,9 +1,16 @@
#!/usr/bin/env node
"use strict";
// Allow importing typescript files.
require("ts-node/register");
// Apply all the configuration provided in the .env file.
require("dotenv").config();
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = "test";
process.env.NODE_ENV = "test";
process.env.PUBLIC_URL = "";
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
@@ -12,10 +19,7 @@ process.on("unhandledRejection", err => {
throw err;
});
// Ensure environment variables are read.
require("../config/env");
const paths = require("../config/paths");
const paths = require("../config/paths.ts").default;
const jest = require("jest");
let argv = process.argv.slice(2);
+447
View File
@@ -0,0 +1,447 @@
import CaseSensitivePathsPlugin from "case-sensitive-paths-webpack-plugin";
import ExtractTextPlugin from "extract-text-webpack-plugin";
import HtmlWebpackPlugin, { Options } from "html-webpack-plugin";
import path from "path";
import InterpolateHtmlPlugin from "react-dev-utils/InterpolateHtmlPlugin";
import WatchMissingNodeModulesPlugin from "react-dev-utils/WatchMissingNodeModulesPlugin";
import TsconfigPathsPlugin from "tsconfig-paths-webpack-plugin";
import UglifyJsPlugin from "uglifyjs-webpack-plugin";
import webpack, { Configuration } from "webpack";
import ManifestPlugin from "webpack-manifest-plugin";
import paths from "./paths";
interface CreateWebpackConfig {
publicPath?: string;
publicURL?: string;
env?: Record<string, string>;
disableSourcemaps?: boolean;
appendPlugins?: any[];
}
export default function createWebpackConfig({
publicPath = "/",
publicURL = "",
env = process.env as Record<string, string>,
appendPlugins = [],
disableSourcemaps,
}: CreateWebpackConfig = {}): Configuration[] {
const envStringified = {
"process.env": Object.keys(env).reduce<Record<string, string>>(
(result, key) => {
result[key] = JSON.stringify(env[key]);
return result;
},
{}
),
};
const isProduction = env.NODE_ENV === "production";
const htmlWebpackConfig: Options = {
minify: isProduction && {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
};
const styleLoader = {
loader: require.resolve("style-loader"),
options: {
hmr: !isProduction,
},
};
const cssLoaders = [
{
loader: require.resolve("css-loader"),
options: {
modules: true,
importLoaders: 1,
localIdentName: "[name]-[local]-[hash:base64:5]",
minimize: isProduction,
sourceMap: isProduction && !disableSourcemaps,
},
},
{
loader: require.resolve("postcss-loader"),
options: {
config: {
path: paths.appPostCssConfig,
},
},
},
];
const additionalPlugins = isProduction
? [
// Minify the code.
new UglifyJsPlugin({
uglifyOptions: {
compress: {
warnings: false,
// Disabled because of an issue with Uglify breaking seemingly valid code:
// https://github.com/facebookincubator/create-react-app/issues/2376
// Pending further investigation:
// https://github.com/mishoo/UglifyJS2/issues/2011
comparisons: false,
},
mangle: {
safari10: true,
},
output: {
comments: false,
// Turned on because emoji and regex is not minified properly using default
// https://github.com/facebookincubator/create-react-app/issues/2488
ascii_only: true,
},
},
sourceMap: !disableSourcemaps,
}),
// Note: this won't work without ExtractTextPlugin.extract(..) in `loaders`.
new ExtractTextPlugin({
// We use [md5:contenthash:hex:20] instead of [contenthash:8]
// because of this bug https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/763.
// TODO: Repalce with mini-css-extract-plugin once it supports HMR.
// https://github.com/webpack-contrib/mini-css-extract-plugin
filename: "assets/css/[name].[md5:contenthash:hex:20].css",
}),
]
: [
// Add module names to factory functions so they appear in browser profiler.
new webpack.NamedModulesPlugin(),
// This is necessary to emit hot updates (currently CSS only):
new webpack.HotModuleReplacementPlugin(),
// Watcher doesn't work well if you mistype casing in a path so we use
// a plugin that prints an error when you attempt to do this.
// See https://github.com/facebookincubator/create-react-app/issues/240
new CaseSensitivePathsPlugin(),
// If you require a missing module and then `npm install` it, you still have
// to restart the development server for Webpack to discover it. This plugin
// makes the discovery automatic so you don't have to restart.
// See https://github.com/facebookincubator/create-react-app/issues/186
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
];
const baseConfig: Configuration = {
// Set webpack mode.
mode: isProduction ? "production" : "development",
devtool:
!disableSourcemaps && isProduction
? // We generate sourcemaps in production. This is slow but gives good results.
// You can exclude the *.map files from the build during deployment.
"source-map"
: // You may want 'eval' instead if you prefer to see the compiled output in DevTools.
// See the discussion in https://github.com/facebookincubator/create-react-app/issues/343.
"cheap-module-source-map",
// These are the "entry points" to our application.
// This means they will be the "root" imports that are included in JS bundle.
// The first two entry points enable "hot" CSS and auto-refreshes for JS.
output: {
// Add /* filename */ comments to generated require()s in the output.
pathinfo: !isProduction,
// The dist folder.
path: paths.appDistStatic,
// Generated JS file names (with nested folders).
// There will be one main bundle, and one file per asynchronous chunk.
filename: isProduction
? "assets/js/[name].[chunkhash:8].js"
: "assets/js/[name].js",
chunkFilename: isProduction
? "assets/js/[name].[chunkhash:8].chunk.js"
: "assets/js/[name].chunk.js",
// We inferred the "public path" (such as / or /my-project) from homepage.
publicPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: (info: any) =>
path
.relative(paths.appSrc, info.absoluteResourcePath)
.replace(/\\/g, "/"),
},
resolve: {
extensions: [".js", ".json", ".ts", ".tsx"],
plugins: [
// Support `tsconfig.json` `path` setting.
new TsconfigPathsPlugin({
configFile: paths.appTsconfig,
extensions: [".js", ".ts", ".tsx"],
}),
],
},
resolveLoader: {
// Add path to our own loaders.
modules: ["node_modules", paths.appLoaders],
},
module: {
strictExportPresence: true,
rules: [
// Disable require.ensure as it's not a standard language feature.
{ parser: { requireEnsure: false } },
// First, run the linter.
// It's important to do this before Babel processes the JS.
{
test: /\.(js|ts|tsx)$/,
enforce: "pre",
use: [
{
options: {
tsConfigFile: paths.appTsconfig,
},
loader: require.resolve("tslint-loader"),
},
],
include: paths.appSrc,
},
{
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
// back to the "file" loader at the end of the loader list.
oneOf: [
{
test: paths.appStreamLocalesTemplate,
use: [
// This is the locales loader that loads available locales
// from a particular target.
{
loader: "locales-loader",
options: {
pathToLocales: paths.appLocales,
// Default locale if non could be negotiated.
defaultLocale: "en-US",
// Fallback locale if a translation was not found.
// If not set, will use the text that is already
// in the code base.
fallbackLocale: "en-US",
// Common fluent files are always included in the locale bundles.
commonFiles: ["framework.ftl", "common.ftl"],
// Locales that come with the main bundle. Others are loaded on demand.
bundled: ["en-US"],
// Target specifies the prefix for fluent files to be loaded.
// ${target}-xyz.ftl and ${†arget}.ftl are loaded into the locales.
target: "stream",
// All available locales can be loadable on demand.
// To restrict available locales set:
// availableLocales: ["en-US"],
},
},
],
},
// Loader for our fluent files.
{
test: /\.ftl$/,
use: ["raw-loader"],
},
// "url" loader works like "file" loader except that it embeds assets
// smaller than specified limit in bytes as data URLs to avoid requests.
// A missing `test` is equivalent to a match.
{
test: [/\.gif$/, /\.jpe?g$/, /\.png$/],
loader: require.resolve("url-loader"),
options: {
limit: 10000,
name: "assets/media/[name].[hash:8].[ext]",
},
},
// Process JS with Babel.
{
test: /\.(ts|tsx)$/,
include: paths.appSrc,
use: [
{
loader: require.resolve("babel-loader"),
options: {
// This is a feature of `babel-loader` for webpack (not Babel itself).
// It enables caching results in ./node_modules/.cache/babel-loader/
// directory for faster rebuilds.
cacheDirectory: true,
},
},
{
loader: "ts-loader",
options: {
configFile: paths.appTsconfig,
compilerOptions: {
target: "es2015",
module: "esnext",
jsx: "preserve",
noEmit: false,
},
// Overwrites the behavior of `include` and `exclude` to only
// include files that are actually being imported and which
// are necessary to compile the bundle.
onlyCompileBundledFiles: true,
},
},
],
},
// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader turns CSS into JS modules that inject <style> tags.
// In production, we use a plugin to extract that CSS to a file, and
// in development "style" loader enables hot editing of CSS.
{
test: /\.css$/,
loader:
(isProduction &&
ExtractTextPlugin.extract({
fallback: styleLoader,
use: cssLoaders,
})) ||
undefined,
use:
(!isProduction && [
require.resolve("style-loader"),
...cssLoaders,
]) ||
undefined,
},
// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename.
// In production, they would get copied to the `build` folder.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
// Exclude `js` files to keep "css" loader working as it injects
// its runtime that would otherwise processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [/\.(js|ts|tsx)$/, /\.html$/, /\.json$/],
loader: require.resolve("file-loader"),
options: {
name: "assets/media/[name].[hash:8].[ext]",
},
},
],
},
// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.
],
},
plugins: [
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'development') { ... }. See `./env.js`.
new webpack.DefinePlugin(envStringified),
...additionalPlugins,
...appendPlugins,
],
// Some libraries import Node modules but don't use them in the browser.
// Tell Webpack to provide empty mocks for them so importing them works.
node: {
dgram: "empty",
fs: "empty",
net: "empty",
tls: "empty",
child_process: "empty",
},
// Turn off performance hints during development because we don't do any
// splitting or minification in interest of speed. These warnings become
// cumbersome.
performance: {
hints: isProduction && "warning",
},
};
return [
/* Webpack config for our different target, e.g. stream, admin... */
{
...baseConfig,
entry: {
stream: [
// We ship polyfills by default
paths.appPolyfill,
// Include an alternative client for WebpackDevServer. A client's job is to
// connect to WebpackDevServer by a socket and get notified about changes.
// When you save a file, the client will either apply hot updates (in case
// of CSS changes), or refresh the page (in case of JS changes). When you
// make a syntax error, this client will display a syntax error overlay.
// Note: instead of the default WebpackDevServer client, we use a custom one
// to bring better experience for Create React App users. You can replace
// the line below with these two lines if you prefer the stock client:
// require.resolve('webpack-dev-server/client') + '?/',
// require.resolve('webpack/hot/dev-server'),
(isProduction && "") ||
require.resolve("react-dev-utils/webpackHotDevClient"),
paths.appStreamIndex,
// Remove deactivated entries.
].filter(s => s),
},
plugins: [
...baseConfig.plugins!,
// Generates an `stream.html` file with the <script> injected.
new HtmlWebpackPlugin({
filename: "stream.html",
template: paths.appStreamHTML,
chunks: ["stream"],
inject: "body",
...htmlWebpackConfig,
}),
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
// In development, this will be an empty string.
new InterpolateHtmlPlugin(env),
// Generate a manifest file which contains a mapping of all asset filenames
// to their corresponding output file so that tools can pick it up without
// having to parse `index.html`.
new ManifestPlugin({
fileName: "asset-manifest.json",
}),
],
},
/* Webpack config for our embed */
{
...baseConfig,
entry: [
// No polyfills for the embed.
(isProduction && "") ||
require.resolve("react-dev-utils/webpackHotDevClient"),
paths.appEmbedIndex,
// Remove deactivated entries.
].filter(s => s),
output: {
...baseConfig.output,
library: "Talk",
// don't hash the embed, cache-busting must be completed by the requester
// as this lives in a static template on the embed site.
filename: "assets/js/embed.js",
},
plugins: [
...baseConfig.plugins!,
// Generates an `stream.html` file with the <script> injected.
new HtmlWebpackPlugin({
filename: "embed.html",
template: paths.appEmbedHTML,
inject: "head",
...htmlWebpackConfig,
}),
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
// In development, this will be an empty string.
new InterpolateHtmlPlugin(env),
// Generate a manifest file which contains a mapping of all asset filenames
// to their corresponding output file so that tools can pick it up without
// having to parse `index.html`.
new ManifestPlugin({
fileName: "embed-manifest.json",
}),
],
},
];
}
+33
View File
@@ -0,0 +1,33 @@
import fs from "fs";
import path from "path";
// Make sure any symlinks in the project folder are resolved:
// https://github.com/facebookincubator/create-react-app/issues/637
const appDirectory = fs.realpathSync(process.cwd());
const resolveApp = (relativePath: string) =>
path.resolve(appDirectory, relativePath);
const resolveSrc = (relativePath: string) =>
path.resolve(__dirname, "../../", relativePath);
export default {
appPostCssConfig: resolveSrc("core/build/postcss.config.js"),
appLoaders: resolveSrc("core/build/loaders"),
appSrc: resolveSrc("."),
appTsconfig: resolveSrc("core/client/tsconfig.json"),
appPolyfill: resolveSrc("core/build/polyfills.js"),
appLocales: resolveSrc("locales"),
appThemeVariables: resolveSrc("core/client/ui/theme/variables.ts"),
appThemeVariablesCSS: resolveSrc("core/client/ui/theme/variables.css"),
appStreamHTML: resolveSrc("core/client/stream/index.html"),
appStreamLocalesTemplate: resolveSrc("core/client/stream/locales.ts"),
appStreamIndex: resolveSrc("core/client/stream/index.tsx"),
appEmbedIndex: resolveSrc("core/client/embed/index.ts"),
appEmbedHTML: resolveSrc("core/client/embed/index.html"),
appDistStatic: resolveApp("dist/static"),
appPublic: resolveApp("public"),
appPackageJson: resolveApp("package.json"),
appNodeModules: resolveApp("node_modules"),
};
@@ -1,12 +1,9 @@
// Allow importing typescript files.
require("ts-node/register");
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");
const paths = require("./paths").default;
const autoprefixer = require("autoprefixer");
const postcssFontMagician = require("postcss-font-magician");
const postcssFlexbugsFixes = require("postcss-flexbugs-fixes");
+55
View File
@@ -0,0 +1,55 @@
import sinon from "sinon";
import { Decorator } from "./decorators";
import PymControl from "./PymControl";
describe("PymControl", () => {
const container: HTMLElement = document.createElement("div");
const cleanupDecorator = sinon.mock().once();
const withMockDecorator: Decorator = sinon
.mock()
.once()
.withArgs(sinon.match.object)
.returns(cleanupDecorator);
let control: PymControl;
beforeAll(() => {
container.id = "pymcontrol-test-id";
document.body.appendChild(container);
});
afterAll(() => {
document.body.removeChild(container);
});
it("should create iframe", () => {
control = new PymControl({
decorators: [withMockDecorator],
id: container.id,
url: "http://coralproject.net",
title: "iFrame title",
});
expect(container.innerHTML).toMatchSnapshot();
});
it("should send message", done => {
const messages: MessageEvent[] = [];
const messageRecorder = (e: MessageEvent) => messages.push(e);
const contentWindow = (container.firstChild as HTMLIFrameElement)
.contentWindow!;
contentWindow.addEventListener("message", messageRecorder, false);
control.sendMessage("test", "hello world");
setTimeout(() => {
contentWindow.removeEventListener("message", messageRecorder, false);
expect(messages).toHaveLength(1);
expect(messages[0].data).toMatchSnapshot();
done();
});
});
it("should remove iframe", () => {
control.remove();
expect(container.innerHTML).toBe("");
});
it("should cleanup decorators", () => {
cleanupDecorator.verify();
});
});
+41
View File
@@ -0,0 +1,41 @@
import pym from "pym.js";
import { CleanupCallback, Decorator } from "./decorators";
interface PymControlConfig {
id: string;
url: string;
title: string;
decorators?: ReadonlyArray<Decorator>;
}
export default class PymControl {
private pym: pym.Parent;
private cleanups: CleanupCallback[];
constructor(config: PymControlConfig) {
const decorators = config.decorators || [];
this.pym = new pym.Parent(config.id, config.url, {
title: config.title,
id: `${config.id}_iframe`,
name: `${config.id}_iframe`,
});
this.cleanups = decorators
.map(enhance => enhance(this.pym))
.filter(cb => cb) as CleanupCallback[];
}
public sendMessage(id: string, raw?: string) {
this.pym.sendMessage(id, raw || "");
}
public remove() {
this.cleanups.forEach(cb => cb());
this.cleanups = [];
// Remove the pym parent.
this.pym.remove();
}
}
+70
View File
@@ -0,0 +1,70 @@
import sinon from "sinon";
import { createStreamInterface } from "./Stream";
it("should call eventEmitter.on", () => {
const control = {};
const cb = () => "";
const eventEmitter = {
on: sinon
.mock()
.once()
.withArgs("eventName", cb),
};
const stream = createStreamInterface(control as any, eventEmitter as any);
stream.on("eventName", cb);
eventEmitter.on.verify();
});
it("should call eventEmitter.off", () => {
const control = {};
const cb = () => "";
const eventEmitter = {
off: sinon
.mock()
.once()
.withArgs("eventName", cb),
};
const stream = createStreamInterface(control as any, eventEmitter as any);
stream.off("eventName", cb);
eventEmitter.off.verify();
});
it("should call control.login", () => {
const control = {
sendMessage: sinon
.mock()
.once()
.withArgs("login", "token"),
};
const eventEmitter = {};
const stream = createStreamInterface(control as any, eventEmitter as any);
stream.login("token");
control.sendMessage.verify();
});
it("should call control.logout", () => {
const control = {
sendMessage: sinon
.mock()
.once()
.withArgs("logout"),
};
const eventEmitter = {};
const stream = createStreamInterface(control as any, eventEmitter as any);
stream.logout();
control.sendMessage.verify();
});
it("should call control.remove", () => {
const control = {
remove: sinon
.mock()
.once()
.withArgs(),
};
const eventEmitter = {};
const stream = createStreamInterface(control as any, eventEmitter as any);
stream.remove();
control.remove.verify();
});
+83
View File
@@ -0,0 +1,83 @@
import { EventEmitter2 } from "eventemitter2";
import qs from "query-string";
import {
Decorator,
withAutoHeight,
withClickEvent,
withCommentID,
withEventEmitter,
withIOSSafariWidthWorkaround,
} from "./decorators";
import PymControl from "./PymControl";
import { ensureEndSlash } from "./utils";
interface CreatePymControlConfig {
assetID?: string;
assetURL?: string;
title?: string;
eventEmitter: EventEmitter2;
id: string;
rootURL: string;
}
export function createPymControl(config: CreatePymControlConfig) {
const streamDecorators: ReadonlyArray<Decorator> = [
withIOSSafariWidthWorkaround,
withAutoHeight,
withClickEvent,
withCommentID,
withEventEmitter(config.eventEmitter),
];
const query = qs.stringify({
assetID: config.assetID,
assetURL: config.assetURL,
});
const url = `${ensureEndSlash(config.rootURL)}stream.html?${query}`;
return new PymControl({
id: config.id,
title: config.title || "Talk Embed Stream",
decorators: streamDecorators,
url,
});
}
type EventCallback = (data: any) => void;
export function createStreamInterface(
control: PymControl,
eventEmitter: EventEmitter2
) {
return {
on(eventName: string, callback: EventCallback) {
return eventEmitter.on(eventName, callback);
},
off(eventName: string, callback: EventCallback) {
return eventEmitter.off(eventName, callback);
},
login(token: string) {
control.sendMessage("login", token);
},
logout() {
control.sendMessage("logout");
},
remove() {
return control.remove();
},
};
}
export type StreamInterface = ReturnType<typeof createStreamInterface>;
export interface CreateConfig {
assetID?: string;
assetURL?: string;
title?: string;
eventEmitter: EventEmitter2;
id: string;
rootURL: string;
}
export default function create(config: CreateConfig) {
return createStreamInterface(createPymControl(config), config.eventEmitter);
}
@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PymControl should create iframe 1`] = `"<iframe src=\\"http://coralproject.net/?initialWidth=0&amp;childId=pymcontrol-test-id&amp;parentTitle=&amp;parentUrl=http%3A%2F%2Flocalhost%2F\\" width=\\"100%\\" scrolling=\\"no\\" marginheight=\\"0\\" frameborder=\\"0\\" title=\\"iFrame title\\" id=\\"pymcontrol-test-id_iframe\\" name=\\"pymcontrol-test-id_iframe\\"></iframe>"`;
exports[`PymControl should send message 1`] = `"pymxPYMxpymcontrol-test-idxPYMxtestxPYMxhello world"`;
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Basic integration test should render iframe 1`] = `"<iframe src=\\"http://localhost/stream.html?&amp;initialWidth=0&amp;childId=basic-integration-test-id&amp;parentTitle=&amp;parentUrl=http%3A%2F%2Flocalhost%2F\\" width=\\"100%\\" scrolling=\\"no\\" marginheight=\\"0\\" frameborder=\\"0\\" title=\\"Talk Embed Stream\\" id=\\"basic-integration-test-id_iframe\\" name=\\"basic-integration-test-id_iframe\\" style=\\"width: 1px; min-width: 100%;\\"></iframe>"`;
+11
View File
@@ -0,0 +1,11 @@
import pym from "pym.js";
export type CleanupCallback = () => void;
export type Decorator = (pym: pym.Parent) => CleanupCallback | void;
export { default as withAutoHeight } from "./withAutoHeight";
export { default as withClickEvent } from "./withClickEvent";
export { default as withCommentID } from "./withCommentID";
export { default as withEventEmitter } from "./withEventEmitter";
export {
default as withIOSSafariWidthWorkaround,
} from "./withIOSSafariWidthWorkaround";
@@ -0,0 +1,16 @@
import withAutoHeight from "./withAutoHeight";
it("should set height", () => {
const fakePym = {
onMessage: (type: string, callback: (height: string) => void) => {
expect(type).toBe("height");
callback("100");
},
el: document.createElement("div"),
};
fakePym.el.innerHTML = "<span>Hello World </span>";
withAutoHeight(fakePym as any);
expect(fakePym.el.innerHTML).toBe(
'<span style="height: 100px;">Hello World </span>'
);
});
@@ -0,0 +1,14 @@
import { Decorator } from "./";
const withAutoHeight: Decorator = pym => {
// Resize parent iframe height when child height changes
let cachedHeight: string;
pym.onMessage("height", (height: string) => {
if (height !== cachedHeight) {
(pym.el.firstChild! as HTMLElement).style.height = `${height}px`;
cachedHeight = height;
}
});
};
export default withAutoHeight;
@@ -0,0 +1,19 @@
import simulant from "simulant";
import sinon from "sinon";
import { CleanupCallback } from ".";
import withClickEvent from "./withClickEvent";
it("should send click events", () => {
const pymMock = {
sendMessage: sinon
.mock()
.once()
.withArgs("click", ""),
};
const cleanup = withClickEvent(pymMock as any) as CleanupCallback;
simulant.fire(document.body, "click");
cleanup();
simulant.fire(document.body, "click");
pymMock.sendMessage.verify();
});
@@ -0,0 +1,16 @@
import { Decorator } from "./";
const withClickEvent: Decorator = pym => {
const handleClick = () => pym.sendMessage("click", "");
// If the user clicks outside the embed, then tell the embed.
document.addEventListener("click", handleClick, true);
// Return cleanup callback.
return () => {
// Remove the event listeners.
document.removeEventListener("click", handleClick, true);
};
};
export default withClickEvent;
@@ -0,0 +1,36 @@
import withCommentID from "./withCommentID";
it("should add commentID", () => {
const previousLocation = location.toString();
const previousState = window.history.state;
const fakePym = {
onMessage: (type: string, callback: (id: string) => void) => {
if (type === "view-comment") {
callback("comment-id");
}
},
};
withCommentID(fakePym as any);
expect(location.toString()).toBe("http://localhost/?commentId=comment-id");
window.history.replaceState(previousState, document.title, previousLocation);
});
it("should remove commentID", () => {
const previousLocation = location.toString();
const previousState = window.history.state;
window.history.replaceState(
previousState,
document.title,
"http://localhost/?commentId=comment-id"
);
const fakePym = {
onMessage: (type: string, callback: () => void) => {
if (type === "view-all-comments") {
callback();
}
},
};
withCommentID(fakePym as any);
expect(location.toString()).toBe("http://localhost/");
window.history.replaceState(previousState, document.title, previousLocation);
});
@@ -0,0 +1,36 @@
import qs from "query-string";
import { buildURL } from "../utils";
import { Decorator } from "./";
const withCommentID: Decorator = pym => {
// Remove the comment id from the query.
pym.onMessage("view-all-comments", () => {
const search = qs.stringify({
...qs.parse(location.search),
commentId: undefined,
});
// Remove the commentId url param.
const url = buildURL({ search });
// Change the url.
window.history.replaceState({}, document.title, url);
});
// Add the permalink comment id to the query.
pym.onMessage("view-comment", (id: string) => {
const search = qs.stringify({
...qs.parse(location.search),
commentId: id,
});
// Remove the commentId url param.
const url = buildURL({ search });
// Change the url.
window.history.replaceState({}, document.title, url);
});
};
export default withCommentID;
@@ -0,0 +1,21 @@
import sinon from "sinon";
import withEventEmitter from "./withEventEmitter";
it("should emit events from pym to eventEmitter", () => {
const eventEmitterMock = {
emit: sinon
.mock()
.once()
.withArgs("eventName", "value"),
};
const fakePym = {
onMessage: (type: string, callback: (raw: string) => void) => {
expect(type).toBe("event");
callback(JSON.stringify({ eventName: "eventName", value: "value" }));
},
el: document.createElement("div"),
};
withEventEmitter(eventEmitterMock as any)(fakePym as any);
eventEmitterMock.emit.verify();
});
@@ -0,0 +1,13 @@
import { EventEmitter2 } from "eventemitter2";
import { Decorator } from "./";
const withEventEmitter = (eventEmitter: EventEmitter2): Decorator => pym => {
// Pass events from iframe to the event emitter.
pym.onMessage("event", (raw: string) => {
const { eventName, value } = JSON.parse(raw);
eventEmitter.emit(eventName, value);
});
};
export default withEventEmitter;
@@ -0,0 +1,12 @@
import withIOSSafariWidthWorkaround from "./withIOSSafariWidthWorkaround";
it("should set width workaround", () => {
const fakePym = {
el: document.createElement("div"),
};
fakePym.el.innerHTML = "<span>Hello World</span>";
withIOSSafariWidthWorkaround(fakePym as any);
expect(fakePym.el.innerHTML).toBe(
'<span style="width: 1px; min-width: 100%;">Hello World</span>'
);
});
@@ -0,0 +1,9 @@
import { Decorator } from "./";
const withIOSSafariWidthWorkaround: Decorator = pym => {
// Workaround: IOS Safari ignores `width` but respects `min-width` value.
(pym.el.firstChild! as HTMLElement).style.width = "1px";
(pym.el.firstChild! as HTMLElement).style.minWidth = "100%";
};
export default withIOSSafariWidthWorkaround;
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>Talk 5.0 Embed Stream</title>
<meta charset="utf-8">
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no">
</head>
<body>
<h1 style="text-align: center" }>Talk 5.0 Embed Stream</h1>
<div id="coralStreamEmbed"></div>
<script>
window.TalkEmbed = Talk.render(document.getElementById('coralStreamEmbed'));
</script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
import * as Talk from "./";
describe("Basic integration test", () => {
const container: HTMLElement = document.createElement("div");
let streamInterface: ReturnType<typeof Talk.render>;
beforeAll(() => {
container.id = "basic-integration-test-id";
document.body.appendChild(container);
});
afterAll(() => {
document.body.removeChild(container);
});
it("should render iframe", () => {
streamInterface = Talk.render({
id: "basic-integration-test-id",
});
expect(container.innerHTML).toMatchSnapshot();
});
it("should remove iframe", () => {
streamInterface.remove();
expect(container.innerHTML).toBe("");
});
});
+30
View File
@@ -0,0 +1,30 @@
import { EventEmitter2 } from "eventemitter2";
import qs from "query-string";
import createStreamInterface from "./Stream";
export interface Config {
assetID?: string;
assetURL?: string;
rootURL?: string;
id?: string;
events?: (eventEmitter: EventEmitter2) => void;
}
export function render(config: Config = {}) {
// Parse query params
const query = qs.parse(location.search);
const eventEmitter = new EventEmitter2({ wildcard: true });
if (config.events) {
config.events(eventEmitter);
}
return createStreamInterface({
assetID: config.assetID || query.assetID,
assetURL: config.assetURL || query.assetURL,
id: config.id || "talk-embed-stream",
rootURL: config.rootURL || location.origin,
eventEmitter,
});
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"lib": ["dom", "es5"],
"types": ["jest"],
"paths": {}
},
"include": [
"./**/*",
"../../../types/pym.d.ts",
"../../../types/simulant.d.ts"
],
"exclude": ["node_modules"]
}
@@ -0,0 +1,18 @@
import buildURL from "./buildURL";
it("should default to window.location", () => {
const url = buildURL();
expect(url).toBe("http://localhost/");
});
it("should build from parameters", () => {
const url = buildURL({
protocol: "https",
hostname: "hostname",
port: "8080",
pathname: "/pathname",
search: "search",
hash: "#hash",
});
expect(url).toBe("https//hostname:8080/pathname?search#hash");
});
+17
View File
@@ -0,0 +1,17 @@
export default function buildURL({
protocol = window.location.protocol,
hostname = window.location.hostname,
port = window.location.port,
pathname = window.location.pathname,
search = window.location.search,
hash = window.location.hash,
} = {}) {
if (search && search[0] !== "?") {
search = `?${search}`;
} else if (search === "?") {
search = "";
}
return `${protocol}//${hostname}${
port ? `:${port}` : ""
}${pathname}${search}${hash}`;
}
@@ -0,0 +1,11 @@
import ensureEndSlash from "./ensureEndSlash";
it("should add slash to the end", () => {
const path = ensureEndSlash("/test");
expect(path).toBe("/test/");
});
it("should not add slash to the end if it's already there", () => {
const path = ensureEndSlash("/test/");
expect(path).toBe("/test/");
});
@@ -0,0 +1,3 @@
export default function ensureEndSlash(p: string) {
return p.match(/\/$/) ? p : `${p}/`;
}
+2
View File
@@ -0,0 +1,2 @@
export { default as buildURL } from "./buildURL";
export { default as ensureEndSlash } from "./ensureEndSlash";
@@ -1,19 +1,31 @@
import { LocalizationProvider } from "fluent-react/compat";
import { MessageContext } from "fluent/compat";
import { Child as PymChild } from "pym.js";
import React, { StatelessComponent } from "react";
import { Formatter } from "react-timeago";
import { Environment } from "relay-runtime";
import { UIContext } from "talk-ui/components";
import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside";
export interface TalkContext {
// relayEnvironment for our relay framework.
/** relayEnvironment for our relay framework. */
relayEnvironment: Environment;
// localMessages for our i18n framework.
/** localMessages for our i18n framework. */
localeMessages: MessageContext[];
// formatter for timeago.
/** formatter for timeago. */
timeagoFormatter?: Formatter;
/**
* A way to listen for clicks that are e.g. outside of the
* current frame for `ClickOutside`
*/
registerClickFarAway?: ClickFarAwayRegister;
/** A pym child that interacts with the pym parent. */
pym?: PymChild;
}
const { Provider, Consumer } = React.createContext<TalkContext>({} as any);
@@ -32,7 +44,12 @@ export const TalkContextProvider: StatelessComponent<{
}> = ({ value, children }) => (
<Provider value={value}>
<LocalizationProvider messages={value.localeMessages}>
<UIContext.Provider value={{ timeagoFormatter: value.timeagoFormatter }}>
<UIContext.Provider
value={{
timeagoFormatter: value.timeagoFormatter,
registerClickFarAway: value.registerClickFarAway,
}}
>
{children}
</UIContext.Provider>
</LocalizationProvider>
@@ -1,22 +1,32 @@
import { EventEmitter2 } from "eventemitter2";
import { Localized } from "fluent-react/compat";
import { noop } from "lodash";
import { Child as PymChild } from "pym.js";
import React from "react";
import { Formatter } from "react-timeago";
import { Environment, Network, RecordSource, Store } from "relay-runtime";
import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside";
import { generateMessages, LocalesData, negotiateLanguages } from "../i18n";
import { fetchQuery } from "../network";
import { TalkContext } from "./TalkContext";
interface CreateContextArguments {
// Locales that the user accepts, usually `navigator.languages`.
/** Locales that the user accepts, usually `navigator.languages`. */
userLocales: ReadonlyArray<string>;
// Locales data that is returned by our `locales-loader`.
/** Locales data that is returned by our `locales-loader`. */
localesData: LocalesData;
// Init will be called after the context has been created.
/** Init will be called after the context has been created. */
init?: ((context: TalkContext) => void | Promise<void>);
/** A pym child that interacts with the pym parent. */
pym?: PymChild;
/** Supports emitting and listening to events. */
eventEmitter?: EventEmitter2;
}
/**
@@ -47,6 +57,8 @@ export default async function createContext({
init = noop,
userLocales,
localesData,
pym,
eventEmitter = new EventEmitter2({ wildcard: true }),
}: CreateContextArguments): Promise<TalkContext> {
// Initialize Relay.
const relayEnvironment = new Environment({
@@ -54,6 +66,21 @@ export default async function createContext({
store: new Store(new RecordSource()),
});
// Listen for outside clicks.
let registerClickFarAway: ClickFarAwayRegister | undefined;
if (pym) {
registerClickFarAway = cb => {
pym.onMessage("click", cb);
// Return unlisten callback.
return () => {
const index = pym.messageHandlers.click.indexOf(cb);
if (index > -1) {
pym.messageHandlers.click.splice(index, 1);
}
};
};
}
// Initialize i18n.
const locales = negotiateLanguages(userLocales, localesData);
@@ -69,6 +96,9 @@ export default async function createContext({
relayEnvironment,
localeMessages,
timeagoFormatter,
pym,
eventEmitter,
registerClickFarAway,
};
// Run custom initializations.
@@ -9,7 +9,7 @@ import Username from "./Username";
export interface CommentProps {
author: {
username: string;
username: string | null;
} | null;
body: string | null;
createdAt: string;
@@ -19,7 +19,8 @@ const Comment: StatelessComponent<CommentProps> = props => {
return (
<div role="article">
<TopBar>
{props.author && <Username>{props.author.username}</Username>}
{props.author &&
props.author.username && <Username>{props.author.username}</Username>}
<Timestamp>{props.createdAt}</Timestamp>
</TopBar>
<Typography>{props.body}</Typography>
@@ -19,3 +19,18 @@ it("renders username and body", () => {
const wrapper = shallow(<CommentContainer {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("renders body only", () => {
const props: PropTypesOf<typeof CommentContainer> = {
data: {
author: {
username: null,
},
body: "Woof",
createdAt: "1995-12-17T03:24:00.000Z",
},
};
const wrapper = shallow(<CommentContainer {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -1,5 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders body only 1`] = `
<Comment
author={
Object {
"username": null,
}
}
body="Woof"
createdAt="1995-12-17T03:24:00.000Z"
/>
`;
exports[`renders username and body 1`] = `
<Comment
author={
+3 -3
View File
@@ -1,15 +1,15 @@
<!DOCTYPE html>
<html prefix="og: http://ogp.me/ns#">
<html>
<head>
<title>Relay Experiments</title>
<title>Talk - Stream</title>
<meta charset="utf-8">
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no">
</head>
<body>
<div id="app" aria-role="application" onclick="void(0)"></div>
<div id="app"></div>
</body>
</html>
+2
View File
@@ -1,3 +1,4 @@
import pym from "pym.js";
import React from "react";
import { StatelessComponent } from "react";
import ReactDOM from "react-dom";
@@ -23,6 +24,7 @@ async function main() {
init,
localesData,
userLocales: navigator.languages,
pym: new pym.Child({ polling: 100 }),
});
const Index: StatelessComponent = () => (
+1 -1
View File
@@ -16,5 +16,5 @@
}
},
"include": ["./**/*", "../../types/**/*.d.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "./embed"]
}
@@ -3,7 +3,14 @@ import React from "react";
import simulant from "simulant";
import sinon from "sinon";
import ClickOutside from "./ClickOutside";
import UIContext from "../UIContext";
import {
ClickFarAwayCallback,
ClickFarAwayRegister,
ClickOutside,
default as ClickOutsideWithContext,
} from "./ClickOutside";
let container: HTMLElement;
@@ -60,3 +67,57 @@ it("should ignore click inside", () => {
expect(onClickOutside.calledOnce).toEqual(false);
wrapper.unmount();
});
it("should detect click far away", () => {
let emitFarAwayClick: ClickFarAwayCallback = Function;
const unlisten = sinon.spy();
const registerClickFarAway: ClickFarAwayRegister = cb => {
emitFarAwayClick = cb;
return unlisten;
};
const onClickOutside = sinon.spy();
const wrapper = mount(
<ClickOutside
onClickOutside={onClickOutside}
registerClickFarAway={registerClickFarAway}
>
<button id="click-outside-test-button">Push Me</button>
</ClickOutside>,
{
attachTo: container,
}
);
expect(onClickOutside.calledOnce).toEqual(false);
emitFarAwayClick();
expect(onClickOutside.calledOnce).toEqual(true);
expect(unlisten.calledOnce).toEqual(false);
wrapper.unmount();
expect(unlisten.calledOnce).toEqual(true);
});
it("should get registerClickFarAway from context", () => {
const registerClickFarAway: ClickFarAwayRegister = sinon.spy();
const onClickOutside = sinon.spy();
const context: any = {
registerClickFarAway,
};
const wrapper = mount(
<UIContext.Provider value={context}>
<ClickOutsideWithContext
onClickOutside={onClickOutside}
registerClickFarAway={registerClickFarAway}
>
<button id="click-outside-test-button">Push Me</button>
</ClickOutsideWithContext>
</UIContext.Provider>,
{
attachTo: container,
}
);
expect(wrapper.find(ClickOutside).prop("registerClickFarAway")).toEqual(
registerClickFarAway
);
wrapper.unmount();
});
@@ -1,13 +1,30 @@
import React from "react";
import React, { StatelessComponent } from "react";
import { findDOMNode } from "react-dom";
import UIContext from "../UIContext";
export type ClickFarAwayCallback = () => void;
export type ClickFarAwayUnlistenCallback = () => void;
export type ClickFarAwayRegister = (
callback: ClickFarAwayCallback
) => ClickFarAwayUnlistenCallback;
interface Props {
onClickOutside: () => void;
/**
* A way to listen for clicks that are e.g. outside of the
* current frame for `ClickOutside`
*/
registerClickFarAway?: ClickFarAwayRegister;
children: React.ReactNode;
}
class ClickOutside extends React.Component<Props> {
export class ClickOutside extends React.Component<Props> {
public domNode: Element | null = null;
private unlisten?: ClickFarAwayUnlistenCallback;
public handleClick = (e: MouseEvent) => {
const { onClickOutside } = this.props;
@@ -17,17 +34,43 @@ class ClickOutside extends React.Component<Props> {
}
};
public handleClickFarAway = () => {
const { onClickOutside } = this.props;
// tslint:disable-next-line:no-unused-expression
onClickOutside && onClickOutside();
};
public componentDidMount() {
this.domNode = findDOMNode(this) as Element;
document.addEventListener("click", this.handleClick, true);
// Listen to far away clicks.
if (this.props.registerClickFarAway) {
this.unlisten = this.props.registerClickFarAway(this.handleClickFarAway);
}
}
public componentWillUnmount() {
document.removeEventListener("click", this.handleClick, true);
// Unlisten to far away clicks.
if (this.unlisten) {
this.unlisten();
this.unlisten = undefined;
}
}
public render() {
return this.props.children;
}
}
export default ClickOutside;
const ClickOutsideWithContext: StatelessComponent<Props> = props => (
<UIContext.Consumer>
{({ registerClickFarAway }) => (
<ClickOutside {...props} registerClickFarAway={registerClickFarAway} />
)}
</UIContext.Consumer>
);
export default ClickOutsideWithContext;
@@ -0,0 +1 @@
export { default as ClickOutside, ClickFarAwayRegister } from "./ClickOutside";
@@ -1,9 +1,11 @@
import { shallow } from "enzyme";
import { mount, shallow } from "enzyme";
import React from "react";
import { MediaQueryMatchers } from "react-responsive";
import { PropTypesOf } from "talk-ui/types";
import { MatchMedia } from "./MatchMedia";
import UIContext from "../UIContext";
import { default as MatchMediaWithContext, MatchMedia } from "./MatchMedia";
it("renders correctly", () => {
const props: PropTypesOf<typeof MatchMedia> = {
@@ -25,3 +27,20 @@ it("map new speech prop to older aural prop", () => {
const wrapper = shallow(<MatchMedia {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("should get mediaQueryValues from context", () => {
const mediaQueryValues: Partial<MediaQueryMatchers> = {
width: 100,
};
const context: any = {
mediaQueryValues,
};
const wrapper = mount(
<UIContext.Provider value={context}>
<MatchMediaWithContext maxWidth="xs">
<span>Hello World</span>
</MatchMediaWithContext>
</UIContext.Provider>
);
expect(wrapper.find(MatchMedia).prop("values")).toEqual(mediaQueryValues);
});
@@ -2,9 +2,18 @@ import React from "react";
import { MediaQueryMatchers } from "react-responsive";
import { Formatter } from "react-timeago";
import { ClickFarAwayRegister } from "../ClickOutside";
export interface UIContextProps {
/** Allows to integrate translated strings into `RelativeTime` Component */
timeagoFormatter?: Formatter | null;
/** Allows testing `MatchMedia` by setting media query values */
mediaQueryValues?: Partial<MediaQueryMatchers>;
/**
* A way to listen for clicks that are e.g. outside of the
* current frame for `ClickOutside`
*/
registerClickFarAway?: ClickFarAwayRegister;
}
const UIContext = React.createContext<UIContextProps>({} as any);
@@ -1,11 +1,6 @@
import convict from "convict";
import dotenv from "dotenv";
import Joi from "joi";
// Apply all the configuration provided in the .env file if it isn't already in
// the environment.
dotenv.config();
// Add custom format for the mongo uri scheme.
convict.addFormat({
name: "mongo-uri",
@@ -60,12 +55,29 @@ const config = convict({
env: "REDIS",
arg: "redis",
},
secret: {
doc: "The secret used to sign and verify JWTs",
signing_secret: {
doc: "",
format: "*",
default: null,
env: "SECRET",
arg: "secret",
default: "keyboard cat", // TODO: (wyattjoh) evaluate best solution
env: "SIGNING_SECRET",
arg: "signingSecret",
},
signing_algorithm: {
doc: "",
format: [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"ES512",
],
default: "HS256",
env: "SIGNING_ALGORITHM",
arg: "signingAlgorithm",
},
logging_level: {
doc: "The logging level to print to the console",
@@ -78,5 +90,9 @@ const config = convict({
export type Config = typeof config;
export const createClientEnv = (c: Config) => ({
NODE_ENV: c.get("env"),
});
// Setup the base configuration.
export default config;
+2
View File
@@ -11,3 +11,5 @@ export type Sub<T, U> = Pick<T, Diff<keyof T, keyof U>>;
* Make all properties in T writeable
*/
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
export type Promiseable<T> = Promise<T> | T;
@@ -0,0 +1,77 @@
import { RequestHandler } from "express";
import Joi from "joi";
import { Db } from "mongodb";
import { handleSuccessfulLogin } from "talk-server/app/middleware/passport";
import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt";
import { validate } from "talk-server/app/request/body";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { LocalProfile } from "talk-server/models/user";
import { upsert } from "talk-server/services/users";
import { Request } from "talk-server/types/express";
export interface SignupBody {
username: string;
password: string;
email: string;
displayName?: string;
}
const SignupBodySchema = Joi.object().keys({
username: Joi.string().trim(),
password: Joi.string().trim(),
email: Joi.string().trim(),
});
export interface SignupOptions {
db: Db;
signingConfig: JWTSigningConfig;
}
export const signupHandler = (options: SignupOptions): RequestHandler => async (
req: Request,
res,
next
) => {
try {
// TODO: rate limit based on the IP address and user agent.
// Tenant is guaranteed at this point.
const tenant = req.tenant!;
// Check to ensure that the local integration has been enabled.
if (!tenant.auth.integrations.local.enabled) {
// TODO: replace with better error.
return next(new Error("integration is disabled"));
}
// Get the fields from the body. Validate will throw an error if the body
// does not conform to the specification.
const { username, password, email }: SignupBody = validate(
SignupBodySchema,
req.body
);
// Configure with profile.
const profile: LocalProfile = {
id: email,
type: "local",
};
// Create the new user.
const user = await upsert(options.db, tenant, {
email,
username,
password,
profiles: [profile],
// New users signing up via local auth will have the commenter role to
// start with.
role: GQLUSER_ROLE.COMMENTER,
});
// Send off to the passport handler.
return handleSuccessfulLogin(user, options.signingConfig, req, res, next);
} catch (err) {
return next(err);
}
};
+20 -9
View File
@@ -3,14 +3,15 @@ import http from "http";
import { Redis } from "ioredis";
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import { Config } from "talk-common/config";
import { notFoundMiddleware } from "talk-server/app/middleware/notFound";
import { createPassport } from "talk-server/app/middleware/passport";
import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt";
import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware";
import { Schemas } from "talk-server/graph/schemas";
import TenantCache from "talk-server/services/tenant/cache";
import {
access as accessLogger,
error as errorLogger,
} from "./middleware/logging";
import { accessLogger, errorLogger } from "./middleware/logging";
import serveStatic from "./middleware/serveStatic";
import { createRouter } from "./router";
@@ -20,6 +21,8 @@ export interface AppOptions {
mongo: Db;
redis: Redis;
schemas: Schemas;
signingConfig: JWTSigningConfig;
tenantCache: TenantCache;
}
/**
@@ -32,13 +35,21 @@ export async function createApp(options: AppOptions): Promise<Express> {
// Logging
parent.use(accessLogger);
// Create some services for the router.
const passport = createPassport(options);
// Mount the router.
parent.use(
await createRouter(options, {
passport,
})
);
// Static Files
parent.use(serveStatic);
// Mount the router.
parent.use(await createRouter(options));
// Error Handling
parent.use(notFoundMiddleware);
parent.use(errorLogger);
return parent;
@@ -64,7 +75,7 @@ export const listenAndServe = (
* handle websocket traffic by upgrading their http connections to websocket.
*
* @param schemas schemas for every schema this application handles
* @param server the http.Server to attach the websocket upgraders to
* @param server the http.Server to attach the websocket upgrader to
*/
export async function attachSubscriptionHandlers(
schemas: Schemas,
+6
View File
@@ -0,0 +1,6 @@
import { ErrorRequestHandler } from "express";
export const apiErrorHandler: ErrorRequestHandler = (err, req, res, next) => {
// TODO: handle better when we improve errors.
res.status(500).json({ error: err.message });
};
+5 -4
View File
@@ -1,8 +1,9 @@
import { ErrorRequestHandler, RequestHandler } from "express";
import now from "performance-now";
import logger from "../../logger";
export const access: RequestHandler = (req, res, next) => {
import logger from "talk-server/logger";
export const accessLogger: RequestHandler = (req, res, next) => {
const startTime = now();
const end = res.end;
res.end = (chunk: any, encodingOrCb?: any, cb?: any) => {
@@ -37,7 +38,7 @@ export const access: RequestHandler = (req, res, next) => {
next();
};
export const error: ErrorRequestHandler = (err, req, res, next) => {
logger.error({ err }, "http error");
export const errorLogger: ErrorRequestHandler = (err, req, res, next) => {
logger.error(err, "http error");
next(err);
};
@@ -0,0 +1,5 @@
import { RequestHandler } from "express";
export const notFoundMiddleware: RequestHandler = (req, res, next) => {
next(new Error("not found"));
};
@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`createJWTSigningConfig parses a RSA certificate 1`] = `
"-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV
EnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+
MHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U
C9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2
nQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV
1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS
2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw
fdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD
KrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB
GdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7
0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ
kPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi
55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN
oLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v
lbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6
9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK
8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY
SvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1
rFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr
xqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb
za9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7
1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0
pYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f
RaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt
ySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=
-----END RSA PRIVATE KEY-----"
`;
@@ -1,13 +1,116 @@
import { NextFunction, RequestHandler, Response } from "express";
import { Db } from "mongodb";
import passport, { Authenticator } from "passport";
import {
createJWTStrategy,
JWTSigningConfig,
SigningTokenOptions,
signTokenString,
} from "talk-server/app/middleware/passport/jwt";
import { createLocalStrategy } from "talk-server/app/middleware/passport/local";
import { createOIDCStrategy } from "talk-server/app/middleware/passport/oidc";
import { createSSOStrategy } from "talk-server/app/middleware/passport/sso";
import { User } from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
import { Request } from "talk-server/types/express";
export type VerifyCallback = (
err?: Error | null,
user?: User | null,
info?: { message: string }
) => void;
export interface PassportOptions {
db: Db;
mongo: Db;
signingConfig: JWTSigningConfig;
tenantCache: TenantCache;
}
export function createPassport(opts: PassportOptions): passport.Authenticator {
export function createPassport(
options: PassportOptions
): passport.Authenticator {
// Create the authenticator.
const auth = new Authenticator();
// Use the OIDC Strategy.
auth.use(createOIDCStrategy(options));
// Use the LocalStrategy.
auth.use(createLocalStrategy(options));
// Use the SSOStrategy.
auth.use(createSSOStrategy(options));
// Use the JWTStrategy.
auth.use(createJWTStrategy(options));
return auth;
}
export async function handleSuccessfulLogin(
user: User,
signingConfig: JWTSigningConfig,
req: Request,
res: Response,
next: NextFunction
) {
try {
// Grab the tenant from the request.
const { tenant } = req;
const options: SigningTokenOptions = {};
if (tenant) {
// Attach the tenant's id to the issued token as a `iss` claim.
options.issuer = tenant.id;
// TODO: (wyattjoh) evaluate the possibility when we have multiple
// integrations per type to use the integration id as the audience.
}
// Grab the token.
const token = await signTokenString(signingConfig, user, options);
// Set the cache control headers.
res.header("Cache-Control", "private, no-cache, no-store, must-revalidate");
res.header("Expires", "-1");
res.header("Pragma", "no-cache");
// Send back the details!
res.json({ token });
} catch (err) {
return next(err);
}
}
/**
* wrapAuthn will wrap a authenticators authenticate method with one that
* will return a valid login token for a valid login by a compatible strategy.
*
* @param authenticator the base authenticator instance
* @param signingConfig used to sign the tokens that are issued.
* @param name the name of the authenticator to use
* @param options any options to be passed to the authenticate call
*/
export const wrapAuthn = (
authenticator: passport.Authenticator,
signingConfig: JWTSigningConfig,
name: string,
options?: any
): RequestHandler => (req: Request, res, next) =>
authenticator.authenticate(
name,
{ ...options, session: false },
(err: Error | null, user: User | null) => {
if (err) {
return next(err);
}
if (!user) {
// TODO: (wyattjoh) replace with better error.
return next(new Error("no user on request"));
}
handleSuccessfulLogin(user, signingConfig, req, res, next);
}
)(req, res, next);
@@ -0,0 +1,84 @@
import sinon from "sinon";
import { Config } from "talk-common/config";
import {
createJWTSigningConfig,
extractJWTFromRequest,
parseAuthHeader,
} from "talk-server/app/middleware/passport/jwt";
import { Request } from "talk-server/types/express";
describe("parseAuthHeader", () => {
it("parses valid headers", () => {
const parsed = {
scheme: "bearer",
value: "token",
};
expect(parseAuthHeader("Bearer token")).toEqual(parsed);
expect(parseAuthHeader("bearer token")).toEqual(parsed);
expect(parseAuthHeader("bearer token")).toEqual(parsed);
});
it("parses invalid headers", () => {
expect(parseAuthHeader("this-is-a-wrong-header")).toEqual(null);
expect(parseAuthHeader("bearerthis-is-a-wrong-header")).toEqual(null);
});
});
describe("extractJWTFromRequest", () => {
it("extracts the token from header", () => {
const req = {
get: sinon
.stub()
.withArgs("authorization")
.returns("Bearer token"),
};
expect(extractJWTFromRequest((req as any) as Request)).toEqual("token");
expect(req.get.calledOnce).toBeTruthy();
req.get.reset();
req.get.returns(null);
expect(extractJWTFromRequest((req as any) as Request)).toEqual(null);
expect(req.get.calledOnce).toBeTruthy();
});
it("extracts the token from query string", () => {
const req = {
get: sinon
.stub()
.withArgs("authorization")
.returns(null),
query: { access_token: "token" },
};
expect(extractJWTFromRequest((req as any) as Request)).toEqual("token");
expect(req.get.calledOnce).toBeTruthy();
delete req.query.access_token;
req.get.reset();
expect(extractJWTFromRequest((req as any) as Request)).toEqual(null);
expect(req.get.calledOnce).toBeTruthy();
});
});
describe("createJWTSigningConfig", () => {
it("parses a RSA certificate", () => {
const input = `-----BEGIN RSA PRIVATE KEY-----\\nMIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV\\nEnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+\\nMHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U\\nC9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2\\nnQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV\\n1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS\\n2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw\\nfdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD\\nKrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB\\nGdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7\\n0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ\\nkPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi\\n55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN\\noLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v\\nlbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6\\n9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK\\n8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY\\nSvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1\\nrFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr\\nxqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb\\nza9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7\\n1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0\\npYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f\\nRaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt\\nySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=\\n-----END RSA PRIVATE KEY-----`;
const config = {
get: sinon.stub(),
};
config.get.withArgs("signing_secret").returns(input);
config.get.withArgs("signing_algorithm").returns("RS256");
const signingConfig = createJWTSigningConfig((config as any) as Config);
expect(signingConfig.algorithm).toEqual("RS256");
expect(signingConfig.secret.toString()).toMatchSnapshot();
});
});
@@ -0,0 +1,203 @@
import jwt, { SignOptions } from "jsonwebtoken";
import uuid from "uuid";
import { Db } from "mongodb";
import { Strategy } from "passport-strategy";
import { Config } from "talk-common/config";
import { retrieveUser, User } from "talk-server/models/user";
import { Request } from "talk-server/types/express";
const authHeaderRegex = /(\S+)\s+(\S+)/;
export function parseAuthHeader(header: string) {
const matches = header.match(authHeaderRegex);
if (!matches || matches.length < 3) {
return null;
}
return {
scheme: matches[1].toLowerCase(),
value: matches[2],
};
}
export function extractJWTFromRequest(req: Request) {
const header = req.get("authorization");
if (header) {
const parts = parseAuthHeader(header);
if (parts && parts.scheme === "bearer") {
return parts.value;
}
}
const token: string | undefined | false = req.query && req.query.access_token;
if (token) {
return token;
}
return null;
}
export enum AsymmetricSigningAlgorithm {
RS256 = "RS256",
RS384 = "RS384",
RS512 = "RS512",
ES256 = "ES256",
ES384 = "ES384",
ES512 = "ES512",
}
export enum SymmetricSigningAlgorithm {
HS256 = "HS256",
HS384 = "HS384",
HS512 = "HS512",
}
export type JWTSigningAlgorithm =
| AsymmetricSigningAlgorithm
| SymmetricSigningAlgorithm;
export interface JWTSigningConfig {
secret: Buffer;
algorithm: JWTSigningAlgorithm;
}
export function createAsymmetricSigningConfig(
algorithm: AsymmetricSigningAlgorithm,
secret: string
): JWTSigningConfig {
return {
// Secrets have their newlines encoded with newline literals.
secret: Buffer.from(secret.replace(/\\n/g, "\n")),
algorithm,
};
}
export function createSymmetricSigningConfig(
algorithm: SymmetricSigningAlgorithm,
secret: string
): JWTSigningConfig {
return {
secret: new Buffer(secret),
algorithm,
};
}
function isSymmetricSigningAlgorithm(
algorithm: string | SymmetricSigningAlgorithm
): algorithm is SymmetricSigningAlgorithm {
return algorithm in SymmetricSigningAlgorithm;
}
function isAsymmetricSigningAlgorithm(
algorithm: string | AsymmetricSigningAlgorithm
): algorithm is AsymmetricSigningAlgorithm {
return algorithm in AsymmetricSigningAlgorithm;
}
/**
* Parses the config and provides the signing config.
*
* @param config the server configuration
*/
export function createJWTSigningConfig(config: Config): JWTSigningConfig {
const secret = config.get("signing_secret");
const algorithm = config.get("signing_algorithm");
if (isSymmetricSigningAlgorithm(algorithm)) {
return createSymmetricSigningConfig(algorithm, secret);
} else if (isAsymmetricSigningAlgorithm(algorithm)) {
return createAsymmetricSigningConfig(algorithm, secret);
}
// TODO: (wyattjoh) return better error.
throw new Error("invalid algorithm specified");
}
export type SigningTokenOptions = Pick<SignOptions, "audience" | "issuer">;
export async function signTokenString(
{ algorithm, secret }: JWTSigningConfig,
user: User,
options: SigningTokenOptions
) {
return jwt.sign({}, secret, {
...options,
jwtid: uuid.v4(),
algorithm,
expiresIn: "1 day", // TODO: (wyattjoh) evaluate allowing configuration?
subject: user.id,
});
}
export interface JWTToken {
jti: string;
sub: string;
exp: number;
iss?: string;
}
export interface JWTStrategyOptions {
signingConfig: JWTSigningConfig;
mongo: Db;
}
export class JWTStrategy extends Strategy {
public name = "jwt";
private signingConfig: JWTSigningConfig;
private mongo: Db;
constructor({ signingConfig, mongo }: JWTStrategyOptions) {
super();
this.signingConfig = signingConfig;
this.mongo = mongo;
}
public authenticate(req: Request) {
// Lookup the token.
const token = extractJWTFromRequest(req);
if (!token) {
// There was no token on the request, so there was no user, so let's mark
// that the strategy was successful.
return this.success(null, null);
}
const { tenant } = req;
if (!tenant) {
// TODO: (wyattjoh) return a better error.
return this.error(new Error("tenant not found"));
}
jwt.verify(
token,
// Use the secret specified in the configuration.
this.signingConfig.secret,
{
// We need to verify that the token is for the specified tenant.
issuer: tenant.id,
// Use the algorithm specified in the configuration.
algorithms: [this.signingConfig.algorithm],
},
async (err: Error | undefined, { sub }: JWTToken) => {
if (err) {
return this.fail(err, 401);
}
try {
// Find the user.
const user = await retrieveUser(this.mongo, tenant.id, sub);
// Return them! The user may be null, but that's ok here.
this.success(user, null);
} catch (err) {
return this.error(err);
}
}
);
}
}
export function createJWTStrategy(options: JWTStrategyOptions) {
return new JWTStrategy(options);
}
@@ -0,0 +1,60 @@
import { Db } from "mongodb";
import { Strategy as LocalStrategy } from "passport-local";
import { VerifyCallback } from "talk-server/app/middleware/passport";
import {
retrieveUserWithProfile,
verifyUserPassword,
} from "talk-server/models/user";
import { Request } from "talk-server/types/express";
const verifyFactory = (mongo: Db) => async (
req: Request,
email: string,
password: string,
done: VerifyCallback
) => {
try {
// TODO: rate limit based on the IP address and user agent.
// The tenant is guaranteed at this point.
const tenant = req.tenant!;
// Get the user from the database.
const user = await retrieveUserWithProfile(mongo, tenant.id, {
id: email,
type: "local",
});
if (!user) {
// The user didn't exist.
return done(null, null);
}
// Verify the password.
const passwordVerified = await verifyUserPassword(user, password);
if (!passwordVerified) {
// TODO: return better error
return done(new Error("invalid password"));
}
return done(null, user);
} catch (err) {
return done(err);
}
};
export interface LocalStrategyOptions {
mongo: Db;
}
export function createLocalStrategy({ mongo }: LocalStrategyOptions) {
return new LocalStrategy(
{
usernameField: "email",
passwordField: "password",
session: false,
passReqToCallback: true,
},
verifyFactory(mongo)
);
}
@@ -0,0 +1,87 @@
import {
OIDCDisplayNameIDTokenSchema,
OIDCIDTokenSchema,
} from "talk-server/app/middleware/passport/oidc";
import { validate } from "talk-server/app/request/body";
describe("OIDCIDTokenSchema", () => {
it("allows a valid payload", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
email_verified: true,
};
expect(validate(OIDCIDTokenSchema, token)).toEqual(token);
});
it("allows an empty email_verified", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
};
expect(validate(OIDCIDTokenSchema, token)).toEqual({
...token,
email_verified: false,
});
});
it("allows an empty picture", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
email_verified: true,
};
expect(validate(OIDCIDTokenSchema, token)).toEqual(token);
});
});
describe("OIDCDisplayNameIDTokenSchema", () => {
it("allows a valid payload", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
email_verified: true,
name: "name",
nickname: "nickname",
};
expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token);
});
it("allows an empty name", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
email_verified: false,
nickname: "nickname",
};
expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token);
});
it("allows an empty nickname", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
email_verified: false,
name: "name",
};
expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token);
});
});
@@ -0,0 +1,381 @@
import Joi from "joi";
import jwt from "jsonwebtoken";
import jwks, { JwksClient } from "jwks-rsa";
import { Db } from "mongodb";
import { Strategy as OAuth2Strategy, VerifyCallback } from "passport-oauth2";
import { Strategy } from "passport-strategy";
import { validate } from "talk-server/app/request/body";
import { reconstructURL } from "talk-server/app/url";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { OIDCAuthIntegration } from "talk-server/models/settings";
import { Tenant } from "talk-server/models/tenant";
import { OIDCProfile, retrieveUserWithProfile } from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
import { upsert } from "talk-server/services/users";
import { Request } from "talk-server/types/express";
export interface Params {
id_token?: string;
}
/**
* OIDCIDToken describes the set of claims that are present in a ID Token. This
* interface confirms with the ID Token specification as defined:
* https://openid.net/specs/openid-connect-core-1_0.html#IDToken
*/
export interface OIDCIDToken {
aud: string;
iss: string;
sub: string;
exp: number; // TODO: use this as the source for how long an OIDC user can be logged in for
email?: string;
email_verified?: boolean;
picture?: string;
name?: string;
nickname?: string;
}
export interface StrategyItem {
strategy: OAuth2Strategy;
jwksClient?: JwksClient;
}
export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken {
if (
(token as OIDCIDToken).iss &&
(token as OIDCIDToken).sub &&
(token as OIDCIDToken).email
) {
return true;
}
return false;
}
/**
* keyFunc will provide the secret based on the given jwkw client.
*
* @param client the jwks client for the specific request being made
*/
const signingKeyFactory = (client: jwks.JwksClient): jwt.KeyFunction => (
{ kid },
callback
) => {
if (!kid) {
// TODO: return better error.
return callback(new Error("no kid in id_token"));
}
// Get the signing key from the jwks provider.
client.getSigningKey(kid, (err, key) => {
if (err) {
// TODO: wrap error?
return callback(err);
}
// Grab the signingKey out of the provided key.
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
};
function getEnabledIntegration(tenant: Tenant) {
const integration = tenant.auth.integrations.oidc;
if (!integration) {
// TODO: return a better error.
throw new Error("integration not found");
}
// Handle when the integration is enabled/disabled.
if (!integration.enabled) {
// TODO: return a better error.
throw new Error("integration not enabled");
}
return integration;
}
export const OIDCIDTokenSchema = Joi.object()
.keys({
sub: Joi.string(),
iss: Joi.string(),
aud: Joi.string(),
email: Joi.string(),
email_verified: Joi.boolean().default(false),
picture: Joi.string().default(undefined),
})
.optionalKeys(["picture", "email_verified"]);
export const OIDCDisplayNameIDTokenSchema = OIDCIDTokenSchema.keys({
name: Joi.string().default(undefined),
nickname: Joi.string().default(undefined),
}).optionalKeys(["name", "nickname"]);
export async function findOrCreateOIDCUser(
db: Db,
tenant: Tenant,
token: OIDCIDToken
) {
// Unpack/validate the token content.
const {
sub,
iss,
aud,
email,
email_verified,
picture,
name,
nickname,
}: OIDCIDToken = validate(
tenant.auth.integrations.oidc!.displayNameEnable
? OIDCDisplayNameIDTokenSchema
: OIDCIDTokenSchema,
token
);
// Construct the profile that will be used to query for the user.
const profile: OIDCProfile = {
type: "oidc",
id: sub,
issuer: iss,
audience: aud,
};
// Try to lookup user given their id provided in the `sub` claim.
let user = await retrieveUserWithProfile(db, tenant.id, profile);
if (!user) {
// FIXME: implement rules.
// Default the displayName. When it is disabled, Joi will strip the
// displayName fields from the token, so it will fallback to undefined.
const displayName = nickname || name || undefined;
// Create the new user, as one didn't exist before!
user = await upsert(db, tenant, {
username: null,
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
email_verified,
avatar: picture,
profiles: [profile],
});
}
// TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch?
return user;
}
/**
* OIDC_SCOPE is the set of scopes requested for users signing up via OIDC.
*/
const OIDC_SCOPE = "openid email profile";
export interface OIDCStrategyOptions {
mongo: Db;
tenantCache: TenantCache;
}
export default class OIDCStrategy extends Strategy {
public name = "oidc";
private mongo: Db;
private cache = new Map<string, StrategyItem>();
constructor({ mongo, tenantCache }: OIDCStrategyOptions) {
super();
this.mongo = mongo;
// Subscribe to updates with Tenants.
tenantCache.subscribe(tenant => {
// Delete the tenant cache item when the tenant changes. The refreshed
// Tenant will come in with the request.
this.cache.delete(tenant.id);
});
}
private lookupJWKSClient(
req: Request,
tenantID: string,
oidc: OIDCAuthIntegration
) {
let entry = this.cache.get(tenantID);
if (!entry) {
const strategy = this.createStrategy(req, oidc);
// Create the entry.
entry = {
strategy,
};
// We don't reset the entry in the cache here because if we just created
// it, we'll be creating the jwksClient anyways, so we'll update it there.
}
if (!entry.jwksClient) {
// Create the new JWKS client.
const jwksClient = jwks({
jwksUri: oidc.jwksURI,
});
// Set the jwksClient on the entry.
entry.jwksClient = jwksClient;
// Update the cached entry.
this.cache.set(tenantID, entry);
}
return entry.jwksClient;
}
private userAuthenticatedCallback = (
req: Request,
accessToken: string, // ignore the access token, we don't use it.
refreshToken: string, // ignore the refresh token, we don't use it.
params: Params,
profile: any, // we don't look inside the profile (yet).
done: VerifyCallback
) => {
// Try to lookup user given their id provided in the `sub` claim of the
// `id_token`.
const { id_token } = params;
if (!id_token) {
// TODO: return better error.
return done(new Error("no id_token in params"));
}
// Grab the tenant out of the request, as we need some more details.
const { tenant } = req;
if (!tenant) {
// TODO: return a better error.
return done(new Error("tenant not found"));
}
// Get the integration from the tenant. If needed, it will be used to create
// a new strategy.
let integration: OIDCAuthIntegration;
try {
integration = getEnabledIntegration(tenant);
} catch (err) {
// TODO: wrap error?
return done(err);
}
// Grab the JWKSClient.
const client = this.lookupJWKSClient(req, tenant.id, integration);
// Verify that the id_token is valid or not.
jwt.verify(
id_token,
signingKeyFactory(client),
{
issuer: integration.issuer,
},
async (err, decoded) => {
if (err) {
// TODO: wrap error?
return done(err);
}
try {
const user = await findOrCreateOIDCUser(
this.mongo,
tenant,
decoded as OIDCIDToken
);
return done(null, user);
} catch (err) {
return done(err);
}
}
);
};
private createStrategy(
req: Request,
integration: OIDCAuthIntegration
): OAuth2Strategy {
const { clientID, clientSecret, authorizationURL, tokenURL } = integration;
// Construct the callbackURL from the request.
const callbackURL = reconstructURL(req, "/api/tenant/auth/oidc/callback");
// Create a new OAuth2Strategy, where we pass the verify callback bound to
// this OIDCStrategy instance.
return new OAuth2Strategy(
{
passReqToCallback: true,
clientID,
clientSecret,
authorizationURL,
tokenURL,
callbackURL,
},
this.userAuthenticatedCallback
);
}
private async lookupStrategy(req: Request) {
const { tenant } = req;
if (!tenant) {
// TODO: return a better error.
throw new Error("tenant not found");
}
// Get the integration from the tenant. If needed, it will be used to create
// a new strategy.
const integration = getEnabledIntegration(tenant);
// Try to get the Tenant's cached integrations.
let entry = this.cache.get(tenant.id);
if (!entry) {
// Create the strategy.
const strategy = this.createStrategy(req, integration);
// Reset the entry.
entry = {
strategy,
};
// Update the cached integrations value.
this.cache.set(tenant.id, entry);
}
return entry.strategy;
}
public async authenticate(req: Request) {
try {
// Lookup the strategy.
const strategy = await this.lookupStrategy(req);
if (!strategy) {
throw new Error("strategy not found");
}
// Augment the strategy with the request method bindings.
strategy.error = this.error.bind(this);
strategy.fail = this.fail.bind(this);
strategy.pass = this.pass.bind(this);
strategy.redirect = this.redirect.bind(this);
strategy.success = this.success.bind(this);
// Authenticate with the strategy, binding the current context to the method
// to provide it with the augmented passport handlers. We also request the
// 'openid' scope so we can get an id_token back.
strategy.authenticate(req, {
scope: OIDC_SCOPE,
session: false,
});
} catch (err) {
return this.error(err);
}
}
}
export function createOIDCStrategy(options: OIDCStrategyOptions) {
return new OIDCStrategy(options);
}
@@ -0,0 +1,83 @@
import {
isSSOToken,
SSODisplayNameUserProfileSchema,
SSOUserProfileSchema,
} from "talk-server/app/middleware/passport/sso";
import { validate } from "talk-server/app/request/body";
describe("isSSOToken", () => {
it("understands valid sso tokens", () => {
const token = { user: { id: "id", email: "email", username: "username" } };
expect(isSSOToken(token)).toBeTruthy();
});
it("understands invalid sso tokens", () => {
expect(isSSOToken({ user: { id: "id", email: "email" } })).toBeFalsy();
expect(
isSSOToken({ user: { id: "id", username: "username" } })
).toBeFalsy();
expect(
isSSOToken({ user: { email: "email", username: "username" } })
).toBeFalsy();
expect(isSSOToken({})).toBeFalsy();
});
});
describe("SSOUserProfileSchema", () => {
it("allows a valid payload", () => {
const profile = {
id: "id",
email: "email",
username: "username",
avatar: "avatar",
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
});
it("allows an empty avatar", () => {
const profile = {
id: "id",
email: "email",
username: "username",
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
});
});
describe("SSODisplayNameUserProfileSchema", () => {
it("allows a valid payload", () => {
const profile = {
id: "id",
email: "email",
username: "username",
avatar: "avatar",
displayName: "displayName",
};
expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile);
});
it("allows an empty avatar", () => {
const profile = {
id: "id",
email: "email",
username: "username",
displayName: "displayName",
};
expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile);
});
it("allows an empty displayName", () => {
const profile = {
id: "id",
email: "email",
username: "username",
avatar: "avatar",
};
expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile);
});
});
@@ -0,0 +1,224 @@
import Joi from "joi";
import jwt, { KeyFunctionCallback } from "jsonwebtoken";
import { Db } from "mongodb";
import { Strategy } from "passport-strategy";
import { extractJWTFromRequest } from "talk-server/app/middleware/passport/jwt";
import {
findOrCreateOIDCUser,
isOIDCToken,
OIDCIDToken,
} from "talk-server/app/middleware/passport/oidc";
import { validate } from "talk-server/app/request/body";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { Tenant } from "talk-server/models/tenant";
import { retrieveUserWithProfile, SSOProfile } from "talk-server/models/user";
import { upsert } from "talk-server/services/users";
import { Request } from "talk-server/types/express";
export interface SSOStrategyOptions {
mongo: Db;
}
export interface SSOUserProfile {
id: string;
email: string;
username: string;
avatar?: string;
displayName?: string;
}
export interface SSOToken {
user: SSOUserProfile;
}
export const SSOUserProfileSchema = Joi.object()
.keys({
id: Joi.string(),
email: Joi.string(),
username: Joi.string(),
avatar: Joi.string().default(undefined),
})
.optionalKeys(["avatar"]);
export const SSODisplayNameUserProfileSchema = SSOUserProfileSchema.keys({
displayName: Joi.string().default(undefined),
}).optionalKeys(["displayName"]);
export async function findOrCreateSSOUser(
db: Db,
tenant: Tenant,
token: SSOToken
) {
if (!token.user) {
// TODO: (wyattjoh) replace with better error.
throw new Error("token is malformed, missing user claim");
}
// Unpack/validate the token content.
const { id, email, username, displayName, avatar }: SSOUserProfile = validate(
tenant.auth.integrations.sso!.displayNameEnable
? SSODisplayNameUserProfileSchema
: SSOUserProfileSchema,
token.user
);
const profile: SSOProfile = {
type: "sso",
id,
};
// Try to lookup user given their id provided in the `sub` claim.
let user = await retrieveUserWithProfile(db, tenant.id, profile);
if (!user) {
// FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method.
// Create the new user, as one didn't exist before!
user = await upsert(db, tenant, {
username,
// When the displayName is disabled on the tenant, the displayName will
// never be set (or even stored in the database).
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
avatar,
profiles: [profile],
});
}
// TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch?
return user;
}
/**
* isSSOUserProfile will check if the given profile is a SSOUserProfile.
*
* @param profile the profile to check for the type
*/
export function isSSOUserProfile(
profile: SSOUserProfile | object
): profile is SSOUserProfile {
return (
typeof (profile as SSOUserProfile).id !== "undefined" &&
typeof (profile as SSOUserProfile).email !== "undefined" &&
typeof (profile as SSOUserProfile).username !== "undefined"
);
}
export function isSSOToken(token: SSOToken | object): token is SSOToken {
return (
typeof (token as SSOToken).user === "object" &&
isSSOUserProfile((token as SSOToken).user)
);
}
export default class SSOStrategy extends Strategy {
public name = "sso";
private mongo: Db;
constructor({ mongo }: SSOStrategyOptions) {
super();
this.mongo = mongo;
}
/**
* retrieves the integration's secret to be used to verify the token.
*/
private getSigningSecretGetter = (tenant: Tenant) => async (
headers: { kid?: string },
done: KeyFunctionCallback
) => {
const integration = tenant.auth.integrations.sso;
if (!integration) {
// TODO: (wyattjoh) return a better error.
return done(new Error("integration not found"));
}
if (!integration.enabled) {
// TODO: (wyattjoh) return a better error.
return done(new Error("integration not enabled"));
}
// TODO: (wyattjoh) do something with the kid... Lookup the secret or verify it matches what we have?
return done(null, integration.key);
};
/**
* findOrCreateUser will interpret the token and use the correct strategy for
* retrieving/creating the user.
*
* @param tenant the tenant for the new/returning user
* @param token the token that was unpacked and validated from the sso strategy
*/
private async findOrCreateUser(
tenant: Tenant,
token: OIDCIDToken | SSOToken
) {
if (isOIDCToken(token)) {
// The token provided for SSO contains an issuer claim. We're assuming
// that this request is associated with an OpenID Connect provider.
return findOrCreateOIDCUser(this.mongo, tenant, token);
}
// Check to see if this token is a SSO Token or not, if it isn't error out.
if (!isSSOToken(token)) {
// TODO: (wyattjoh) return a better error.
throw new Error("token is invalid");
}
// The token provided does not confirm to the OpenID Connect provider
// spec, but id does conform to a SSOToken so we should expect the token to
// contain the user profile.
return findOrCreateSSOUser(this.mongo, tenant, token);
}
public authenticate(req: Request) {
const { tenant } = req;
if (!tenant) {
// TODO: (wyattjoh) return a better error.
return this.error(new Error("tenant not found"));
}
// Lookup the token.
const token = extractJWTFromRequest(req);
if (!token) {
// TODO: (wyattjoh) return a better error.
return this.fail(new Error("no token on request"), 400);
}
// Perform the JWT validation.
jwt.verify(
token,
this.getSigningSecretGetter(tenant),
{
// Force the use of the HS256 algorithm. We can explore switching this
// out in the future..
algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm.
},
async (err: Error | undefined, decoded: OIDCIDToken | SSOToken) => {
if (err) {
// TODO: (wyattjoh) wrap error?
return this.error(err);
}
try {
// Find or create the user based on the decoded token.
const user = await this.findOrCreateUser(tenant, decoded);
// The user was found or created!
return this.success(user, null);
} catch (err) {
return this.error(err);
}
}
);
}
}
export function createSSOStrategy(options: SSOStrategyOptions) {
return new SSOStrategy(options);
}
+9 -5
View File
@@ -1,11 +1,10 @@
import { NextFunction, Response } from "express";
import { Db } from "mongodb";
import { retrieveTenantByDomain } from "talk-server/models/tenant";
import TenantCache from "talk-server/services/tenant/cache";
import { Request } from "talk-server/types/express";
export interface MiddlewareOptions {
db: Db;
cache: TenantCache;
}
export default (options: MiddlewareOptions) => async (
@@ -14,13 +13,18 @@ export default (options: MiddlewareOptions) => async (
next: NextFunction
) => {
try {
// TODO: replace with shared synced cache instead of direct db access.
const tenant = await retrieveTenantByDomain(options.db, req.hostname);
const { cache } = options;
// Attach the tenant to the request.
const tenant = await cache.retrieveByDomain(req.hostname);
if (!tenant) {
// TODO: send a http.StatusNotFound?
return next(new Error("tenant not found"));
}
// Attach the tenant cache to the request.
req.tenantCache = cache;
// Attach the tenant to the request.
req.tenant = tenant;
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`throws an error for missing fields 1`] = `"child \\"d\\" fails because [\\"d\\" is required]"`;
+32
View File
@@ -0,0 +1,32 @@
import Joi from "joi";
import { validate } from "talk-server/app/request/body";
it("strips out unknown fields", () => {
const payload = { a: 1, b: 2, c: 3 };
const schema = Joi.object().keys({});
expect(validate(schema, payload)).toEqual({});
});
it("allows valid fields", () => {
const payload = { a: 1, b: 2, c: 3 };
const schema = Joi.object().keys({ a: Joi.number() });
expect(validate(schema, payload)).toEqual({ a: 1 });
});
it("allows valid fields from extended schema", () => {
const payload = { a: 1, b: 2, c: 3 };
const schema = Joi.object().keys({ a: Joi.number() });
const extendedSchema = schema.keys({ b: Joi.number() });
expect(validate(extendedSchema, payload)).toEqual({ a: 1, b: 2 });
});
it("throws an error for missing fields", () => {
const payload = { a: 1, b: 2, c: 3 };
const schema = Joi.object().keys({ d: Joi.number() });
expect(() => validate(schema, payload)).toThrowErrorMatchingSnapshot();
});
+24
View File
@@ -0,0 +1,24 @@
import Joi from "joi";
/**
* validate will strip unknown fields and perform validation against it. It will
* throw any error encountered.
*
* @param schema the Joi schema to validate against
* @param body the body to parse and strip of unknown fields
*/
export const validate = (schema: Joi.SchemaLike, body: any) => {
// Extract the schema from the request.
const { value, error: err } = Joi.validate(body, schema, {
stripUnknown: true,
presence: "required",
abortEarly: false,
});
if (err) {
// TODO: wrap error?
throw err;
}
return value;
};
+72 -17
View File
@@ -1,5 +1,10 @@
import express from "express";
import passport from "passport";
import { signupHandler } from "talk-server/app/handlers/auth/local";
import { apiErrorHandler } from "talk-server/app/middleware/error";
import { errorLogger } from "talk-server/app/middleware/logging";
import { wrapAuthn } from "talk-server/app/middleware/passport";
import tenantMiddleware from "talk-server/app/middleware/tenant";
import managementGraphMiddleware from "talk-server/graph/management/middleware";
import tenantGraphMiddleware from "talk-server/graph/tenant/middleware";
@@ -7,7 +12,7 @@ import tenantGraphMiddleware from "talk-server/graph/tenant/middleware";
import { AppOptions } from "./index";
import playground from "./middleware/playground";
async function createManagementRouter(opts: AppOptions) {
async function createManagementRouter(app: AppOptions, options: RouterOptions) {
const router = express.Router();
// Management API
@@ -15,51 +20,101 @@ async function createManagementRouter(opts: AppOptions) {
"/graphql",
express.json(),
await managementGraphMiddleware(
opts.schemas.management,
opts.config,
opts.mongo
app.schemas.management,
app.config,
app.mongo
)
);
return router;
}
async function createTenantRouter(opts: AppOptions) {
async function createTenantRouter(app: AppOptions, options: RouterOptions) {
const router = express.Router();
// Tenant identification middleware.
router.use(tenantMiddleware({ db: opts.mongo }));
router.use(tenantMiddleware({ cache: app.tenantCache }));
// Setup Passport middleware.
router.use(options.passport.initialize());
// Setup auth routes.
router.use("/auth", createNewAuthRouter(app, options));
// Tenant API
router.use(
"/graphql",
express.json(),
await tenantGraphMiddleware(opts.schemas.tenant, opts.config, opts.mongo)
// Any users may submit their GraphQL requests with authentication, this
// middleware will unpack their user into the request.
options.passport.authenticate("jwt", { session: false }),
await tenantGraphMiddleware({
schema: app.schemas.tenant,
config: app.config,
mongo: app.mongo,
redis: app.redis,
})
);
return router;
}
async function createAPIRouter(opts: AppOptions) {
// Create a router.
function createNewAuthRouter(app: AppOptions, options: RouterOptions) {
const router = express.Router();
// Configure the tenant routes.
router.use("/tenant", await createTenantRouter(opts));
// Configure the management routes.
router.use("/management", await createManagementRouter(opts));
// Mount the passport routes.
router.post(
"/local",
express.json(),
wrapAuthn(options.passport, app.signingConfig, "local")
);
router.post(
"/local/signup",
express.json(),
signupHandler({ db: app.mongo, signingConfig: app.signingConfig })
);
router.post("/sso", wrapAuthn(options.passport, app.signingConfig, "sso"));
router.get("/oidc", wrapAuthn(options.passport, app.signingConfig, "oidc"));
router.get(
"/oidc/callback",
wrapAuthn(options.passport, app.signingConfig, "oidc")
);
return router;
}
export async function createRouter(opts: AppOptions) {
async function createAPIRouter(app: AppOptions, options: RouterOptions) {
// Create a router.
const router = express.Router();
router.use("/api", await createAPIRouter(opts));
// Configure the tenant routes.
router.use("/tenant", await createTenantRouter(app, options));
if (opts.config.get("env") === "development") {
// Configure the management routes.
router.use("/management", await createManagementRouter(app, options));
// General API error handler.
router.use(errorLogger);
router.use(apiErrorHandler);
return router;
}
export interface RouterOptions {
/**
* passport is the instance of the Authenticator that can be used to create
* and mount new authentication middleware.
*/
passport: passport.Authenticator;
}
export async function createRouter(app: AppOptions, options: RouterOptions) {
// Create a router.
const router = express.Router();
router.use("/api", await createAPIRouter(app, options));
if (app.config.get("env") === "development") {
// Tenant GraphiQL
router.get(
"/tenant/graphiql",
+12
View File
@@ -0,0 +1,12 @@
import { Request } from "talk-server/types/express";
import { URL } from "url";
export function reconstructURL(req: Request, path: string = "/"): string {
const scheme = req.secure ? "https" : "http";
const host = req.get("host");
const base = `${scheme}://${host}`;
const url = new URL(path, base);
return url.href;
}
+17
View File
@@ -0,0 +1,17 @@
import { User } from "talk-server/models/user";
import { Request } from "talk-server/types/express";
export interface CommonContextOptions {
user?: User;
req?: Request;
}
export default class CommonContext {
public user?: User;
public req?: Request;
constructor({ user, req }: CommonContextOptions) {
this.user = user;
this.req = req;
}
}
@@ -0,0 +1,39 @@
import { DirectiveResolverFn } from "graphql-tools";
import CommonContext from "talk-server/graph/common/context";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
export interface AuthDirectiveArgs {
roles?: GQLUSER_ROLE[];
userIDField?: string;
}
const auth: DirectiveResolverFn<
Record<string, string | undefined>,
CommonContext
> = (next, src, { roles, userIDField }: AuthDirectiveArgs, { user }) => {
// If there is a user on the request.
if (user) {
// If the role and user owner checks are disabled, then allow them based on
// their authenticated status.
if (!roles && !userIDField) {
return next();
}
// And the user has the expected role.
if (roles && roles.includes(user.role)) {
// Let the request continue.
return next();
}
// Or the item is owned by the specific user.
if (userIDField && src[userIDField] && src[userIDField] === user.id) {
return next();
}
}
// TODO: return better error.
throw new Error("not authorized");
};
export default auth;
@@ -5,7 +5,7 @@ import {
GraphQLOptions,
} from "apollo-server-express";
import { FieldDefinitionNode, GraphQLError, ValidationContext } from "graphql";
import { Config } from "talk-server/config";
import { Config } from "talk-common/config";
// Sourced from: https://github.com/apollographql/apollo-server/blob/958846887598491fadea57b3f9373d129300f250/packages/apollo-server-core/src/ApolloServer.ts#L46-L57
const NoIntrospection = (context: ValidationContext) => ({
@@ -0,0 +1,136 @@
import { Kind } from "graphql";
import { DateTime } from "luxon";
import Cursor from "./cursor";
describe("parseLiteral", () => {
it("parses a date from a string", () => {
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "2018-07-16T18:34:26.744Z",
})
).toBeInstanceOf(Date);
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "this-should-fail",
})
).toEqual(null);
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "",
})
).toEqual(null);
});
it("parses a number from a string", () => {
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "20",
})
).toEqual(20);
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "0",
})
).toEqual(0);
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "null",
})
).toEqual(null);
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "0",
})
).toEqual(0);
});
it("parses a number from a number", () => {
expect(
Cursor.parseLiteral({
kind: Kind.INT,
value: "20",
})
).toEqual(20);
expect(
Cursor.parseLiteral({
kind: Kind.INT,
value: "0",
})
).toEqual(0);
expect(
Cursor.parseLiteral({
kind: Kind.INT,
value: "",
})
).toEqual(null);
});
it("does not parse unknown kinds", () => {
expect(
Cursor.parseLiteral({
kind: Kind.FLOAT,
value: "0.0",
})
).toEqual(null);
});
});
describe("serialize", () => {
it("renders native dates correctly", () => {
const date = new Date();
const expected = date.toISOString();
expect(Cursor.serialize(date)).toEqual(expected);
expect(Cursor.serialize({})).toEqual(null);
});
it("renders luxon dates correctly", () => {
const date = DateTime.fromJSDate(new Date());
const expected = date.toISO();
expect(Cursor.serialize(date)).toEqual(expected);
});
it("renders numbers correctly", () => {
let value = 50;
let expected = "50";
expect(Cursor.serialize(value)).toEqual(expected);
value = 0;
expected = "0";
expect(Cursor.serialize(value)).toEqual(expected);
expect(Cursor.serialize(null)).toEqual(null);
});
});
describe("parseValue", () => {
it("parses the string value of a Date", () => {
const date = new Date();
const expected = date.toISOString();
expect(Cursor.parseValue(expected)).toBeInstanceOf(Date);
});
it("parses the string value of a number", () => {
expect(Cursor.parseValue("0")).toEqual(0);
});
it("handles invalid properties", () => {
expect(Cursor.parseValue(null)).toEqual(null);
expect(Cursor.parseValue(2)).toEqual(null);
});
});
@@ -1,11 +1,15 @@
import { GraphQLScalarType } from "graphql";
import { Kind } from "graphql/language";
import { DateTime } from "luxon";
import { Cursor } from "talk-server/models/connection";
function parseIntegerCursor(value: string): number | null {
try {
const cursor = parseInt(value, 10);
if (isNaN(cursor)) {
return null;
}
return cursor;
} catch (err) {
@@ -1,5 +1,5 @@
import { RedisPubSub } from "graphql-redis-subscriptions";
import { Config } from "talk-server/config";
import { Config } from "talk-common/config";
import { createRedisClient } from "talk-server/services/redis";
export async function createPubSub(config: Config): Promise<RedisPubSub> {
+11 -5
View File
@@ -1,13 +1,19 @@
import { Db } from "mongodb";
import CommonContext from "talk-server/graph/common/context";
import { Request } from "talk-server/types/express";
export interface ManagementContextOptions {
db: Db;
mongo: Db;
req?: Request;
}
export default class ManagementContext {
public db: Db;
export default class ManagementContext extends CommonContext {
public mongo: Db;
constructor({ db }: ManagementContextOptions) {
this.db = db;
constructor({ req, mongo }: ManagementContextOptions) {
super({ req });
this.mongo = mongo;
}
}
@@ -1,13 +1,14 @@
import { GraphQLSchema } from "graphql";
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import { Config } from "talk-common/config";
import { graphqlMiddleware } from "talk-server/graph/common/middleware";
import { Request } from "talk-server/types/express";
import Context from "./context";
import ManagementContext from "./context";
export default (schema: GraphQLSchema, config: Config, db: Db) =>
graphqlMiddleware(config, async () => ({
export default (schema: GraphQLSchema, config: Config, mongo: Db) =>
graphqlMiddleware(config, async (req: Request) => ({
schema,
context: new Context({ db }),
context: new ManagementContext({ req, mongo }),
}));
@@ -1,5 +1,5 @@
import Cursor from "../../common/scalars/cursor";
import { GQLResolver } from "talk-server/graph/management/schema/__generated__/types";
export default {
Cursor,
};
const Resolvers: GQLResolver = {};
export default Resolvers;
@@ -1,6 +1,8 @@
import { IResolvers } from "graphql-tools";
import { loadSchema } from "talk-common/graphql";
import resolvers from "talk-server/graph/management/resolvers";
export default function getManagementSchema() {
return loadSchema("management", resolvers);
return loadSchema("management", resolvers as IResolvers);
}
@@ -7,27 +7,22 @@ Time represented as an ISO8601 string.
"""
scalar Time
"""
Cursor represents a paginating cursor.
"""
scalar Cursor
################################################################################
## Tenant
################################################################################
type Tenant {
id: ID!
id: ID!
"""
organizationName is the name of the organization.
"""
organizationName: String
"""
organizationName is the name of the organization.
"""
organizationName: String
"""
organizationContactEmail is the email of the organization.
"""
organizationContactEmail: String
"""
organizationContactEmail is the email of the organization.
"""
organizationContactEmail: String
}
################################################################################
@@ -35,5 +30,5 @@ type Tenant {
################################################################################
type Query {
tenant(id: ID!): Tenant
tenant(id: ID!): Tenant
}
+28 -6
View File
@@ -1,27 +1,49 @@
import { Redis } from "ioredis";
import { Db } from "mongodb";
import CommonContext from "talk-server/graph/common/context";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
import { Request } from "talk-server/types/express";
import loaders from "./loaders";
import mutators from "./mutators";
export interface TenantContextOptions {
db: Db;
mongo: Db;
redis: Redis;
tenant: Tenant;
tenantCache: TenantCache;
req?: Request;
user?: User;
}
export default class TenantContext {
export default class TenantContext extends CommonContext {
public loaders: ReturnType<typeof loaders>;
public mutators: ReturnType<typeof mutators>;
public db: Db;
public tenant: Tenant;
public mongo: Db;
public redis: Redis;
public user?: User;
public tenant: Tenant;
public tenantCache: TenantCache;
constructor({
req,
user,
tenant,
mongo,
redis,
tenantCache,
}: TenantContextOptions) {
super({ user, req });
constructor({ user, tenant, db }: TenantContextOptions) {
this.tenant = tenant;
this.tenantCache = tenantCache;
this.user = user;
this.mongo = mongo;
this.redis = redis;
this.loaders = loaders(this);
this.mutators = mutators(this);
this.db = db;
}
}
+10 -2
View File
@@ -1,9 +1,17 @@
import DataLoader from "dataloader";
import TenantContext from "talk-server/graph/tenant/context";
import { Asset, retrieveManyAssets } from "talk-server/models/asset";
import {
Asset,
FindOrCreateAssetInput,
retrieveManyAssets,
} from "talk-server/models/asset";
import { findOrCreate } from "talk-server/services/assets";
export default (ctx: TenantContext) => ({
findOrCreate: (input: FindOrCreateAssetInput) =>
findOrCreate(ctx.mongo, ctx.tenant, input),
asset: new DataLoader<string, Asset | null>(ids =>
retrieveManyAssets(ctx.db, ctx.tenant.id, ids)
retrieveManyAssets(ctx.mongo, ctx.tenant.id, ids)
),
});
@@ -1,18 +1,54 @@
import DataLoader from "dataloader";
import Context from "talk-server/graph/tenant/context";
import {
ConnectionInput,
retrieveAssetConnection,
retrieveMany,
retrieveRepliesConnection,
AssetToCommentsArgs,
CommentToRepliesArgs,
GQLCOMMENT_SORT,
} from "talk-server/graph/tenant/schema/__generated__/types";
import {
retrieveCommentAssetConnection,
retrieveCommentRepliesConnection,
retrieveManyComments,
} from "talk-server/models/comment";
export default (ctx: Context) => ({
comment: new DataLoader((ids: string[]) =>
retrieveMany(ctx.db, ctx.tenant.id, ids)
retrieveManyComments(ctx.mongo, ctx.tenant.id, ids)
),
forAsset: (assetID: string, input: ConnectionInput) =>
retrieveAssetConnection(ctx.db, ctx.tenant.id, assetID, input),
forParent: (assetID: string, parentID: string, input: ConnectionInput) =>
retrieveRepliesConnection(ctx.db, ctx.tenant.id, assetID, parentID, input),
forAsset: (
assetID: string,
// Apply the graph schema defaults at the loader.
{
first = 10,
orderBy = GQLCOMMENT_SORT.CREATED_AT_DESC,
after,
}: AssetToCommentsArgs
) =>
retrieveCommentAssetConnection(ctx.mongo, ctx.tenant.id, assetID, {
first,
orderBy,
after,
}),
forParent: (
assetID: string,
parentID: string,
// Apply the graph schema defaults at the loader.
{
first = 10,
orderBy = GQLCOMMENT_SORT.CREATED_AT_DESC,
after,
}: CommentToRepliesArgs
) =>
retrieveCommentRepliesConnection(
ctx.mongo,
ctx.tenant.id,
assetID,
parentID,
{
first,
orderBy,
after,
}
),
});
@@ -1,9 +1,9 @@
import DataLoader from "dataloader";
import Context from "talk-server/graph/tenant/context";
import { retrieveMany, User } from "talk-server/models/user";
import { retrieveManyUsers, User } from "talk-server/models/user";
export default (ctx: Context) => ({
user: new DataLoader<string, User | null>(ids =>
retrieveMany(ctx.db, ctx.tenant.id, ids)
retrieveManyUsers(ctx.mongo, ctx.tenant.id, ids)
),
});
+24 -4
View File
@@ -1,21 +1,41 @@
import { GraphQLSchema } from "graphql";
import { Redis } from "ioredis";
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import { Config } from "talk-common/config";
import { graphqlMiddleware } from "talk-server/graph/common/middleware";
import { Request } from "talk-server/types/express";
import TenantContext from "./context";
export default async (schema: GraphQLSchema, config: Config, db: Db) => {
export interface TenantGraphQLMiddlewareOptions {
schema: GraphQLSchema;
config: Config;
mongo: Db;
redis: Redis;
}
export default async ({
schema,
config,
mongo,
redis,
}: TenantGraphQLMiddlewareOptions) => {
return graphqlMiddleware(config, async (req: Request) => {
// Load the tenant and user from the request.
const { tenant, user } = req;
const { tenant, user, tenantCache } = req;
// Return the graph options.
return {
schema,
context: new TenantContext({ db, tenant: tenant!, user }),
context: new TenantContext({
req,
mongo,
redis,
tenant: tenant!,
user,
tenantCache,
}),
};
});
};

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