1import { ExpoUpdatesManifest } from '@expo/config'; 2import { Updates } from '@expo/config-plugins'; 3import accepts from 'accepts'; 4import crypto from 'crypto'; 5import FormData from 'form-data'; 6import { serializeDictionary, Dictionary } from 'structured-headers'; 7 8import { APISettings } from '../../../api/settings'; 9import UserSettings from '../../../api/user/UserSettings'; 10import { ANONYMOUS_USERNAME } from '../../../api/user/user'; 11import * as Log from '../../../log'; 12import { logEventAsync } from '../../../utils/analytics/rudderstackClient'; 13import { 14 CodeSigningInfo, 15 getCodeSigningInfoAsync, 16 signManifestString, 17} from '../../../utils/codesigning'; 18import { CommandError } from '../../../utils/errors'; 19import { stripPort } from '../../../utils/url'; 20import { ManifestMiddleware, ManifestRequestInfo } from './ManifestMiddleware'; 21import { assertRuntimePlatform, parsePlatformHeader } from './resolvePlatform'; 22import { ServerHeaders, ServerRequest } from './server.types'; 23 24const debug = require('debug')('expo:start:server:middleware:ExpoGoManifestHandlerMiddleware'); 25 26interface ExpoGoManifestRequestInfo extends ManifestRequestInfo { 27 explicitlyPrefersMultipartMixed: boolean; 28 expectSignature: string | null; 29} 30 31export class ExpoGoManifestHandlerMiddleware extends ManifestMiddleware<ExpoGoManifestRequestInfo> { 32 public getParsedHeaders(req: ServerRequest): ExpoGoManifestRequestInfo { 33 let platform = parsePlatformHeader(req); 34 35 if (!platform) { 36 debug( 37 `No "expo-platform" header or "platform" query parameter specified. Falling back to "none".` 38 ); 39 platform = 'none'; 40 } 41 42 assertRuntimePlatform(platform); 43 44 // Expo Updates clients explicitly accept "multipart/mixed" responses while browsers implicitly 45 // accept them with "accept: */*". To make it easier to debug manifest responses by visiting their 46 // URLs in a browser, we denote the response as "text/plain" if the user agent appears not to be 47 // an Expo Updates client. 48 const accept = accepts(req); 49 const explicitlyPrefersMultipartMixed = 50 accept.types(['unknown/unknown', 'multipart/mixed']) === 'multipart/mixed'; 51 52 const expectSignature = req.headers['expo-expect-signature']; 53 54 return { 55 explicitlyPrefersMultipartMixed, 56 platform, 57 expectSignature: expectSignature ? String(expectSignature) : null, 58 hostname: stripPort(req.headers['host']), 59 }; 60 } 61 62 private getDefaultResponseHeaders(): ServerHeaders { 63 const headers = new Map<string, number | string | readonly string[]>(); 64 // set required headers for Expo Updates manifest specification 65 headers.set('expo-protocol-version', 0); 66 headers.set('expo-sfv-version', 0); 67 headers.set('cache-control', 'private, max-age=0'); 68 return headers; 69 } 70 71 public async _getManifestResponseAsync(requestOptions: ExpoGoManifestRequestInfo): Promise<{ 72 body: string; 73 version: string; 74 headers: ServerHeaders; 75 }> { 76 const { exp, hostUri, expoGoConfig, bundleUrl } = await this._resolveProjectSettingsAsync( 77 requestOptions 78 ); 79 80 const runtimeVersion = Updates.getRuntimeVersion( 81 { ...exp, runtimeVersion: exp.runtimeVersion ?? { policy: 'sdkVersion' } }, 82 requestOptions.platform 83 ); 84 if (!runtimeVersion) { 85 throw new CommandError( 86 'MANIFEST_MIDDLEWARE', 87 `Unable to determine runtime version for platform '${requestOptions.platform}'` 88 ); 89 } 90 91 const codeSigningInfo = await getCodeSigningInfoAsync( 92 exp, 93 requestOptions.expectSignature, 94 this.options.privateKeyPath 95 ); 96 97 const easProjectId = exp.extra?.eas?.projectId as string | undefined | null; 98 const scopeKey = await ExpoGoManifestHandlerMiddleware.getScopeKeyAsync({ 99 slug: exp.slug, 100 codeSigningInfo, 101 }); 102 103 const expoUpdatesManifest: ExpoUpdatesManifest = { 104 id: crypto.randomUUID(), 105 createdAt: new Date().toISOString(), 106 runtimeVersion, 107 launchAsset: { 108 key: 'bundle', 109 contentType: 'application/javascript', 110 url: bundleUrl, 111 }, 112 assets: [], // assets are not used in development 113 metadata: {}, // required for the client to detect that this is an expo-updates manifest 114 extra: { 115 eas: { 116 projectId: easProjectId ?? undefined, 117 }, 118 expoClient: { 119 ...exp, 120 hostUri, 121 }, 122 expoGo: expoGoConfig, 123 scopeKey, 124 }, 125 }; 126 127 const stringifiedManifest = JSON.stringify(expoUpdatesManifest); 128 129 let manifestPartHeaders: { 'expo-signature': string } | null = null; 130 let certificateChainBody: string | null = null; 131 if (codeSigningInfo) { 132 const signature = signManifestString(stringifiedManifest, codeSigningInfo); 133 manifestPartHeaders = { 134 'expo-signature': serializeDictionary( 135 convertToDictionaryItemsRepresentation({ 136 keyid: codeSigningInfo.keyId, 137 sig: signature, 138 alg: 'rsa-v1_5-sha256', 139 }) 140 ), 141 }; 142 certificateChainBody = codeSigningInfo.certificateChainForResponse.join('\n'); 143 } 144 145 const form = this.getFormData({ 146 stringifiedManifest, 147 manifestPartHeaders, 148 certificateChainBody, 149 }); 150 151 const headers = this.getDefaultResponseHeaders(); 152 headers.set( 153 'content-type', 154 requestOptions.explicitlyPrefersMultipartMixed 155 ? `multipart/mixed; boundary=${form.getBoundary()}` 156 : 'text/plain' 157 ); 158 159 return { 160 body: form.getBuffer().toString(), 161 version: runtimeVersion, 162 headers, 163 }; 164 } 165 166 private getFormData({ 167 stringifiedManifest, 168 manifestPartHeaders, 169 certificateChainBody, 170 }: { 171 stringifiedManifest: string; 172 manifestPartHeaders: { 'expo-signature': string } | null; 173 certificateChainBody: string | null; 174 }): FormData { 175 const form = new FormData(); 176 form.append('manifest', stringifiedManifest, { 177 contentType: 'application/json', 178 header: { 179 ...manifestPartHeaders, 180 }, 181 }); 182 if (certificateChainBody && certificateChainBody.length > 0) { 183 form.append('certificate_chain', certificateChainBody, { 184 contentType: 'application/x-pem-file', 185 }); 186 } 187 return form; 188 } 189 190 protected trackManifest(version?: string) { 191 logEventAsync('Serve Expo Updates Manifest', { 192 runtimeVersion: version, 193 }); 194 } 195 196 private static async getScopeKeyAsync({ 197 slug, 198 codeSigningInfo, 199 }: { 200 slug: string; 201 codeSigningInfo: CodeSigningInfo | null; 202 }): Promise<string> { 203 const scopeKeyFromCodeSigningInfo = codeSigningInfo?.scopeKey; 204 if (scopeKeyFromCodeSigningInfo) { 205 return scopeKeyFromCodeSigningInfo; 206 } 207 208 Log.warn( 209 APISettings.isOffline 210 ? 'Using anonymous scope key in manifest for offline mode with no cached development code signing info.' 211 : 'Using anonymous scope key in manifest.' 212 ); 213 return await getAnonymousScopeKeyAsync(slug); 214 } 215} 216 217async function getAnonymousScopeKeyAsync(slug: string): Promise<string> { 218 const userAnonymousIdentifier = await UserSettings.getAnonymousIdentifierAsync(); 219 return `@${ANONYMOUS_USERNAME}/${slug}-${userAnonymousIdentifier}`; 220} 221 222function convertToDictionaryItemsRepresentation(obj: { [key: string]: string }): Dictionary { 223 return new Map( 224 Object.entries(obj).map(([k, v]) => { 225 return [k, [v, new Map()]]; 226 }) 227 ); 228} 229