diff --git a/docs/Configuration.md b/docs/Configuration.md index 3724ee16fc..2ddfa4f08d 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -615,7 +615,7 @@ The possibility to add custom middleware to the server response chain. Type: `string => string` -A function that will be called every time Metro processes a URL. Metro will use the return value of this function as if it were the original URL provided by the client. This applies to all incoming HTTP requests (after any custom middleware), as well as bundle URLs in `/symbolicate` request payloads and within the hot reloading protocol. +A function that will be called every time Metro processes a URL, after normalization of non-standard query-string delimiters using [`jsc-safe-url`](https://www.npmjs.com/package/jsc-safe-url). Metro will use the return value of this function as if it were the original URL provided by the client. This applies to all incoming HTTP requests (after any custom middleware), as well as bundle URLs in `/symbolicate` request payloads and within the hot reloading protocol. #### `runInspectorProxy` diff --git a/packages/metro/package.json b/packages/metro/package.json index 189e1f20de..e5aa641585 100644 --- a/packages/metro/package.json +++ b/packages/metro/package.json @@ -33,6 +33,7 @@ "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^27.2.0", + "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.76.4", "metro-cache": "0.76.4", diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index 6c48c249de..0c294168ec 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -65,6 +65,8 @@ const {codeFrameColumns} = require('@babel/code-frame'); const MultipartResponse = require('./Server/MultipartResponse'); const debug = require('debug')('Metro:Server'); const fs = require('graceful-fs'); +const invariant = require('invariant'); +const jscSafeUrl = require('jsc-safe-url'); const { Logger, Logger: {createActionStartEntry, createActionEndEntry, log}, @@ -489,14 +491,19 @@ class Server { return parseOptionsFromUrl(url, new Set(this._config.resolver.platforms)); } + _rewriteAndNormalizeUrl(requestUrl: string): string { + return jscSafeUrl.toNormalUrl( + this._config.server.rewriteRequestUrl(jscSafeUrl.toNormalUrl(requestUrl)), + ); + } + async _processRequest( req: IncomingMessage, res: ServerResponse, next: (?Error) => mixed, ) { const originalUrl = req.url; - req.url = this._config.server.rewriteRequestUrl(req.url); - + req.url = this._rewriteAndNormalizeUrl(req.url); const urlObj = url.parse(req.url, true); const {host} = req.headers; debug( @@ -1112,19 +1119,33 @@ class Server { /* $FlowFixMe: where is `rawBody` defined? Is it added by the `connect` framework? */ const body = await req.rawBody; const parsedBody = JSON.parse(body); - const stack = parsedBody.stack.map(frame => { - if (frame.file && frame.file.includes('://')) { + + const rewriteAndNormalizeStackFrame = ( + frame: T, + lineNumber: number, + ): T => { + invariant( + frame != null && typeof frame === 'object', + 'Bad stack frame at line %d, expected object, received: %s', + lineNumber, + typeof frame, + ); + const frameFile = frame.file; + if (typeof frameFile === 'string' && frameFile.includes('://')) { return { ...frame, - file: this._config.server.rewriteRequestUrl(frame.file), + file: this._rewriteAndNormalizeUrl(frameFile), }; } return frame; - }); + }; + + const stack = parsedBody.stack.map(rewriteAndNormalizeStackFrame); // In case of multiple bundles / HMR, some stack frames can have different URLs from others const urls = new Set(); stack.forEach(frame => { + // These urls have been rewritten and normalized above. const sourceUrl = frame.file; // Skip `/debuggerWorker.js` which does not need symbolication. if ( @@ -1139,8 +1160,11 @@ class Server { debug('Getting source maps for symbolication'); const sourceMaps = await Promise.all( - // $FlowFixMe[method-unbinding] added when improving typing for this parameters - Array.from(urls.values()).map(this._explodedSourceMapForURL, this), + Array.from(urls.values()).map(normalizedUrl => + this._explodedSourceMapForBundleOptions( + this._parseOptions(normalizedUrl), + ), + ), ); debug('Performing fast symbolication'); @@ -1168,12 +1192,9 @@ class Server { } } - async _explodedSourceMapForURL(reqUrl: string): Promise { - const options = parseOptionsFromUrl( - reqUrl, - new Set(this._config.resolver.platforms), - ); - + async _explodedSourceMapForBundleOptions( + bundleOptions: BundleOptions, + ): Promise { const { entryFile, graphOptions, @@ -1181,7 +1202,7 @@ class Server { resolverOptions, serializerOptions, transformOptions, - } = splitBundleOptions(options); + } = splitBundleOptions(bundleOptions); /** * `entryFile` is relative to projectRoot, we need to use resolution function diff --git a/packages/metro/src/Server/__tests__/Server-test.js b/packages/metro/src/Server/__tests__/Server-test.js index 83cfa093a5..644b4655eb 100644 --- a/packages/metro/src/Server/__tests__/Server-test.js +++ b/packages/metro/src/Server/__tests__/Server-test.js @@ -132,13 +132,13 @@ describe('processRequest', () => { reporter: require('../../lib/reporting').nullReporter, server: { - rewriteRequestUrl(requrl) { + rewriteRequestUrl: jest.fn().mockImplementation(requrl => { const rewritten = requrl.replace(/__REMOVE_THIS_WHEN_REWRITING__/g, ''); if (rewritten !== requrl) { return rewritten + '&TEST_URL_WAS_REWRITTEN=true'; } return requrl; - }, + }), }, symbolicator: { customizeFrame: ({file}) => { @@ -319,20 +319,26 @@ describe('processRequest', () => { fs.realpath = jest.fn((file, cb) => cb?.(null, '/root/foo.js')); }); - it('returns JS bundle source on request of *.bundle', async () => { - const response = await makeRequest('mybundle.bundle?runModule=true', null); + it.each(['?', '//&'])( + 'returns JS bundle source on request of *.bundle (delimiter: %s)', + async delimiter => { + const response = await makeRequest( + `mybundle.bundle${delimiter}runModule=true`, + null, + ); - expect(response._getString()).toEqual( - [ - 'function () {require();}', - '__d(function() {entry();},0,[1],"mybundle.js");', - '__d(function() {foo();},1,[],"foo.js");', - 'require(0);', - '//# sourceMappingURL=//localhost:8081/mybundle.map?runModule=true', - '//# sourceURL=http://localhost:8081/mybundle.bundle?runModule=true', - ].join('\n'), - ); - }); + expect(response._getString()).toEqual( + [ + 'function () {require();}', + '__d(function() {entry();},0,[1],"mybundle.js");', + '__d(function() {foo();},1,[],"foo.js");', + 'require(0);', + '//# sourceMappingURL=//localhost:8081/mybundle.map?runModule=true', + '//# sourceURL=http://localhost:8081/mybundle.bundle?runModule=true', + ].join('\n'), + ); + }, + ); it('returns JS bundle without the initial require() call', async () => { const response = await makeRequest('mybundle.bundle?runModule=false', null); @@ -703,23 +709,32 @@ describe('processRequest', () => { }); }); - it('rewrites URLs before bundling', async () => { - const response = await makeRequest( - 'mybundle.bundle?runModule=true__REMOVE_THIS_WHEN_REWRITING__', - null, - ); + it.each(['?', '//&'])( + 'rewrites URLs before bundling (query delimiter: %s)', + async delimiter => { + jest.clearAllMocks(); - expect(response._getString()).toEqual( - [ - 'function () {require();}', - '__d(function() {entry();},0,[1],"mybundle.js");', - '__d(function() {foo();},1,[],"foo.js");', - 'require(0);', - '//# sourceMappingURL=//localhost:8081/mybundle.map?runModule=true&TEST_URL_WAS_REWRITTEN=true', - '//# sourceURL=http://localhost:8081/mybundle.bundle?runModule=true&TEST_URL_WAS_REWRITTEN=true', - ].join('\n'), - ); - }); + const response = await makeRequest( + `mybundle.bundle${delimiter}runModule=true__REMOVE_THIS_WHEN_REWRITING__`, + null, + ); + + expect(config.server.rewriteRequestUrl).toHaveBeenCalledWith( + 'mybundle.bundle?runModule=true__REMOVE_THIS_WHEN_REWRITING__', + ); + + expect(response._getString()).toEqual( + [ + 'function () {require();}', + '__d(function() {entry();},0,[1],"mybundle.js");', + '__d(function() {foo();},1,[],"foo.js");', + 'require(0);', + '//# sourceMappingURL=//localhost:8081/mybundle.map?runModule=true&TEST_URL_WAS_REWRITTEN=true', + '//# sourceURL=http://localhost:8081/mybundle.bundle?runModule=true&TEST_URL_WAS_REWRITTEN=true', + ].join('\n'), + ); + }, + ); it('does not rebuild the bundle when making concurrent requests', async () => { // Delay the response of the buildGraph method. @@ -926,31 +941,33 @@ describe('processRequest', () => { }); }); - describe('/symbolicate endpoint', () => { - beforeEach(() => { - fs.mkdirSync('/root'); - fs.writeFileSync( - '/root/mybundle.js', - 'this\nis\njust an example and it is all fake data, yay!', - ); - }); - - it('should symbolicate given stack trace', async () => { - const response = await makeRequest('/symbolicate', { - rawBody: JSON.stringify({ - stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true', - lineNumber: 2, - column: 18, - customPropShouldBeLeftUnchanged: 'foo', - methodName: 'clientSideMethodName', - }, - ], - }), + describe.each(['?', '//&'])( + '/symbolicate endpoint (query delimiter: %s)', + queryDelimiter => { + beforeEach(() => { + fs.mkdirSync('/root'); + fs.writeFileSync( + '/root/mybundle.js', + 'this\nis\njust an example and it is all fake data, yay!', + ); }); - expect(response._getJSON()).toMatchInlineSnapshot(` + it('should symbolicate given stack trace', async () => { + const response = await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true`, + lineNumber: 2, + column: 18, + customPropShouldBeLeftUnchanged: 'foo', + methodName: 'clientSideMethodName', + }, + ], + }), + }); + + expect(response._getJSON()).toMatchInlineSnapshot(` Object { "codeFrame": Object { "content": "> 1 | this @@ -974,145 +991,148 @@ describe('processRequest', () => { ], } `); - }); + }); - describe('should rewrite URLs before symbolicating', () => { - test('mapped location symbolicates correctly', async () => { - const mappedLocation = { - lineNumber: 2, - column: 18, - customPropShouldBeLeftUnchanged: 'foo', - methodName: 'clientSideMethodName', - }; + describe('should rewrite URLs before symbolicating', () => { + test('mapped location symbolicates correctly', async () => { + const mappedLocation = { + lineNumber: 2, + column: 18, + customPropShouldBeLeftUnchanged: 'foo', + methodName: 'clientSideMethodName', + }; - const response = await makeRequest('/symbolicate', { + const response = await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/my__REMOVE_THIS_WHEN_REWRITING__bundle.bundle${queryDelimiter}runModule=true`, + ...mappedLocation, + }, + ], + }), + }); + + expect(response._getJSON()).toEqual( + JSON.parse( + ( + await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true`, + ...mappedLocation, + }, + ], + }), + }) + )._getString(), + ), + ); + }); + + test('unmapped location returns the rewritten URL', async () => { + const unmappedLocation = { + lineNumber: 200000, + column: 18, + customPropShouldBeLeftUnchanged: 'foo', + methodName: 'clientSideMethodName', + }; + + const response = await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/my__REMOVE_THIS_WHEN_REWRITING__bundle.bundle${queryDelimiter}runModule=true`, + ...unmappedLocation, + }, + ], + }), + }); + + expect(response._getJSON().stack[0].file).toBe( + 'http://localhost:8081/mybundle.bundle?runModule=true&TEST_URL_WAS_REWRITTEN=true', + ); + }); + }); + + it('should update the graph when symbolicating a second time', async () => { + const requestData = { rawBody: JSON.stringify({ stack: [ { - file: 'http://localhost:8081/my__REMOVE_THIS_WHEN_REWRITING__bundle.bundle?runModule=true', - ...mappedLocation, + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true`, + lineNumber: 2, + column: 18, + customPropShouldBeLeftUnchanged: 'foo', + methodName: 'clientSideMethodName', }, ], }), - }); + }; - expect(response._getJSON()).toEqual( - JSON.parse( - ( - await makeRequest('/symbolicate', { - rawBody: JSON.stringify({ - stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true', - ...mappedLocation, - }, - ], - }), - }) - )._getString(), - ), + const IncrementalBundler = require('../../IncrementalBundler'); + const updateSpy = jest.spyOn( + IncrementalBundler.prototype, + 'updateGraph', + ); + const initSpy = jest.spyOn( + IncrementalBundler.prototype, + 'initializeGraph', ); - }); - test('unmapped location returns the rewritten URL', async () => { - const unmappedLocation = { - lineNumber: 200000, - column: 18, - customPropShouldBeLeftUnchanged: 'foo', - methodName: 'clientSideMethodName', - }; + // When symbolicating a bundle the first time, we expect to create a graph for it. + await makeRequest('/symbolicate', requestData); + expect(initSpy).toBeCalledTimes(1); + expect(updateSpy).not.toBeCalled(); + + // When symbolicating the same bundle a second time, the bundle graph may be out of date. + // Let's be sure to update the bundle graph. + await makeRequest('/symbolicate', requestData); + expect(initSpy).toBeCalledTimes(1); + expect(updateSpy).toBeCalledTimes(1); + }); + it('supports the `modulesOnly` option', async () => { const response = await makeRequest('/symbolicate', { rawBody: JSON.stringify({ stack: [ { - file: 'http://localhost:8081/my__REMOVE_THIS_WHEN_REWRITING__bundle.bundle?runModule=true', - ...unmappedLocation, + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true&modulesOnly=true`, + lineNumber: 2, + column: 16, }, ], }), }); - expect(response._getJSON().stack[0].file).toBe( - 'http://localhost:8081/mybundle.bundle?runModule=true&TEST_URL_WAS_REWRITTEN=true', - ); - }); - }); - - it('should update the graph when symbolicating a second time', async () => { - const requestData = { - rawBody: JSON.stringify({ - stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true', - lineNumber: 2, - column: 18, - customPropShouldBeLeftUnchanged: 'foo', - methodName: 'clientSideMethodName', - }, - ], - }), - }; - - const IncrementalBundler = require('../../IncrementalBundler'); - const updateSpy = jest.spyOn(IncrementalBundler.prototype, 'updateGraph'); - const initSpy = jest.spyOn( - IncrementalBundler.prototype, - 'initializeGraph', - ); - - // When symbolicating a bundle the first time, we expect to create a graph for it. - await makeRequest('/symbolicate', requestData); - expect(initSpy).toBeCalledTimes(1); - expect(updateSpy).not.toBeCalled(); - - // When symbolicating the same bundle a second time, the bundle graph may be out of date. - // Let's be sure to update the bundle graph. - await makeRequest('/symbolicate', requestData); - expect(initSpy).toBeCalledTimes(1); - expect(updateSpy).toBeCalledTimes(1); - }); - - it('supports the `modulesOnly` option', async () => { - const response = await makeRequest('/symbolicate', { - rawBody: JSON.stringify({ + expect(response._getJSON()).toMatchObject({ stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true&modulesOnly=true', - lineNumber: 2, - column: 16, - }, + expect.objectContaining({ + column: 0, + file: '/root/foo.js', + lineNumber: 1, + }), ], - }), + }); }); - expect(response._getJSON()).toMatchObject({ - stack: [ - expect.objectContaining({ - column: 0, - file: '/root/foo.js', - lineNumber: 1, + it('supports the `shallow` option', async () => { + const response = await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true&shallow=true`, + lineNumber: 2, + column: 18, + customPropShouldBeLeftUnchanged: 'foo', + methodName: 'clientSideMethodName', + }, + ], }), - ], - }); - }); - - it('supports the `shallow` option', async () => { - const response = await makeRequest('/symbolicate', { - rawBody: JSON.stringify({ - stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true&shallow=true', - lineNumber: 2, - column: 18, - customPropShouldBeLeftUnchanged: 'foo', - methodName: 'clientSideMethodName', - }, - ], - }), - }); + }); - expect(response._getJSON()).toMatchInlineSnapshot(` + expect(response._getJSON()).toMatchInlineSnapshot(` Object { "codeFrame": Object { "content": "> 1 | this @@ -1136,115 +1156,118 @@ describe('processRequest', () => { ], } `); - }); - - it('should symbolicate function name if available', async () => { - const response = await makeRequest('/symbolicate', { - rawBody: JSON.stringify({ - stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true', - lineNumber: 3, - column: 18, - }, - ], - }), }); - expect(response._getJSON()).toMatchObject({ - stack: [ - expect.objectContaining({ - methodName: '', + it('should symbolicate function name if available', async () => { + const response = await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true`, + lineNumber: 3, + column: 18, + }, + ], }), - ], - }); - }); - - it('should collapse frames as specified in customizeFrame', async () => { - // NOTE: See implementation of symbolicator.customizeFrame above. + }); - const response = await makeRequest('/symbolicate', { - rawBody: JSON.stringify({ + expect(response._getJSON()).toMatchObject({ stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true', - lineNumber: 3, - column: 18, - }, + expect.objectContaining({ + methodName: '', + }), ], - }), + }); }); - expect(response._getJSON()).toMatchObject({ - stack: [ - expect.objectContaining({ - file: '/root/foo.js', - collapse: true, - }), - ], - }); - }); + it('should collapse frames as specified in customizeFrame', async () => { + // NOTE: See implementation of symbolicator.customizeFrame above. - it('should transform frames as specified in customizeStack', async () => { - // NOTE: See implementation of symbolicator.customizeStack above. + const response = await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true`, + lineNumber: 3, + column: 18, + }, + ], + }), + }); - const response = await makeRequest('/symbolicate', { - rawBody: JSON.stringify({ + expect(response._getJSON()).toMatchObject({ stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true', - lineNumber: 3, - column: 18, - }, + expect.objectContaining({ + file: '/root/foo.js', + collapse: true, + }), ], - extraData: { - customAnnotation: 'Baz', - }, - }), + }); }); - expect(response._getJSON()).toMatchObject({ - stack: [ - expect.objectContaining({ - file: '/root/foo.js', - wasCollapsedBefore: true, - customAnnotation: 'Baz', + it('should transform frames as specified in customizeStack', async () => { + // NOTE: See implementation of symbolicator.customizeStack above. + + const response = await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true`, + lineNumber: 3, + column: 18, + }, + ], + extraData: { + customAnnotation: 'Baz', + }, }), - ], - }); - }); + }); - it('should leave original file and position when cannot symbolicate', async () => { - const response = await makeRequest('/symbolicate', { - rawBody: JSON.stringify({ + expect(response._getJSON()).toMatchObject({ stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true', - lineNumber: 200, - column: 18, - customPropShouldBeLeftUnchanged: 'foo', - methodName: 'clientSideMethodName', - }, + expect.objectContaining({ + file: '/root/foo.js', + wasCollapsedBefore: true, + customAnnotation: 'Baz', + }), ], - }), + }); }); - expect(response._getJSON()).toMatchInlineSnapshot(` + // TODO: This probably should restore the *original* file before rewrite + // or normalisation. + it('should leave original file and position when cannot symbolicate (after normalisation and rewriting?)', async () => { + const response = await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true&foo__REMOVE_THIS_WHEN_REWRITING__=bar`, + lineNumber: 200, + column: 18, + customPropShouldBeLeftUnchanged: 'foo', + methodName: 'clientSideMethodName', + }, + ], + }), + }); + + expect(response._getJSON()).toMatchInlineSnapshot(` Object { "codeFrame": null, "stack": Array [ Object { "column": 18, "customPropShouldBeLeftUnchanged": "foo", - "file": "http://localhost:8081/mybundle.bundle?runModule=true", + "file": "http://localhost:8081/mybundle.bundle?runModule=true&foo=bar&TEST_URL_WAS_REWRITTEN=true", "lineNumber": 200, "methodName": "clientSideMethodName", }, ], } `); - }); - }); + }); + }, + ); describe('/symbolicate handles errors', () => { it('should symbolicate given stack trace', async () => { diff --git a/packages/metro/src/lib/parseOptionsFromUrl.js b/packages/metro/src/lib/parseOptionsFromUrl.js index 7de7f01c1e..11ffa9b3ef 100644 --- a/packages/metro/src/lib/parseOptionsFromUrl.js +++ b/packages/metro/src/lib/parseOptionsFromUrl.js @@ -39,10 +39,10 @@ const getTransformProfile = (transformProfile: string): TransformProfile => : 'default'; module.exports = function parseOptionsFromUrl( - requestUrl: string, + normalizedRequestUrl: string, platforms: Set, ): BundleOptions { - const parsedURL = nullthrows(url.parse(requestUrl, true)); // `true` to parse the query param as an object. + const parsedURL = nullthrows(url.parse(normalizedRequestUrl, true)); // `true` to parse the query param as an object. const query = nullthrows(parsedURL.query); const pathname = query.bundleEntry || @@ -77,7 +77,7 @@ module.exports = function parseOptionsFromUrl( platform != null && platform.match(/^(android|ios)$/) ? 'http' : '', pathname: pathname.replace(/\.(bundle|delta)$/, '.map'), }), - sourceUrl: requestUrl, + sourceUrl: normalizedRequestUrl, unstable_transformProfile: getTransformProfile( query.unstable_transformProfile, ), diff --git a/yarn.lock b/yarn.lock index f5b0ede2e4..5e96652eb8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4483,6 +4483,11 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +jsc-safe-url@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/jsc-safe-url/-/jsc-safe-url-0.2.2.tgz#9ce79116d6271fce4b3d1b59b879e66b82457be1" + integrity sha512-F6ezJ+Ys7yUaZ2tG7VVGwDgmCB8T1kaDB2AlxhLnPIfTpJqgFWSjptCAU04wz7RB3oEta/SiDuy4vQxh2F4jXg== + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"