diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index 0452e9ba4c..27a4b7e948 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -123,6 +123,7 @@ const GUIComponent = props => { authorAvatarBadge, basePath, backdropLibraryVisible, + backpackConfigured, backpackHost, backpackVisible, blocksId, @@ -520,7 +521,7 @@ const GUIComponent = props => { /> : null} - {backpackVisible ? ( + {backpackVisible && backpackConfigured ? ( ({ blocksId: state.scratchGui.timeTravel.year.toString(), stageSizeMode: state.scratchGui.stageSize.stageSize, colorMode: state.scratchGui.settings.colorMode, - theme: state.scratchGui.settings.theme + theme: state.scratchGui.settings.theme, + backpackConfigured: !!state.scratchGui.config.storage?.backpackStorage }); const mapDispatchToProps = dispatch => ({ diff --git a/packages/scratch-gui/src/containers/backpack.jsx b/packages/scratch-gui/src/containers/backpack.jsx index aef0afe163..2c07cd6689 100644 --- a/packages/scratch-gui/src/containers/backpack.jsx +++ b/packages/scratch-gui/src/containers/backpack.jsx @@ -2,15 +2,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import bindAll from 'lodash.bindall'; import BackpackComponent from '../components/backpack/backpack.jsx'; -import { - getBackpackContents, - saveBackpackObject, - deleteBackpackObject, - soundPayload, - costumePayload, - spritePayload, - codePayload -} from '../lib/backpack-api'; +import soundPayload from '../lib/backpack/sound-payload'; +import costumePayload from '../lib/backpack/costume-payload'; +import spritePayload from '../lib/backpack/sprite-payload'; +import codePayload from '../lib/backpack/code-payload'; +import {PayloadSerializableData} from '../lib/backpack/payload-serializable-data.ts'; import DragConstants from '../lib/drag-constants'; import DropAreaHOC from '../lib/drop-area-hoc.jsx'; import {GUIStoragePropType} from '../gui-config'; @@ -53,15 +49,31 @@ class Backpack extends React.Component { if (props.host) { props.storage.setBackpackHost?.(props.host); } + // Set initial session + this.updateBackpackSession(props); } componentDidMount () { this.props.vm.addListener('BLOCK_DRAG_END', this.handleBlockDragEnd); this.props.vm.addListener('BLOCK_DRAG_UPDATE', this.handleBlockDragUpdate); } + componentDidUpdate (prevProps) { + // Update session when credentials change + if (prevProps.username !== this.props.username || prevProps.token !== this.props.token) { + this.updateBackpackSession(this.props); + } + } componentWillUnmount () { this.props.vm.removeListener('BLOCK_DRAG_END', this.handleBlockDragEnd); this.props.vm.removeListener('BLOCK_DRAG_UPDATE', this.handleBlockDragUpdate); } + updateBackpackSession (props) { + const {username, token} = props; + if (username && token) { + props.storage.backpackStorage?.setSession?.({username, token}); + } else { + props.storage.backpackStorage?.setSession?.(null); + } + } handleToggle () { const newState = !this.state.expanded; this.setState({expanded: newState, contents: []}, () => { @@ -74,6 +86,7 @@ class Backpack extends React.Component { } handleDrop (dragInfo) { const scratchStorage = this.props.storage.scratchStorage; + const backpackStorage = this.props.storage.backpackStorage; let payloader = null; let presaveAsset = null; @@ -111,12 +124,22 @@ class Backpack extends React.Component { } return payload; }) - .then(payload => saveBackpackObject({ - host: this.props.host, - token: this.props.token, - username: this.props.username, - ...payload - })) + .then(payload => { + if (!backpackStorage) { + // Shouldn't happen as this component shouldn't be rendered without a backpack, but + // adding this just in case + return; + } + + const serializableData = new PayloadSerializableData(payload); + return backpackStorage.save( + { + type: serializableData.getType(), + name: serializableData.getName() + }, + serializableData + ); + }) .then(item => { this.setState({ loading: false, @@ -131,12 +154,7 @@ class Backpack extends React.Component { } handleDelete (id) { this.setState({loading: true}, () => { - deleteBackpackObject({ - host: this.props.host, - token: this.props.token, - username: this.props.username, - id: id - }) + this.props.storage.backpackStorage.delete(id) .then(() => { this.setState({ loading: false, @@ -150,28 +168,23 @@ class Backpack extends React.Component { }); } getContents () { - if (this.props.token && this.props.username) { - this.setState({loading: true, error: false}, () => { - getBackpackContents({ - host: this.props.host, - token: this.props.token, - username: this.props.username, - offset: this.state.contents.length, - limit: this.state.itemsPerPage - }) - .then(contents => { - this.setState({ - contents: this.state.contents.concat(contents), - moreToLoad: contents.length === this.state.itemsPerPage, - loading: false - }); - }) - .catch(error => { - this.setState({error: true, loading: false}); - throw error; + this.setState({loading: true, error: false}, () => { + this.props.storage.backpackStorage.list({ + offset: this.state.contents.length, + limit: this.state.itemsPerPage + }) + .then(contents => { + this.setState({ + contents: this.state.contents.concat(contents), + moreToLoad: contents.length === this.state.itemsPerPage, + loading: false }); - }); - } + }) + .catch(error => { + this.setState({error: true, loading: false}); + throw error; + }); + }); } handleBlockDragUpdate (isOutsideWorkspace) { this.setState({ diff --git a/packages/scratch-gui/src/gui-config.ts b/packages/scratch-gui/src/gui-config.ts index 0773972d32..5b43ae3049 100644 --- a/packages/scratch-gui/src/gui-config.ts +++ b/packages/scratch-gui/src/gui-config.ts @@ -10,6 +10,7 @@ export interface GUIConfig { export interface GUIStorage { scratchStorage: ScratchStorage; + backpackStorage?: GUIBackpackStorage; // Called multiple times (as changes happen) setProjectHost?(host: string): void; @@ -31,8 +32,90 @@ export interface GUIStorage { ): Promise<{ id: ProjectId }>; saveProjectThumbnail?(projectId: ProjectId, thumbnail: Blob): void; +} + +export interface GUIBackpackStorage { + setSession?(session: BackpackSession | null | undefined): void; + + list(request: BackpackListItemsInput): Promise; + save(item: BackpackSaveItemInput, data: SerializableData): Promise; + delete(id: string): Promise; +} + +export interface BackpackSession { + username: string; + token: string; +} + +export interface BackpackListItemsInput { + limit: number, + offset: number +} + +export interface BackpackSaveItemInput { + /** + * Type of backpack object + */ + type: BackpackItemType, + + /** + * User-facing name of the object being saved + */ + name: string, +} - // TODO: Support backpack storage +export interface SerializableData { + mimeType(): string, + dataAsBase64(): Promise, + thumbnailAsBase64(): Promise +} + +export type BackpackItemType = 'costume' | 'sound' | 'script' | 'sprite'; + +export interface BackpackItem { + /** + * A unique identifier for the backpack item. + * UUID format. + */ + id: string, + + /** + * Name of the item + */ + name: string, + + /** + * The type of backpack item + */ + type: BackpackItemType, + + /** + * The path (URL without host) of the thumbnail + */ + thumbnail: string, + + /** + * The full URL (incl. host) of the thumbnail + */ + thumbnailUrl: string, + + /** + * The md5ext of the backpack item. + * + * Different backpack items are loaded from different places: + * + * - costume -> the md5ext specified here is loaded from + * the asset server (has to be registered on the storage instance) + * - sound -> same as above + * - script -> loaded from the backpack server using `bodyUrl`. The `body` field isn't used. + * - sprite -> same as above + */ + body: string, + + /** + * The full URL (incl. host) of the backpack body + */ + bodyUrl: string, } export type TranslatorFunction = ( @@ -46,8 +129,16 @@ export interface MessageObject { defaultMessage: string; } +export const GUIBackpackStoragePropType = PropTypes.shape({ + list: PropTypes.func.isRequired, + save: PropTypes.func.isRequired, + delete: PropTypes.func.isRequired, + setSession: PropTypes.func +}); + export const GUIStoragePropType = PropTypes.shape({ scratchStorage: PropTypes.object.isRequired, + backpackStorage: GUIBackpackStoragePropType, setProjectHost: PropTypes.func, setProjectToken: PropTypes.func, diff --git a/packages/scratch-gui/src/index-standalone.tsx b/packages/scratch-gui/src/index-standalone.tsx index 2bf3d94f87..f3113af37e 100644 --- a/packages/scratch-gui/src/index-standalone.tsx +++ b/packages/scratch-gui/src/index-standalone.tsx @@ -16,10 +16,12 @@ export * from './exported-reducers'; export * from 'scratch-storage'; +export * from './lib/legacy-backpack-storage'; + export {default as buildDefaultProject} from './lib/default-project'; // TODO: Better typing once ScratchGUI has types - + export type GUIProps = any; // ComponentPropsWithoutRef; export type HigherOrderComponent = (component: ReactComponentLike) => ReactComponentLike; diff --git a/packages/scratch-gui/src/lib/backpack-api.js b/packages/scratch-gui/src/lib/backpack-api.js index 37a0aded03..41eb670c9d 100644 --- a/packages/scratch-gui/src/lib/backpack-api.js +++ b/packages/scratch-gui/src/lib/backpack-api.js @@ -1,77 +1,4 @@ import xhr from 'xhr'; -import costumePayload from './backpack/costume-payload'; -import soundPayload from './backpack/sound-payload'; -import spritePayload from './backpack/sprite-payload'; -import codePayload from './backpack/code-payload'; - -// Add a new property for the full thumbnail url, which includes the host. -// Also include a full body url for loading sprite zips -// TODO retreiving the images through storage would allow us to remove this. -const includeFullUrls = (item, host) => Object.assign({}, item, { - thumbnailUrl: `${host}/${item.thumbnail}`, - bodyUrl: `${host}/${item.body}` -}); - -const getBackpackContents = ({ - host, - username, - token, - limit, - offset -}) => new Promise((resolve, reject) => { - xhr({ - method: 'GET', - uri: `${host}/${username}?limit=${limit}&offset=${offset}`, - headers: {'x-token': token}, - json: true - }, (error, response) => { - if (error || response.statusCode !== 200) { - return reject(new Error(response.status)); - } - return resolve(response.body.map(item => includeFullUrls(item, host))); - }); -}); - -const saveBackpackObject = ({ - host, - username, - token, - type, // Type of object being saved to the backpack - mime, // Mime-type of the object being saved - name, // User-facing name of the object being saved - body, // Base64-encoded body of the object being saved - thumbnail // Base64-encoded JPEG thumbnail of the object being saved -}) => new Promise((resolve, reject) => { - xhr({ - method: 'POST', - uri: `${host}/${username}`, - headers: {'x-token': token}, - json: {type, mime, name, body, thumbnail} - }, (error, response) => { - if (error || response.statusCode !== 200) { - return reject(new Error(response.status)); - } - return resolve(includeFullUrls(response.body, host)); - }); -}); - -const deleteBackpackObject = ({ - host, - username, - token, - id -}) => new Promise((resolve, reject) => { - xhr({ - method: 'DELETE', - uri: `${host}/${username}/${id}`, - headers: {'x-token': token} - }, (error, response) => { - if (error || response.statusCode !== 200) { - return reject(new Error(response.status)); - } - return resolve(response.body); - }); -}); // Two types of backpack items are not retreivable through storage // code, as json and sprite3 as arraybuffer zips. @@ -90,13 +17,6 @@ const fetchCode = fetchAs.bind(null, 'json'); const fetchSprite = fetchAs.bind(null, 'arraybuffer'); export { - getBackpackContents, - saveBackpackObject, - deleteBackpackObject, - costumePayload, - soundPayload, - spritePayload, - codePayload, fetchCode, fetchSprite }; diff --git a/packages/scratch-gui/src/lib/backpack/payload-serializable-data.ts b/packages/scratch-gui/src/lib/backpack/payload-serializable-data.ts new file mode 100644 index 0000000000..e56d6aaa49 --- /dev/null +++ b/packages/scratch-gui/src/lib/backpack/payload-serializable-data.ts @@ -0,0 +1,51 @@ +import {SerializableData, BackpackItemType} from '../../gui-config'; + +/** + * The shape of a backpack payload as returned by the existing payload functions + * (costume-payload, sound-payload, sprite-payload, code-payload) + */ +export interface BackpackPayload { + type: BackpackItemType; + name: string; + mime: string; + body: string; + thumbnail: string; +} + +/** + * Adapter class that wraps an existing payload object to implement the SerializableData interface. + * This allows the legacy payload functions to be used with the new GUIBackpackStorage interface. + */ +export class PayloadSerializableData implements SerializableData { + private payload: BackpackPayload; + + constructor (payload: BackpackPayload) { + this.payload = payload; + } + + mimeType (): string { + return this.payload.mime; + } + + dataAsBase64 (): Promise { + return Promise.resolve(this.payload.body); + } + + thumbnailAsBase64 (): Promise { + return Promise.resolve(this.payload.thumbnail); + } + + /** + * Returns the type of the backpack item + */ + getType (): BackpackItemType { + return this.payload.type; + } + + /** + * Returns the name of the backpack item + */ + getName (): string { + return this.payload.name; + } +} diff --git a/packages/scratch-gui/src/lib/legacy-backpack-storage.ts b/packages/scratch-gui/src/lib/legacy-backpack-storage.ts new file mode 100644 index 0000000000..f785b0c9b1 --- /dev/null +++ b/packages/scratch-gui/src/lib/legacy-backpack-storage.ts @@ -0,0 +1,173 @@ +import xhr from 'xhr'; +import {ScratchStorage, Asset} from 'scratch-storage'; +import { + GUIBackpackStorage, + BackpackListItemsInput, + BackpackSaveItemInput, + BackpackItem, + BackpackSession, + SerializableData +} from '../gui-config'; + +type BackpackItemWithoutUrls = Omit; + +// Add a new property for the full thumbnail url, which includes the host. +// Also include a full body url for loading sprite zips +// TODO retreiving the images through storage would allow us to remove this. +const includeFullUrls = (item: BackpackItemWithoutUrls, host: string): BackpackItem => ({ + ...item, + thumbnailUrl: `${host}/${item.thumbnail}`, + bodyUrl: `${host}/${item.body}` +}); + +export interface LegacyBackpackStorageConfig { + /** + * Reads the current authentication session - necessary for making a backpack request. + * + * It can be called with a missing session if the session was not set on the Redux store. + * In general, the session will be missing when the Standalone version of the editor is used. + */ + readAuth(session: BackpackSession | null | undefined): Promise +} + +export interface LegacyBackpackAuth { + /** + * The username of the user. This is part of the request URL so it's mandatory + */ + username: string, + + /** + * The authentication type - only these two are supported by this backpack service. + */ + authType: 'x-token' | 'jwt' + + /** + * The token to be provided as authentication + */ + authToken: string +} + +export class LegacyBackpackStorage implements GUIBackpackStorage { + private host?: string; + private webStoreRegistered = false; + private session: BackpackSession | null | undefined = null; + + constructor ( + private config: LegacyBackpackStorageConfig + ) {} + + /** + * Set the session for backpack API requests. + * + * This is only used by the non-standalone version of the editor, where the session + * is taken directly from scratch-www's Redux store. In all other cases this will be + * missing. + */ + setSession (session: BackpackSession | null | undefined): void { + this.session = session; + } + + // TODO: This is unsafe to call multiple times. It's fine in our usages for now, but should + // maybe be updated to remove the old webStore setting before adding the new one + setHostAndRegisterWebStore (host: string, scratchStorage: ScratchStorage): void { + this.host = host; + + if (!this.webStoreRegistered) { + const AssetType = scratchStorage.AssetType; + scratchStorage.addWebStore( + [AssetType.ImageVector, AssetType.ImageBitmap, AssetType.Sound], + this.getBackpackAssetURL.bind(this) + ); + this.webStoreRegistered = true; + } + } + + async list (request: BackpackListItemsInput): Promise { + const host = this.host; + if (!host) { + return Promise.reject(new Error('Backpack host not set')); + } + + const auth = await this.config.readAuth(this.session); + + return new Promise((resolve, reject) => { + xhr({ + method: 'GET', + uri: `${host}/${auth.username}?limit=${request.limit}&offset=${request.offset}`, + headers: auth.authType === 'x-token' ? + {'x-token': auth.authToken} : + {Authorization: `Bearer ${auth.authToken}`}, + json: true + }, (error, response) => { + if (error || response.statusCode !== 200) { + return reject(new Error(String(response.statusCode))); + } + const items = response.body as BackpackItemWithoutUrls[]; + return resolve(items.map(item => includeFullUrls(item, host))); + }); + }); + } + + async save (item: BackpackSaveItemInput, data: SerializableData): Promise { + const host = this.host; + if (!host) { + return Promise.reject(new Error('Backpack host not set')); + } + + const auth = await this.config.readAuth(this.session); + + return Promise.all([ + data.dataAsBase64(), + data.thumbnailAsBase64() + ]).then(([body, thumbnail]) => new Promise((resolve, reject) => { + xhr({ + method: 'POST', + uri: `${host}/${auth.username}`, + headers: auth.authType === 'x-token' ? + {'x-token': auth.authToken} : + {Authorization: `Bearer ${auth.authToken}`}, + json: { + type: item.type, + mime: data.mimeType(), + name: item.name, + + body, + thumbnail + } as any // The type of the json param is wrong + }, (error, response) => { + if (error || response.statusCode !== 200) { + return reject(new Error(String(response.statusCode))); + } + return resolve(includeFullUrls(response.body as BackpackItemWithoutUrls, host)); + }); + })); + } + + async delete (id: string): Promise { + const host = this.host; + if (!host) { + return Promise.reject(new Error('Backpack host not set')); + } + + const auth = await this.config.readAuth(this.session); + + return new Promise((resolve, reject) => { + xhr({ + method: 'DELETE', + uri: `${host}/${auth.username}/${id}`, + headers: auth.authType === 'x-token' ? + {'x-token': auth.authToken} : + {Authorization: `Bearer ${auth.authToken}`} + }, (error, response) => { + if (error || response.statusCode !== 200) { + return reject(new Error(String(response.statusCode))); + } + return resolve(); + }); + }); + } + + private getBackpackAssetURL (asset: Asset): string { + return `${this.host}/${asset.assetId}.${asset.dataFormat}`; + } +} diff --git a/packages/scratch-gui/src/lib/legacy-storage.ts b/packages/scratch-gui/src/lib/legacy-storage.ts index 33cb8ece0c..f68ff2b83b 100644 --- a/packages/scratch-gui/src/lib/legacy-storage.ts +++ b/packages/scratch-gui/src/lib/legacy-storage.ts @@ -2,6 +2,7 @@ import {ScratchStorage, Asset} from 'scratch-storage'; import defaultProject from './default-project'; import {GUIStorage, TranslatorFunction} from '../gui-config'; +import {LegacyBackpackStorage} from './legacy-backpack-storage'; import saveProjectToServer from '../lib/save-project-to-server'; @@ -9,10 +10,22 @@ export class LegacyStorage implements GUIStorage { private projectHost?: string; private projectToken?: string; private assetHost?: string; - private backpackHost?: string; private translator?: TranslatorFunction; readonly scratchStorage = new ScratchStorage(); + readonly backpackStorage = new LegacyBackpackStorage({ + readAuth (session) { + if (!session) { + return Promise.reject(new Error('missing session')); + } + + return Promise.resolve({ + username: session.username, + authType: 'x-token', + authToken: session.token + }); + } + }); constructor () { this.cacheDefaultProject(this.scratchStorage); @@ -46,22 +59,11 @@ export class LegacyStorage implements GUIStorage { setTranslatorFunction (translator: TranslatorFunction): void { this.translator = translator; - // TODO: Verify that this is correct this.cacheDefaultProject(this.scratchStorage); } setBackpackHost (host: string): void { - const shouldAddSource = !this.backpackHost; - if (shouldAddSource) { - const AssetType = this.scratchStorage.AssetType; - - this.scratchStorage.addWebStore( - [AssetType.ImageVector, AssetType.ImageBitmap, AssetType.Sound], - this.getBackpackAssetURL.bind(this) - ); - } - - this.backpackHost = host; + this.backpackStorage.setHostAndRegisterWebStore(host, this.scratchStorage); } saveProject ( @@ -147,8 +149,4 @@ export class LegacyStorage implements GUIStorage { withCredentials: true }; } - - private getBackpackAssetURL (asset) { - return `${this.backpackHost}/${asset.assetId}.${asset.dataFormat}`; - } }