From d308f3635ae818a875e22d9657002a19fd1908fe Mon Sep 17 00:00:00 2001 From: pyphilia Date: Tue, 11 Aug 2020 15:20:12 +0200 Subject: [PATCH 1/3] feat: save file locally when receiving postFile message --- public/app/config/channels.js | 1 + public/app/config/config.js | 8 +++++++ public/app/config/messages.js | 2 ++ public/app/listeners/index.js | 2 ++ public/app/listeners/postFile.js | 36 ++++++++++++++++++++++++++++ public/app/utilities.js | 14 +++++++++++ public/electron.js | 6 +++++ src/actions/file.js | 41 ++++++++++++++++++++++++++++++++ src/actions/index.js | 1 + src/components/phase/PhaseApp.js | 20 +++++++++++++++- src/config/channels.js | 1 + src/config/messages.js | 2 ++ src/types/file.js | 5 ++++ src/types/index.js | 1 + 14 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 public/app/listeners/postFile.js create mode 100644 src/actions/file.js create mode 100644 src/types/file.js diff --git a/public/app/config/channels.js b/public/app/config/channels.js index c84daf57..d18d7272 100644 --- a/public/app/config/channels.js +++ b/public/app/config/channels.js @@ -71,4 +71,5 @@ module.exports = { GET_SPACE_IN_CLASSROOM_CHANNEL: 'classroom:space:get', LOAD_SPACE_IN_CLASSROOM_CHANNEL: 'classroom:space:load', GET_SPACE_TO_LOAD_IN_CLASSROOM_CHANNEL: 'classroom:space:load:get-space', + POST_FILE_CHANNEL: 'file:post', }; diff --git a/public/app/config/config.js b/public/app/config/config.js index 963dc9b8..c2b975c5 100644 --- a/public/app/config/config.js +++ b/public/app/config/config.js @@ -1,5 +1,6 @@ // eslint-disable-next-line import/no-extraneous-dependencies const { app } = require('electron'); +const ObjectId = require('bson-objectid'); const isWindows = require('../utils/isWindows'); // types that we support downloading @@ -84,6 +85,12 @@ const ACTIONS_VERBS = { LOGOUT: 'logout', }; +const buildFilesPath = ({ userId, spaceId, name }) => { + // add generated id to handle duplicate files + const generatedId = ObjectId().str; + return `${VAR_FOLDER}/${spaceId}/files/${userId}/${generatedId}_${name}`; +}; + module.exports = { DEFAULT_LOGGING_LEVEL, DEFAULT_PROTOCOL, @@ -110,4 +117,5 @@ module.exports = { VISIBILITIES, DEFAULT_FORMAT, ACTIONS_VERBS, + buildFilesPath, }; diff --git a/public/app/config/messages.js b/public/app/config/messages.js index 4c4350e1..05f974c3 100644 --- a/public/app/config/messages.js +++ b/public/app/config/messages.js @@ -102,6 +102,7 @@ const ERROR_GETTING_SPACE_IN_CLASSROOM_MESSAGE = const ERROR_SETTING_ACTION_ACCESSIBILITY = 'There was an error setting the action accessibility'; const ERROR_SETTING_ACTIONS_AS_ENABLED = 'There was an error enabling actions'; +const ERROR_POSTING_FILE_MESSAGE = 'There was an error uploading the file'; module.exports = { ERROR_GETTING_DEVELOPER_MODE, @@ -172,4 +173,5 @@ module.exports = { ERROR_INVALID_USERNAME_MESSAGE, ERROR_SETTING_ACTION_ACCESSIBILITY, ERROR_SETTING_ACTIONS_AS_ENABLED, + ERROR_POSTING_FILE_MESSAGE, }; diff --git a/public/app/listeners/index.js b/public/app/listeners/index.js index 0ad7c61d..72d4c82f 100644 --- a/public/app/listeners/index.js +++ b/public/app/listeners/index.js @@ -50,6 +50,7 @@ const loadSpaceInClassroom = require('./loadSpaceInClassroom'); const setActionAccessibility = require('./setActionAccessibility'); const setActionsAsEnabled = require('./setActionsAsEnabled'); const windowAllClosed = require('./windowAllClosed'); +const postFile = require('./postFile'); module.exports = { loadSpace, @@ -103,4 +104,5 @@ module.exports = { setActionAccessibility, setActionsAsEnabled, windowAllClosed, + postFile, }; diff --git a/public/app/listeners/postFile.js b/public/app/listeners/postFile.js new file mode 100644 index 00000000..bf61044a --- /dev/null +++ b/public/app/listeners/postFile.js @@ -0,0 +1,36 @@ +const fs = require('fs'); +const { POST_FILE_CHANNEL } = require('../config/channels'); +const { buildFilesPath } = require('../config/config'); +const { ensureDirectoryExistence } = require('../utilities'); +const logger = require('../logger'); +const { ERROR_GENERAL } = require('../config/errors'); + +const postFile = mainWindow => (event, payload = {}) => { + try { + const { userId, spaceId, data } = payload; + + // download file given path + const { path, name } = data; + + const savePath = buildFilesPath({ userId, spaceId, name }); + ensureDirectoryExistence(savePath); + fs.copyFile(path, savePath, err => { + if (err) { + throw err; + } + logger.debug(`the file ${name} was uploaded`); + }); + + // update data + const newData = { name, uri: `file://${savePath}` }; + const newPayload = { ...payload, data: newData }; + + // send back the resource + mainWindow.webContents.send(POST_FILE_CHANNEL, newPayload); + } catch (e) { + console.error(e); + mainWindow.webContents.send(POST_FILE_CHANNEL, ERROR_GENERAL); + } +}; + +module.exports = postFile; diff --git a/public/app/utilities.js b/public/app/utilities.js index 13d7ff59..3ec9ee07 100644 --- a/public/app/utilities.js +++ b/public/app/utilities.js @@ -1,5 +1,6 @@ const mkdirp = require('mkdirp'); const rimraf = require('rimraf'); +const path = require('path'); const { promisify } = require('util'); const mime = require('mime-types'); const md5 = require('md5'); @@ -135,8 +136,21 @@ const clean = async dir => { return promisify(rimraf)(dir); }; +/** + * Ensure directories for given filepath exist + */ +function ensureDirectoryExistence(filePath) { + const dirname = path.dirname(filePath); + if (fs.existsSync(dirname)) { + return true; + } + ensureDirectoryExistence(dirname); + return fs.mkdirSync(dirname); +} + module.exports = { clean, + ensureDirectoryExistence, performFileSystemOperation, getExtension, isDownloadable, diff --git a/public/electron.js b/public/electron.js index c7f351f7..5e825d26 100644 --- a/public/electron.js +++ b/public/electron.js @@ -74,6 +74,7 @@ const { LOAD_SPACE_IN_CLASSROOM_CHANNEL, SET_ACTION_ACCESSIBILITY_CHANNEL, SET_ACTIONS_AS_ENABLED_CHANNEL, + POST_FILE_CHANNEL, } = require('./app/config/channels'); const env = require('./env.json'); const { @@ -127,6 +128,7 @@ const { setActionAccessibility, setActionsAsEnabled, windowAllClosed, + postFile, } = require('./app/listeners'); const isMac = require('./app/utils/isMac'); @@ -488,6 +490,10 @@ app.on('ready', async () => { // called when creating an action ipcMain.on(POST_ACTION_CHANNEL, postAction(mainWindow, db)); + + // called when creating a file + ipcMain.on(POST_FILE_CHANNEL, postFile(mainWindow, db)); + // called when logging in a user ipcMain.on(SIGN_IN_CHANNEL, signIn(mainWindow, db)); diff --git a/src/actions/file.js b/src/actions/file.js new file mode 100644 index 00000000..0efd4b23 --- /dev/null +++ b/src/actions/file.js @@ -0,0 +1,41 @@ +import { POST_FILE_CHANNEL } from '../config/channels'; +import { POST_FILE_SUCCEEDED, POST_FILE_FAILED } from '../types'; +import { ERROR_GENERAL } from '../config/errors'; +import { ERROR_POSTING_FILE_MESSAGE } from '../config/messages'; + +// eslint-disable-next-line import/prefer-default-export +export const postFile = async ( + { userId, appInstanceId, spaceId, subSpaceId, format, data, type } = {}, + callback +) => () => { + try { + window.ipcRenderer.send(POST_FILE_CHANNEL, { + userId, + appInstanceId, + spaceId, + subSpaceId, + format, + type, + data, + }); + + window.ipcRenderer.once(POST_FILE_CHANNEL, async (event, response) => { + if (response === ERROR_GENERAL) { + callback({ + appInstanceId, + type: POST_FILE_FAILED, + payload: ERROR_POSTING_FILE_MESSAGE, + }); + } else { + callback({ + // have to include the appInstanceId to avoid broadcasting + appInstanceId, + type: POST_FILE_SUCCEEDED, + payload: response, + }); + } + }); + } catch (err) { + console.error(err); + } +}; diff --git a/src/actions/index.js b/src/actions/index.js index 63a96092..bbc19f9a 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -11,3 +11,4 @@ export * from './syncSpace'; export * from './loadSpace'; export * from './exportSpace'; export * from './classroom'; +export * from './file'; diff --git a/src/components/phase/PhaseApp.js b/src/components/phase/PhaseApp.js index b4a4ee8d..3d3bc302 100644 --- a/src/components/phase/PhaseApp.js +++ b/src/components/phase/PhaseApp.js @@ -15,6 +15,8 @@ import { APP_INSTANCE_RESOURCE_TYPES, POST_ACTION, ACTION_TYPES, + POST_FILE, + FILE_TYPES, } from '../../types'; import { getAppInstanceResources, @@ -22,6 +24,7 @@ import { postAppInstanceResource, getAppInstance, postAction, + postFile, } from '../../actions'; import { DEFAULT_LANGUAGE, @@ -51,6 +54,7 @@ class PhaseApp extends Component { folder: PropTypes.string.isRequired, dispatchGetAppInstance: PropTypes.func.isRequired, dispatchPostAction: PropTypes.func.isRequired, + dispatchPostFile: PropTypes.func.isRequired, id: PropTypes.string.isRequired, phaseId: PropTypes.string.isRequired, spaceId: PropTypes.string.isRequired, @@ -115,6 +119,7 @@ class PhaseApp extends Component { try { const { dispatchGetAppInstance, + dispatchPostFile, appInstance, dispatchPostAction, user, @@ -125,7 +130,13 @@ class PhaseApp extends Component { const { id: componentAppInstanceId } = appInstance || {}; const { type, payload } = JSON.parse(event.data); let { id: messageAppInstanceId } = payload; - if ([...APP_INSTANCE_RESOURCE_TYPES, ...ACTION_TYPES].includes(type)) { + if ( + [ + ...APP_INSTANCE_RESOURCE_TYPES, + ...ACTION_TYPES, + ...FILE_TYPES, + ].includes(type) + ) { ({ appInstanceId: messageAppInstanceId } = payload); } @@ -153,6 +164,12 @@ class PhaseApp extends Component { } break; } + case POST_FILE: { + if (isSpaceSaved) { + return dispatchPostFile(payload, this.postMessage); + } + break; + } default: return false; } @@ -324,6 +341,7 @@ const mapStateToProps = ({ authentication, Space }) => ({ const mapDispatchToProps = { dispatchGetAppInstance: getAppInstance, dispatchPostAction: postAction, + dispatchPostFile: postFile, }; const ConnectedComponent = connect( diff --git a/src/config/channels.js b/src/config/channels.js index c84daf57..d18d7272 100644 --- a/src/config/channels.js +++ b/src/config/channels.js @@ -71,4 +71,5 @@ module.exports = { GET_SPACE_IN_CLASSROOM_CHANNEL: 'classroom:space:get', LOAD_SPACE_IN_CLASSROOM_CHANNEL: 'classroom:space:load', GET_SPACE_TO_LOAD_IN_CLASSROOM_CHANNEL: 'classroom:space:load:get-space', + POST_FILE_CHANNEL: 'file:post', }; diff --git a/src/config/messages.js b/src/config/messages.js index 5c5d7dce..4a723898 100644 --- a/src/config/messages.js +++ b/src/config/messages.js @@ -102,6 +102,7 @@ const ERROR_INVALID_USERNAME_MESSAGE = 'This username is invalid'; const ERROR_SETTING_ACTION_ACCESSIBILITY = 'There was an error setting the action accessibility'; const ERROR_SETTING_ACTIONS_AS_ENABLED = 'There was an error enabling actions'; +const ERROR_POSTING_FILE_MESSAGE = 'There was an error uploading the file'; module.exports = { ERROR_GETTING_DEVELOPER_MODE, @@ -172,4 +173,5 @@ module.exports = { ERROR_INVALID_USERNAME_MESSAGE, ERROR_SETTING_ACTION_ACCESSIBILITY, ERROR_SETTING_ACTIONS_AS_ENABLED, + ERROR_POSTING_FILE_MESSAGE, }; diff --git a/src/types/file.js b/src/types/file.js new file mode 100644 index 00000000..fa61dc28 --- /dev/null +++ b/src/types/file.js @@ -0,0 +1,5 @@ +export const POST_FILE = 'POST_FILE'; +export const POST_FILE_SUCCEEDED = 'POST_FILE_SUCCEEDED'; +export const POST_FILE_FAILED = 'POST_FILE_FAILED'; + +export const FILE_TYPES = [POST_FILE, POST_FILE_SUCCEEDED, POST_FILE_FAILED]; diff --git a/src/types/index.js b/src/types/index.js index 573af302..6c495723 100644 --- a/src/types/index.js +++ b/src/types/index.js @@ -11,3 +11,4 @@ export * from './syncSpace'; export * from './loadSpace'; export * from './exportSpace'; export * from './classroom'; +export * from './file'; From d9145b66623a75043387c2e42fe2711ec1faf775 Mon Sep 17 00:00:00 2001 From: pyphilia Date: Mon, 31 Aug 2020 19:14:08 +0200 Subject: [PATCH 2/3] fix: use logger in postFile, refactor postFile --- public/app/listeners/postFile.js | 2 +- src/actions/file.js | 8 ++++++-- src/components/phase/PhaseApp.js | 5 +---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/public/app/listeners/postFile.js b/public/app/listeners/postFile.js index bf61044a..1f1f2f52 100644 --- a/public/app/listeners/postFile.js +++ b/public/app/listeners/postFile.js @@ -28,7 +28,7 @@ const postFile = mainWindow => (event, payload = {}) => { // send back the resource mainWindow.webContents.send(POST_FILE_CHANNEL, newPayload); } catch (e) { - console.error(e); + logger.error(e); mainWindow.webContents.send(POST_FILE_CHANNEL, ERROR_GENERAL); } }; diff --git a/src/actions/file.js b/src/actions/file.js index 0efd4b23..c583685f 100644 --- a/src/actions/file.js +++ b/src/actions/file.js @@ -7,7 +7,7 @@ import { ERROR_POSTING_FILE_MESSAGE } from '../config/messages'; export const postFile = async ( { userId, appInstanceId, spaceId, subSpaceId, format, data, type } = {}, callback -) => () => { +) => { try { window.ipcRenderer.send(POST_FILE_CHANNEL, { userId, @@ -36,6 +36,10 @@ export const postFile = async ( } }); } catch (err) { - console.error(err); + callback({ + appInstanceId, + type: POST_FILE_FAILED, + payload: ERROR_POSTING_FILE_MESSAGE, + }); } }; diff --git a/src/components/phase/PhaseApp.js b/src/components/phase/PhaseApp.js index 3d3bc302..e61c3947 100644 --- a/src/components/phase/PhaseApp.js +++ b/src/components/phase/PhaseApp.js @@ -54,7 +54,6 @@ class PhaseApp extends Component { folder: PropTypes.string.isRequired, dispatchGetAppInstance: PropTypes.func.isRequired, dispatchPostAction: PropTypes.func.isRequired, - dispatchPostFile: PropTypes.func.isRequired, id: PropTypes.string.isRequired, phaseId: PropTypes.string.isRequired, spaceId: PropTypes.string.isRequired, @@ -119,7 +118,6 @@ class PhaseApp extends Component { try { const { dispatchGetAppInstance, - dispatchPostFile, appInstance, dispatchPostAction, user, @@ -166,7 +164,7 @@ class PhaseApp extends Component { } case POST_FILE: { if (isSpaceSaved) { - return dispatchPostFile(payload, this.postMessage); + return postFile(payload, this.postMessage); } break; } @@ -341,7 +339,6 @@ const mapStateToProps = ({ authentication, Space }) => ({ const mapDispatchToProps = { dispatchGetAppInstance: getAppInstance, dispatchPostAction: postAction, - dispatchPostFile: postFile, }; const ConnectedComponent = connect( From 5a7627ac3c59abd643757ceec3e0e6cbf74104d8 Mon Sep 17 00:00:00 2001 From: pyphilia Date: Tue, 1 Sep 2020 10:08:36 +0200 Subject: [PATCH 3/3] refactor: apply changes for PR --- package.json | 1 + public/app/config/config.js | 4 ++-- public/app/listeners/postFile.js | 27 +++++++++++---------------- public/app/utilities.js | 14 -------------- yarn.lock | 29 +++++++++++++++++++++++++++++ 5 files changed, 43 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index a4ef323b..817cc562 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "enzyme": "3.11.0", "enzyme-adapter-react-16": "1.15.2", "extract-zip": "1.6.7", + "fs-extra": "9.0.1", "history": "4.10.1", "i18next": "19.3.2", "immutable": "4.0.0-rc.12", diff --git a/public/app/config/config.js b/public/app/config/config.js index c2b975c5..1ca824d0 100644 --- a/public/app/config/config.js +++ b/public/app/config/config.js @@ -85,7 +85,7 @@ const ACTIONS_VERBS = { LOGOUT: 'logout', }; -const buildFilesPath = ({ userId, spaceId, name }) => { +const buildFilePath = ({ userId, spaceId, name }) => { // add generated id to handle duplicate files const generatedId = ObjectId().str; return `${VAR_FOLDER}/${spaceId}/files/${userId}/${generatedId}_${name}`; @@ -117,5 +117,5 @@ module.exports = { VISIBILITIES, DEFAULT_FORMAT, ACTIONS_VERBS, - buildFilesPath, + buildFilePath, }; diff --git a/public/app/listeners/postFile.js b/public/app/listeners/postFile.js index 1f1f2f52..86f30d9c 100644 --- a/public/app/listeners/postFile.js +++ b/public/app/listeners/postFile.js @@ -1,25 +1,20 @@ -const fs = require('fs'); +const fse = require('fs-extra'); +const path = require('path'); const { POST_FILE_CHANNEL } = require('../config/channels'); -const { buildFilesPath } = require('../config/config'); -const { ensureDirectoryExistence } = require('../utilities'); +const { buildFilePath } = require('../config/config'); const logger = require('../logger'); const { ERROR_GENERAL } = require('../config/errors'); const postFile = mainWindow => (event, payload = {}) => { + const { userId, spaceId, data } = payload; + // download file given path + const { path: filepath, name } = data; + const savePath = buildFilePath({ userId, spaceId, name }); + const dirPath = path.dirname(savePath); try { - const { userId, spaceId, data } = payload; - - // download file given path - const { path, name } = data; - - const savePath = buildFilesPath({ userId, spaceId, name }); - ensureDirectoryExistence(savePath); - fs.copyFile(path, savePath, err => { - if (err) { - throw err; - } - logger.debug(`the file ${name} was uploaded`); - }); + fse.ensureDirSync(dirPath); + fse.copySync(filepath, path.resolve(savePath)); + logger.debug(`the file ${name} was uploaded`); // update data const newData = { name, uri: `file://${savePath}` }; diff --git a/public/app/utilities.js b/public/app/utilities.js index 3ec9ee07..13d7ff59 100644 --- a/public/app/utilities.js +++ b/public/app/utilities.js @@ -1,6 +1,5 @@ const mkdirp = require('mkdirp'); const rimraf = require('rimraf'); -const path = require('path'); const { promisify } = require('util'); const mime = require('mime-types'); const md5 = require('md5'); @@ -136,21 +135,8 @@ const clean = async dir => { return promisify(rimraf)(dir); }; -/** - * Ensure directories for given filepath exist - */ -function ensureDirectoryExistence(filePath) { - const dirname = path.dirname(filePath); - if (fs.existsSync(dirname)) { - return true; - } - ensureDirectoryExistence(dirname); - return fs.mkdirSync(dirname); -} - module.exports = { clean, - ensureDirectoryExistence, performFileSystemOperation, getExtension, isDownloadable, diff --git a/yarn.lock b/yarn.lock index eea7f2fe..704039cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2789,6 +2789,11 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + atob@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" @@ -6950,6 +6955,16 @@ fs-extra-p@^4.6.1: bluebird-lst "^1.0.5" fs-extra "^6.0.1" +fs-extra@9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc" + integrity sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^1.0.0" + fs-extra@^4.0.1, fs-extra@^4.0.2: version "4.0.3" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94" @@ -9221,6 +9236,15 @@ jsonfile@^4.0.0: optionalDependencies: graceful-fs "^4.1.6" +jsonfile@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.0.1.tgz#98966cba214378c8c84b82e085907b40bf614179" + integrity sha512-jR2b5v7d2vIOust+w3wtFKZIfpC2pnRmFAhAC/BuweZFQR8qZzxH1OyrQ10HmdVYiXWkYUqPVsz91cG7EL2FBg== + dependencies: + universalify "^1.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + jsonify@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" @@ -15084,6 +15108,11 @@ universalify@^0.1.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== +universalify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d" + integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug== + unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"