From b1d04077d9cabe7d7124e523e245876762a99067 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Sat, 13 Aug 2016 14:58:16 -0700 Subject: [PATCH 01/12] Fix(challenges): load legacy code from localStorage --- client/sagas/code-storage-saga.js | 43 ++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/client/sagas/code-storage-saga.js b/client/sagas/code-storage-saga.js index fe5856ded2..aa3f47871f 100644 --- a/client/sagas/code-storage-saga.js +++ b/client/sagas/code-storage-saga.js @@ -4,32 +4,46 @@ import types from '../../common/app/routes/challenges/redux/types'; import { savedCodeFound } from '../../common/app/routes/challenges/redux/actions'; +import { + updateContents +} from '../../common/utils/polyvinyl'; -const legecyPrefixes = [ +const legacyPrefixes = [ 'Bonfire: ', 'Waypoint: ', 'Zipline: ', 'Basejump: ', 'Checkpoint: ' ]; +const legacyPostfix = 'Val'; -function getCode(id, legacy) { +function getCode(id) { if (store.has(id)) { return store.get(id); } - if (store.has(legacy)) { - const code = '' + store.get(legacy); - store.remove(legacy); + return null; +} + +function getLegacyCode(legacy) { + const key = legacy + legacyPostfix; + let code = null; + if (store.has(key)) { + code = '' + store.get(key); + store.remove(key); return code; } - return legecyPrefixes.reduce((code, prefix) => { + return legacyPrefixes.reduce((code, prefix) => { if (code) { return code; } - return store.get(prefix + legacy + 'Val'); + return store.get(prefix + key); }, null); } +function legacyToFile(code, files, key) { + return { [key]: updateContents(code, files[key]) }; +} + export default function codeStorageSaga(actions$, getState) { return actions$ .filter(({ type }) => ( @@ -37,15 +51,26 @@ export default function codeStorageSaga(actions$, getState) { type === types.loadCode )) .map(({ type }) => { - const { id = '', files = {}, legacyKey = '' } = getState().challengesApp; + const { + challengesApp: { + id = '', + files = {}, + legacyKey = '', + key + } + } = getState(); if (type === types.saveCode) { store.set(id, files); return null; } - const codeFound = getCode(id, legacyKey); + const codeFound = getCode(id); if (codeFound) { return savedCodeFound(codeFound); } + const legacyCode = getLegacyCode(legacyKey); + if (legacyCode) { + return savedCodeFound(legacyToFile(legacyCode, files, key)); + } return null; }); } From 1c460e3319890efc4031a32a418086436e4999a0 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Sat, 13 Aug 2016 15:18:33 -0700 Subject: [PATCH 02/12] Fix(challenges): load stored code on challenge change --- .../routes/challenges/components/classic/Classic.jsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/common/app/routes/challenges/components/classic/Classic.jsx b/common/app/routes/challenges/components/classic/Classic.jsx index e463ae6d60..81790bbf30 100644 --- a/common/app/routes/challenges/components/classic/Classic.jsx +++ b/common/app/routes/challenges/components/classic/Classic.jsx @@ -18,15 +18,18 @@ import { const mapStateToProps = createSelector( challengeSelector, + state => state.challengesApp.id, state => state.challengesApp.tests, state => state.challengesApp.files, state => state.challengesApp.key, ( { showPreview, mode }, + id, tests, files = {}, key = '' ) => ({ + id, content: files[key] && files[key].contents || '', file: files[key], showPreview, @@ -46,6 +49,7 @@ export class Challenge extends PureComponent { static displayName = 'Challenge'; static propTypes = { + id: PropTypes.string, showPreview: PropTypes.bool, content: PropTypes.string, mode: PropTypes.string, @@ -60,6 +64,12 @@ export class Challenge extends PureComponent { this.props.updateMain(); } + componentWillReceiveProps(nextProps) { + if (this.props.id !== nextProps.id) { + this.props.loadCode(); + } + } + renderPreview(showPreview) { if (!showPreview) { return null; From c3d9d48b01c4b91111138da96872d3d0aa7407c8 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Sat, 13 Aug 2016 17:53:03 -0700 Subject: [PATCH 03/12] Fix(challenges): Let code load update the main frame Also display a message to the user that we loaded in-progress code. --- client/sagas/code-storage-saga.js | 30 ++++++++++++++----- .../challenges/components/classic/Classic.jsx | 4 --- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/client/sagas/code-storage-saga.js b/client/sagas/code-storage-saga.js index aa3f47871f..fad5eed49b 100644 --- a/client/sagas/code-storage-saga.js +++ b/client/sagas/code-storage-saga.js @@ -1,8 +1,11 @@ +import { Observable } from 'rx'; import store from 'store'; +import { makeToast } from '../../common/app/toasts/redux/actions'; import types from '../../common/app/routes/challenges/redux/types'; import { - savedCodeFound + savedCodeFound, + updateMain } from '../../common/app/routes/challenges/redux/actions'; import { updateContents @@ -50,7 +53,8 @@ export default function codeStorageSaga(actions$, getState) { type === types.saveCode || type === types.loadCode )) - .map(({ type }) => { + .flatMap(({ type }) => { + let finalFiles; const { challengesApp: { id = '', @@ -63,14 +67,26 @@ export default function codeStorageSaga(actions$, getState) { store.set(id, files); return null; } + const codeFound = getCode(id); if (codeFound) { - return savedCodeFound(codeFound); + finalFiles = codeFound; + } else { + const legacyCode = getLegacyCode(legacyKey); + if (legacyCode) { + finalFiles = legacyToFile(legacyCode, files, key); + } } - const legacyCode = getLegacyCode(legacyKey); - if (legacyCode) { - return savedCodeFound(legacyToFile(legacyCode, files, key)); + + if (finalFiles) { + return Observable.of( + makeToast({ + message: 'I found some saved work. Loading now' + }), + savedCodeFound(finalFiles), + updateMain() + ); } - return null; + return Observable.empty(); }); } diff --git a/common/app/routes/challenges/components/classic/Classic.jsx b/common/app/routes/challenges/components/classic/Classic.jsx index 81790bbf30..cbfac6292c 100644 --- a/common/app/routes/challenges/components/classic/Classic.jsx +++ b/common/app/routes/challenges/components/classic/Classic.jsx @@ -11,7 +11,6 @@ import BugModal from '../Bug-Modal.jsx'; import { challengeSelector } from '../../redux/selectors'; import { executeChallenge, - updateMain, updateFile, loadCode } from '../../redux/actions'; @@ -41,7 +40,6 @@ const mapStateToProps = createSelector( const bindableActions = { executeChallenge, updateFile, - updateMain, loadCode }; @@ -55,13 +53,11 @@ export class Challenge extends PureComponent { mode: PropTypes.string, updateFile: PropTypes.func, executeChallenge: PropTypes.func, - updateMain: PropTypes.func, loadCode: PropTypes.func }; componentDidMount() { this.props.loadCode(); - this.props.updateMain(); } componentWillReceiveProps(nextProps) { From 5fb2802e32ba089a150dd456edd7146ea1609e62 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Sat, 13 Aug 2016 18:09:32 -0700 Subject: [PATCH 04/12] Chore(challenges): separate code storage sagas --- client/sagas/code-storage-saga.js | 32 +++++++++++-------- .../app/redux/load-current-challenge-saga.js | 2 +- .../routes/settings/redux/update-user-saga.js | 2 +- common/{app => }/utils/combine-sagas.js | 0 common/utils/get-actions-of-type.js | 17 ++++++++++ 5 files changed, 38 insertions(+), 15 deletions(-) rename common/{app => }/utils/combine-sagas.js (100%) diff --git a/client/sagas/code-storage-saga.js b/client/sagas/code-storage-saga.js index fad5eed49b..440cf0a830 100644 --- a/client/sagas/code-storage-saga.js +++ b/client/sagas/code-storage-saga.js @@ -1,15 +1,16 @@ import { Observable } from 'rx'; import store from 'store'; +import { ofType } from '../../common/utils/get-actions-of-type'; +import { updateContents } from '../../common/utils/polyvinyl'; +import combineSagas from '../../common/utils/combine-sagas'; + import { makeToast } from '../../common/app/toasts/redux/actions'; import types from '../../common/app/routes/challenges/redux/types'; import { savedCodeFound, updateMain } from '../../common/app/routes/challenges/redux/actions'; -import { - updateContents -} from '../../common/utils/polyvinyl'; const legacyPrefixes = [ 'Bonfire: ', @@ -47,13 +48,20 @@ function legacyToFile(code, files, key) { return { [key]: updateContents(code, files[key]) }; } -export default function codeStorageSaga(actions$, getState) { +export function saveCodeSaga(actions, getState) { + return actions + ::ofType(types.saveCode) + .map(() => { + const { challengesApp: { id = '', files = {} } } = getState(); + store.set(id, files); + return null; + }); +} + +export function loadCodeSaga(actions$, getState) { return actions$ - .filter(({ type }) => ( - type === types.saveCode || - type === types.loadCode - )) - .flatMap(({ type }) => { + ::ofType(types.loadCode) + .flatMap(() => { let finalFiles; const { challengesApp: { @@ -63,10 +71,6 @@ export default function codeStorageSaga(actions$, getState) { key } } = getState(); - if (type === types.saveCode) { - store.set(id, files); - return null; - } const codeFound = getCode(id); if (codeFound) { @@ -90,3 +94,5 @@ export default function codeStorageSaga(actions$, getState) { return Observable.empty(); }); } + +export default combineSagas(saveCodeSaga, loadCodeSaga); diff --git a/common/app/redux/load-current-challenge-saga.js b/common/app/redux/load-current-challenge-saga.js index 2905516db9..c89c8f87cf 100644 --- a/common/app/redux/load-current-challenge-saga.js +++ b/common/app/redux/load-current-challenge-saga.js @@ -13,7 +13,7 @@ import { } from './selectors'; import { updateCurrentChallenge } from '../routes/challenges/redux/actions'; import getActionsOfType from '../../utils/get-actions-of-type'; -import combineSagas from '../utils/combine-sagas'; +import combineSagas from '../../utils/combine-sagas'; import { postJSON$ } from '../../utils/ajax-stream'; const log = debug('fcc:app/redux/load-current-challenge-saga'); diff --git a/common/app/routes/settings/redux/update-user-saga.js b/common/app/routes/settings/redux/update-user-saga.js index a004f96740..3e62f15128 100644 --- a/common/app/routes/settings/redux/update-user-saga.js +++ b/common/app/routes/settings/redux/update-user-saga.js @@ -2,7 +2,6 @@ import { Observable } from 'rx'; import { push } from 'react-router-redux'; import { types } from './actions'; -import combineSagas from '../../../utils/combine-sagas'; import { makeToast } from '../../../toasts/redux/actions'; import { fetchChallenges } from '../../challenges/redux/actions'; import { @@ -14,6 +13,7 @@ import { import { userSelector } from '../../../redux/selectors'; import { postJSON$ } from '../../../../utils/ajax-stream'; import langs from '../../../../utils/supported-languages'; +import combineSagas from '../../../../utils/combine-sagas'; const urlMap = { isLocked: 'lockdown', diff --git a/common/app/utils/combine-sagas.js b/common/utils/combine-sagas.js similarity index 100% rename from common/app/utils/combine-sagas.js rename to common/utils/combine-sagas.js diff --git a/common/utils/get-actions-of-type.js b/common/utils/get-actions-of-type.js index 458e5d33ee..2a32d1617c 100644 --- a/common/utils/get-actions-of-type.js +++ b/common/utils/get-actions-of-type.js @@ -1,3 +1,20 @@ +// redux-observable compatible operator +export function ofType(...keys) { + return this.filter(({ type }) => { + const len = keys.length; + if (len === 1) { + return type === keys[0]; + } else { + for (let i = 0; i < len; i++) { + if (keys[i] === type) { + return true; + } + } + } + return false; + }); +} + export default function getActionsOfType(actions, ...types) { const length = types.length; return actions From 3f3aab3ff7ef64e9cac429b094e2a20754d2a6ac Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Sun, 14 Aug 2016 23:22:34 -0700 Subject: [PATCH 05/12] Feature(challenges): add code-uri utils Fix(nav): points nav item propTypes --- client/utils/code-uri.js | 64 +++++++++++++++++++ common/app/components/Nav/Points-Nav-Item.jsx | 4 +- common/app/routes/challenges/utils.js | 35 ++-------- common/utils/encode-decode.js | 46 +++++++++++++ 4 files changed, 117 insertions(+), 32 deletions(-) create mode 100644 client/utils/code-uri.js create mode 100644 common/utils/encode-decode.js diff --git a/client/utils/code-uri.js b/client/utils/code-uri.js new file mode 100644 index 0000000000..3622e5843c --- /dev/null +++ b/client/utils/code-uri.js @@ -0,0 +1,64 @@ +import flow from 'lodash/flow'; +import { decodeFcc } from '../../common/utils/encode-decode'; + +const queryRegex = /^(\?|#\?)/; +export function legacyIsInQuery(query, decode) { + let decoded; + try { + decoded = decode(query); + } catch (err) { + return false; + } + if (!decoded || typeof decoded.split !== 'function') { + return false; + } + return decoded + .replace(queryRegex, '') + .split('&') + .reduce(function(found, param) { + var key = param.split('=')[0]; + if (key === 'solution') { + return true; + } + return found; + }, false); +} + +export function getKeyInQuery(query, keyToFind = '') { + return query + .split('&') + .reduce((oldValue, param) => { + const key = param.split('=')[0]; + const value = param + .split('=') + .slice(1) + .join('='); + + if (key === keyToFind) { + return value; + } + return oldValue; + }, null); +} + +export function getLegacySolutionFromQuery(query = '', decode) { + return flow( + getKeyInQuery, + decode, + decodeFcc + )(query, 'solution'); +} + +export function getCodeUri({ location, decodeURIComponent }) { + let query; + if ( + location.search && + legacyIsInQuery(location.search, decodeURIComponent) + ) { + query = location.search.replace(/^\?/, ''); + } else { + return null; + } + + return getLegacySolutionFromQuery(query, decodeURIComponent); +} diff --git a/common/app/components/Nav/Points-Nav-Item.jsx b/common/app/components/Nav/Points-Nav-Item.jsx index 633a237cd2..a738c6da33 100644 --- a/common/app/components/Nav/Points-Nav-Item.jsx +++ b/common/app/components/Nav/Points-Nav-Item.jsx @@ -7,8 +7,8 @@ export default React.createClass({ 'aria-controls': React.PropTypes.string, className: React.PropTypes.string, href: React.PropTypes.string, - onClick: React.PropTypes.func.isRequired, - points: React.PropTypes.func, + onClick: React.PropTypes.func, + points: React.PropTypes.number, title: React.PropTypes.node }, diff --git a/common/app/routes/challenges/utils.js b/common/app/routes/challenges/utils.js index 8eda75aabc..9b2355e133 100644 --- a/common/app/routes/challenges/utils.js +++ b/common/app/routes/challenges/utils.js @@ -1,42 +1,17 @@ -import { compose } from 'redux'; +import flow from 'lodash/flow'; import { bonfire, html, js } from '../../utils/challengeTypes'; +import { decodeScriptTags } from '../../../utils/encode-decode'; import protect from '../../utils/empty-protector'; -export function encodeScriptTags(value) { - return value - .replace(/'); -} - -export function encodeFormAction(value) { - return value.replace( - /]*>/, - val => val.replace(/action(\s*?)=/, 'fccfaa$1=') - ); -} - -export function decodeFccfaaAttr(value) { - return value.replace( - /]*>/, - val => val.replace(/fccfaa(\s*?)=/, 'action$1=') - ); -} - export function arrayToString(seedData = ['']) { seedData = Array.isArray(seedData) ? seedData : [seedData]; return seedData.reduce((seed, line) => '' + seed + line + '\n', '\n'); } export function buildSeed({ challengeSeed = [] } = {}) { - return compose( - decodeSafeTags, - arrayToString + return flow( + arrayToString, + decodeScriptTags )(challengeSeed); } diff --git a/common/utils/encode-decode.js b/common/utils/encode-decode.js new file mode 100644 index 0000000000..27fe4faa5a --- /dev/null +++ b/common/utils/encode-decode.js @@ -0,0 +1,46 @@ +import flow from 'lodash/flow'; + +// we don't store loop protect disable key +export function removeNoprotect(val) { + return val.replace(/noprotect/gi, ''); +} + +export function encodeScriptTags(val) { + return val + .replace(/'); +} + +export function encodeFormAction(val) { + return val.replace( + // look for attributes in a form + /]*>/, + // val is the string within the opening form tag + // look for an `action` attribute, replace it with a fcc tag + val => val.replace(/action(\s*?)=/, 'fccfaa$1=') + ); +} + +export function decodeFormAction(val) { + return val.replace( + /]*>/, + val => val.replace(/fccfaa(\s*?)=/, 'action$1=') + ); +} + +export const encodeFcc = flow([ + removeNoprotect, + encodeFormAction, + encodeScriptTags +]); + +export const decodeFcc = flow([ + decodeFormAction, + decodeScriptTags +]); From c919ce5dc8416e25413ebed363cd152c3c4ce190 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Mon, 15 Aug 2016 12:10:09 -0700 Subject: [PATCH 06/12] Feature(code-uri): Load and remove code uri on loadCode --- client/sagas/code-storage-saga.js | 21 +++++++++++++++++-- client/utils/code-uri.js | 17 ++++++++++++++- common/app/routes/challenges/redux/actions.js | 1 + common/app/routes/challenges/redux/reducer.js | 9 ++++++++ common/app/routes/challenges/redux/types.js | 1 + 5 files changed, 46 insertions(+), 3 deletions(-) diff --git a/client/sagas/code-storage-saga.js b/client/sagas/code-storage-saga.js index 440cf0a830..ce77025c28 100644 --- a/client/sagas/code-storage-saga.js +++ b/client/sagas/code-storage-saga.js @@ -1,6 +1,7 @@ import { Observable } from 'rx'; import store from 'store'; +import { removeCodeUri, getCodeUri } from '../utils/code-uri'; import { ofType } from '../../common/utils/get-actions-of-type'; import { updateContents } from '../../common/utils/polyvinyl'; import combineSagas from '../../common/utils/combine-sagas'; @@ -9,7 +10,8 @@ import { makeToast } from '../../common/app/toasts/redux/actions'; import types from '../../common/app/routes/challenges/redux/types'; import { savedCodeFound, - updateMain + updateMain, + lockUntrustedCode } from '../../common/app/routes/challenges/redux/actions'; const legacyPrefixes = [ @@ -58,7 +60,7 @@ export function saveCodeSaga(actions, getState) { }); } -export function loadCodeSaga(actions$, getState) { +export function loadCodeSaga(actions$, getState, { window, location }) { return actions$ ::ofType(types.loadCode) .flatMap(() => { @@ -71,6 +73,21 @@ export function loadCodeSaga(actions$, getState) { key } } = getState(); + const codeUriFound = getCodeUri( + location, + window.decodeURIComponent + ); + if (codeUriFound) { + finalFiles = legacyToFile(codeUriFound, files, key); + removeCodeUri(location, window.history); + return Observable.of( + lockUntrustedCode(), + makeToast({ + message: 'I found code in the URI. Loading now' + }), + savedCodeFound(finalFiles) + ); + } const codeFound = getCode(id); if (codeFound) { diff --git a/client/utils/code-uri.js b/client/utils/code-uri.js index 3622e5843c..715b034ba0 100644 --- a/client/utils/code-uri.js +++ b/client/utils/code-uri.js @@ -49,7 +49,7 @@ export function getLegacySolutionFromQuery(query = '', decode) { )(query, 'solution'); } -export function getCodeUri({ location, decodeURIComponent }) { +export function getCodeUri(location, decodeURIComponent) { let query; if ( location.search && @@ -62,3 +62,18 @@ export function getCodeUri({ location, decodeURIComponent }) { return getLegacySolutionFromQuery(query, decodeURIComponent); } + +export function removeCodeUri(location, history) { + if ( + typeof location.search.split !== 'function' || + typeof history.replaceState !== 'function' + ) { + return false; + } + history.replaceState( + history.state, + null, + location.search.split('?')[0] + ); + return true; +} diff --git a/common/app/routes/challenges/redux/actions.js b/common/app/routes/challenges/redux/actions.js index 05467fa999..0d8c291224 100644 --- a/common/app/routes/challenges/redux/actions.js +++ b/common/app/routes/challenges/redux/actions.js @@ -22,6 +22,7 @@ export const fetchChallengeCompleted = createAction( ); export const resetUi = createAction(types.resetUi); export const updateHint = createAction(types.updateHint); +export const lockUntrustedCode = createAction(types.lockUntrustedCode); export const fetchChallenges = createAction(types.fetchChallenges); export const fetchChallengesCompleted = createAction( diff --git a/common/app/routes/challenges/redux/reducer.js b/common/app/routes/challenges/redux/reducer.js index ed14c18c77..12248cc689 100644 --- a/common/app/routes/challenges/redux/reducer.js +++ b/common/app/routes/challenges/redux/reducer.js @@ -45,6 +45,7 @@ const initialUiState = { shouldShowQuestions: false }; const initialState = { + isCodeLocked: false, id: '', challenge: '', helpChatRoom: 'Help', @@ -88,6 +89,14 @@ const mainReducer = handleActions( 0 : state.hintIndex + 1 }), + [types.lockUntrustedCode]: state => ({ + ...state, + isCodeLocked: true + }), + [types.unlockCode]: state => ({ + ...state, + isCodeLocked: false + }), [types.executeChallenge]: state => ({ ...state, tests: state.tests.map(test => ({ ...test, err: false, pass: false })) diff --git a/common/app/routes/challenges/redux/types.js b/common/app/routes/challenges/redux/types.js index 5fdb679724..15e4675b99 100644 --- a/common/app/routes/challenges/redux/types.js +++ b/common/app/routes/challenges/redux/types.js @@ -17,6 +17,7 @@ export default createTypes([ 'replaceChallenge', 'resetUi', 'updateHint', + 'lockUntrustedCode', // map 'updateFilter', From fb9c1001b053939b2fce715ba139e41e3374e163 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Mon, 15 Aug 2016 13:34:01 -0700 Subject: [PATCH 07/12] Fix(code-uri): use href instead of search to remove code uri --- client/utils/code-uri.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/utils/code-uri.js b/client/utils/code-uri.js index 715b034ba0..99572b3f68 100644 --- a/client/utils/code-uri.js +++ b/client/utils/code-uri.js @@ -65,7 +65,7 @@ export function getCodeUri(location, decodeURIComponent) { export function removeCodeUri(location, history) { if ( - typeof location.search.split !== 'function' || + typeof location.href.split !== 'function' || typeof history.replaceState !== 'function' ) { return false; @@ -73,7 +73,7 @@ export function removeCodeUri(location, history) { history.replaceState( history.state, null, - location.search.split('?')[0] + location.href.split('?')[0] ); return true; } From f9cf212fe72070623c6146a836c4f0c2c57dde4a Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Tue, 16 Aug 2016 15:59:23 -0700 Subject: [PATCH 08/12] Feature(code-uri): Lock untrusted code from playing on first load --- client/sagas/code-storage-saga.js | 2 + client/sagas/execute-challenge-saga.js | 2 + client/sagas/frame-saga.js | 14 ++--- .../components/classic/Side-Panel.jsx | 22 +++++--- .../components/classic/Tool-Panel.jsx | 54 ++++++++++++++----- common/app/routes/challenges/redux/actions.js | 4 ++ common/app/routes/challenges/redux/reducer.js | 2 +- common/app/routes/challenges/redux/types.js | 1 + 8 files changed, 75 insertions(+), 26 deletions(-) diff --git a/client/sagas/code-storage-saga.js b/client/sagas/code-storage-saga.js index ce77025c28..e1ad5d7b4f 100644 --- a/client/sagas/code-storage-saga.js +++ b/client/sagas/code-storage-saga.js @@ -53,6 +53,8 @@ function legacyToFile(code, files, key) { export function saveCodeSaga(actions, getState) { return actions ::ofType(types.saveCode) + // do not save challenge if code is locked + .filter(() => !getState().challengesApp.isCodeLocked) .map(() => { const { challengesApp: { id = '', files = {} } } = getState(); store.set(id, files); diff --git a/client/sagas/execute-challenge-saga.js b/client/sagas/execute-challenge-saga.js index 247502a910..3c28814faf 100644 --- a/client/sagas/execute-challenge-saga.js +++ b/client/sagas/execute-challenge-saga.js @@ -91,6 +91,8 @@ export default function executeChallengeSaga(action$, getState) { type === types.executeChallenge || type === types.updateMain )) + // if isCodeLockedTrue do not run challenges + .filter(() => !getState().challengesApp.isCodeLocked) .debounce(750) .flatMapLatest(({ type }) => { const state = getState(); diff --git a/client/sagas/frame-saga.js b/client/sagas/frame-saga.js index c9acf2f0ae..0aac0dd741 100644 --- a/client/sagas/frame-saga.js +++ b/client/sagas/frame-saga.js @@ -2,6 +2,7 @@ import Rx, { Observable, Subject } from 'rx'; /* eslint-disable import/no-unresolved */ import loopProtect from 'loop-protect'; /* eslint-enable import/no-unresolved */ +import { ofType } from '../../common/utils/get-actions-of-type'; import types from '../../common/app/routes/challenges/redux/types'; import { updateOutput, @@ -89,12 +90,13 @@ export default function frameSaga(actions$, getState, { window, document }) { const proxyLogger$ = new Subject(); const runTests$ = window.__common[testId + 'Ready$'] = new Subject(); - const result$ = actions$ - .filter(({ type }) => ( - type === types.frameMain || - type === types.frameTests || - type === types.frameOutput - )) + const result$ = actions$::ofType( + types.frameMain, + types.frameTests, + types.frameOutput + ) + // if isCodeLocked is true do not frame user code + .filter(() => !getState().challengesApp.isCodeLocked) .map(action => { if (action.type === types.frameMain) { return frameMain(action.payload, document, proxyLogger$); diff --git a/common/app/routes/challenges/components/classic/Side-Panel.jsx b/common/app/routes/challenges/components/classic/Side-Panel.jsx index 4175f9c122..3c45c6903b 100644 --- a/common/app/routes/challenges/components/classic/Side-Panel.jsx +++ b/common/app/routes/challenges/components/classic/Side-Panel.jsx @@ -12,7 +12,8 @@ import { challengeSelector } from '../../redux/selectors'; import { openBugModal, updateHint, - executeChallenge + executeChallenge, + unlockUntrustedCode } from '../../redux/actions'; import { makeToast } from '../../../../toasts/redux/actions'; import { toggleHelpChat } from '../../../../redux/actions'; @@ -22,7 +23,8 @@ const bindableActions = { executeChallenge, updateHint, toggleHelpChat, - openBugModal + openBugModal, + unlockUntrustedCode }; const mapStateToProps = createSelector( challengeSelector, @@ -31,20 +33,23 @@ const mapStateToProps = createSelector( state => state.challengesApp.tests, state => state.challengesApp.output, state => state.challengesApp.hintIndex, + state => state.challengesApp.isCodeLocked, ( { challenge: { title, description, hints = [] } = {} }, windowHeight, navHeight, tests, output, - hintIndex + hintIndex, + isCodeLocked ) => ({ title, description, height: windowHeight - navHeight - 20, tests, output, - hint: hints[hintIndex] + hint: hints[hintIndex], + isCodeLocked }) ); @@ -65,7 +70,8 @@ export class SidePanel extends PureComponent { updateHint: PropTypes.func, makeToast: PropTypes.func, toggleHelpChat: PropTypes.func, - openBugModal: PropTypes.func + openBugModal: PropTypes.func, + unlockUntrustedCode: PropTypes.func }; renderDescription(description = [ 'Happy Coding!' ], descriptionRegex) { @@ -106,7 +112,9 @@ export class SidePanel extends PureComponent { updateHint, makeToast, toggleHelpChat, - openBugModal + openBugModal, + isCodeLocked, + unlockUntrustedCode } = this.props; const style = {}; if (height) { @@ -135,9 +143,11 @@ export class SidePanel extends PureComponent { diff --git a/common/app/routes/challenges/components/classic/Tool-Panel.jsx b/common/app/routes/challenges/components/classic/Tool-Panel.jsx index adabc3e94a..44f09aa518 100644 --- a/common/app/routes/challenges/components/classic/Tool-Panel.jsx +++ b/common/app/routes/challenges/components/classic/Tool-Panel.jsx @@ -14,8 +14,10 @@ export default class ToolPanel extends PureComponent { executeChallenge: PropTypes.func, updateHint: PropTypes.func, hint: PropTypes.string, + isCodeLocked: PropTypes.bool, toggleHelpChat: PropTypes.func, - openBugModal: PropTypes.func + openBugModal: PropTypes.func, + unlockUntrustedCode: PropTypes.func.isRequired }; makeHint() { @@ -51,24 +53,50 @@ export default class ToolPanel extends PureComponent { ); } - render() { - const { - hint, - executeChallenge, - toggleHelpChat, - openBugModal - } = this.props; - return ( -
- { this.renderHint(hint, this.makeHint) } + renderExecute(isCodeLocked, executeChallenge, unlockUntrustedCode) { + if (isCodeLocked) { + return ( + ); + } + return ( + + ); + } + + render() { + const { + hint, + isCodeLocked, + executeChallenge, + toggleHelpChat, + openBugModal, + unlockUntrustedCode + } = this.props; + return ( +
+ { this.renderHint(hint, this.makeHint) } + { + this.renderExecute( + isCodeLocked, + executeChallenge, + unlockUntrustedCode + ) + }
null +); export const fetchChallenges = createAction(types.fetchChallenges); export const fetchChallengesCompleted = createAction( diff --git a/common/app/routes/challenges/redux/reducer.js b/common/app/routes/challenges/redux/reducer.js index 12248cc689..7c88d212d1 100644 --- a/common/app/routes/challenges/redux/reducer.js +++ b/common/app/routes/challenges/redux/reducer.js @@ -93,7 +93,7 @@ const mainReducer = handleActions( ...state, isCodeLocked: true }), - [types.unlockCode]: state => ({ + [types.unlockUntrustedCode]: state => ({ ...state, isCodeLocked: false }), diff --git a/common/app/routes/challenges/redux/types.js b/common/app/routes/challenges/redux/types.js index 15e4675b99..5601ff2935 100644 --- a/common/app/routes/challenges/redux/types.js +++ b/common/app/routes/challenges/redux/types.js @@ -18,6 +18,7 @@ export default createTypes([ 'resetUi', 'updateHint', 'lockUntrustedCode', + 'unlockUntrustedCode', // map 'updateFilter', From 9f7c9a89cd410f41c73d300373a6eb8298e2f8f1 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Tue, 16 Aug 2016 16:07:52 -0700 Subject: [PATCH 09/12] Feature(code-uri): Add tooltip warning about untrusted code --- .../components/classic/Tool-Panel.jsx | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/common/app/routes/challenges/components/classic/Tool-Panel.jsx b/common/app/routes/challenges/components/classic/Tool-Panel.jsx index 44f09aa518..c501b2a283 100644 --- a/common/app/routes/challenges/components/classic/Tool-Panel.jsx +++ b/common/app/routes/challenges/components/classic/Tool-Panel.jsx @@ -1,7 +1,14 @@ import React, { PropTypes } from 'react'; -import { Button, ButtonGroup } from 'react-bootstrap'; +import { Button, ButtonGroup, Tooltip, OverlayTrigger } from 'react-bootstrap'; import PureComponent from 'react-pure-render/component'; +const unlockWarning = ( + +

+ Careful! Only run code you trust +

+
+); export default class ToolPanel extends PureComponent { constructor(...props) { super(...props); @@ -56,14 +63,19 @@ export default class ToolPanel extends PureComponent { renderExecute(isCodeLocked, executeChallenge, unlockUntrustedCode) { if (isCodeLocked) { return ( - + + ); } return ( From 0e8c0b847044181492e731e339ba46e77c533f8b Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Thu, 18 Aug 2016 19:33:24 -0700 Subject: [PATCH 10/12] Feature(code-storage): clear code storage on challenge completion --- client/sagas/code-storage-saga.js | 11 ++++++++++- common/app/routes/challenges/redux/actions.js | 1 + common/app/routes/challenges/redux/completion-saga.js | 8 ++++++-- common/app/routes/challenges/redux/types.js | 1 + 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/client/sagas/code-storage-saga.js b/client/sagas/code-storage-saga.js index e1ad5d7b4f..a8aebbdea3 100644 --- a/client/sagas/code-storage-saga.js +++ b/client/sagas/code-storage-saga.js @@ -50,6 +50,15 @@ function legacyToFile(code, files, key) { return { [key]: updateContents(code, files[key]) }; } +export function clearCodeSaga(actions, getState) { + return actions + ::ofType(types.clearSavedCode) + .map(() => { + const { challengesApp: { id = '' } } = getState(); + store.clear(id); + return null; + }); +} export function saveCodeSaga(actions, getState) { return actions ::ofType(types.saveCode) @@ -114,4 +123,4 @@ export function loadCodeSaga(actions$, getState, { window, location }) { }); } -export default combineSagas(saveCodeSaga, loadCodeSaga); +export default combineSagas(saveCodeSaga, loadCodeSaga, clearCodeSaga); diff --git a/common/app/routes/challenges/redux/actions.js b/common/app/routes/challenges/redux/actions.js index c2feeded8c..47a6b61b2d 100644 --- a/common/app/routes/challenges/redux/actions.js +++ b/common/app/routes/challenges/redux/actions.js @@ -90,6 +90,7 @@ export const moveToNextChallenge = createAction(types.moveToNextChallenge); export const saveCode = createAction(types.saveCode); export const loadCode = createAction(types.loadCode); export const savedCodeFound = createAction(types.savedCodeFound); +export const clearSavedCode = createAction(types.clearSavedCode); // video challenges diff --git a/common/app/routes/challenges/redux/completion-saga.js b/common/app/routes/challenges/redux/completion-saga.js index d5cad8f32f..c12f820787 100644 --- a/common/app/routes/challenges/redux/completion-saga.js +++ b/common/app/routes/challenges/redux/completion-saga.js @@ -1,7 +1,10 @@ import { Observable } from 'rx'; import types from './types'; -import { moveToNextChallenge } from './actions'; +import { + moveToNextChallenge, + clearSavedCode +} from './actions'; import { challengeSelector } from './selectors'; import { randomCompliment } from '../../../utils/get-words'; @@ -24,7 +27,8 @@ function postChallenge(url, username, _csrf, challengeInfo) { updateUserChallenge( username, { ...challengeInfo, lastUpdated, completedDate } - ) + ), + clearSavedCode() ); }) .catch(createErrorObservable); diff --git a/common/app/routes/challenges/redux/types.js b/common/app/routes/challenges/redux/types.js index 5601ff2935..d6189961a9 100644 --- a/common/app/routes/challenges/redux/types.js +++ b/common/app/routes/challenges/redux/types.js @@ -51,6 +51,7 @@ export default createTypes([ 'saveCode', 'loadCode', 'savedCodeFound', + 'clearSavedCode', // video challenges 'toggleQuestionView', From 8f0ce00dc41908a280d2a5cc4425e78f79068d26 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Fri, 19 Aug 2016 12:13:24 -0700 Subject: [PATCH 11/12] Feature(challenge): Load previously solved solution. --- client/sagas/code-storage-saga.js | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/client/sagas/code-storage-saga.js b/client/sagas/code-storage-saga.js index a8aebbdea3..d61f19d8d2 100644 --- a/client/sagas/code-storage-saga.js +++ b/client/sagas/code-storage-saga.js @@ -6,6 +6,7 @@ import { ofType } from '../../common/utils/get-actions-of-type'; import { updateContents } from '../../common/utils/polyvinyl'; import combineSagas from '../../common/utils/combine-sagas'; +import { userSelector } from '../../common/app/redux/selectors'; import { makeToast } from '../../common/app/toasts/redux/actions'; import types from '../../common/app/routes/challenges/redux/types'; import { @@ -76,6 +77,8 @@ export function loadCodeSaga(actions$, getState, { window, location }) { ::ofType(types.loadCode) .flatMap(() => { let finalFiles; + const state = getState(); + const { user } = userSelector(state); const { challengesApp: { id = '', @@ -83,7 +86,7 @@ export function loadCodeSaga(actions$, getState, { window, location }) { legacyKey = '', key } - } = getState(); + } = state; const codeUriFound = getCodeUri( location, window.decodeURIComponent @@ -94,7 +97,7 @@ export function loadCodeSaga(actions$, getState, { window, location }) { return Observable.of( lockUntrustedCode(), makeToast({ - message: 'I found code in the URI. Loading now' + message: 'I found code in the URI. Loading now.' }), savedCodeFound(finalFiles) ); @@ -113,12 +116,31 @@ export function loadCodeSaga(actions$, getState, { window, location }) { if (finalFiles) { return Observable.of( makeToast({ - message: 'I found some saved work. Loading now' + message: 'I found some saved work. Loading now.' }), savedCodeFound(finalFiles), updateMain() ); } + + if (user.challengeMap && user.challengeMap[id]) { + const userChallenge = user.challengeMap[id]; + if (userChallenge.files) { + finalFiles = userChallenge.files; + } else if (userChallenge.solution) { + finalFiles = legacyToFile(userChallenge.solution, files, key); + } + if (finalFiles) { + return Observable.of( + makeToast({ + message: 'I found a previous solved solution. Loading now.' + }), + savedCodeFound(finalFiles), + updateMain() + ); + } + } + return Observable.empty(); }); } From be91ce3c42ea3e8af02251cc3cca7c1a220bb122 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 31 Aug 2016 15:16:00 -0700 Subject: [PATCH 12/12] Fix(code-uri): should use store.remove instead of clear --- client/sagas/code-storage-saga.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/sagas/code-storage-saga.js b/client/sagas/code-storage-saga.js index d61f19d8d2..cc7904ba67 100644 --- a/client/sagas/code-storage-saga.js +++ b/client/sagas/code-storage-saga.js @@ -56,7 +56,7 @@ export function clearCodeSaga(actions, getState) { ::ofType(types.clearSavedCode) .map(() => { const { challengesApp: { id = '' } } = getState(); - store.clear(id); + store.remove(id); return null; }); }