diff --git a/client/sagas/code-storage-saga.js b/client/sagas/code-storage-saga.js index fe5856ded2..cc7904ba67 100644 --- a/client/sagas/code-storage-saga.js +++ b/client/sagas/code-storage-saga.js @@ -1,51 +1,148 @@ +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'; + +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 { - savedCodeFound + savedCodeFound, + updateMain, + lockUntrustedCode } from '../../common/app/routes/challenges/redux/actions'; -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); } -export default function codeStorageSaga(actions$, getState) { - return actions$ - .filter(({ type }) => ( - type === types.saveCode || - type === types.loadCode - )) - .map(({ type }) => { - const { id = '', files = {}, legacyKey = '' } = getState().challengesApp; - if (type === types.saveCode) { - store.set(id, files); - return null; - } - const codeFound = getCode(id, legacyKey); - if (codeFound) { - return savedCodeFound(codeFound); - } +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.remove(id); return null; }); } +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); + return null; + }); +} + +export function loadCodeSaga(actions$, getState, { window, location }) { + return actions$ + ::ofType(types.loadCode) + .flatMap(() => { + let finalFiles; + const state = getState(); + const { user } = userSelector(state); + const { + challengesApp: { + id = '', + files = {}, + legacyKey = '', + key + } + } = state; + 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) { + finalFiles = codeFound; + } else { + const legacyCode = getLegacyCode(legacyKey); + if (legacyCode) { + finalFiles = legacyToFile(legacyCode, files, key); + } + } + + if (finalFiles) { + return Observable.of( + makeToast({ + 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(); + }); +} + +export default combineSagas(saveCodeSaga, loadCodeSaga, clearCodeSaga); 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/client/utils/code-uri.js b/client/utils/code-uri.js new file mode 100644 index 0000000000..99572b3f68 --- /dev/null +++ b/client/utils/code-uri.js @@ -0,0 +1,79 @@ +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); +} + +export function removeCodeUri(location, history) { + if ( + typeof location.href.split !== 'function' || + typeof history.replaceState !== 'function' + ) { + return false; + } + history.replaceState( + history.state, + null, + location.href.split('?')[0] + ); + return true; +} 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/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/challenges/components/classic/Classic.jsx b/common/app/routes/challenges/components/classic/Classic.jsx index e463ae6d60..cbfac6292c 100644 --- a/common/app/routes/challenges/components/classic/Classic.jsx +++ b/common/app/routes/challenges/components/classic/Classic.jsx @@ -11,22 +11,24 @@ import BugModal from '../Bug-Modal.jsx'; import { challengeSelector } from '../../redux/selectors'; import { executeChallenge, - updateMain, updateFile, loadCode } from '../../redux/actions'; 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, @@ -38,7 +40,6 @@ const mapStateToProps = createSelector( const bindableActions = { executeChallenge, updateFile, - updateMain, loadCode }; @@ -46,18 +47,23 @@ export class Challenge extends PureComponent { static displayName = 'Challenge'; static propTypes = { + id: PropTypes.string, showPreview: PropTypes.bool, content: PropTypes.string, mode: PropTypes.string, updateFile: PropTypes.func, executeChallenge: PropTypes.func, - updateMain: PropTypes.func, loadCode: PropTypes.func }; componentDidMount() { this.props.loadCode(); - this.props.updateMain(); + } + + componentWillReceiveProps(nextProps) { + if (this.props.id !== nextProps.id) { + this.props.loadCode(); + } } renderPreview(showPreview) { 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..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); @@ -14,8 +21,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 +60,55 @@ export default class ToolPanel extends PureComponent { ); } + renderExecute(isCodeLocked, executeChallenge, unlockUntrustedCode) { + if (isCodeLocked) { + return ( + + + + ); + } + return ( + + ); + } + render() { const { hint, + isCodeLocked, executeChallenge, toggleHelpChat, - openBugModal + 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( @@ -85,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/reducer.js b/common/app/routes/challenges/redux/reducer.js index ed14c18c77..7c88d212d1 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.unlockUntrustedCode]: 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..d6189961a9 100644 --- a/common/app/routes/challenges/redux/types.js +++ b/common/app/routes/challenges/redux/types.js @@ -17,6 +17,8 @@ export default createTypes([ 'replaceChallenge', 'resetUi', 'updateHint', + 'lockUntrustedCode', + 'unlockUntrustedCode', // map 'updateFilter', @@ -49,6 +51,7 @@ export default createTypes([ 'saveCode', 'loadCode', 'savedCodeFound', + 'clearSavedCode', // video challenges 'toggleQuestionView', 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/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/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 +]); 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