import React from 'react'; import PropTypes from 'prop-types'; import { intlShape, injectIntl } from 'react-intl'; import bindAll from 'lodash.bindall'; import { connect } from 'react-redux'; import JSZip from 'jszip'; import { protobufToJson } from 'pmp-protobuf' import { setProjectUnchanged } from '../reducers/project-changed'; import { LoadingStates, getIsCreatingNew, getIsFetchingWithId, getIsLoading, getIsShowingProject, onFetchedProjectData, projectError, setProjectId } from '../reducers/project-state'; import { activateTab, BLOCKS_TAB_INDEX } from '../reducers/editor-tab'; import log from './log'; import storage from './storage'; import { MISSING_PROJECT_ID } from './tw-missing-project'; import VM from 'scratch-vm'; import * as progressMonitor from '../components/loader/tw-progress-monitor'; // TW: Temporary hack for project tokens const fetchProjectToken = projectId => { if (projectId === '0') { return Promise.resolve(null); } // Parse ?token=abcdef const searchParams = new URLSearchParams(location.search); if (searchParams.has('token')) { return Promise.resolve(searchParams.get('token')); } // Parse #1?token=abcdef const hashParams = new URLSearchParams(location.hash.split('?')[1]); if (hashParams.has('token')) { return Promise.resolve(hashParams.get('token')); } return fetch(`https://projects.penguinmod.com/api/v1/projects/getproject?projectID=${projectId}&requestType=metadata`) .then(r => { if (!r.ok) return null; return r.json(); }) .then(dataOrNull => { const token = dataOrNull ? dataOrNull.id : null; return token; }) .catch(err => { log.error(err); return null; }); }; /* Higher Order Component to provide behavior for loading projects by id. If * there's no id, the default project is loaded. * @param {React.Component} WrappedComponent component to receive projectData prop * @returns {React.Component} component with project loading behavior */ const ProjectFetcherHOC = function (WrappedComponent) { class ProjectFetcherComponent extends React.Component { constructor(props) { super(props); bindAll(this, [ 'fetchProject' ]); storage.setProjectHost(props.projectHost); storage.setProjectToken(props.projectToken); storage.setAssetHost(props.assetHost); storage.setTranslatorFunction(props.intl.formatMessage); // props.projectId might be unset, in which case we use our default; // or it may be set by an even higher HOC, and passed to us. // Either way, we now know what the initial projectId should be, so // set it in the redux store. if ( props.projectId !== '' && props.projectId !== null && typeof props.projectId !== 'undefined' ) { this.props.setProjectId(props.projectId.toString()); } } componentDidUpdate(prevProps) { if (prevProps.projectHost !== this.props.projectHost) { storage.setProjectHost(this.props.projectHost); } if (prevProps.projectToken !== this.props.projectToken) { storage.setProjectToken(this.props.projectToken); } if (prevProps.assetHost !== this.props.assetHost) { storage.setAssetHost(this.props.assetHost); } if (this.props.isFetchingWithId && !prevProps.isFetchingWithId) { this.fetchProject(this.props.reduxProjectId, this.props.loadingState); } if (this.props.isShowingProject && !prevProps.isShowingProject) { this.props.onProjectUnchanged(); } if (this.props.isShowingProject && (prevProps.isLoadingProject || prevProps.isCreatingNew)) { this.props.onActivateTab(BLOCKS_TAB_INDEX); } } fetchProject(projectId, loadingState) { // tw: clear and stop the VM before fetching // these will also happen later after the project is fetched, but fetching may take a while and // the project shouldn't be running while fetching the new project this.props.vm.clear(); this.props.vm.stop(); let assetPromise; // In case running in node... let projectUrl = typeof URLSearchParams === 'undefined' ? null : new URLSearchParams(location.search).get('project_url'); if (projectUrl) { if (!projectUrl.startsWith('http:') && !projectUrl.startsWith('https:')) { projectUrl = `https://${projectUrl}`; } assetPromise = progressMonitor.fetchWithProgress(projectUrl) .then(r => { if (this.props.vm.runtime.renderer?.setPrivateSkinAccess) this.props.vm.runtime.renderer.setPrivateSkinAccess(false); if (!r.ok) { throw new Error(`Request returned status ${r.status}`); } return r.arrayBuffer(); }) .then(buffer => ({ data: buffer })); } else { // patch for default project if (projectId === '0') { storage.setProjectToken(projectId); assetPromise = storage.load(storage.AssetType.Project, projectId, storage.DataFormat.JSON); } else { projectUrl = `https://projects.penguinmod.com/api/v1/projects/getprojectwrapper?safe=true&projectId=${projectId}`; assetPromise = progressMonitor.fetchWithProgress(projectUrl) .then(async r => { if (this.props.vm.runtime.renderer?.setPrivateSkinAccess) this.props.vm.runtime.renderer.setPrivateSkinAccess(false); if (!r.ok) { throw new Error(`Request returned status ${r.status}`); } const project = await r.json(); const json = protobufToJson(new Uint8Array(project.project.data)); // now get the assets let zip = new JSZip(); zip.file("project.json", JSON.stringify(json)); for (const asset of project.assets) { zip.file(asset.id, new Uint8Array(asset.buffer.data).buffer); } const arrayBuffer = await zip.generateAsync({ type: "arraybuffer" }); return arrayBuffer; }) .then(buffer => ({ data: buffer })) .catch(error => { console.log(error) }) } } return assetPromise .then(projectAsset => { // tw: If the project data appears to be HTML, then the result is probably an nginx 404 page, // and the "missing project" project should be loaded instead. // See: https://projects.scratch.mit.edu/9999999999999999999999 if (projectAsset && projectAsset.data) { const firstChar = projectAsset.data[0]; if (firstChar === '<' || firstChar === '<'.charCodeAt(0)) { return storage.load(storage.AssetType.Project, MISSING_PROJECT_ID, storage.DataFormat.JSON); } } return projectAsset; }) .then(projectAsset => { if (projectAsset) { this.props.onFetchedProjectData(projectAsset.data, loadingState); } else { // pm: Failed to grab data, use the "fetch" API as a backup // we shouldnt be interrupted by the fetch replacement in tw-progress-monitor // as it uses projects.scratch.mit.edu still fetch(projectUrl).then(async res => { if (!res.ok) { // Treat failure to load as an error // Throw to be caught by catch later on throw new Error('Could not find project; ' + projectUrl); } const project = await res.json(); const json = protobufToJson(new Uint8Array(project.project.data)); // now get the assets let zip = new JSZip(); zip.file("project.json", JSON.stringify(json)); for (const asset of project.assets) { zip.file(asset.id, new Uint8Array(asset.buffer.data).buffer); } const arrayBuffer = await zip.generateAsync({ type: "arraybuffer" }); this.props.onFetchedProjectData(arrayBuffer, loadingState); }).catch(err => { throw new Error('Could not find project; ' + err); }) } }) .catch(err => { this.props.onError(err); log.error(err); }); } render() { const { /* eslint-disable no-unused-vars */ assetHost, intl, isLoadingProject: isLoadingProjectProp, loadingState, onActivateTab, onError: onErrorProp, onFetchedProjectData: onFetchedProjectDataProp, onProjectUnchanged, projectHost, projectId, reduxProjectId, setProjectId: setProjectIdProp, /* eslint-enable no-unused-vars */ isFetchingWithId: isFetchingWithIdProp, ...componentProps } = this.props; return ( ); } } ProjectFetcherComponent.propTypes = { assetHost: PropTypes.string, canSave: PropTypes.bool, intl: intlShape.isRequired, isCreatingNew: PropTypes.bool, isFetchingWithId: PropTypes.bool, isLoadingProject: PropTypes.bool, isShowingProject: PropTypes.bool, loadingState: PropTypes.oneOf(LoadingStates), onActivateTab: PropTypes.func, onError: PropTypes.func, onFetchedProjectData: PropTypes.func, onProjectUnchanged: PropTypes.func, projectHost: PropTypes.string, projectToken: PropTypes.string, projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), reduxProjectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), setProjectId: PropTypes.func, vm: PropTypes.instanceOf(VM) }; ProjectFetcherComponent.defaultProps = { assetHost: 'https://assets.scratch.mit.edu', projectHost: 'https://projects.scratch.mit.edu' }; const mapStateToProps = state => ({ isCreatingNew: getIsCreatingNew(state.scratchGui.projectState.loadingState), isFetchingWithId: getIsFetchingWithId(state.scratchGui.projectState.loadingState), isLoadingProject: getIsLoading(state.scratchGui.projectState.loadingState), isShowingProject: getIsShowingProject(state.scratchGui.projectState.loadingState), loadingState: state.scratchGui.projectState.loadingState, reduxProjectId: state.scratchGui.projectState.projectId, vm: state.scratchGui.vm }); const mapDispatchToProps = dispatch => ({ onActivateTab: tab => dispatch(activateTab(tab)), onError: error => dispatch(projectError(error)), onFetchedProjectData: (projectData, loadingState) => dispatch(onFetchedProjectData(projectData, loadingState)), setProjectId: projectId => dispatch(setProjectId(projectId)), onProjectUnchanged: () => dispatch(setProjectUnchanged()) }); // Allow incoming props to override redux-provided props. Used to mock in tests. const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign( {}, stateProps, dispatchProps, ownProps ); return injectIntl(connect( mapStateToProps, mapDispatchToProps, mergeProps )(ProjectFetcherComponent)); }; export { ProjectFetcherHOC as default };