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 {
+ Careful! Only run code you trust
+
+