mirror of
https://github.com/wassname/talk.git
synced 2026-07-10 07:17:05 +08:00
Merge branch 'master' into feature/talk-plugin-toxic-comments
This commit is contained in:
@@ -16,7 +16,7 @@ class ConfigureStreamContainer extends Component {
|
||||
this.state = {
|
||||
changed: false,
|
||||
dirtySettings: props.asset.settings,
|
||||
closedAt: (props.asset.closedAt === null ? 'open' : 'closed')
|
||||
closedAt: !props.asset.isClosed ? 'open' : 'closed'
|
||||
};
|
||||
|
||||
this.toggleStatus = this.toggleStatus.bind(this);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pym from 'coral-framework/services/pym';
|
||||
import * as actions from '../constants/stream';
|
||||
import {buildUrl} from 'coral-framework/utils';
|
||||
import {buildUrl} from 'coral-framework/utils/url';
|
||||
import queryString from 'query-string';
|
||||
|
||||
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
|
||||
|
||||
@@ -19,6 +19,7 @@ import cn from 'classnames';
|
||||
|
||||
import {getTopLevelParent, attachCommentToParent} from '../graphql/utils';
|
||||
import AllCommentsPane from './AllCommentsPane';
|
||||
import AutomaticAssetClosure from '../containers/AutomaticAssetClosure';
|
||||
|
||||
import styles from './Stream.css';
|
||||
|
||||
@@ -101,7 +102,7 @@ class Stream extends React.Component {
|
||||
editName
|
||||
} = this.props;
|
||||
const {keepCommentBox} = this.state;
|
||||
const open = asset.closedAt === null;
|
||||
const open = !asset.isClosed;
|
||||
|
||||
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
|
||||
let highlightedComment = comment && getTopLevelParent(comment);
|
||||
@@ -141,6 +142,7 @@ class Stream extends React.Component {
|
||||
|
||||
return (
|
||||
<div id="stream" className={styles.root}>
|
||||
<AutomaticAssetClosure assetId={asset.id} closedAt={asset.closedAt}/>
|
||||
{comment &&
|
||||
<Button
|
||||
cStyle="darkGrey"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {gql} from 'react-apollo';
|
||||
|
||||
const FRAGMENT = gql`
|
||||
fragment CoralEmbedStream_AutomaticAssetClosure_Fragment on Asset {
|
||||
id
|
||||
isClosed
|
||||
}
|
||||
`;
|
||||
|
||||
function getFragmentId(assetId) {
|
||||
return `Asset_${assetId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* AutomaticAssetClosure updates the graphql state of the provide asset
|
||||
* to `isClosed=true` when passed `closedAt`.
|
||||
*/
|
||||
class AutomaticAssetClosure extends React.Component {
|
||||
static contextTypes = {
|
||||
client: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
timer = null;
|
||||
|
||||
componentWillMount() {
|
||||
this.setupTimer(this.props.assetId, this.props.closedAt);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
if (
|
||||
this.props.assetId !== next.assetId ||
|
||||
this.props.closedAt !== next.closedAt
|
||||
) {
|
||||
this.setupTimer(next.assetId, next.closedAt);
|
||||
}
|
||||
}
|
||||
|
||||
closeAsset(assetId) {
|
||||
this.context.client.writeFragment({
|
||||
fragment: FRAGMENT,
|
||||
id: getFragmentId(assetId),
|
||||
data: {
|
||||
isClosed: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setupTimer(assetId, closedAt) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
|
||||
if (assetId && closedAt) {
|
||||
const asset = this.context.client.readFragment({
|
||||
fragment: FRAGMENT,
|
||||
id: getFragmentId(assetId),
|
||||
});
|
||||
|
||||
if (!asset.isClosed && closedAt) {
|
||||
const diff = (new Date(closedAt) - new Date());
|
||||
if (diff >= 0) {
|
||||
this.timer = setTimeout(() => this.closeAsset(assetId), diff);
|
||||
} else {
|
||||
this.closeAsset(assetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
AutomaticAssetClosure.PropTypes = {
|
||||
assetId: PropTypes.string,
|
||||
closedAt: PropTypes.string,
|
||||
};
|
||||
|
||||
export default AutomaticAssetClosure;
|
||||
@@ -251,6 +251,7 @@ const fragments = {
|
||||
title
|
||||
url
|
||||
closedAt
|
||||
isClosed
|
||||
created_at
|
||||
settings {
|
||||
moderation
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
const DEFAULT_STYLE = {
|
||||
position: 'fixed',
|
||||
cursor: 'default',
|
||||
userSelect: 'none',
|
||||
backgroundColor: '#323232',
|
||||
zIndex: 3,
|
||||
willChange: 'transform, opacity',
|
||||
transition: 'transform .35s cubic-bezier(.55,0,.1,1), opacity .35s',
|
||||
pointerEvents: 'none',
|
||||
padding: '12px 18px',
|
||||
color: '#fff',
|
||||
borderRadius: '3px 3px 0 0',
|
||||
textAlign: 'center',
|
||||
maxWidth: '400px',
|
||||
left: '50%',
|
||||
opacity: 0,
|
||||
transform: 'translate(-50%, 20px)',
|
||||
bottom: 0,
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'Helvetica, "Helvetica Neue", Verdana, sans-serif'
|
||||
};
|
||||
|
||||
export default class Snackbar {
|
||||
constructor(customStyle = {}) {
|
||||
this.timeout = null;
|
||||
this.el = document.createElement('div');
|
||||
this.el.id = 'coral-notif';
|
||||
|
||||
// Apply custom styles to the snackbar.
|
||||
const style = Object.assign({}, DEFAULT_STYLE, customStyle);
|
||||
for (let key in style) {
|
||||
this.el.style[key] = style[key];
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.el.style.opacity = 0;
|
||||
}
|
||||
|
||||
alert(message) {
|
||||
const [type, text] = message.split('|');
|
||||
this.el.style.transform = 'translate(-50%, 20px)';
|
||||
this.el.style.opacity = 0;
|
||||
this.el.className = `coral-notif-${type}`;
|
||||
this.el.textContent = text;
|
||||
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
this.timeout = setTimeout(() => {
|
||||
this.el.style.transform = 'translate(-50%, 0)';
|
||||
this.el.style.opacity = 1;
|
||||
|
||||
this.timeout = setTimeout(() => {
|
||||
this.el.style.opacity = 0;
|
||||
}, 7000);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
attach(el, pym) {
|
||||
el.appendChild(this.el);
|
||||
|
||||
// Attach the clear clear notification event to the clear method.
|
||||
pym.onMessage('coral-clear-notification', this.clear.bind(this));
|
||||
|
||||
// Attach the alert to the alert method.
|
||||
pym.onMessage('coral-alert', this.alert.bind(this));
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.el.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import queryString from 'query-string';
|
||||
import pym from 'pym.js';
|
||||
import EventEmitter from 'eventemitter2';
|
||||
import {buildUrl} from 'coral-framework/utils/url';
|
||||
import Snackbar from './Snackbar';
|
||||
|
||||
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, opts) {
|
||||
|
||||
// Create and save the options.
|
||||
|
||||
this.opts = opts;
|
||||
this.query = query;
|
||||
|
||||
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(opts.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 (opts.events) {
|
||||
opts.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 (opts.onAuthChanged) {
|
||||
this.pym.onMessage('coral-auth-changed', (message) => {
|
||||
opts.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 search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
commentId: undefined,
|
||||
});
|
||||
|
||||
// Remove the commentId url param.
|
||||
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);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export default class StreamInterface {
|
||||
constructor(stream) {
|
||||
this._stream = stream;
|
||||
}
|
||||
|
||||
on(eventName, callback) {
|
||||
return this._stream.emitter.on(eventName, callback);
|
||||
}
|
||||
|
||||
login(token) {
|
||||
return this._stream.login(token);
|
||||
}
|
||||
|
||||
logout() {
|
||||
return this._stream.logout();
|
||||
}
|
||||
|
||||
remove() {
|
||||
return this._stream.remove();
|
||||
}
|
||||
}
|
||||
+20
-224
@@ -1,196 +1,10 @@
|
||||
import pym from 'pym.js';
|
||||
import URLSearchParams from 'url-search-params';
|
||||
|
||||
import {buildUrl} from 'coral-framework/utils';
|
||||
import queryString from 'query-string';
|
||||
import EventEmitter from 'eventemitter2';
|
||||
|
||||
// TODO: Styles should live in a separate file
|
||||
const snackbarStyles = {
|
||||
position: 'fixed',
|
||||
cursor: 'default',
|
||||
userSelect: 'none',
|
||||
backgroundColor: '#323232',
|
||||
zIndex: 3,
|
||||
willChange: 'transform, opacity',
|
||||
transition: 'transform .35s cubic-bezier(.55,0,.1,1), opacity .35s',
|
||||
pointerEvents: 'none',
|
||||
padding: '12px 18px',
|
||||
color: '#fff',
|
||||
borderRadius: '3px 3px 0 0',
|
||||
textAlign: 'center',
|
||||
maxWidth: '400px',
|
||||
left: '50%',
|
||||
opacity: 0,
|
||||
transform: 'translate(-50%, 20px)',
|
||||
bottom: 0,
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'Helvetica, "Helvetica Neue", Verdana, sans-serif'
|
||||
};
|
||||
import Stream from './Stream';
|
||||
import StreamInterface from './StreamInterface';
|
||||
|
||||
// This function should return value of window.Coral
|
||||
const Coral = {};
|
||||
const Talk = (Coral.Talk = {});
|
||||
let notificationTimeout = null;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Set up postMessage listeners/handlers on the pymParent
|
||||
// e.g. to resize the iframe, and navigate the host page
|
||||
function configurePymParent(pymParent, eventEmitter, opts) {
|
||||
let notificationOffset = 200;
|
||||
let cachedHeight;
|
||||
const snackbar = document.createElement('div');
|
||||
|
||||
// Sends config to pymChild
|
||||
function sendConfig(config) {
|
||||
pymParent.sendMessage('config', JSON.stringify(config));
|
||||
}
|
||||
|
||||
if (opts.events) {
|
||||
opts.events(eventEmitter);
|
||||
}
|
||||
|
||||
pymParent.onMessage('coral-auth-changed', function(message) {
|
||||
if (opts.onAuthChanged) {
|
||||
opts.onAuthChanged(message ? JSON.parse(message) : null);
|
||||
}
|
||||
});
|
||||
|
||||
// Sends config to the child
|
||||
pymParent.onMessage('getConfig', function() {
|
||||
sendConfig(opts || {});
|
||||
});
|
||||
|
||||
snackbar.id = 'coral-notif';
|
||||
|
||||
for (let key in snackbarStyles) {
|
||||
snackbar.style[key] = snackbarStyles[key];
|
||||
}
|
||||
|
||||
window.document.body.appendChild(snackbar);
|
||||
|
||||
// Notify embed that there was a click outside.
|
||||
document.addEventListener('click', () => {
|
||||
pymParent.sendMessage('click');
|
||||
}, true);
|
||||
|
||||
// Workaround: IOS Safari ignores `width` but respects `min-width` value.
|
||||
pymParent.el.firstChild.style.width = '1px';
|
||||
pymParent.el.firstChild.style.minWidth = '100%';
|
||||
|
||||
// Resize parent iframe height when child height changes
|
||||
pymParent.onMessage('height', function(height) {
|
||||
if (height !== cachedHeight) {
|
||||
pymParent.el.firstChild.style.height = `${height}px`;
|
||||
cachedHeight = height;
|
||||
}
|
||||
});
|
||||
|
||||
pymParent.onMessage('coral-clear-notification', function() {
|
||||
snackbar.style.opacity = 0;
|
||||
});
|
||||
|
||||
// remove the permalink comment id from the hash
|
||||
pymParent.onMessage('coral-view-all-comments', function() {
|
||||
|
||||
const search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
commentId: undefined,
|
||||
});
|
||||
|
||||
// remove the commentId url param
|
||||
const url = buildUrl({...location, search});
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
url,
|
||||
);
|
||||
});
|
||||
|
||||
// remove the permalink comment id from the hash
|
||||
pymParent.onMessage('coral-view-comment', function(id) {
|
||||
|
||||
const search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
commentId: id,
|
||||
});
|
||||
|
||||
// remove the commentId url param
|
||||
const url = buildUrl({...location, search});
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
url,
|
||||
);
|
||||
});
|
||||
|
||||
pymParent.onMessage('coral-alert', function(message) {
|
||||
const [type, text] = message.split('|');
|
||||
snackbar.style.transform = 'translate(-50%, 20px)';
|
||||
snackbar.style.opacity = 0;
|
||||
snackbar.className = `coral-notif-${type}`;
|
||||
snackbar.textContent = text;
|
||||
|
||||
clearTimeout(notificationTimeout);
|
||||
notificationTimeout = setTimeout(() => {
|
||||
snackbar.style.transform = 'translate(-50%, 0)';
|
||||
snackbar.style.opacity = 1;
|
||||
|
||||
notificationTimeout = setTimeout(() => {
|
||||
snackbar.style.opacity = 0;
|
||||
}, 7000);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Helps child show notifications at the right scrollTop
|
||||
pymParent.onMessage('getPosition', function() {
|
||||
let position = viewport().height + document.body.scrollTop;
|
||||
|
||||
if (position > notificationOffset) {
|
||||
position = position - notificationOffset;
|
||||
}
|
||||
|
||||
pymParent.sendMessage('position', position);
|
||||
});
|
||||
|
||||
// When end-user clicks link in iframe, open it in parent context
|
||||
pymParent.onMessage('navigate', function(url) {
|
||||
window.open(url, '_blank').focus();
|
||||
});
|
||||
|
||||
// Pass events from iframe to the event emitter
|
||||
pymParent.onMessage('event', (raw) => {
|
||||
const {eventName, value} = JSON.parse(raw);
|
||||
eventEmitter.emit(eventName, value);
|
||||
});
|
||||
|
||||
// get dimensions of viewport
|
||||
const viewport = () => {
|
||||
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`]
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Talk stream
|
||||
@@ -219,17 +33,14 @@ function configurePymParent(pymParent, eventEmitter, opts) {
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
Talk.render = function(el, opts) {
|
||||
Talk.render = (el, opts) => {
|
||||
if (!el) {
|
||||
throw new Error(
|
||||
'Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.'
|
||||
);
|
||||
throw new Error('Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.');
|
||||
}
|
||||
if (typeof el !== 'object') {
|
||||
throw new Error(
|
||||
`Coral.Talk.render() expected HTMLElement but got ${el} (${typeof el})`
|
||||
);
|
||||
throw new Error(`Coral.Talk.render() expected HTMLElement but got ${el} (${typeof el})`);
|
||||
}
|
||||
|
||||
opts = opts || {};
|
||||
|
||||
// TODO: infer this URL without explicit user input (if possible, may have to be added at build/render time of this script)
|
||||
@@ -245,22 +56,27 @@ Talk.render = function(el, opts) {
|
||||
}
|
||||
|
||||
// Compose the query to send down to the Talk API so it knows what to load.
|
||||
let query = {};
|
||||
|
||||
let urlParams = new URLSearchParams(window.location.search);
|
||||
const query = {};
|
||||
|
||||
// Parse the url parameters to extract some of the information.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('commentId')) {
|
||||
query.comment_id = urlParams.get('commentId');
|
||||
}
|
||||
|
||||
// Extract the asset id from the options.
|
||||
if (opts.asset_id) {
|
||||
query.asset_id = opts.asset_id;
|
||||
}
|
||||
|
||||
// Extract the asset url.
|
||||
if (opts.asset_url) {
|
||||
query.asset_url = opts.asset_url;
|
||||
}
|
||||
else {
|
||||
} else if (!opts.asset_id) {
|
||||
|
||||
// The asset url was not provided and the asset id was also not provided,
|
||||
// we need to infer the asset url from details on the page.
|
||||
|
||||
try {
|
||||
query.asset_url = document.querySelector('link[rel="canonical"]').href;
|
||||
} catch (e) {
|
||||
@@ -276,31 +92,11 @@ Talk.render = function(el, opts) {
|
||||
}
|
||||
}
|
||||
|
||||
const pymParent = new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, query), {
|
||||
title: opts.title,
|
||||
id: `${el.id}_iframe`,
|
||||
name: `${el.id}_iframe`
|
||||
});
|
||||
// Create the new Stream.
|
||||
const stream = new Stream(el, opts.talk, query, opts);
|
||||
|
||||
const eventEmitter = new EventEmitter({wildcard: true});
|
||||
|
||||
configurePymParent(
|
||||
pymParent,
|
||||
eventEmitter,
|
||||
opts
|
||||
);
|
||||
|
||||
return {
|
||||
on(eventName, callback) {
|
||||
eventEmitter.on(eventName, callback);
|
||||
},
|
||||
login(token) {
|
||||
pymParent.sendMessage('login', token);
|
||||
},
|
||||
logout() {
|
||||
pymParent.sendMessage('logout');
|
||||
}
|
||||
};
|
||||
// Return the public interface for the stream.
|
||||
return new StreamInterface(stream);
|
||||
};
|
||||
|
||||
export default Coral;
|
||||
|
||||
@@ -169,14 +169,7 @@ export function insertCommentsSorted(nodes, comments, sortOrder = 'CHRONOLOGICAL
|
||||
|
||||
export const isTagged = (tags, which) => tags.some((t) => t.tag.name === which);
|
||||
|
||||
export function buildUrl({protocol, hostname, port, pathname, search, hash} = window.location) {
|
||||
if (search && search[0] !== '?') {
|
||||
search = `?${search}`;
|
||||
} else if (search === '?') {
|
||||
search = '';
|
||||
}
|
||||
return `${protocol}//${hostname}${port ? `:${port}` : ''}${pathname}${search}${hash}`;
|
||||
}
|
||||
export * from './url';
|
||||
|
||||
/**
|
||||
* getSlotFragmentSpreads will return a string in the
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export function buildUrl({protocol, hostname, port, pathname, search, hash} = window.location) {
|
||||
if (search && search[0] !== '?') {
|
||||
search = `?${search}`;
|
||||
} else if (search === '?') {
|
||||
search = '';
|
||||
}
|
||||
return `${protocol}//${hostname}${port ? `:${port}` : ''}${pathname}${search}${hash}`;
|
||||
}
|
||||
@@ -35,11 +35,22 @@ const CONFIG = {
|
||||
// cleared when the user is logged out.
|
||||
JWT_CLEAR_COOKIE_LOGOUT: process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT ? process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT !== 'FALSE' : true,
|
||||
|
||||
// JWT_DISABLE_AUDIENCE when TRUE will disable the audience claim (aud) from tokens.
|
||||
JWT_DISABLE_AUDIENCE: process.env.TALK_JWT_DISABLE_AUDIENCE === 'TRUE',
|
||||
|
||||
// JWT_AUDIENCE is the value for the audience claim for the tokens that will be
|
||||
// verified when decoding. If `JWT_AUDIENCE` is not in the environment, then it
|
||||
// will default to `talk`.
|
||||
JWT_AUDIENCE: process.env.TALK_JWT_AUDIENCE || 'talk',
|
||||
|
||||
// JWT_DISABLE_ISSUER when TRUE will disable the issuer claim (iss) from tokens.
|
||||
JWT_DISABLE_ISSUER: process.env.TALK_JWT_DISABLE_ISSUER === 'TRUE',
|
||||
|
||||
// JWT_USER_ID_CLAIM is the claim which stores the user's id. This may be a deep
|
||||
// object delimited using dot notation. Example `user.id` would store it like:
|
||||
// {user: {id}} on the claims object. (Default `sub`)
|
||||
JWT_USER_ID_CLAIM: process.env.TALK_JWT_USER_ID_CLAIM || 'sub',
|
||||
|
||||
// JWT_ISSUER is the value for the issuer for the tokens that will be verified
|
||||
// when decoding. If `JWT_ISSUER` is not in the environment, then it will try
|
||||
// `TALK_ROOT_URL`, otherwise, it will be undefined.
|
||||
@@ -130,20 +141,28 @@ if (process.env.NODE_ENV === 'test' && !CONFIG.ROOT_URL) {
|
||||
|
||||
if (CONFIG.JWT_SECRETS) {
|
||||
CONFIG.JWT_SECRETS = JSON.parse(CONFIG.JWT_SECRETS);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'test' && !CONFIG.JWT_SECRET) {
|
||||
CONFIG.JWT_SECRET = 'keyboard cat';
|
||||
} else if (!CONFIG.JWT_SECRET) {
|
||||
throw new Error(
|
||||
'TALK_JWT_SECRET must be provided in the environment to sign/verify tokens'
|
||||
);
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
if (!CONFIG.JWT_ALG.startsWith('HS')) {
|
||||
throw new Error('Providing a asymmetric signing/verfying algorithm without a corresponding secret is not permitted');
|
||||
}
|
||||
|
||||
CONFIG.JWT_SECRET = 'keyboard cat';
|
||||
} else {
|
||||
throw new Error(
|
||||
'TALK_JWT_SECRET must be provided in the environment to sign/verify tokens'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If this is not employing a HMAC based signing method, then we need to turn
|
||||
// the secret into a buffer.
|
||||
if (!CONFIG.JWT_ALG.startsWith('HS')) {
|
||||
CONFIG.JWT_SECRET = Buffer.from(CONFIG.JWT_SECRET);
|
||||
// Disable the audience claim if requested.
|
||||
if (CONFIG.JWT_DISABLE_AUDIENCE) {
|
||||
CONFIG.JWT_AUDIENCE = undefined;
|
||||
}
|
||||
|
||||
// Disable the issuer claim if requested.
|
||||
if (CONFIG.JWT_DISABLE_ISSUER) {
|
||||
CONFIG.JWT_ISSUER = undefined;
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
@@ -77,15 +77,43 @@ The following are configuration shared with every type of secret used.
|
||||
tokens. (Default `process.env.TALK_ROOT_URL`)
|
||||
- `TALK_JWT_AUDIENCE` (_optional_) - the audience (`aud`) claim for login JWT
|
||||
tokens. (Default `talk`)
|
||||
|
||||
**You must also specify secrets as either the `TALK_JWT_SECRET` or the `TALK_JWT_SECRETS`
|
||||
variable. Refer to the [Secrets Documentation]({{ "/docs/running/secrets/" | absolute_url }})
|
||||
on the contents of those variables.**
|
||||
|
||||
#### Advanced
|
||||
|
||||
These are advanced settings for fine tuning the auth integration, and
|
||||
is not needed in most situations.
|
||||
|
||||
- `TALK_JWT_COOKIE_NAME` (_optional_) - the name of the cookie to extract the
|
||||
JWT from (Default `authorization`)
|
||||
- `TALK_JWT_CLEAR_COOKIE_LOGOUT` (_optional_) - when `FALSE`, Talk will not
|
||||
clear the cookie with name `TALK_JWT_COOKIE_NAME` when logging out (Default
|
||||
`TRUE`)
|
||||
- `TALK_JWT_DISABLE_AUDIENCE` (_optional_) - when `TRUE`, Talk will not verify or sign JWT's
|
||||
with an audience (`aud`) claim, even if the `TALK_JWT_AUDIENCE` config is set. (Default `FALSE`)
|
||||
- `TALK_JWT_DISABLE_ISSUER` (_optional_) - when `TRUE`, Talk will not verify or sign JWT's
|
||||
with an issuer (`iss`) claim, even if the `TALK_JWT_ISSUER` config is set. (Default `FALSE`)
|
||||
- `TALK_JWT_USER_ID_CLAIM` (_optional_) - specify the claim using dot notation for where the
|
||||
user id should be stored/read to/from. Example `user.id` would store it like: `{user: {id}}`
|
||||
on the claims object. (Default `sub`)
|
||||
|
||||
**You must also specify secrets as either the `TALK_JWT_SECRET` or the `TALK_JWT_SECRETS`
|
||||
variable. Refer to the [Secrets Documentation]({{ "/docs/running/secrets/" | absolute_url }})
|
||||
on the contents of those variables.**
|
||||
When integrating with an external authentication system, the following JWT claims
|
||||
will be used:
|
||||
|
||||
```js
|
||||
{
|
||||
"jti": "<the unique token identifier>", // *required* unique id used for blacklisting
|
||||
"aud": TALK_JWT_AUDIENCE, // *optional* if TALK_JWT_DISABLE_AUDIENCE === 'TRUE', *required* otherwise
|
||||
"iss": TALK_JWT_ISSUER, // *optional* if TALK_JWT_DISABLE_ISSUER === 'TRUE', *required* otherwise
|
||||
|
||||
[TALK_JWT_USER_ID_CLAIM]: "<the user id>", // *required* the id of the user
|
||||
// Note, if TALK_JWT_USER_ID_CLAIM contains '.', it will be used to deliniate an object, for example
|
||||
// `user.id` would store it like: `{user: {id}}`
|
||||
}
|
||||
```
|
||||
|
||||
### Email
|
||||
|
||||
|
||||
@@ -60,6 +60,11 @@ must have their newlines replaced with `\\n`, this is to ensure that the
|
||||
newlines are preserved after JSON decoding. Not doing so will result in parsing
|
||||
errors.
|
||||
|
||||
To assist with this process, we have developed a tool that can generate new
|
||||
certificates that match our required format: [coralcert](https://github.com/coralproject/coralcert).
|
||||
This tool can generate RSA and ECDSA certificates, check it's [README](https://github.com/coralproject/coralcert)
|
||||
for more details.
|
||||
|
||||
## Authentication Types
|
||||
|
||||
Talk also supports two methods of providing authenticationd details.
|
||||
|
||||
@@ -596,6 +596,9 @@ type Asset {
|
||||
# The date that the asset was closed at.
|
||||
closedAt: Date
|
||||
|
||||
# True if asset is closed.
|
||||
isClosed: Boolean!
|
||||
|
||||
# Summary of all Actions against all entities associated with the Asset.
|
||||
# (likes, flags, etc.). Requires the `ADMIN` role.
|
||||
action_summaries: [AssetActionSummary!]
|
||||
|
||||
+2
-2
@@ -61,7 +61,7 @@ const AssetSchema = new Schema({
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
AssetSchema.index({
|
||||
@@ -79,7 +79,7 @@ AssetSchema.index({
|
||||
* Returns true if the asset is closed, false else.
|
||||
*/
|
||||
AssetSchema.virtual('isClosed').get(function() {
|
||||
return this.closedAt && this.closedAt.getTime() <= new Date().getTime();
|
||||
return Boolean(this.closedAt && this.closedAt.getTime() <= new Date().getTime());
|
||||
});
|
||||
|
||||
const Asset = mongoose.model('Asset', AssetSchema);
|
||||
|
||||
+5
-1
@@ -22,6 +22,10 @@ if (JWT_SECRETS) {
|
||||
throw new Error('when multiple keys are specified, kid\'s must be specified');
|
||||
}
|
||||
|
||||
if (typeof secret.kid !== 'string' || secret.kid.length === 0) {
|
||||
throw new Error('kid must be a unique string');
|
||||
}
|
||||
|
||||
// HMAC secrets do not have public/private keys.
|
||||
if (JWT_ALG.startsWith('HS')) {
|
||||
return new jwt.SharedSecret(secret, JWT_ALG);
|
||||
@@ -34,7 +38,7 @@ if (JWT_SECRETS) {
|
||||
return new jwt.AsymmetricSecret(secret, JWT_ALG);
|
||||
}));
|
||||
|
||||
debug(`loaded ${JWT_SECRET.length} ${JWT_ALG.startsWith('HS') ? 'shared' : 'asymmetric'} secrets`);
|
||||
debug(`loaded ${JWT_SECRETS.length} ${JWT_ALG.startsWith('HS') ? 'shared' : 'asymmetric'} secrets`);
|
||||
} else if (JWT_SECRET) {
|
||||
if (JWT_ALG.startsWith('HS')) {
|
||||
module.exports.jwt = new jwt.SharedSecret({
|
||||
|
||||
+13
-2
@@ -1,4 +1,5 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const uniq = require('lodash/uniq');
|
||||
|
||||
/**
|
||||
* MultiSecret will take many secrets and provide a unified interface for
|
||||
@@ -6,6 +7,12 @@ const jwt = require('jsonwebtoken');
|
||||
*/
|
||||
class MultiSecret {
|
||||
constructor(secrets) {
|
||||
this.kids = secrets.map(({kid}) => kid);
|
||||
|
||||
if (uniq(this.kids).length !== secrets.length) {
|
||||
throw new Error('Duplicate kid\'s cannot be used to construct a MultiSecret');
|
||||
}
|
||||
|
||||
this.secrets = secrets;
|
||||
}
|
||||
|
||||
@@ -86,7 +93,11 @@ class Secret {
|
||||
/**
|
||||
* SharedSecret is the HMAC based secret that's used for signing/verifying.
|
||||
*/
|
||||
function SharedSecret({kid = undefined, secret}, algorithm) {
|
||||
function SharedSecret({kid = undefined, secret = null}, algorithm) {
|
||||
if (secret === null || secret.length === 0) {
|
||||
throw new Error('Secret cannot have a zero length');
|
||||
}
|
||||
|
||||
return new Secret({
|
||||
kid,
|
||||
signingKey: secret,
|
||||
@@ -101,7 +112,7 @@ function SharedSecret({kid = undefined, secret}, algorithm) {
|
||||
*/
|
||||
function AsymmetricSecret({kid = undefined, private: privateKey, public: publicKey}, algorithm) {
|
||||
publicKey = Buffer.from(publicKey.replace(/\\n/g, '\n'));
|
||||
privateKey = privateKey ? Buffer.from(privateKey.replace(/\\n/g, '\n')) : null;
|
||||
privateKey = privateKey && privateKey.length > 0 ? Buffer.from(privateKey.replace(/\\n/g, '\n')) : null;
|
||||
|
||||
return new Secret({
|
||||
kid,
|
||||
|
||||
+23
-12
@@ -1,4 +1,5 @@
|
||||
const passport = require('passport');
|
||||
const {set, get} = require('lodash');
|
||||
const UsersService = require('./users');
|
||||
const SettingsService = require('./settings');
|
||||
const TokensService = require('./tokens');
|
||||
@@ -23,21 +24,26 @@ const {
|
||||
RECAPTCHA_SECRET,
|
||||
RECAPTCHA_ENABLED,
|
||||
JWT_COOKIE_NAME,
|
||||
JWT_CLEAR_COOKIE_LOGOUT
|
||||
JWT_CLEAR_COOKIE_LOGOUT,
|
||||
JWT_USER_ID_CLAIM,
|
||||
} = require('../config');
|
||||
|
||||
const {
|
||||
jwt: JWT_SECRET
|
||||
jwt
|
||||
} = require('../secrets');
|
||||
|
||||
// GenerateToken will sign a token to include all the authorization information
|
||||
// needed for the front end.
|
||||
const GenerateToken = (user) => {
|
||||
return JWT_SECRET.sign({}, {
|
||||
const claims = {};
|
||||
|
||||
// Set the user id.
|
||||
set(claims, JWT_USER_ID_CLAIM, user.id);
|
||||
|
||||
return jwt.sign(claims, {
|
||||
jwtid: uuid.v4(),
|
||||
expiresIn: JWT_EXPIRY,
|
||||
issuer: JWT_ISSUER,
|
||||
subject: user.id,
|
||||
audience: JWT_AUDIENCE,
|
||||
algorithm: JWT_ALG
|
||||
});
|
||||
@@ -191,11 +197,13 @@ const CheckBlacklisted = async (jwt) => {
|
||||
|
||||
// Check to see if this is a PAT.
|
||||
if (jwt.pat) {
|
||||
return TokensService.validate(jwt.sub, jwt.jti);
|
||||
return TokensService.validate(get(jwt, JWT_USER_ID_CLAIM), jwt.jti);
|
||||
}
|
||||
|
||||
// It wasn't a PAT! Check to see if it is valid anyways.
|
||||
return checkGeneralTokenBlacklist(jwt);
|
||||
await checkGeneralTokenBlacklist(jwt);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const JwtStrategy = require('passport-jwt').Strategy;
|
||||
@@ -214,7 +222,7 @@ let cookieExtractor = function(req) {
|
||||
// Override the JwtVerifier method on the JwtStrategy so we can pack the
|
||||
// original token into the payload.
|
||||
JwtStrategy.JwtVerifier = (token, secretOrKey, options, callback) => {
|
||||
return JWT_SECRET.verify(token, options, (err, jwt) => {
|
||||
return jwt.verify(token, options, (err, jwt) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
@@ -236,7 +244,7 @@ passport.use(new JwtStrategy({
|
||||
|
||||
// Use the secret passed in which is loaded from the environment. This can be
|
||||
// a certificate (loaded) or a HMAC key.
|
||||
secretOrKey: JWT_SECRET,
|
||||
secretOrKey: jwt,
|
||||
|
||||
// Verify the issuer.
|
||||
issuer: JWT_ISSUER,
|
||||
@@ -257,11 +265,14 @@ passport.use(new JwtStrategy({
|
||||
try {
|
||||
|
||||
// Check to see if the token has been revoked
|
||||
await CheckBlacklisted(jwt);
|
||||
let user = await CheckBlacklisted(jwt);
|
||||
|
||||
// Try to get the user from the database or crack it from the token and
|
||||
// plugin integrations.
|
||||
let user = await UsersService.findOrCreateByIDToken(jwt.sub, {token, jwt});
|
||||
if (user === null) {
|
||||
|
||||
// Try to get the user from the database or crack it from the token and
|
||||
// plugin integrations.
|
||||
user = await UsersService.findOrCreateByIDToken(get(jwt, JWT_USER_ID_CLAIM), {token, jwt});
|
||||
}
|
||||
|
||||
// Attach the JWT to the request.
|
||||
req.jwt = jwt;
|
||||
|
||||
+8
-3
@@ -1,10 +1,12 @@
|
||||
const errors = require('../errors');
|
||||
const UserModel = require('../models/user');
|
||||
const uuid = require('uuid');
|
||||
const {set} = require('lodash');
|
||||
|
||||
const {
|
||||
JWT_ISSUER,
|
||||
JWT_AUDIENCE
|
||||
JWT_AUDIENCE,
|
||||
JWT_USER_ID_CLAIM,
|
||||
} = require('../config');
|
||||
|
||||
const {
|
||||
@@ -30,10 +32,11 @@ module.exports = class TokenService {
|
||||
jti: uuid.v4(),
|
||||
iss: JWT_ISSUER,
|
||||
aud: JWT_AUDIENCE,
|
||||
sub: userID,
|
||||
pat: true
|
||||
};
|
||||
|
||||
set(payload, JWT_USER_ID_CLAIM, userID);
|
||||
|
||||
// Sign the payload.
|
||||
const jwt = JWT_SECRET.sign(payload, {});
|
||||
|
||||
@@ -93,7 +96,7 @@ module.exports = class TokenService {
|
||||
// Find the user.
|
||||
let user = await UserModel.findOne({
|
||||
id: userID
|
||||
}).select('tokens');
|
||||
});
|
||||
if (!user || !user.tokens) {
|
||||
throw new errors.ErrAuthentication('user does not exist');
|
||||
}
|
||||
@@ -108,6 +111,8 @@ module.exports = class TokenService {
|
||||
if (!token.active) {
|
||||
throw new errors.ErrAuthentication('token is not active');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('/api/v1/assets', () => {
|
||||
.set(passport.inject({roles: ['ADMIN']}))
|
||||
.then((res) => {
|
||||
const body = res.body;
|
||||
|
||||
|
||||
expect(body).to.have.property('count', 2);
|
||||
expect(body).to.have.property('result');
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('/api/v1/assets', () => {
|
||||
|
||||
return AssetsService.findOrCreateByUrl('http://test.com')
|
||||
.then((asset) => {
|
||||
expect(asset).to.have.property('isClosed', null);
|
||||
expect(asset).to.have.property('isClosed', false);
|
||||
expect(asset).to.have.property('closedAt', null);
|
||||
|
||||
return chai.request(app)
|
||||
|
||||
Reference in New Issue
Block a user