From 9ba20f9ef3203f450829d9ce9274d2e1a3fd666f Mon Sep 17 00:00:00 2001 From: LuYifei Date: Sat, 3 Oct 2026 09:50:49 +0800 Subject: [PATCH] feat: connect editor to ScCN backend and asset library --- docs/sccn-backend.md | 69 +++++++ src/components/menu-bar/menu-bar.jsx | 14 +- .../menu-bar/project-title-input.jsx | 6 +- src/components/sccn-account/account.css | 14 ++ src/components/sccn-account/account.jsx | 176 ++++++++++++++++++ .../tw-project-input/project-input.jsx | 9 +- .../project-load-error.jsx | 19 +- src/containers/library-item.jsx | 3 +- src/lib/alerts/index.jsx | 6 +- src/lib/project-fetcher-hoc.jsx | 39 +--- src/lib/project-saver-hoc.jsx | 27 +-- src/lib/save-project-to-server.js | 71 +------ src/lib/sccn/api.js | 123 ++++++++++++ src/lib/sccn/gui-hoc.jsx | 58 ++++++ src/lib/sccn/library-assets.js | 4 + src/lib/tw-load-project-error.js | 8 + src/lib/tw-project-meta-fetcher-hoc.jsx | 44 +---- src/playground/render-gui.jsx | 15 +- src/playground/render-interface.jsx | 17 -- src/reducers/project-state.js | 9 +- .../reducers/project-state-reducer.test.js | 4 +- test/unit/sccn/api.test.js | 82 ++++++++ test/unit/sccn/autosave.test.js | 54 ++++++ test/unit/sccn/library-assets.test.js | 9 + test/unit/sccn/save-project.test.js | 26 +++ webpack.config.js | 2 + 26 files changed, 724 insertions(+), 184 deletions(-) create mode 100644 docs/sccn-backend.md create mode 100644 src/components/sccn-account/account.css create mode 100644 src/components/sccn-account/account.jsx create mode 100644 src/lib/sccn/api.js create mode 100644 src/lib/sccn/gui-hoc.jsx create mode 100644 src/lib/sccn/library-assets.js create mode 100644 test/unit/sccn/api.test.js create mode 100644 test/unit/sccn/autosave.test.js create mode 100644 test/unit/sccn/library-assets.test.js create mode 100644 test/unit/sccn/save-project.test.js diff --git a/docs/sccn-backend.md b/docs/sccn-backend.md new file mode 100644 index 000000000..6ab5d6292 --- /dev/null +++ b/docs/sccn-backend.md @@ -0,0 +1,69 @@ +# 中社后端接入 + +编辑器、播放器和嵌入页使用本站作品 ID。社区主站 `Frontend` 不在本次改动范围。 + +## 本地开发 + +1. 在 Backend 配置数据库并执行 `npm ci`、`npm run migrate`,确认迁移 008 已应用。 +2. Backend 的 `ALLOWED_ORIGINS` 加上 `http://localhost:8601`;保留已有主站 Origin。 +3. 在 Backend 执行 `npm run assets:import -- --gui ../scratch-gui`,再加 `--check` 校验素材完整。 +4. 启动 Backend(默认 3000),在本仓库执行 `npm ci` 和 `npm start`。 + 如果安装时跳过了 lifecycle scripts,先执行 `npm run prepublish` 下载并校验 micro:bit 资源。 +5. 打开 `http://localhost:8601/editor.html`,点右上角“登录以保存”,使用后端已注册账号。 + 本页面不创建账号,注册沿用主站。 + +开发构建默认 API 为 `http://localhost:3000/api`。可在启动或构建时用环境变量指定,例如: + +```sh +SCCN_API_BASE=https://api.example.com/api npm start +SCCN_API_BASE=https://api.example.com/api NODE_ENV=production npm run build +``` + +生产未指定时默认同源 `/api`,需反向代理到 Backend。API 地址不能由 URL 参数覆写。 +Cookie 请求始终携带 credentials;跨站部署需要后端启用 HTTPS、Secure、SameSite=None, +并允许编辑器 Origin。浏览器限制第三方 Cookie 时优先部署同源反向代理。 + +## 使用流程 + +- 登录恢复后,编辑中的新作品自动建立私有草稿;修改后 30 秒自动保存。 +- 文件菜单“立即保存”或 Ctrl/Cmd+S 保存到服务器;未登录时仍可下载到本地。 +- “保存副本”把当前编辑状态保存为新 ID,不修改原作;版本冲突后也可用它保留修改。 +- 右上角“我的作品”分页列出自己的作品;切换时对未保存修改提示确认。 +- `editor.html#123` 打开作品 123;刷新后读取后端最新版本、标题和素材。 +- 保存本地导入或 `project_url` 导入的项目后,清除导入参数,刷新时使用本站副本。 +- 本地 `.sb3` 导入/导出、打包功能保留。默认角色、背景、库素材和字体也会首次上传到本站, + 不会误用 Scratch 的 `asset.clean` 标记跳过上传;当前页面成功上传过的素材不重复上传。 +- 素材库缩略图、角色全部造型、声音预览与添加统一读取本站;不再回退到 Scratch 官方素材站。 +- 后端未提供云变量服务,因此不接入 TurboWarp 公共云变量服务器,避免本站 ID 与官方 ID 混用。 + +## 保存与错误处理 + +保存使用与实际加载内容绑定的 revision。恢复登录、窗口重新获得焦点时刷新权限, +不会替换本地 revision,因此不会把旧作品当作最新版本覆盖服务器。401 提示重新登录; +409 保留编辑内容,提示下载备份或保存副本。私有作品未登录时显示登录与重新加载入口。 +上传或保存失败不会显示保存成功;保存期间继续修改的内容保持未保存状态。 + +后端保存后的缩略图由 VM 截图上传。标题修改同样标记作品已变更并参与保存。 +复制/改编的新作品默认私有;本次未增加发布/审核、删除、社区页面或注册流程。 + +## 验证 + +```sh +npx jest --runInBand test/unit/sccn test/unit/reducers/project-state-reducer.test.js +npm run test:unit +npm run build +``` + +接入与状态机 53 项测试、既有 addon 33 项测试通过。浏览器联调使用隔离 MySQL 9.7 数据库, +验证登录/退出、新建、标题与快捷键保存、刷新恢复角色与背景、副本、我的作品、私有访问限制和 +并发冲突后另存副本。未使用业务库,未部署生产。 + +旧的 `test/unit/util/project-saver-hoc.test.jsx` 与 `project-fetcher-hoc.test.jsx` 不在默认测试脚本中: +当前 Jest 配置缺 DOM 环境及 webpack inline-loader 映射,直接执行会失败;本轮未扩展该旧测试环境。 + +本次新增及其余改动源码 lint 通过;菜单文件仍有 24 项既存 lint 错误(HEAD 为 25 项),未做无关清理。 + +## 素材与生产部署 + +完整的素材导入、离线迁移、Nginx/systemd 配置、升级和备份步骤见 [Backend 部署说明](../../Backend/docs/07-editor-deployment.md)。 +首次部署必须先导入素材,再发布 GUI;本轮已导入 1,305 个素材并完成完整性校验。 diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index 6caddb51b..55022f76b 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -32,6 +32,7 @@ import FramerateChanger from '../../containers/tw-framerate-changer.jsx'; import ChangeUsername from '../../containers/tw-change-username.jsx'; import CloudVariablesToggler from '../../containers/tw-cloud-toggler.jsx'; import TWSaveStatus from './tw-save-status.jsx'; +import Account from '../sccn-account/account.jsx'; import TWNews from './tw-news.jsx'; import {openTipsLibrary, openSettingsModal, openRestorePointModal} from '../../reducers/modals'; @@ -340,7 +341,11 @@ class MenuBar extends React.Component { const modifier = bowser.mac ? event.metaKey : event.ctrlKey; if (modifier) { if (event.key.toLowerCase() === 's') { - this.props.handleSaveProject(); + if (this.props.canSave) { + this.props.onClickSave(); + } else { + this.props.handleSaveProject(); + } event.preventDefault(); } else if (event.key.toLowerCase() === 'o') { event.preventDefault(); @@ -1035,9 +1040,10 @@ class MenuBar extends React.Component {
- + {this.props.canSave ? : ( + + )} +
{aboutButton} diff --git a/src/components/menu-bar/project-title-input.jsx b/src/components/menu-bar/project-title-input.jsx index fe7647cb0..23a348658 100644 --- a/src/components/menu-bar/project-title-input.jsx +++ b/src/components/menu-bar/project-title-input.jsx @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import {defineMessages, intlShape, injectIntl} from 'react-intl'; import {setProjectTitle} from '../../reducers/project-title'; +import {setProjectChanged} from '../../reducers/project-changed'; import BufferedInputHOC from '../forms/buffered-input-hoc.jsx'; import Input from '../forms/input.jsx'; @@ -48,7 +49,10 @@ const mapStateToProps = state => ({ }); const mapDispatchToProps = dispatch => ({ - onSubmit: title => dispatch(setProjectTitle(title)) + onSubmit: title => { + dispatch(setProjectTitle(title)); + dispatch(setProjectChanged()); + } }); export default injectIntl(connect( diff --git a/src/components/sccn-account/account.css b/src/components/sccn-account/account.css new file mode 100644 index 000000000..d01dd4730 --- /dev/null +++ b/src/components/sccn-account/account.css @@ -0,0 +1,14 @@ +@import "../../css/colors.css"; +.account { display: flex; align-items: center; gap: .5rem; } +.account button { cursor: pointer; border: 1px solid rgba(255,255,255,.5); border-radius: .35rem; padding: .45rem .65rem; color: inherit; background: transparent; font: inherit; } +.account button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } +.modal { width: min(30rem, 95vw); margin: 6rem auto; } +.content { padding: 1.25rem; color: $text-primary; background: $ui-primary; } +.content form, .content label { display: flex; flex-direction: column; gap: .75rem; } +.content label { margin-bottom: 1rem; } +.content input { padding: .65rem; border: 1px solid $ui-black-transparent; border-radius: .3rem; } +.content button { padding: .5rem .75rem; cursor: pointer; } +.projects { list-style: none; padding: 0; max-height: 45vh; overflow: auto; } +.projects button { width: 100%; text-align: left; margin-bottom: .5rem; overflow-wrap: anywhere; } +.pagination { display: flex; align-items: center; justify-content: space-between; gap: .5rem; } +.notice { position: fixed; top: 3.5rem; right: 1rem; max-width: min(34rem, 90vw); padding: 1rem; color: $text-primary; background: $ui-primary; border: 1px solid $ui-black-transparent; border-radius: .5rem; box-shadow: 0 4px 20px #0003; z-index: 10000; } diff --git a/src/components/sccn-account/account.jsx b/src/components/sccn-account/account.jsx new file mode 100644 index 000000000..8a9a43e76 --- /dev/null +++ b/src/components/sccn-account/account.jsx @@ -0,0 +1,176 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; +import backend from '../../lib/sccn/api'; +import {setProjectId} from '../../reducers/project-state'; +import Modal from '../modal/modal.jsx'; +import styles from './account.css'; + +class Account extends React.Component { + constructor (props) { + super(props); + this.state = {session: backend.state, panel: null, busy: false, items: [], page: 1, total: 0, error: ''}; + this.handleClose = () => this.setState({panel: null, error: ''}); + this.handleOpenLogin = () => this.setState({panel: 'login', error: ''}); + this.handleOpenProjects = () => this.loadProjects(1); + this.handleLogin = this.handleLogin.bind(this); + this.handleLogout = this.handleLogout.bind(this); + } + componentDidMount () { + this.unsubscribe = backend.subscribe(session => this.setState({session})); + } + componentWillUnmount () { + this.unsubscribe(); + } + async handleLogin (event) { + event.preventDefault(); + const fields = new FormData(event.currentTarget); + this.setState({busy: true, error: ''}); + try { + await backend.login(fields.get('email'), fields.get('password')); + this.handleClose(); + } catch (error) { + this.setState({error: error.message}); + } finally { + this.setState({busy: false}); + } + } + async handleLogout () { + this.setState({busy: true}); + try { + await backend.logout(); + this.handleClose(); + } catch (error) { + this.setState({error: error.message}); + } finally { + this.setState({busy: false}); + } + } + async loadProjects (page) { + this.setState({panel: 'projects', busy: true, error: ''}); + try { + const data = await backend.request(`/projects?page=${page}&pageSize=10`); + this.setState({items: data.items, total: data.total, page}); + } catch (error) { + this.setState({error: error.message}); + } finally { + this.setState({busy: false}); + } + } + openProject (id) { + // Do not silently discard unsaved work when navigating from the project list. + if (this.props.projectChanged && + !window.confirm('当前修改尚未保存,确定打开其他作品吗?')) return; // eslint-disable-line no-alert + // An imported URL must not keep overriding the selected server project. + const url = new URL(window.location.href); + url.searchParams.delete('project_url'); + url.searchParams.delete('project_title'); + history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`); + this.props.openProject(String(id)); + this.handleClose(); + } + /* eslint-disable react/jsx-no-bind */ + render () { + const {session, panel, busy, items, page, total, error} = this.state; + return ( +
+ {session.user ? ( + + + + + ) : } + {session.error && !panel ? ( +
+ {session.error.message} + +
+ ) : null} + {panel ? ( + +
+ {error ?

{error}

: null} + {panel === 'login' ? ( +
+ + + +
+ ) : ( + + {busy ?

{'正在读取作品…'}

: null} + {!busy && !items.length ?

{'还没有保存的作品。'}

: null} +
    + {items.map(project => ( +
  • + +
  • + ))} +
+
+ + {`第 ${page} 页 · 共 ${total} 个作品`} + +
+
+ )} +
+
+ ) : null} +
+ ); + } +} +Account.propTypes = {projectChanged: PropTypes.bool, openProject: PropTypes.func}; +export default connect(state => ({projectChanged: state.scratchGui.projectChanged}), dispatch => ({ + openProject: id => dispatch(setProjectId(id)) +}))(Account); diff --git a/src/components/tw-project-input/project-input.jsx b/src/components/tw-project-input/project-input.jsx index 0282402ff..cd32b49e2 100644 --- a/src/components/tw-project-input/project-input.jsx +++ b/src/components/tw-project-input/project-input.jsx @@ -8,13 +8,13 @@ import {defaultProjectId} from '../../reducers/project-state'; import styles from './project-input.css'; import {setProjectId} from '../../lib/tw-navigation-utils'; -const PROJECT_BASE = 'https://scratch.mit.edu/projects/'; +const PROJECT_BASE = ''; const messages = defineMessages({ tooltip: { - defaultMessage: 'Copy and paste a Scratch project link here!', + defaultMessage: '输入中社作品编号', description: 'Project ID input tooltip', - id: 'tw.input.tooltip' + id: 'sccn.input.tooltip' } }); @@ -48,7 +48,7 @@ class ProjectInput extends React.Component { } } extractProjectId (text) { - const numberMatch = text.match(/\d+/); + const numberMatch = text.trim().match(/^(\d+)$/); return numberMatch ? numberMatch[0] : null; } readProjectId (e) { @@ -96,6 +96,7 @@ class ProjectInput extends React.Component { /> ( + error instanceof BackendProjectError || error instanceof ProjectUnsharedError || error instanceof ProjectUnavailableLegalReasons ); @@ -136,7 +140,18 @@ const ProjectLoadError = ({error, isFullScreen}) => ( })} > - {error instanceof ProjectUnavailableLegalReasons ? ( + {error instanceof BackendProjectError ? ( + +

{'无法打开作品'}

+

{error.message}

+

{'私有作品需要使用作者账号登录。登录后请重新加载。'}

+ + +
+ ) : error instanceof ProjectUnavailableLegalReasons ? ( ) : ( diff --git a/src/containers/library-item.jsx b/src/containers/library-item.jsx index 65ad50d01..e92cc199d 100644 --- a/src/containers/library-item.jsx +++ b/src/containers/library-item.jsx @@ -4,6 +4,7 @@ import React from 'react'; import {injectIntl, intlShape, defineMessages} from 'react-intl'; import LibraryItemComponent from '../components/library-item/library-item.jsx'; +import {libraryAssetURL} from '../lib/sccn/library-assets'; const messages = defineMessages({ incompatible: { @@ -139,7 +140,7 @@ class LibraryItem extends React.PureComponent { render () { const iconMd5 = this.curIconMd5(); const iconURL = iconMd5 ? - `https://cdn.assets.scratch.mit.edu/internalapi/asset/${iconMd5}/get/` : + libraryAssetURL(iconMd5) : this.props.iconRawURL; return ( { - if (projectId === '0') { - return null; - } - // Parse ?token=abcdef - const searchParams = new URLSearchParams(location.search); - if (searchParams.has('token')) { - return searchParams.get('token'); - } - // Parse #1?token=abcdef - const hashParams = new URLSearchParams(location.hash.split('?')[1]); - if (hashParams.has('token')) { - return hashParams.get('token'); - } - try { - const metadata = await fetchProjectMeta(projectId); - return metadata.project_token; - } catch (e) { - log.error(e); - if (e instanceof ProjectUnavailableLegalReasons) { - throw e; - } - throw new ProjectUnsharedError('Cannot access project token. Project is probably unshared. See https://docs.turbowarp.org/unshared-projects'); - } -}; +const fetchProjectToken = projectId => (projectId === '0' ? + Promise.resolve(null) : backend.project(projectId).then(config => config.projectToken)); /* Higher Order Component to provide behavior for loading projects by id. If * there's no id, the default project is loaded. @@ -158,7 +133,7 @@ const ProjectFetcherHOC = function (WrappedComponent) { } }) .catch(err => { - this.props.onError(err); + this.props.onError(new BackendProjectError(err.message || '无法加载作品')); log.error(err); }); } @@ -210,8 +185,8 @@ const ProjectFetcherHOC = function (WrappedComponent) { vm: PropTypes.instanceOf(VM) }; ProjectFetcherComponent.defaultProps = { - assetHost: 'https://assets.scratch.mit.edu', - projectHost: 'https://projects.scratch.mit.edu' + assetHost: backend.url('/editor/assets'), + projectHost: backend.url('/editor/projects') }; const mapStateToProps = state => ({ diff --git a/src/lib/project-saver-hoc.jsx b/src/lib/project-saver-hoc.jsx index 3f1dc997f..a373a068b 100644 --- a/src/lib/project-saver-hoc.jsx +++ b/src/lib/project-saver-hoc.jsx @@ -6,7 +6,7 @@ import VM from 'scratch-vm'; import collectMetadata from '../lib/collect-metadata'; import log from '../lib/log'; -import storage from '../lib/storage'; +import backend from './sccn/api'; import dataURItoBlob from '../lib/data-uri-to-blob'; import saveProjectToServer from '../lib/save-project-to-server'; @@ -67,6 +67,10 @@ const ProjectSaverHOC = function (WrappedComponent) { this.props.onSetProjectSaver(this.tryToAutoSave); } componentDidUpdate (prevProps) { + if (this.rescheduleAfterSave && this.props.isShowingSaveable) { + this.rescheduleAfterSave = false; + this.scheduleAutoSave(); + } if (!this.props.isAnyCreatingNewState && prevProps.isAnyCreatingNewState) { this.reportTelemetryEvent('projectWasCreated'); } @@ -74,7 +78,8 @@ const ProjectSaverHOC = function (WrappedComponent) { this.reportTelemetryEvent('projectDidLoad'); } - if (this.props.projectChanged && !prevProps.projectChanged) { + if ((this.props.projectChanged && !prevProps.projectChanged) || + this.props.reduxProjectTitle !== prevProps.reduxProjectTitle) { this.scheduleAutoSave(); } if (this.props.isUpdating && !prevProps.isUpdating) { @@ -217,7 +222,7 @@ const ProjectSaverHOC = function (WrappedComponent) { * @param {?object} requestParams - object of params to add to request body */ storeProject (projectId, requestParams) { - requestParams = requestParams || {}; + requestParams = {title: this.props.reduxProjectTitle, ...requestParams}; this.clearAutoSaveTimeout(); // Serialize VM state now before embarking on // the asynchronous journey of storing assets to @@ -227,14 +232,8 @@ const ProjectSaverHOC = function (WrappedComponent) { // we just finished saving). const savedVMState = this.props.vm.toJSON(); return Promise.all(this.props.vm.assets - .filter(asset => !asset.clean) .map( - asset => storage.store( - asset.assetType, - asset.dataFormat, - asset.data, - asset.assetId - ).then(response => { + asset => backend.upload(asset).then(response => { // Asset servers respond with {status: ok} for successful POSTs if (response.status !== 'ok') { // Errors include a `code` property, e.g. "Forbidden" @@ -246,7 +245,13 @@ const ProjectSaverHOC = function (WrappedComponent) { ) .then(() => this.props.onUpdateProjectData(projectId, savedVMState, requestParams)) .then(response => { - this.props.onSetProjectUnchanged(); + if (this.props.vm.toJSON() === savedVMState && + this.props.reduxProjectTitle === requestParams.title) { + this.props.onSetProjectUnchanged(); + } else { + // The reducer is still in a saving state here; schedule once it returns to the editor. + this.rescheduleAfterSave = true; + } const id = response.id.toString(); if (id && this.props.onUpdateProjectThumbnail) { this.storeProjectThumbnail(id); diff --git a/src/lib/save-project-to-server.js b/src/lib/save-project-to-server.js index 74e01b4b6..765458541 100644 --- a/src/lib/save-project-to-server.js +++ b/src/lib/save-project-to-server.js @@ -1,63 +1,10 @@ -import queryString from 'query-string'; -import xhr from 'xhr'; -import storage from '../lib/storage'; +import backend from './sccn/api'; -/** - * Save a project JSON to the project server. - * This should eventually live in scratch-www. - * @param {number} projectId the ID of the project, null if a new project. - * @param {object} vmState the JSON project representation. - * @param {object} params the request params. - * @property {?number} params.originalId the original project ID if a copy/remix. - * @property {?boolean} params.isCopy a flag indicating if this save is creating a copy. - * @property {?boolean} params.isRemix a flag indicating if this save is creating a remix. - * @property {?string} params.title the title of the project. - * @return {Promise} A promise that resolves when the network request resolves. - */ -export default function (projectId, vmState, params) { - const opts = { - body: vmState, - // If we set json:true then the body is double-stringified, so don't - headers: { - 'Content-Type': 'application/json' - }, - withCredentials: true - }; - const creatingProject = projectId === null || typeof projectId === 'undefined'; - const queryParams = {}; - if (Object.prototype.hasOwnProperty.call(params, 'originalId')) queryParams.original_id = params.originalId; - if (Object.prototype.hasOwnProperty.call(params, 'isCopy')) queryParams.is_copy = params.isCopy; - if (Object.prototype.hasOwnProperty.call(params, 'isRemix')) queryParams.is_remix = params.isRemix; - if (Object.prototype.hasOwnProperty.call(params, 'title')) queryParams.title = params.title; - let qs = queryString.stringify(queryParams); - if (qs) qs = `?${qs}`; - if (creatingProject) { - Object.assign(opts, { - method: 'post', - url: `${storage.projectHost}/${qs}` - }); - } else { - Object.assign(opts, { - method: 'put', - url: `${storage.projectHost}/${projectId}${qs}` - }); - } - return new Promise((resolve, reject) => { - xhr(opts, (err, response) => { - if (err) return reject(err); - if (response.statusCode !== 200) return reject(response.statusCode); - let body; - try { - // Since we didn't set json: true, we have to parse manually - body = JSON.parse(response.body); - } catch (e) { - return reject(e); - } - body.id = projectId; - if (creatingProject) { - body.id = body['content-name']; - } - resolve(body); - }); - }); -} +export default async (projectId, vmState, params) => { + const result = await backend.save(projectId, vmState, params); + // Once an imported URL has been saved, reloading must open the server copy. + const url = new URL(window.location.href); + for (const key of ['project_url', 'project_title', 'token']) url.searchParams.delete(key); + history.replaceState(null, '', `${url.pathname}${url.search}${url.hash.split('?')[0]}`); + return result; +}; diff --git a/src/lib/sccn/api.js b/src/lib/sccn/api.js new file mode 100644 index 000000000..84af6837d --- /dev/null +++ b/src/lib/sccn/api.js @@ -0,0 +1,123 @@ +// The destination is deployment configuration, never a URL parameter supplied by a project. +const apiBase = (process.env.SCCN_API_BASE || '/api').replace(/\/$/, ''); + +class BackendClient { + constructor () { + this.state = {user: null, ready: false, error: null, projects: {}}; + this.listeners = new Set(); + this.uploaded = new Set(); + this.revisions = new Map(); + } + url (path) { + return `${apiBase}${path}`; + } + subscribe (listener) { + this.listeners.add(listener); + listener(this.state); + return () => this.listeners.delete(listener); + } + update (changes) { + this.state = {...this.state, ...changes}; + this.listeners.forEach(listener => listener(this.state)); + } + async request (path, options = {}) { + let response; + try { + response = await fetch(this.url(path), {credentials: 'include', ...options}); + } catch (cause) { + const error = new Error('无法连接服务器,请检查网络后重试。作品仍保留在编辑器中。'); + this.update({error}); + throw error; + } + let body; + try { + body = await response.json(); + } catch (cause) { + const error = new Error(`服务器返回了无效响应(${response.status}),请稍后重试。`); + this.update({error}); + throw error; + } + if (!response.ok || (body.code && body.code !== 0)) { + const error = new Error(response.status === 409 ? + '作品已在其他窗口更新。请先下载备份或另存副本,再重新打开作品。' : + response.status === 401 ? '登录已失效,请重新登录后保存。' : + body.message || `请求失败(${response.status})`); + error.status = response.status; + error.code = body.code; + this.update({error, ...(response.status === 401 ? {user: null} : {})}); + throw error; + } + return Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body; + } + async session () { + try { + const session = await this.request('/editor/session'); + const changedUser = (this.state.user && this.state.user.id) !== (session.user && session.user.id); + this.update({user: session.user, ready: true, ...(changedUser ? {projects: {}} : {})}); + } catch (error) { + this.update({user: null, ready: true}); + } + } + async login (email, password) { + await this.request('/auth/login', { + method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({email, password}) + }); + await this.session(); + if (!this.state.user) throw new Error('无法恢复登录会话,请检查浏览器 Cookie 设置。'); + this.update({error: null}); + } + async logout () { + await this.request('/auth/logout', {method: 'POST'}); + this.update({user: null, projects: {}}); + } + async project (id) { + const config = await this.request(`/projects/${encodeURIComponent(id)}/editor`); + this.revisions.set(String(id), config.revision); + this.update({projects: {...this.state.projects, [id]: config}}); + return config; + } + async permissions (id) { + // Refresh permissions after login without replacing the loaded revision (lost-update protection). + const config = await this.request(`/projects/${encodeURIComponent(id)}/editor`); + this.update({projects: {...this.state.projects, [id]: config}}); + } + async upload (asset) { + const name = `${asset.assetId}.${asset.dataFormat}`; + if (this.uploaded.has(name)) return {status: 'ok'}; + const result = await this.request(`/editor/assets/${name}`, { + method: 'POST', headers: {'Content-Type': 'application/octet-stream'}, body: asset.data + }); + this.uploaded.add(name); + return result; + } + async save (id, json, params = {}) { + const creating = id === null || typeof id === 'undefined' || String(id) === '0'; + const query = new URLSearchParams(); + if (params.title) query.set('title', params.title); + if (params.originalId) query.set('original_id', params.originalId); + if (params.isCopy) query.set('is_copy', '1'); + if (params.isRemix) query.set('is_remix', '1'); + if (!creating) { + const revision = this.revisions.get(String(id)); + if (!revision) throw new Error('作品版本信息缺失,请先下载备份并重新打开作品。'); + query.set('revision', revision); + } + const result = await this.request(`/editor/projects/${creating ? '' : id}?${query}`, { + method: creating ? 'POST' : 'PUT', headers: {'Content-Type': 'application/json'}, body: json + }); + this.revisions.set(String(result.id), result.revision); + this.update({error: null, + projects: {...this.state.projects, + [result.id]: { + canSave: true, canCreateCopy: true, canRemix: false, revision: result.revision + }}}); + return result; + } + thumbnail (id, blob) { + return this.request(`/projects/${id}/thumbnail`, {method: 'POST', body: blob}); + } +} + +const backend = new BackendClient(); +export {BackendClient}; +export default backend; diff --git a/src/lib/sccn/gui-hoc.jsx b/src/lib/sccn/gui-hoc.jsx new file mode 100644 index 000000000..d80c3b83b --- /dev/null +++ b/src/lib/sccn/gui-hoc.jsx @@ -0,0 +1,58 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; +import backend from './api'; + +const BackendHOC = WrappedComponent => { + class BackendGUI extends React.Component { + constructor (props) { + super(props); + this.state = backend.state; + this.refresh = () => backend.session(); + this.handleThumbnail = (id, blob) => backend.thumbnail(id, blob).catch(() => {}); + } + componentDidMount () { + this.unsubscribe = backend.subscribe(state => this.setState(state)); + this.refresh(); + window.addEventListener('focus', this.refresh); + } + componentDidUpdate (prevProps, prevState) { + if (this.state.user && this.state.user !== prevState.user && + this.props.backendProjectId && this.props.backendProjectId !== '0') { + backend.permissions(this.props.backendProjectId).catch(() => {}); + } + } + componentWillUnmount () { + this.unsubscribe(); + window.removeEventListener('focus', this.refresh); + } + render () { + const {backendProjectId, backendPlayerOnly, ...props} = this.props; + const project = this.state.projects[backendProjectId]; + const ownsProject = this.state.user && project && project.canSave; + return ( + + ); + } + } + BackendGUI.propTypes = { + backendProjectId: PropTypes.string, + backendPlayerOnly: PropTypes.bool + }; + return connect(state => ({ + backendProjectId: state.scratchGui.projectState.projectId, + backendPlayerOnly: state.scratchGui.mode.isPlayerOnly + }))(BackendGUI); +}; + +export default BackendHOC; diff --git a/src/lib/sccn/library-assets.js b/src/lib/sccn/library-assets.js new file mode 100644 index 000000000..dca8d2a3b --- /dev/null +++ b/src/lib/sccn/library-assets.js @@ -0,0 +1,4 @@ +import backend from './api'; + +// Thumbnails (including rotating sprite costumes) use the same backend as VM assets. +export const libraryAssetURL = name => backend.url(`/editor/assets/internalapi/asset/${name}/get/`); diff --git a/src/lib/tw-load-project-error.js b/src/lib/tw-load-project-error.js index 6dea32226..009126189 100644 --- a/src/lib/tw-load-project-error.js +++ b/src/lib/tw-load-project-error.js @@ -43,7 +43,15 @@ class ProjectUnavailableLegalReasons extends LoadProjectError { } } +class BackendProjectError extends LoadProjectError { + constructor (message) { + super(message); + this.name = 'BackendProjectError'; + } +} + export { + BackendProjectError, LoadProjectError, ProjectUnsharedError, ProjectFetchError, diff --git a/src/lib/tw-project-meta-fetcher-hoc.jsx b/src/lib/tw-project-meta-fetcher-hoc.jsx index df34bf4ea..f70991a50 100644 --- a/src/lib/tw-project-meta-fetcher-hoc.jsx +++ b/src/lib/tw-project-meta-fetcher-hoc.jsx @@ -5,42 +5,16 @@ import log from './log'; import {setProjectTitle} from '../reducers/project-title'; import {setAuthor, setDescription} from '../reducers/tw'; -import {ProjectUnavailableLegalReasons} from './tw-load-project-error'; +import backend from './sccn/api'; export const fetchProjectMeta = async projectId => { - // When people reopen tabs, sometimes the browser is *very* aggressive about caching even when the - // trampoline says not to, so we're going to try putting a cache buster in here. - const cacheBuster = `?rudebuster=${Math.random()}`; - const urls = [ - `https://trampoline.turbowarp.org/api/projects/${projectId}${cacheBuster}`, - `https://trampoline.turbowarp.xyz/api/projects/${projectId}${cacheBuster}` - ]; - let firstError; - for (const url of urls) { - try { - const res = await fetch(url); - const data = await res.json(); - if (res.ok) { - return data; - } - if (res.status === 451) { - throw new ProjectUnavailableLegalReasons('Project is unavailable for legal reasons', data.url); - } - if (res.status === 404) { - throw new Error('Project is probably unshared'); - } - throw new Error(`Unexpected status code: ${res.status}`); - } catch (err) { - // Mirror would get the same error so don't try again - if (err instanceof ProjectUnavailableLegalReasons) { - throw err; - } - if (!firstError) { - firstError = err; - } - } - } - throw firstError; + const metadata = await backend.request(`/projects/${projectId}`); + return { + ...metadata, + author: {id: metadata.owner_id, + username: backend.state.user && + backend.state.user.id === metadata.owner_id ? backend.state.user.username : `用户 ${metadata.owner_id}`} + }; }; const getNoIndexTag = () => document.querySelector('meta[name="robots"][content="noindex"]'); @@ -81,7 +55,7 @@ const TWProjectMetaFetcherHOC = function (WrappedComponent) { this.props.onSetProjectTitle(title); } const authorName = data.author.username; - const authorThumbnail = `https://trampoline.turbowarp.org/avatars/${data.author.id}`; + const authorThumbnail = ''; this.props.onSetAuthor(authorName, authorThumbnail); const instructions = data.instructions || ''; const credits = data.description || ''; diff --git a/src/playground/render-gui.jsx b/src/playground/render-gui.jsx index 41e7a60e4..71e6b04f5 100644 --- a/src/playground/render-gui.jsx +++ b/src/playground/render-gui.jsx @@ -1,15 +1,18 @@ import React from 'react'; import GUI from '../containers/gui.jsx'; +import BackendHOC from '../lib/sccn/gui-hoc.jsx'; -const searchParams = new URLSearchParams(location.search); -const cloudHost = searchParams.get('cloud_host') || 'wss://clouddata.turbowarp.org'; + +const initializeStorage = storage => { + storage.addOfficialScratchWebStores(); +}; const RenderGUI = props => ( ( /> ); -export default RenderGUI; +export default BackendHOC(RenderGUI); diff --git a/src/playground/render-interface.jsx b/src/playground/render-interface.jsx index 8f417c7ee..f22590706 100644 --- a/src/playground/render-interface.jsx +++ b/src/playground/render-interface.jsx @@ -32,7 +32,6 @@ import '../lib/tw-fix-history-api'; import GUI from './render-gui.jsx'; import MenuBar from '../components/menu-bar/menu-bar.jsx'; import ProjectInput from '../components/tw-project-input/project-input.jsx'; -import FeaturedProjects from '../components/tw-featured-projects/featured-projects.jsx'; import Description from '../components/tw-description/description.jsx'; import BrowserModal from '../components/browser-modal/browser-modal.jsx'; import CloudVariableBadge from '../containers/tw-cloud-variable-badge.jsx'; @@ -279,22 +278,6 @@ class Interface extends React.Component { /> ) : null} -
-

- -

-
-
- -
) : null} diff --git a/src/reducers/project-state.js b/src/reducers/project-state.js index 76955d589..23629e94f 100644 --- a/src/reducers/project-state.js +++ b/src/reducers/project-state.js @@ -20,7 +20,7 @@ const START_FETCHING_NEW = 'scratch-gui/project-state/START_FETCHING_NEW'; const START_LOADING_VM_FILE_UPLOAD = 'scratch-gui/project-state/START_LOADING_VM_FILE_UPLOAD'; const START_MANUAL_UPDATING = 'scratch-gui/project-state/START_MANUAL_UPDATING'; const START_REMIXING = 'scratch-gui/project-state/START_REMIXING'; -const START_UPDATING_BEFORE_CREATING_COPY = 'scratch-gui/project-state/START_UPDATING_BEFORE_CREATING_COPY'; +const START_CREATING_COPY = 'scratch-gui/project-state/START_CREATING_COPY'; const START_UPDATING_BEFORE_CREATING_NEW = 'scratch-gui/project-state/START_UPDATING_BEFORE_CREATING_NEW'; const defaultProjectId = '0'; // hardcoded id of default project @@ -308,10 +308,11 @@ const reducer = function (state, action) { }); } return state; - case START_UPDATING_BEFORE_CREATING_COPY: + case START_CREATING_COPY: if (state.loadingState === LoadingState.SHOWING_WITH_ID) { + // Save the current snapshot as a new project, even when the original has a version conflict. return Object.assign({}, state, { - loadingState: LoadingState.UPDATING_BEFORE_COPY + loadingState: LoadingState.CREATING_COPY }); } return state; @@ -497,7 +498,7 @@ const manualUpdateProject = () => ({ }); const saveProjectAsCopy = () => ({ - type: START_UPDATING_BEFORE_CREATING_COPY + type: START_CREATING_COPY }); const remixProject = () => ({ diff --git a/test/unit/reducers/project-state-reducer.test.js b/test/unit/reducers/project-state-reducer.test.js index 8aabc1072..fca3db6ae 100644 --- a/test/unit/reducers/project-state-reducer.test.js +++ b/test/unit/reducers/project-state-reducer.test.js @@ -378,14 +378,14 @@ test('autoUpdateProject should prepare to update', () => { expect(resultState.projectId).toBe('100'); }); -test('saveProjectAsCopy should save, before preparing to save as a copy', () => { +test('saveProjectAsCopy creates a copy without overwriting the original', () => { const initialState = { projectId: '100', loadingState: LoadingState.SHOWING_WITH_ID }; const action = saveProjectAsCopy(); const resultState = projectStateReducer(initialState, action); - expect(resultState.loadingState).toBe(LoadingState.UPDATING_BEFORE_COPY); + expect(resultState.loadingState).toBe(LoadingState.CREATING_COPY); expect(resultState.projectId).toBe('100'); }); diff --git a/test/unit/sccn/api.test.js b/test/unit/sccn/api.test.js new file mode 100644 index 000000000..ede0f26ba --- /dev/null +++ b/test/unit/sccn/api.test.js @@ -0,0 +1,82 @@ +import {BackendClient} from '../../../src/lib/sccn/api'; + +const response = (data, status = 200) => ({ok: status < 400, status, json: async () => data}); + +describe('ScCN backend adapter', () => { + let client; + beforeEach(() => { + client = new BackendClient(); + global.fetch = jest.fn(); + }); + afterEach(() => { delete global.fetch; }); + + test('uploads even clean builtin assets once per server session', async () => { + fetch.mockResolvedValue(response({status: 'ok'})); + const asset = {assetId: 'abc', dataFormat: 'svg', data: new Uint8Array([1, 2]), clean: true}; + await client.upload(asset); + await client.upload(asset); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch.mock.calls[0][1]).toMatchObject({credentials: 'include', method: 'POST', body: asset.data}); + }); + test('failed uploads remain retryable', async () => { + fetch.mockResolvedValueOnce(response({code: 40101}, 401)).mockResolvedValue(response({status: 'ok'})); + const asset = {assetId: 'abc', dataFormat: 'svg', data: new Uint8Array([1])}; + await expect(client.upload(asset)).rejects.toThrow('登录'); + await client.upload(asset); + expect(fetch).toHaveBeenCalledTimes(2); + }); + test('creates a project with raw JSON and updates with the loaded revision', async () => { + fetch.mockResolvedValueOnce(response({'content-name': '12', id: 12, revision: 1})) + .mockResolvedValueOnce(response({id: 12, revision: 2})); + await client.save(null, '{"targets":[]}', {title: '标题'}); + await client.save('12', '{"targets":[]}', {title: '新标题'}); + expect(fetch.mock.calls[0][0]).toContain('/editor/projects/?title='); + expect(fetch.mock.calls[0][1].body).toBe('{"targets":[]}'); + expect(fetch.mock.calls[1][0]).toContain('revision=1'); + expect(client.revisions.get('12')).toBe(2); + }); + test('does not overwrite the loaded revision when refreshing permissions', async () => { + fetch.mockResolvedValueOnce(response({data: {revision: 4, canSave: true}})) + .mockResolvedValueOnce(response({data: {revision: 9, canSave: true}})) + .mockResolvedValueOnce(response({code: 40902}, 409)); + await client.project('12'); + await client.permissions('12'); + expect(client.revisions.get('12')).toBe(4); + await expect(client.save('12', '{}')).rejects.toThrow('其他窗口'); + expect(client.revisions.get('12')).toBe(4); + expect(fetch.mock.calls[2][0]).toContain('revision=4'); + }); + test('copy and remix create new IDs without a revision precondition', async () => { + fetch.mockResolvedValue(response({id: 13, revision: 1})); + await client.save(null, '{}', {originalId: 12, isCopy: 1}); + expect(fetch.mock.calls[0][0]).toContain('original_id=12&is_copy=1'); + expect(fetch.mock.calls[0][0]).not.toContain('revision='); + await client.save(null, '{}', {originalId: 12, isRemix: 1}); + expect(fetch.mock.calls[1][0]).toContain('original_id=12&is_remix=1'); + }); + test('refuses updates without a loaded version', async () => { + await expect(client.save('12', '{}')).rejects.toThrow('版本'); + expect(fetch).not.toHaveBeenCalled(); + }); + test('switching accounts clears cached ownership', async () => { + client.state = {...client.state, user: {id: 1}, projects: {12: {canSave: true}}}; + fetch.mockResolvedValue(response({data: {user: {id: 2}}})); + await client.session(); + expect(client.state.projects).toEqual({}); + expect(client.state.user.id).toBe(2); + }); + test('logout clears ownership and uses the HttpOnly cookie', async () => { + client.state = {...client.state, user: {id: 1}, projects: {12: {canSave: true}}}; + fetch.mockResolvedValue(response({code: 0})); + await client.logout(); + expect(client.state.user).toBe(null); + expect(client.state.projects).toEqual({}); + expect(fetch.mock.calls[0][1]).toMatchObject({credentials: 'include', method: 'POST'}); + }); + test('handles network and non-JSON responses without claiming a successful save', async () => { + fetch.mockRejectedValueOnce(new Error('network')); + await expect(client.save(null, '{}')).rejects.toThrow('无法连接服务器'); + fetch.mockResolvedValueOnce({ok: true, status: 200, json: async () => { throw new Error('html'); }}); + await expect(client.save(null, '{}')).rejects.toThrow('无效响应'); + }); +}); diff --git a/test/unit/sccn/autosave.test.js b/test/unit/sccn/autosave.test.js new file mode 100644 index 000000000..6edd69dac --- /dev/null +++ b/test/unit/sccn/autosave.test.js @@ -0,0 +1,54 @@ +import projectSaverHOC from '../../../src/lib/project-saver-hoc.jsx'; + +jest.mock('react-redux', () => ({connect: () => Component => Component})); +jest.mock('scratch-vm', () => class VM {}); +jest.mock('../../../src/lib/collect-metadata', () => jest.fn()); +jest.mock('../../../src/lib/log', () => ({error: jest.fn()})); +jest.mock('../../../src/reducers/alerts', () => ({})); +jest.mock('../../../src/lib/sccn/api', () => ({upload: jest.fn()})); + +const makeSaver = () => { + const Component = projectSaverHOC(() => null); + const props = { + reduxProjectTitle: 'Original title', + vm: {toJSON: jest.fn(() => '{"targets":[]}'), assets: []}, + autoSaveTimeoutId: null, + autoSaveIntervalSecs: 30, + isShowingSaveable: false, + isUpdating: true, + projectChanged: true, + setAutoSaveTimeoutId: jest.fn(), + onSetProjectUnchanged: jest.fn() + }; + const instance = new Component(props); + instance.reportTelemetryEvent = jest.fn(); + return {instance, props}; +}; + +afterEach(() => jest.useRealTimers()); + +test('edits during a save remain dirty and schedule another save after the saving state ends', async () => { + jest.useFakeTimers(); + const {instance, props} = makeSaver(); + let finish; + props.onUpdateProjectData = jest.fn(() => new Promise(resolve => { finish = resolve; })); + const saving = instance.storeProject('1'); + await Promise.resolve(); + instance.props = {...props, reduxProjectTitle: 'Edited while saving'}; + finish({id: 1}); + await saving; + expect(props.onSetProjectUnchanged).not.toHaveBeenCalled(); + expect(props.setAutoSaveTimeoutId).not.toHaveBeenCalled(); + const previous = instance.props; + instance.props = {...previous, isUpdating: false, isShowingSaveable: true}; + instance.componentDidUpdate(previous); + expect(props.setAutoSaveTimeoutId).toHaveBeenCalledTimes(1); +}); + +test('an unchanged snapshot becomes clean without scheduling another save', async () => { + const {instance, props} = makeSaver(); + props.onUpdateProjectData = jest.fn().mockResolvedValue({id: 1}); + await instance.storeProject('1'); + expect(props.onSetProjectUnchanged).toHaveBeenCalledTimes(1); + expect(props.setAutoSaveTimeoutId).not.toHaveBeenCalled(); +}); diff --git a/test/unit/sccn/library-assets.test.js b/test/unit/sccn/library-assets.test.js new file mode 100644 index 000000000..61e309241 --- /dev/null +++ b/test/unit/sccn/library-assets.test.js @@ -0,0 +1,9 @@ +import {libraryAssetURL} from '../../../src/lib/sccn/library-assets'; + +jest.mock('../../../src/lib/sccn/api', () => ({url: path => `https://backend.example/api${path}`})); + +test('library previews use the configured backend including its API prefix', () => { + expect(libraryAssetURL('809d9b47347a6af2860e7a3a35bce057.svg')).toBe( + 'https://backend.example/api/editor/assets/internalapi/asset/809d9b47347a6af2860e7a3a35bce057.svg/get/' + ); +}); diff --git a/test/unit/sccn/save-project.test.js b/test/unit/sccn/save-project.test.js new file mode 100644 index 000000000..3a4a56298 --- /dev/null +++ b/test/unit/sccn/save-project.test.js @@ -0,0 +1,26 @@ +import saveProject from '../../../src/lib/save-project-to-server'; +import backend from '../../../src/lib/sccn/api'; + +jest.mock('../../../src/lib/sccn/api', () => ({save: jest.fn()})); + +describe('saved import navigation', () => { + beforeEach(() => { + global.window = {location: {href: 'http://localhost/editor.html?project_url=https%3A%2F%2Fexample.test%2Fa.sb3&project_title=old&token=read-token&locale=zh-cn#0?token=old'}}; + global.history = {replaceState: jest.fn()}; + }); + afterEach(() => { + delete global.window; + delete global.history; + jest.clearAllMocks(); + }); + test('successful save removes stale import overrides but preserves editor options', async () => { + backend.save.mockResolvedValue({id: 8, revision: 1}); + expect(await saveProject(null, '{}', {title: '作品'})).toEqual({id: 8, revision: 1}); + expect(history.replaceState).toHaveBeenCalledWith(null, '', '/editor.html?locale=zh-cn#0'); + }); + test('failed saves leave the import URL intact', async () => { + backend.save.mockRejectedValue(new Error('offline')); + await expect(saveProject(null, '{}')).rejects.toThrow('offline'); + expect(history.replaceState).not.toHaveBeenCalled(); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index 5060dbc62..145387904 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -179,6 +179,8 @@ module.exports = [ 'process.env.DEBUG': Boolean(process.env.DEBUG), 'process.env.ENABLE_SERVICE_WORKER': JSON.stringify(process.env.ENABLE_SERVICE_WORKER || ''), 'process.env.ROOT': JSON.stringify(root), + 'process.env.SCCN_API_BASE': JSON.stringify(process.env.SCCN_API_BASE || + (process.env.NODE_ENV === 'production' ? '/api' : 'http://localhost:3000/api')), 'process.env.ROUTING_STYLE': JSON.stringify(process.env.ROUTING_STYLE || 'filehash'), 'process.env.ENABLE_WINDCHIMES': JSON.stringify(process.env.ENABLE_WINDCHIMES || '') }),