129975bfdSEvan Baconimport { ExpoUpdatesManifest } from '@expo/config'; 28d307f52SEvan Baconimport { Updates } from '@expo/config-plugins'; 31fb548e8SWill Schurmanimport accepts from 'accepts'; 4b1f66971SLinus Unnebäckimport crypto from 'crypto'; 51fb548e8SWill Schurmanimport FormData from 'form-data'; 6e377ff85SWill Schurmanimport { serializeDictionary, Dictionary } from 'structured-headers'; 78d307f52SEvan Bacon 88a424bebSJames Ideimport { ManifestMiddleware, ManifestRequestInfo } from './ManifestMiddleware'; 98a424bebSJames Ideimport { assertRuntimePlatform, parsePlatformHeader } from './resolvePlatform'; 108a424bebSJames Ideimport { ServerHeaders, ServerRequest } from './server.types'; 118d307f52SEvan Baconimport UserSettings from '../../../api/user/UserSettings'; 129fe3dc72SWill Schurmanimport { ANONYMOUS_USERNAME } from '../../../api/user/user'; 13ea99eec9SEvan Baconimport { logEventAsync } from '../../../utils/analytics/rudderstackClient'; 14e377ff85SWill Schurmanimport { 15e377ff85SWill Schurman CodeSigningInfo, 16e377ff85SWill Schurman getCodeSigningInfoAsync, 17e377ff85SWill Schurman signManifestString, 18e377ff85SWill Schurman} from '../../../utils/codesigning'; 1929975bfdSEvan Baconimport { CommandError } from '../../../utils/errors'; 208d307f52SEvan Baconimport { stripPort } from '../../../utils/url'; 218d307f52SEvan Bacon 22921ca1b4SJon Sampconst debug = require('debug')('expo:start:server:middleware:ExpoGoManifestHandlerMiddleware'); 23921ca1b4SJon Samp 245794e77cSWill Schurmanexport enum ResponseContentType { 255794e77cSWill Schurman TEXT_PLAIN, 265794e77cSWill Schurman APPLICATION_JSON, 275794e77cSWill Schurman APPLICATION_EXPO_JSON, 285794e77cSWill Schurman MULTIPART_MIXED, 295794e77cSWill Schurman} 305794e77cSWill Schurman 311fb548e8SWill Schurmaninterface ExpoGoManifestRequestInfo extends ManifestRequestInfo { 325794e77cSWill Schurman responseContentType: ResponseContentType; 33e377ff85SWill Schurman expectSignature: string | null; 341fb548e8SWill Schurman} 351fb548e8SWill Schurman 361fb548e8SWill Schurmanexport class ExpoGoManifestHandlerMiddleware extends ManifestMiddleware<ExpoGoManifestRequestInfo> { 371fb548e8SWill Schurman public getParsedHeaders(req: ServerRequest): ExpoGoManifestRequestInfo { 38921ca1b4SJon Samp let platform = parsePlatformHeader(req); 39921ca1b4SJon Samp 40921ca1b4SJon Samp if (!platform) { 41921ca1b4SJon Samp debug( 42d7ad395fSEvan Bacon `No "expo-platform" header or "platform" query parameter specified. Falling back to "ios".` 43921ca1b4SJon Samp ); 44d7ad395fSEvan Bacon platform = 'ios'; 45921ca1b4SJon Samp } 46921ca1b4SJon Samp 478d307f52SEvan Bacon assertRuntimePlatform(platform); 488d307f52SEvan Bacon 491fb548e8SWill Schurman // Expo Updates clients explicitly accept "multipart/mixed" responses while browsers implicitly 501fb548e8SWill Schurman // accept them with "accept: */*". To make it easier to debug manifest responses by visiting their 511fb548e8SWill Schurman // URLs in a browser, we denote the response as "text/plain" if the user agent appears not to be 521fb548e8SWill Schurman // an Expo Updates client. 531fb548e8SWill Schurman const accept = accepts(req); 545794e77cSWill Schurman const acceptedType = accept.types([ 555794e77cSWill Schurman 'unknown/unknown', 565794e77cSWill Schurman 'multipart/mixed', 575794e77cSWill Schurman 'application/json', 585794e77cSWill Schurman 'application/expo+json', 595794e77cSWill Schurman 'text/plain', 605794e77cSWill Schurman ]); 615794e77cSWill Schurman 625794e77cSWill Schurman let responseContentType; 635794e77cSWill Schurman switch (acceptedType) { 645794e77cSWill Schurman case 'multipart/mixed': 655794e77cSWill Schurman responseContentType = ResponseContentType.MULTIPART_MIXED; 665794e77cSWill Schurman break; 675794e77cSWill Schurman case 'application/json': 685794e77cSWill Schurman responseContentType = ResponseContentType.APPLICATION_JSON; 695794e77cSWill Schurman break; 705794e77cSWill Schurman case 'application/expo+json': 715794e77cSWill Schurman responseContentType = ResponseContentType.APPLICATION_EXPO_JSON; 725794e77cSWill Schurman break; 735794e77cSWill Schurman default: 745794e77cSWill Schurman responseContentType = ResponseContentType.TEXT_PLAIN; 755794e77cSWill Schurman break; 765794e77cSWill Schurman } 771fb548e8SWill Schurman 78e377ff85SWill Schurman const expectSignature = req.headers['expo-expect-signature']; 79e377ff85SWill Schurman 808d307f52SEvan Bacon return { 815794e77cSWill Schurman responseContentType, 828d307f52SEvan Bacon platform, 83e377ff85SWill Schurman expectSignature: expectSignature ? String(expectSignature) : null, 848d307f52SEvan Bacon hostname: stripPort(req.headers['host']), 858d307f52SEvan Bacon }; 868d307f52SEvan Bacon } 878d307f52SEvan Bacon 881fb548e8SWill Schurman private getDefaultResponseHeaders(): ServerHeaders { 891fb548e8SWill Schurman const headers = new Map<string, number | string | readonly string[]>(); 908d307f52SEvan Bacon // set required headers for Expo Updates manifest specification 918d307f52SEvan Bacon headers.set('expo-protocol-version', 0); 928d307f52SEvan Bacon headers.set('expo-sfv-version', 0); 938d307f52SEvan Bacon headers.set('cache-control', 'private, max-age=0'); 948d307f52SEvan Bacon return headers; 958d307f52SEvan Bacon } 968d307f52SEvan Bacon 971fb548e8SWill Schurman public async _getManifestResponseAsync(requestOptions: ExpoGoManifestRequestInfo): Promise<{ 988d307f52SEvan Bacon body: string; 998d307f52SEvan Bacon version: string; 1008d307f52SEvan Bacon headers: ServerHeaders; 1018d307f52SEvan Bacon }> { 102*05863844SEvan Bacon const { exp, hostUri, expoGoConfig, bundleUrl } = 103*05863844SEvan Bacon await this._resolveProjectSettingsAsync(requestOptions); 1048d307f52SEvan Bacon 105f0d67e12SMateus Craveiro const runtimeVersion = await Updates.getRuntimeVersionAsync( 106f0d67e12SMateus Craveiro this.projectRoot, 1078d307f52SEvan Bacon { ...exp, runtimeVersion: exp.runtimeVersion ?? { policy: 'sdkVersion' } }, 1088d307f52SEvan Bacon requestOptions.platform 1098d307f52SEvan Bacon ); 11029975bfdSEvan Bacon if (!runtimeVersion) { 11129975bfdSEvan Bacon throw new CommandError( 11229975bfdSEvan Bacon 'MANIFEST_MIDDLEWARE', 11329975bfdSEvan Bacon `Unable to determine runtime version for platform '${requestOptions.platform}'` 11429975bfdSEvan Bacon ); 11529975bfdSEvan Bacon } 1168d307f52SEvan Bacon 117e377ff85SWill Schurman const codeSigningInfo = await getCodeSigningInfoAsync( 118e377ff85SWill Schurman exp, 119e377ff85SWill Schurman requestOptions.expectSignature, 120e377ff85SWill Schurman this.options.privateKeyPath 121e377ff85SWill Schurman ); 122e377ff85SWill Schurman 1239fe3dc72SWill Schurman const easProjectId = exp.extra?.eas?.projectId as string | undefined | null; 1249fe3dc72SWill Schurman const scopeKey = await ExpoGoManifestHandlerMiddleware.getScopeKeyAsync({ 1259fe3dc72SWill Schurman slug: exp.slug, 1269fe3dc72SWill Schurman codeSigningInfo, 1279fe3dc72SWill Schurman }); 1288d307f52SEvan Bacon 12929975bfdSEvan Bacon const expoUpdatesManifest: ExpoUpdatesManifest = { 130b1f66971SLinus Unnebäck id: crypto.randomUUID(), 1318d307f52SEvan Bacon createdAt: new Date().toISOString(), 1328d307f52SEvan Bacon runtimeVersion, 1338d307f52SEvan Bacon launchAsset: { 1348d307f52SEvan Bacon key: 'bundle', 1358d307f52SEvan Bacon contentType: 'application/javascript', 1368d307f52SEvan Bacon url: bundleUrl, 1378d307f52SEvan Bacon }, 1388d307f52SEvan Bacon assets: [], // assets are not used in development 1398d307f52SEvan Bacon metadata: {}, // required for the client to detect that this is an expo-updates manifest 1408d307f52SEvan Bacon extra: { 1418d307f52SEvan Bacon eas: { 1428d307f52SEvan Bacon projectId: easProjectId ?? undefined, 1438d307f52SEvan Bacon }, 1448d307f52SEvan Bacon expoClient: { 1458d307f52SEvan Bacon ...exp, 1468d307f52SEvan Bacon hostUri, 1478d307f52SEvan Bacon }, 1488d307f52SEvan Bacon expoGo: expoGoConfig, 1498d307f52SEvan Bacon scopeKey, 1508d307f52SEvan Bacon }, 1518d307f52SEvan Bacon }; 1528d307f52SEvan Bacon 153e377ff85SWill Schurman const stringifiedManifest = JSON.stringify(expoUpdatesManifest); 154e377ff85SWill Schurman 155e377ff85SWill Schurman let manifestPartHeaders: { 'expo-signature': string } | null = null; 156e377ff85SWill Schurman let certificateChainBody: string | null = null; 157e377ff85SWill Schurman if (codeSigningInfo) { 158e377ff85SWill Schurman const signature = signManifestString(stringifiedManifest, codeSigningInfo); 159e377ff85SWill Schurman manifestPartHeaders = { 160e377ff85SWill Schurman 'expo-signature': serializeDictionary( 161e377ff85SWill Schurman convertToDictionaryItemsRepresentation({ 162c14835f6SWill Schurman keyid: codeSigningInfo.keyId, 163e377ff85SWill Schurman sig: signature, 164e377ff85SWill Schurman alg: 'rsa-v1_5-sha256', 165e377ff85SWill Schurman }) 166e377ff85SWill Schurman ), 167e377ff85SWill Schurman }; 168e377ff85SWill Schurman certificateChainBody = codeSigningInfo.certificateChainForResponse.join('\n'); 169e377ff85SWill Schurman } 170e377ff85SWill Schurman 1715794e77cSWill Schurman const headers = this.getDefaultResponseHeaders(); 1725794e77cSWill Schurman 1735794e77cSWill Schurman switch (requestOptions.responseContentType) { 1745794e77cSWill Schurman case ResponseContentType.MULTIPART_MIXED: { 1751fb548e8SWill Schurman const form = this.getFormData({ 176e377ff85SWill Schurman stringifiedManifest, 177e377ff85SWill Schurman manifestPartHeaders, 178e377ff85SWill Schurman certificateChainBody, 1791fb548e8SWill Schurman }); 1805794e77cSWill Schurman headers.set('content-type', `multipart/mixed; boundary=${form.getBoundary()}`); 1818d307f52SEvan Bacon return { 1821fb548e8SWill Schurman body: form.getBuffer().toString(), 1838d307f52SEvan Bacon version: runtimeVersion, 1848d307f52SEvan Bacon headers, 1858d307f52SEvan Bacon }; 1868d307f52SEvan Bacon } 1875794e77cSWill Schurman case ResponseContentType.APPLICATION_EXPO_JSON: 1885794e77cSWill Schurman case ResponseContentType.APPLICATION_JSON: 1895794e77cSWill Schurman case ResponseContentType.TEXT_PLAIN: { 1905794e77cSWill Schurman headers.set( 1915794e77cSWill Schurman 'content-type', 1925794e77cSWill Schurman ExpoGoManifestHandlerMiddleware.getContentTypeForResponseContentType( 1935794e77cSWill Schurman requestOptions.responseContentType 1945794e77cSWill Schurman ) 1955794e77cSWill Schurman ); 1965794e77cSWill Schurman if (manifestPartHeaders) { 1975794e77cSWill Schurman Object.entries(manifestPartHeaders).forEach(([key, value]) => { 1985794e77cSWill Schurman headers.set(key, value); 1995794e77cSWill Schurman }); 2005794e77cSWill Schurman } 2015794e77cSWill Schurman 2025794e77cSWill Schurman return { 2035794e77cSWill Schurman body: stringifiedManifest, 2045794e77cSWill Schurman version: runtimeVersion, 2055794e77cSWill Schurman headers, 2065794e77cSWill Schurman }; 2075794e77cSWill Schurman } 2085794e77cSWill Schurman } 2095794e77cSWill Schurman } 2105794e77cSWill Schurman 2115794e77cSWill Schurman private static getContentTypeForResponseContentType( 2125794e77cSWill Schurman responseContentType: ResponseContentType 2135794e77cSWill Schurman ): string { 2145794e77cSWill Schurman switch (responseContentType) { 2155794e77cSWill Schurman case ResponseContentType.MULTIPART_MIXED: 2165794e77cSWill Schurman return 'multipart/mixed'; 2175794e77cSWill Schurman case ResponseContentType.APPLICATION_EXPO_JSON: 2185794e77cSWill Schurman return 'application/expo+json'; 2195794e77cSWill Schurman case ResponseContentType.APPLICATION_JSON: 2205794e77cSWill Schurman return 'application/json'; 2215794e77cSWill Schurman case ResponseContentType.TEXT_PLAIN: 2225794e77cSWill Schurman return 'text/plain'; 2235794e77cSWill Schurman } 2245794e77cSWill Schurman } 2258d307f52SEvan Bacon 226e377ff85SWill Schurman private getFormData({ 227e377ff85SWill Schurman stringifiedManifest, 228e377ff85SWill Schurman manifestPartHeaders, 229e377ff85SWill Schurman certificateChainBody, 230e377ff85SWill Schurman }: { 231e377ff85SWill Schurman stringifiedManifest: string; 232e377ff85SWill Schurman manifestPartHeaders: { 'expo-signature': string } | null; 233e377ff85SWill Schurman certificateChainBody: string | null; 234e377ff85SWill Schurman }): FormData { 2351fb548e8SWill Schurman const form = new FormData(); 2361fb548e8SWill Schurman form.append('manifest', stringifiedManifest, { 2371fb548e8SWill Schurman contentType: 'application/json', 238e377ff85SWill Schurman header: { 239e377ff85SWill Schurman ...manifestPartHeaders, 240e377ff85SWill Schurman }, 2411fb548e8SWill Schurman }); 242e377ff85SWill Schurman if (certificateChainBody && certificateChainBody.length > 0) { 243e377ff85SWill Schurman form.append('certificate_chain', certificateChainBody, { 244e377ff85SWill Schurman contentType: 'application/x-pem-file', 245e377ff85SWill Schurman }); 246e377ff85SWill Schurman } 2471fb548e8SWill Schurman return form; 2481fb548e8SWill Schurman } 2491fb548e8SWill Schurman 2508d307f52SEvan Bacon protected trackManifest(version?: string) { 251ea99eec9SEvan Bacon logEventAsync('Serve Expo Updates Manifest', { 2528d307f52SEvan Bacon runtimeVersion: version, 2538d307f52SEvan Bacon }); 2548d307f52SEvan Bacon } 2558d307f52SEvan Bacon 2569fe3dc72SWill Schurman private static async getScopeKeyAsync({ 2579fe3dc72SWill Schurman slug, 2589fe3dc72SWill Schurman codeSigningInfo, 2599fe3dc72SWill Schurman }: { 2609fe3dc72SWill Schurman slug: string; 2619fe3dc72SWill Schurman codeSigningInfo: CodeSigningInfo | null; 2629fe3dc72SWill Schurman }): Promise<string> { 2639fe3dc72SWill Schurman const scopeKeyFromCodeSigningInfo = codeSigningInfo?.scopeKey; 2649fe3dc72SWill Schurman if (scopeKeyFromCodeSigningInfo) { 2659fe3dc72SWill Schurman return scopeKeyFromCodeSigningInfo; 2668d307f52SEvan Bacon } 2678d307f52SEvan Bacon 268d7ad395fSEvan Bacon // Log.warn( 269d7ad395fSEvan Bacon // env.EXPO_OFFLINE 270d7ad395fSEvan Bacon // ? 'Using anonymous scope key in manifest for offline mode.' 271d7ad395fSEvan Bacon // : 'Using anonymous scope key in manifest.' 272d7ad395fSEvan Bacon // ); 2739fe3dc72SWill Schurman return await getAnonymousScopeKeyAsync(slug); 2749fe3dc72SWill Schurman } 2758d307f52SEvan Bacon} 2768d307f52SEvan Bacon 2779fe3dc72SWill Schurmanasync function getAnonymousScopeKeyAsync(slug: string): Promise<string> { 2789fe3dc72SWill Schurman const userAnonymousIdentifier = await UserSettings.getAnonymousIdentifierAsync(); 2799fe3dc72SWill Schurman return `@${ANONYMOUS_USERNAME}/${slug}-${userAnonymousIdentifier}`; 2808d307f52SEvan Bacon} 281e377ff85SWill Schurman 282e377ff85SWill Schurmanfunction convertToDictionaryItemsRepresentation(obj: { [key: string]: string }): Dictionary { 283e377ff85SWill Schurman return new Map( 284e377ff85SWill Schurman Object.entries(obj).map(([k, v]) => { 285e377ff85SWill Schurman return [k, [v, new Map()]]; 286e377ff85SWill Schurman }) 287e377ff85SWill Schurman ); 288e377ff85SWill Schurman} 289