Compare commits

..
8 Commits
Author SHA1 Message Date
Wyatt Johnson 22d263c6d2 Embed Optimizations (#2121)
* feat: refactored bridge

- Cleaned up implementation of bridge
- Removed unneeded babel-polyfill and webpack globals

* fix: fixed up some static headers

- bumped version

* fix: moved intersection observer out of chunk

* fix: fixed misplaced "!"
2018-12-13 20:41:40 +00:00
Wyatt Johnson d331bdee18 fix: fixed misplaced "!" 2018-12-12 12:59:54 -07:00
Wyatt Johnson d6f6fbcd9c CDN + Observabilities fixes (#2119)
* feat: added heroku fixes

- Support pulling the request id from the request
  headers
- Add proper cache headers to static files

* Update package.json
2018-12-12 18:45:50 +00:00
immber 1ed5df71da fix bug in cli-setup (#2116)
* fix bug in cli-setup

* fix cli setup bug

* fix: fixed password bug
2018-12-12 00:20:16 +00:00
Kim Gardner f1a3a5ca28 Lazy Load Fix (#2110)
* Bump version 4.6.8

* fix: added JS fix for static URL's
2018-12-06 22:45:52 +00:00
Wyatt Johnson 8ef44dc6f5 fix: dep order 2018-12-06 14:41:39 -07:00
Kiwi 362f29f77e Support lazy rendering (#2109)
* feat: support lazy rendering

* fix: added new env var to onbuild args

* review: apply suggestions
2018-12-06 21:24:31 +00:00
immber 08d342ea2e Docs update (#2105)
* merging in typo fixes from docs (rtd) branch

* revert generateIntrospectionResult.json

* revert git ignore

* added asset mgmt to prelaunch checklist

* updated cusotm plugin docker onbuild instructions

* add google to auth options

* add google to auth options

* added yarn watch to from source

* added dev links to quickstart

* typo on following

* fixed typos

* fixing links
2018-12-03 19:47:46 +00:00
24 changed files with 656 additions and 414 deletions
+3 -3
View File
@@ -41,9 +41,9 @@ integration_job: &integration_job
- attach_workspace:
at: ~/coralproject/talk
- <<: *create_indexes
- run:
name: Setup the database with defaults
command: ./bin/cli setup --defaults
# - run:
# name: Setup the database with defaults
# command: ./bin/cli setup --defaults
- run:
name: Run the integration tests
command: bash .circleci/e2e.sh
+1
View File
@@ -11,6 +11,7 @@ ONBUILD ARG TALK_DEFAULT_LANG=en
ONBUILD ARG TALK_WHITELISTED_LANGUAGES
ONBUILD ARG TALK_PLUGINS_JSON
ONBUILD ARG TALK_WEBPACK_SOURCE_MAP
ONBUILD ARG TALK_DEFAULT_LAZY_RENDER
# Bundle app source
ONBUILD COPY . /usr/src/app
+37 -31
View File
@@ -45,11 +45,10 @@ const performSetup = async () => {
} catch (err) {
// If the error is `not init`, then we're good, otherwise, it's something
// else.
if (err instanceof ErrSettingsNotInit) {
if (!err instanceof ErrSettingsNotInit) {
throw err;
return;
}
throw err;
}
if (program.defaults) {
@@ -138,7 +137,7 @@ const performSetup = async () => {
console.log("\nWe'll ask you some questions about your first admin user.\n");
let user = await inquirer.prompt([
let { username, email } = await inquirer.prompt([
{
type: 'input',
name: 'username',
@@ -161,39 +160,46 @@ const performSetup = async () => {
return 'Email is required';
},
},
{
name: 'password',
message: 'Password',
type: 'password',
filter: password => {
return UsersService.isValidPassword(password).catch(err => {
throw err.message;
});
},
},
{
name: 'confirmPassword',
message: 'Confirm Password',
type: 'password',
filter: (confirmPassword, { password }) => {
if (password !== confirmPassword) {
return Promise.reject(new Error('Passwords do not match'));
}
return UsersService.isValidPassword(confirmPassword).catch(err => {
throw err.message;
});
},
},
]);
let password = '';
while (!password) {
answers = await inquirer.prompt([
{
name: 'password',
message: 'Password',
type: 'password',
filter: password => {
try {
UsersService.isValidPassword(password);
} catch (err) {
throw err.message;
}
return password;
},
},
{
name: 'confirmPassword',
message: 'Confirm Password',
type: 'password',
},
]);
if (answers.password !== answers.confirmPassword) {
console.error('Passwords do not match');
} else {
password = answers.password;
}
}
const ctx = Context.forSystem();
let { user: newUser } = await SetupService.setup(ctx, {
settings: settings.toObject(),
user: {
email: user.email,
username: user.username,
password: user.password,
email,
username,
password,
},
});
+324
View File
@@ -0,0 +1,324 @@
import queryString from 'querystringify';
import URLSearchParams from '@ungap/url-search-params';
import pym from 'pym.js';
import EventEmitter from 'eventemitter2';
import { buildUrl } from 'coral-framework/utils/url';
import SnackBar from './SnackBar';
import onIntersect from './onIntersect';
import {
createStorage,
connectStorageToPym,
} from 'coral-framework/services/storage';
// Rebuild the origin if it isn't defined. This is our poor-mans polyfill
// for the location APIs.
if (!window.location.origin) {
window.location.origin = `${window.location.protocol}//${
window.location.hostname
}${window.location.port ? `:${window.location.port}` : ''}`;
}
const NOTIFICATION_OFFSET = 200;
// Ensure there is a trailing slash.
function ensureEndSlash(p) {
return p.match(/\/$/) ? p : `${p}/`;
}
// Build the URL to load in the pym iframe.
function buildStreamIframeUrl(talkBaseUrl, query) {
let url = talkBaseUrl + 'embed/stream?';
url += queryString.stringify(query);
return url;
}
// detectAssetURL will try to grab the canonical url from the head, if it isn't
// available, source the url from the current one.
function detectAssetURL() {
try {
// Try to get the url from the canonical tag on the page.
return document.querySelector('link[rel="canonical"]').href;
} catch (e) {
window.console.warn(
'This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.'
);
return window.location.origin + window.location.pathname;
}
}
function buildQuery({ asset_id, asset_url }) {
// Compose the query to send down to the Talk API so it knows what to load.
const query = {};
// Parse the url parameters to extract some of the information.
const search = new URLSearchParams(window.location.search);
// Pull the Comment ID out of the query string.
const commentID = search.get('commentId') || search.get('commentID');
if (commentID) {
query.comment_id = commentID;
}
// Insert the asset_id into the query.
if (asset_id) {
query.asset_id = asset_id;
}
// If the asset_url is defined, use it, otherwise, detect it.
if (asset_url) {
query.asset_url = asset_url;
} else {
query.asset_url = detectAssetURL();
}
return query;
}
// Get dimensions of viewport.
function viewportDimensions() {
let target = window;
let prefix = 'inner';
if (!('innerWidth' in window)) {
prefix = 'client';
target = document.documentElement || document.body;
}
return {
width: target[`${prefix}Width`],
height: target[`${prefix}Height`],
};
}
export default class Bridge {
constructor(
element,
{
// Pull out the URLs used to setup Talk.
talk: talkBaseUrl,
talkStaticUrl = talkBaseUrl,
// Default the following to null.
events = null,
snackBarStyles = null,
onAuthChanged = null,
// Determine if we're in lazy mode or not. By default, the build argument
// will determine the lazy render status. This default is primarily used
// when the embed code cannot be changed, but control of the Talk serving
// domain is available.
lazy = process.env.TALK_DEFAULT_LAZY_RENDER === 'TRUE',
// Any additional options are extracted to be sent to the embed via the
// pym bridge.
...opts
}
) {
this.pym = null;
this.element = element;
this.opts = opts;
this.query = buildQuery(this.opts);
this.emitter = new EventEmitter({ wildcard: true });
this.snackBar = new SnackBar(snackBarStyles || {});
this.onAuthChanged = onAuthChanged;
this.talkBaseUrl = ensureEndSlash(talkBaseUrl);
this.talkStaticUrl = ensureEndSlash(talkStaticUrl);
this.lazy = lazy;
// Store queued operations in a queue that can be processed once the stream
// is rendered.
this.queued = [];
// Attach to the events emitted by the pym parent.
if (events) {
events(this.emitter);
}
// Start the embed loading process.
if (this.lazy) {
// When the dom element containing the talk embed container is in view,
// render the stream with force turned on so that it skips this portion.
onIntersect(this.element, () => this.load());
} else {
// We aren't being lazy, load it now!
this.load();
}
}
ensureRendered() {
// Check to see if the pym bridge is created, and the embed is loaded.
if (this.pym === null) {
throw new Error('Stream Embed must be rendered first');
}
}
queueWhenRendered(callback) {
// Check to see if the queue is alive, if it isn't, run the callback now,
// otherwise, push the callback to be processed when the stream has loaded.
if (this.queued !== null) {
this.queued.push(callback);
} else {
callback();
}
}
setupPym() {
const url = buildStreamIframeUrl(this.talkBaseUrl, this.query);
this.pym = new pym.Parent(this.element.id, url, {
title: this.opts.title,
id: `${this.element.id}_iframe`,
name: `${this.element.id}_iframe`,
});
// NOTE: Workaround for iOS Safari which ignores `width` but respects `min-width` value.
this.pym.el.firstChild.style.width = '1px';
this.pym.el.firstChild.style.minWidth = '100%';
// Resize parent iframe height when child height changes
let cachedHeight;
this.pym.onMessage('height', height => {
if (height !== cachedHeight) {
this.pym.el.firstChild.style.height = `${height}px`;
cachedHeight = height;
}
});
// Send the config back over the pym bridge if requested.
this.pym.onMessage('getConfig', () => {
this.pym.sendMessage('config', JSON.stringify(this.opts));
});
// If the auth changes, and someone is listening for it, then re-emit it.
if (this.onAuthChanged) {
this.pym.onMessage('coral-auth-changed', message => {
this.onAuthChanged(message ? JSON.parse(message) : null);
});
}
// Remove the permalink comment id from the search.
this.pym.onMessage('coral-view-all-comments', () => {
const query = queryString.parse(location.search);
// Remove the commentId/commentID url param.
delete query.commentId;
delete query.commentID;
// Rebuild the search field without the commentId in it.
const search = queryString.stringify(query);
// Change the url.
window.history.replaceState(
{},
document.title,
buildUrl({ ...location, search })
);
});
// Remove the permalink comment id from the hash.
this.pym.onMessage('coral-view-comment', id => {
const search = queryString.stringify({
...queryString.parse(location.search),
commentId: id,
});
// Change the url to the permalink url.
window.history.replaceState(
{},
document.title,
buildUrl({ ...location, search })
);
});
// Helps child show notifications at the right scrollTop.
this.pym.onMessage('getPosition', () => {
const { height } = viewportDimensions();
let position = height + document.body.scrollTop;
if (position > NOTIFICATION_OFFSET) {
position = position - NOTIFICATION_OFFSET;
}
this.pym.sendMessage('position', position);
});
// When end-user clicks link in iframe, open it in parent context
this.pym.onMessage('navigate', url => {
// Open the new window, detach the opener, and focus on it.
const w = window.open(url, '_blank');
w.opener = null;
w.focus();
});
// Pass events from iframe to the event emitter.
this.pym.onMessage('event', raw => {
const { eventName, value } = JSON.parse(raw);
this.emitter.emit(eventName, value);
});
}
/**
* load will configure the pym parent if it hasn't already been, and setup the
* snackBar. If there are any queued operations, it will run them first.
*/
load() {
if (this.pym !== null) {
throw new Error('Stream Embed already rendered');
}
// Setup Pym.
this.setupPym();
// Attach the snackBar to the pym parent and to the body of the page.
this.snackBar.attach(window.document.body, this.pym);
// If the user clicks outside the embed, then tell the embed.
document.addEventListener('click', this.handleClick.bind(this), true);
// Listens to ${name}Storage requests on pym and relay it to
// ${name}Storage.
['local', 'session'].forEach(name => {
connectStorageToPym(
createStorage(`${name}Storage`),
this.pym,
`${name}Storage`
);
});
// Process any queued operations.
const queued = this.queued;
this.queued = null;
queued.forEach(callback => callback());
}
handleClick() {
this.pym.sendMessage('click');
}
enablePluginsDebug() {
this.pym.sendMessage('enablePluginsDebug');
}
disablePluginsDebug() {
this.pym.sendMessage('disablePluginsDebug');
}
login(token) {
this.pym.sendMessage('login', token);
}
logout() {
this.pym.sendMessage('logout');
}
remove() {
// Remove the event listeners.
document.removeEventListener('click', this.handleClick.bind(this));
this.emitter.removeAllListeners();
// Remove the snackbar.
this.snackBar.remove();
// Remove the pym parent.
this.pym.remove();
this.pym = null;
}
}
-196
View File
@@ -1,196 +0,0 @@
import queryString from 'querystringify';
import pym from 'pym.js';
import EventEmitter from 'eventemitter2';
import { buildUrl } from 'coral-framework/utils/url';
import Snackbar from './Snackbar';
import {
createStorage,
connectStorageToPym,
} from 'coral-framework/services/storage';
const NOTIFICATION_OFFSET = 200;
// Build the URL to load in the pym iframe.
function buildStreamIframeUrl(talkBaseUrl, query) {
let url = [
talkBaseUrl,
talkBaseUrl.match(/\/$/) ? '' : '/', // make sure no double-'/' if opts.talk already ends with '/'
'embed/stream?',
].join('');
url += queryString.stringify(query);
return url;
}
// Get dimensions of viewport.
function viewportDimensions() {
let e = window,
a = 'inner';
if (!('innerWidth' in window)) {
a = 'client';
e = document.documentElement || document.body;
}
return {
width: e[`${a}Width`],
height: e[`${a}Height`],
};
}
export default class Stream {
constructor(el, talkBaseUrl, query, config) {
this.query = query;
// Extract the non-opts opts from the object.
const {
events = null,
snackBarStyles = null,
onAuthChanged = null,
...opts
} = config;
this.opts = opts;
this.emitter = new EventEmitter({ wildcard: true });
this.pym = new pym.Parent(el.id, buildStreamIframeUrl(talkBaseUrl, query), {
title: opts.title,
id: `${el.id}_iframe`,
name: `${el.id}_iframe`,
});
this.snackBar = new Snackbar(snackBarStyles || {});
// Workaround: IOS Safari ignores `width` but respects `min-width` value.
this.pym.el.firstChild.style.width = '1px';
this.pym.el.firstChild.style.minWidth = '100%';
// Resize parent iframe height when child height changes
let cachedHeight;
this.pym.onMessage('height', height => {
if (height !== cachedHeight) {
this.pym.el.firstChild.style.height = `${height}px`;
cachedHeight = height;
}
});
// Attach to the events emitted by the pym parent.
if (events) {
events(this.emitter);
}
this.pym.onMessage('getConfig', () => {
this.pym.sendMessage('config', JSON.stringify(opts));
});
// If the auth changes, and someone is listening for it, then re-emit it.
if (onAuthChanged) {
this.pym.onMessage('coral-auth-changed', message => {
onAuthChanged(message ? JSON.parse(message) : null);
});
}
// Attach the snackbar to the pym parent and to the body of the page.
this.snackBar.attach(window.document.body, this.pym);
// Remove the permalink comment id from the hash.
this.pym.onMessage('coral-view-all-comments', () => {
const query = queryString.parse(location.search);
// Remove the commentId url param.
delete query.commentId;
const search = queryString.stringify(query);
const url = buildUrl({ ...location, search });
// Change the url.
window.history.replaceState({}, document.title, url);
});
// Remove the permalink comment id from the hash.
this.pym.onMessage('coral-view-comment', id => {
const search = queryString.stringify({
...queryString.parse(location.search),
commentId: id,
});
// Remove the commentId url param.
const url = buildUrl({ ...location, search });
// Change the url.
window.history.replaceState({}, document.title, url);
});
// Helps child show notifications at the right scrollTop.
this.pym.onMessage('getPosition', () => {
const { height } = viewportDimensions();
let position = height + document.body.scrollTop;
if (position > NOTIFICATION_OFFSET) {
position = position - NOTIFICATION_OFFSET;
}
this.pym.sendMessage('position', position);
});
// When end-user clicks link in iframe, open it in parent context
this.pym.onMessage('navigate', url => {
window.open(url, '_blank').focus();
});
// Pass events from iframe to the event emitter.
this.pym.onMessage('event', raw => {
const { eventName, value } = JSON.parse(raw);
this.emitter.emit(eventName, value);
});
// If the user clicks outside the embed, then tell the embed.
document.addEventListener('click', this.handleClick.bind(this), true);
// Listens to local storage requests on pym and relay it to local storage.
connectStorageToPym(
createStorage('localStorage'),
this.pym,
'localStorage'
);
// Listens to session storage requests on pym and relay it to session storage.
connectStorageToPym(
createStorage('sessionStorage'),
this.pym,
'sessionStorage'
);
}
enablePluginsDebug() {
this.pym.sendMessage('enablePluginsDebug');
}
disablePluginsDebug() {
this.pym.sendMessage('disablePluginsDebug');
}
login(token) {
this.pym.sendMessage('login', token);
}
logout() {
this.pym.sendMessage('logout');
}
remove() {
// Remove the event listeners.
document.removeEventListener('click', this.handleClick.bind(this));
this.emitter.removeAllListeners();
// Remove the snackbar.
this.snackBar.remove();
// Remove the pym parent.
this.pym.remove();
}
handleClick() {
this.pym.sendMessage('click');
}
}
-33
View File
@@ -1,33 +0,0 @@
export default class StreamInterface {
constructor(stream) {
this._stream = stream;
}
on(eventName, callback) {
return this._stream.emitter.on(eventName, callback);
}
off(eventName, callback) {
return this._stream.emitter.off(eventName, callback);
}
login(token) {
return this._stream.login(token);
}
logout() {
return this._stream.logout();
}
remove() {
return this._stream.remove();
}
enablePluginsDebug() {
return this._stream.enablePluginsDebug();
}
disablePluginsDebug() {
return this._stream.disablePluginsDebug();
}
}
+55 -90
View File
@@ -1,106 +1,71 @@
import URLSearchParams from 'url-search-params';
import Stream from './Stream';
import StreamInterface from './StreamInterface';
// Polyfill IntersectionObserver always, the alternative is that we have to also
// polyfill for Promise, which itself adds 1KB gziped, which means that the
// 4KB that the intersection observer really doesn't take up that much in terms
// of size.
import 'intersection-observer';
// Rebuild the origin if it isn't defined. This is our poor-mans polyfill
// for the location API's.
if (!window.location.origin) {
window.location.origin = `${window.location.protocol}//${
window.location.hostname
}${window.location.port ? `:${window.location.port}` : ''}`;
}
import Bridge from './Bridge';
import wrapBridge from './wrapBridge';
// parses the Asset URL from the config variable
function parseAssetURL() {
try {
// Try to get the url from the canonical tag on the page.
return document.querySelector('link[rel="canonical"]').href;
} catch (e) {
window.console.warn(
'This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.'
/**
* validateElement will throw an error when the element is not configured
* correctly or is not an HTMLElement at all.
*
* @param {HTMLElement} element the HTMLElement where the stream will be rendered
*/
function validateElement(element) {
if (!element) {
throw new Error(
'Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.'
);
}
return window.location.origin + window.location.pathname;
if (typeof element !== 'object') {
throw new Error(
`Coral.Talk.render() expected HTMLElement but got ${element} (${typeof element})`
);
}
// Ensure element has an id, as pym can't directly accept the HTMLElement.
if (!element.id) {
element.id = `_${Math.random()}`;
}
}
export class Talk {
/**
* validateConfig is the configuration validation tool.
*
* @param {Object} config the configuration that will be used to setup Talk.
*/
function validateConfig(config) {
if (!config || typeof config !== 'object' || !config.talk) {
throw new Error(
'Coral.Talk.render() expected configuration with at least opts.talk as the Talk Base URL, none found'
);
}
}
export const Talk = {
/**
* Render a Talk stream
* @param {HTMLElement} element - Element to render the stream in
* @param {Object} config - Configuration options for talk
* @param {String} config.talk - Talk base URL
* @param {String} [config.title] - Title of Stream (rendered in iframe)
* @param {String} [config.asset_url] - Asset URL
* @param {String} [config.asset_id] - Asset ID
* @param {String} config.talk - URL to the Talk installation
* @param {String} [config.asset_id] - (optional) ID for the Asset
* @param {String} [config.asset_url] - (optional) URL where the Asset is located
* @param {String} [config.auth_token] - (optional) A jwt representing the session
* @param {String} [config.lazy] - (optional) If set the stream will only render lazily
* @param {String} [config.talkStaticUrl] - (optional) Static URL used to serve Talk
* @return {Object}
*
* Example:
* ```
* const embed = Talk.render(document.getElementById('talkStreamEmbed'), config);
*
* // trigger a login with optional token.
* embed.login(token);
*
* // trigger a logout.
* embed.logout();
*
* // listen to events (in this case all events).
* embed.on('**', function(value) {
* console.log(this.event, value);
* });
* ```
*/
static render(element, config) {
if (!element) {
throw new Error(
'Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.'
);
}
if (typeof element !== 'object') {
throw new Error(
`Coral.Talk.render() expected HTMLElement but got ${element} (${typeof element})`
);
}
if (!config || typeof config !== 'object' || !config.talk) {
throw new Error(
'Coral.Talk.render() expected configuration with at least opts.talk as the Talk Base URL, none found'
);
}
render: (element, config) => {
// Validate the element.
validateElement(element);
// Ensure el has an id, as pym can't directly accept the HTMLElement.
if (!element.id) {
element.id = `_${Math.random()}`;
}
// Validate the configuration.
validateConfig(config);
// Compose the query to send down to the Talk API so it knows what to load.
const query = {};
// Parse the url parameters to extract some of the information.
const search = new URLSearchParams(window.location.search);
// Pull the commentID out from the query params.
const commentID = search.get('commentId') || search.get('commentID');
if (commentID) {
query.comment_id = commentID;
}
// Extract the asset id from the options.
if (config.asset_id) {
query.asset_id = config.asset_id;
}
// Parse the Asset URL.
query.asset_url = config.asset_url;
if (!query.asset_url) {
query.asset_url = parseAssetURL();
}
// Create the new Stream.
const stream = new Stream(element, config.talk, query, config);
// Return the public interface for the stream.
return new StreamInterface(stream);
}
}
// Create the new Bridge, and wrap it up.
return wrapBridge(new Bridge(element, config));
},
};
+30
View File
@@ -0,0 +1,30 @@
export default function onIntersect(el, callback) {
// Ensure that the intersection observer is available.
if (!IntersectionObserver) {
// tslint:disable-next-line:no-console
window.console.warn('IntersectionObserver not available, rendering now');
callback();
return;
}
// Create the Intersection Observer that will wait till the embed is within
// view and will then call the callback.
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting) {
// Stop receiving intersection events.
observer.disconnect();
// Fire the callback.
callback();
}
},
{
rootMargin: '100px',
threshold: 1.0,
}
);
// Start observing the element for visibility.
observer.observe(el);
}
+31
View File
@@ -0,0 +1,31 @@
export default bridge => {
// whenRendered will call the callback when the stream has rendered (or now if
// the stream is already rendered).
const whenRendered = callback => bridge.queueWhenRendered(callback);
// onlyWhenRendered will guard the callback unless the stream is rendered.
const onlyWhenRendered = callback => {
bridge.ensureRendered();
callback();
};
// Return the limited stream interface.
return {
// Directly map the on/off from the event emitter to the stream.
on: (eventName, callback) => bridge.emitter.on(eventName, callback),
off: (eventName, callback) => bridge.emitter.off(eventName, callback),
// Queue up the login operation until the stream has been rendered.
login: token => whenRendered(() => bridge.login(token)),
// Queue up the logout operation until the stream has been rendered.
logout: () => whenRendered(() => bridge.logout()),
// Remove the stream if it's already been rendered.
remove: () => onlyWhenRendered(() => bridge.remove()),
// Queue up the plugin config until the embed has rendered.
enablePluginsDebug: () => whenRendered(() => bridge.enablePluginsDebug()),
disablePluginsDebug: () => whenRendered(() => bridge.disablePluginsDebug()),
};
};
+12
View File
@@ -76,6 +76,10 @@ const CONFIG = {
// on the scraper when it makes requests.
SCRAPER_HEADERS: process.env.TALK_SCRAPER_HEADERS || '{}',
// HTTP_X_REQUEST_ID is a string which represents the request header where we
// should source the request ID from, otherwise, a new one will be generated.
HTTP_X_REQUEST_ID: process.env.TALK_HTTP_X_REQUEST_ID || null,
//------------------------------------------------------------------------------
// JWT based configuration
//------------------------------------------------------------------------------
@@ -220,6 +224,14 @@ const CONFIG = {
CACHE_EXPIRY_COMMENT_COUNT:
process.env.TALK_CACHE_EXPIRY_COMMENT_COUNT || '1hr',
// EMBED_EXPIRY_TIME is the time that the embed will be cacheable for, sent as
// the max-age= directive on the Cache-Control header.
EMBED_EXPIRY_TIME: ms(process.env.TALK_EMBED_EXPIRY || '24hr'),
// EMBED_EXPIRY_TIME is the time that the rest of the static files will be
// cacheable for, sent as the max-age= directive on the Cache-Control header.
STATIC_EXPIRY_TIME: ms(process.env.TALK_STATIC_EXPIRY || '1w'),
//------------------------------------------------------------------------------
// Recaptcha configuration
//------------------------------------------------------------------------------
+13
View File
@@ -187,8 +187,10 @@ and walk through the initial setup steps.
* First, enter your **Organization Name** and **Organization Contact Email**. This will appear in emails when inviting new team members.
* Next, create your Admin user. You can specify an **Email Address**, **Username**, and **Password**
* Finally, enter your list of **Permitted Domains**, read [here](/talk/configuring-talk/#permitted-domains) about whitelisting domains
_During development, ensure you whitelist 127.0.0.1:3000 otherwise the
[http://127.0.0.1:3000/](http://127.0.0.1:3000/) page will not
load._
@@ -220,6 +222,17 @@ Once you have added the domain of these docs, you can click the button below.
<div class="mount"></div>
</div>
### Developer Endpoints
With your local instance of Talk running in development mode (env variable `NODE_ENV=development`) you should now also be able to access the following developer routes:
* [http://127.0.0.1:3000/dev](http://127.0.0.1:3000/dev]) provides a sample comment stream
* [http://127.0.0.1:3000/dev/assets](http://127.0.0.1:3000/dev/assets]) provides a list of all stories in Talk and can generate new sample assets
### Conclusion
At this point you've successfully installed, configured, and ran your very own
instance of Talk! Continue through this documentation on this site to learn more
on how to configure, develop with, and contribute to Talk!
@@ -70,5 +70,7 @@ You can now start the application by running:
yarn watch:server
```
If you are developing a custom plugin you can use `yarn watch:client` or `yarn watch` to run both client and server.
At this stage, you should refer to the [configuration](/talk/configuration/) for
configuration variables that are specific to your installation.
+3 -1
View File
@@ -23,11 +23,13 @@ permalink: /pre-launch-checklist/
- [ ] Do you need to migrate comments from a legacy system? We currently support Disqus, Livefyre, and Civil Comments.
- Use the [Talk Import](https://github.com/coralproject/talk-importer) framework
- [ ] Do you want to provide single sign-on (SSO) by integrating with an external auth system?
- See [Authenticating with Talk](/talk/integrating/authentication/)
- [ ] Do you want to integrate Talk with your CMS to automate embedding Talk Comment Stream into your site?
- See [Asset Management](/talk/integrating/asset-management/)
- [ ] Do you want to use Social sign-on?
- Facebook
@@ -5,13 +5,15 @@ permalink: /commenter-features/
## Signing up for Talk
There are 2 ways that newsrooms can support signup/login functionality with Talk:
There are 3 ways that newsrooms can support signup/login functionality with Talk:
* Use Talks auth plugin out of the box (supports account registration with username and password, as well as features like forgot password)
* Use 3rd party authentication provider such as FaceBook or Google. We provide plugins that support logging in with either [Facebook](/talk/plugin/talk-plugin-facebook-auth/)
or [Google](/talk/plugin/talk-plugin-google-auth/). (Note: you must provide your own Facebook App ID and Secret, which you can read more about here: [https://developers.facebook.com](https://developers.facebook.com))
* Create their own auth plugin to integrate with your own auth systems
We also provide a Facebook auth plugin that supports logging in with Facebook (you must provide your own Facebook App ID and Secret, which you can read more about here: [https://developers.facebook.com](https://developers.facebook.com))
## Comments and Replies
+2 -1
View File
@@ -18,6 +18,7 @@ By default, Talk will use "Lazy Asset Creation" to dynamically generate Assets
in Talk in order to make it easier for lighter installations. In order to have
more strict control over this flow, we will create a plugin that will:
1. Disable "Lazy Asset Creation" by [Overriding a Resolver](#overriding-a-resolver).
2. Create Assets from our CMS by [Creating a New Asset Route](#creating-a-new-asset-route).
3. Facilitate updates from our CMS to keep Talk in sync by [Creating an Asset Update Route](#creating-an-asset-update-route).
@@ -248,7 +249,7 @@ module.exports = router => {
};
```
As you can see from the previous step of [Creating a New Asset Route](#creating-a-New-Asset-Route)
As you can see from the previous step of [Creating a New Asset Route](#Creating%20a%20New%20Asset%20Route)
, we have added the new `PUT` route to the router. This is a simple addition
that allows your CMS to call into Talk when the asset has updated it's title,
it's url (or really anything in the [AssetSchema](https://github.com/coralproject/talk/blob/master/models/asset.js)) to keep the Talk Admin and links up to date.
+10 -4
View File
@@ -3,8 +3,10 @@ title: Authenticating with Talk
permalink: /integrating/authentication/
---
You can integrate Talk with any external authentication service that will enable
seamless single sign-on for users within your organization. There are a few
Out of the box Talk supports account registration with username and password, as well as features like forgot password.
You can also integrate Talk with any external authentication service that will enable
seamless single sign-on (SSO) for users within your organization. There are a few
methods of doing so:
1. Passport Middleware
@@ -17,8 +19,12 @@ choice.
You would choose the **Passport Middleware** route when you are OK using an auth
that is triggered from inside Talk that is not connected to an external auth
state (you don't use the auth anywhere else now). A great example of this is our
[talk-plugin-facebook-auth](/talk/plugin/talk-plugin-facebook-auth/) plugin.
state (you don't use the auth anywhere else now).
Plugins are available for the following 3rd party authentication providers:
* [Facebook](/talk/plugin/talk-plugin-facebook-auth/)
* [Google](/talk/plugin/talk-plugin-google-auth/)
## Custom Token Integration
+12 -2
View File
@@ -105,6 +105,8 @@ Then the application can be started as is.
### Docker
To deploy customized plugins to a production instance of Talk, we recommend using the Docker onbuild strategy outlined below.
If you deploy using Docker, you can extend from the `*-onbuild` image, an
example `Dockerfile` for your project could be:
@@ -112,14 +114,22 @@ example `Dockerfile` for your project could be:
FROM coralproject/talk:4.5-onbuild
```
Where the directory for your instance would contain a `plugins.json` file
describing the plugin requirements and a `plugins` directory containing any
Establish a private repository for your instance that includes the following:
* a `plugins.json` file
listing the plugin requirements
* a `plugins` directory containing any
other local plugins that should be included.
* a Dockerfile as outlined above
Git submodules can also be used to point to plugin directories that might be outside your primary repository.
Onbuild triggers will execute when the image is building with your custom
configuration and will ensure that the image is ready to use by building all
assets inside the image as well.
Once built, you can deploy the docker image to your architecture by tagging the image and including it in your docker-compose.yml.
For more information on the onbuild image, refer to the
[Installation from Docker](/talk/installation-from-docker/) documentation.
+44
View File
@@ -0,0 +1,44 @@
const express = require('express');
const path = require('path');
const staticServer = require('express-static-gzip');
const { merge } = require('lodash');
const { EMBED_EXPIRY_TIME, STATIC_EXPIRY_TIME } = require('../config');
// EMBED_CACHE_CONTROL_HEADER is the header sent when the file is embed.js.
const EMBED_CACHE_CONTROL_HEADER = [
'public',
`max-age=${Math.floor(EMBED_EXPIRY_TIME / 1000)}`,
'immutable',
].join(', ');
// Define the options to be applied to all static files, the embed itself has a
// separate override.
const defaultOpts = {
maxAge: STATIC_EXPIRY_TIME,
immutable: true,
setHeaders: (res, path) => {
if (path.includes('/dist/embed.js')) {
// embed.js has a different max-age then the rest of the static files.
res.setHeader('Cache-Control', EMBED_CACHE_CONTROL_HEADER);
}
},
};
// Setup the configuration for the compression options on how to serve files.
const compressionOpts = {
indexFromEmptyFile: false,
enableBrotli: true,
customCompressions: [{ encodingName: 'deflate', fileExtension: 'zz' }],
};
// Serve the directories under ../dist.
const dist = path.resolve(path.join(__dirname, '../dist'));
/**
* middleware in production will serve compressed files if available, otherwise
* it will use express's static middleware.
*/
module.exports =
process.env.NODE_ENV === 'production'
? staticServer(dist, merge(compressionOpts, defaultOpts))
: express.static(dist, defaultOpts);
+16 -6
View File
@@ -1,11 +1,21 @@
const { HTTP_X_REQUEST_ID } = require('../config');
const uuid = require('uuid/v1');
// Trace middleware attaches a request id to each incoming request.
module.exports = (req, res, next) => {
req.id = uuid();
module.exports = HTTP_X_REQUEST_ID
? (req, res, next) => {
req.id = req.get(HTTP_X_REQUEST_ID) || uuid();
// Add the context ID to the request as an HTTP header.
res.set('X-Talk-Trace-ID', req.id);
// Add the context ID to the request as an HTTP header.
res.set('X-Talk-Trace-ID', req.id);
next();
};
next();
}
: (req, res, next) => {
req.id = uuid();
// Add the context ID to the request as an HTTP header.
res.set('X-Talk-Trace-ID', req.id);
next();
};
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "talk",
"version": "4.6.7",
"version": "4.6.10",
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
"main": "app.js",
"private": true,
@@ -58,6 +58,7 @@
"dependencies": {
"@coralproject/gql-merge": "^0.1.0",
"@coralproject/graphql-anywhere-optimized": "^0.1.0",
"@ungap/url-search-params": "^0.1.2",
"apollo-client": "^1.9.1",
"apollo-engine": "^0.8.1",
"apollo-server-express": "^1.2.0",
@@ -105,7 +106,7 @@
"eventemitter2": "^4.1.2",
"exports-loader": "^0.6.4",
"express": "4.16.0",
"express-static-gzip": "^0.3.1",
"express-static-gzip": "^1.1.1",
"extract-text-webpack-plugin": "^3.0.2",
"file-loader": "^0.11.2",
"final-form": "^4.8.1",
@@ -130,6 +131,7 @@
"imports-loader": "^0.7.1",
"inquirer": "^3.2.2",
"inquirer-autocomplete-prompt": "^0.12.1",
"intersection-observer": "^0.5.1",
"ioredis": "3.1.4",
"ip": "^1.1.5",
"jest": "^23.0.0",
@@ -210,7 +212,6 @@
"url-join": "^2.0.2",
"url-loader": "^0.6.0",
"url-parse": "^1.4.3",
"url-search-params": "^0.9.0",
"uuid": "^3.1.0",
"webpack": "^3.10.0",
"webpack-manifest-plugin": "^2.0.0-rc.2",
+9 -20
View File
@@ -10,7 +10,7 @@ const compression = require('compression');
const plugins = require('../services/plugins');
const staticTemplate = require('../middleware/staticTemplate');
const nonce = require('../middleware/nonce');
const staticServer = require('express-static-gzip');
const staticFiles = require('../middleware/staticFiles');
const { DISABLE_STATIC_SERVER } = require('../config');
const { passport } = require('../services/passport');
const { MOUNT_PATH } = require('../url');
@@ -32,6 +32,8 @@ if (!DISABLE_STATIC_SERVER) {
/**
* Redirect old embed calls.
*
* TODO: (wyattjoh) remove this on the next minor release
*/
const oldEmbed = url.resolve(MOUNT_PATH, 'embed.js');
const newEmbed = url.resolve(MOUNT_PATH, 'static/embed.js');
@@ -43,28 +45,15 @@ if (!DISABLE_STATIC_SERVER) {
});
/**
* Serve the directories under dist.
* Setup static file serving.
*/
const dist = path.resolve(path.join(__dirname, '../dist'));
if (process.env.NODE_ENV === 'production') {
router.use(
'/static',
staticServer(dist, {
indexFromEmptyFile: false,
enableBrotli: true,
customCompressions: [
{
encodingName: 'deflate',
fileExtension: 'zz',
},
],
})
);
} else {
router.use('/static', express.static(dist));
}
router.use('/static', staticFiles);
}
//==============================================================================
// Shared Middleware
//==============================================================================
// Add the i18n middleware to all routes.
router.use(i18n);
+30 -13
View File
@@ -159,6 +159,9 @@ const config = {
'process.env': {
VERSION: `"${require('./package.json').version}"`,
NODE_ENV: `${JSON.stringify(process.env.NODE_ENV)}`,
TALK_DEFAULT_LAZY_RENDER: `${JSON.stringify(
process.env.TALK_DEFAULT_LAZY_RENDER
)}`,
},
}),
new webpack.EnvironmentPlugin({
@@ -307,19 +310,32 @@ const applyConfig = (entries, root = {}) =>
config,
{
entry: entries.reduce(
(entry, { name, path: modulePath, disablePolyfill = false }) => {
const entries = [
path.join(
__dirname,
'client/coral-framework/helpers/webpackGlobals'
),
];
if (disablePolyfill) {
entries.push(modulePath);
} else {
entries.unshift('babel-polyfill');
entries.push(modulePath);
(
entry,
{
name,
path: modulePath,
disablePolyfill = false,
disableWebpackGlobals = false,
}
) => {
// Create all the entries to be added to the final build target.
const entries = [];
if (!disablePolyfill) {
entries.push('babel-polyfill');
}
if (!disableWebpackGlobals) {
entries.push(
path.join(
__dirname,
'client/coral-framework/helpers/webpackGlobals'
)
);
}
entries.push(modulePath);
entry[name] = entries;
@@ -347,7 +363,8 @@ module.exports = [
{
name: 'embed',
path: path.join(__dirname, 'client/coral-embed/src/index'),
disablePolyfill: process.env.TALK_DISABLE_EMBED_POLYFILL === 'TRUE',
disablePolyfill: true,
disableWebpackGlobals: true,
},
],
{
+14 -9
View File
@@ -221,6 +221,11 @@
dependencies:
"@types/node" "*"
"@ungap/url-search-params@^0.1.2":
version "0.1.2"
resolved "https://registry.yarnpkg.com/@ungap/url-search-params/-/url-search-params-0.1.2.tgz#8ba8c0527543fe675d1c29ae0a2daca842e8ee4f"
integrity sha512-WVk5+lJ+AoNLh2sIDMhnEAgLsVQuI067hWLJCzirErH1GYiy1gs09q4+XZxYWSvdAsslKsaO4q1iXXMx2c72dA==
JSONStream@^1.0.7:
version "1.3.3"
resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.3.tgz#27b4b8fbbfeab4e71bcf551e7f27be8d952239bf"
@@ -4277,10 +4282,10 @@ exports-loader@^0.6.4:
loader-utils "^1.0.2"
source-map "0.5.x"
express-static-gzip@^0.3.1:
version "0.3.2"
resolved "https://registry.yarnpkg.com/express-static-gzip/-/express-static-gzip-0.3.2.tgz#89ede84547a5717de3146315f62dc996c071a88d"
integrity sha512-xFOW5Lxrh4xLey5i6gGWHOFznJayGCxazUau0kq7ElUh1t7q2B6IlvWv4d3UJwJej+aXEu9os/VpzPvRchdNiA==
express-static-gzip@^1.1.1:
version "1.1.3"
resolved "https://registry.yarnpkg.com/express-static-gzip/-/express-static-gzip-1.1.3.tgz#345ea02637d9d5865777d6fb57ccc0884abcda65"
integrity sha512-k8Q4Dx4PDpzEb8kth4uiPWrBeJWJYSgnWMzNdjQUOsEyXfYKbsyZDkU/uXYKcorRwOie5Vzp4RMEVrJLMfB6rA==
dependencies:
serve-static "^1.12.3"
@@ -6042,6 +6047,11 @@ interpret@^1.0.0, interpret@^1.0.1:
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.1.0.tgz#7ed1b1410c6a0e0f78cf95d3b8440c63f78b8614"
integrity sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ=
intersection-observer@^0.5.1:
version "0.5.1"
resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.5.1.tgz#e340fc56ce74290fe2b2394d1ce88c4353ac6dfa"
integrity sha512-Zd7Plneq82kiXFixs7bX62YnuZ0BMRci9br7io88LwDyF3V43cQMI+G5IiTlTNTt+LsDUppl19J/M2Fp9UkH6g==
invariant@^2.0.0, invariant@^2.2.0, invariant@^2.2.1, invariant@^2.2.2:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
@@ -13357,11 +13367,6 @@ url-regex@~4.1.1:
ip-regex "^1.0.1"
tlds "^1.187.0"
url-search-params@^0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/url-search-params/-/url-search-params-0.9.0.tgz#e71d7764a6503533cbfe9771b2963cb61ea1c225"
integrity sha1-5x13ZKZQNTPL/pdxspY8th6hwiU=
url@^0.11.0:
version "0.11.0"
resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"