Merge pull request #10200 from BerkeleyTrue/fix/code-uri

Fix(challenges): code storage/uri
This commit is contained in:
Quincy Larson
2016-08-31 16:18:50 -07:00
committed by GitHub
18 changed files with 383 additions and 87 deletions

View File

@ -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);

View File

@ -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();

View File

@ -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$);

79
client/utils/code-uri.js Normal file
View File

@ -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;
}

View File

@ -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
},

View File

@ -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');

View File

@ -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) {

View File

@ -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 {
<ToolPanel
executeChallenge={ executeChallenge }
hint={ hint }
isCodeLocked={ isCodeLocked }
makeToast={ makeToast }
openBugModal={ openBugModal }
toggleHelpChat={ toggleHelpChat }
unlockUntrustedCode={ unlockUntrustedCode }
updateHint={ updateHint }
/>
<Output output={ output }/>

View File

@ -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 = (
<Tooltip id='tooltip'>
<h4>
<strong>Careful!</strong> Only run code you trust
</h4>
</Tooltip>
);
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,16 +60,25 @@ export default class ToolPanel extends PureComponent {
);
}
render() {
const {
hint,
executeChallenge,
toggleHelpChat,
openBugModal
} = this.props;
renderExecute(isCodeLocked, executeChallenge, unlockUntrustedCode) {
if (isCodeLocked) {
return (
<OverlayTrigger
overlay={ unlockWarning }
placement='right'
>
<Button
block={ true }
bsStyle='primary'
className='btn-big'
onClick={ unlockUntrustedCode }
>
Code Locked. Unlock?
</Button>
</OverlayTrigger>
);
}
return (
<div>
{ this.renderHint(hint, this.makeHint) }
<Button
block={ true }
bsStyle='primary'
@ -69,6 +87,28 @@ export default class ToolPanel extends PureComponent {
>
Run tests (ctrl + enter)
</Button>
);
}
render() {
const {
hint,
isCodeLocked,
executeChallenge,
toggleHelpChat,
openBugModal,
unlockUntrustedCode
} = this.props;
return (
<div>
{ this.renderHint(hint, this.makeHint) }
{
this.renderExecute(
isCodeLocked,
executeChallenge,
unlockUntrustedCode
)
}
<div className='button-spacer' />
<ButtonGroup
className='input-group'

View File

@ -22,6 +22,11 @@ export const fetchChallengeCompleted = createAction(
);
export const resetUi = createAction(types.resetUi);
export const updateHint = createAction(types.updateHint);
export const lockUntrustedCode = createAction(types.lockUntrustedCode);
export const unlockUntrustedCode = createAction(
types.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

View File

@ -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);

View File

@ -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 }))

View File

@ -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',

View File

@ -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(/<script>/gi, 'fccss')
.replace(/<\/script>/gi, 'fcces');
}
export function decodeSafeTags(value) {
return value
.replace(/fccss/gi, '<script>')
.replace(/fcces/gi, '</script>');
}
export function encodeFormAction(value) {
return value.replace(
/<form[^>]*>/,
val => val.replace(/action(\s*?)=/, 'fccfaa$1=')
);
}
export function decodeFccfaaAttr(value) {
return value.replace(
/<form[^>]*>/,
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);
}

View File

@ -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',

View File

@ -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(/<script>/gi, 'fccss')
.replace(/<\/script>/gi, 'fcces');
}
export function decodeScriptTags(val) {
return val
.replace(/fccss/gi, '<script>')
.replace(/fcces/gi, '</script>');
}
export function encodeFormAction(val) {
return val.replace(
// look for attributes in a form
/<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(
/<form[^>]*>/,
val => val.replace(/fccfaa(\s*?)=/, 'action$1=')
);
}
export const encodeFcc = flow([
removeNoprotect,
encodeFormAction,
encodeScriptTags
]);
export const decodeFcc = flow([
decodeFormAction,
decodeScriptTags
]);

View File

@ -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