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 26export enum ResponseContentType { 27 TEXT_PLAIN, 28 APPLICATION_JSON, 29 APPLICATION_EXPO_JSON, 30 MULTIPART_MIXED, 31} 32 33interface ExpoGoManifestRequestInfo extends ManifestRequestInfo { 34 responseContentType: ResponseContentType; 35 expectSignature: string | null; 36} 37 38export class ExpoGoManifestHandlerMiddleware extends ManifestMiddleware<ExpoGoManifestRequestInfo> { 39 public getParsedHeaders(req: ServerRequest): ExpoGoManifestRequestInfo { 40 let platform = parsePlatformHeader(req); 41 42 if (!platform) { 43 debug( 44 `No "expo-platform" header or "platform" query parameter specified. Falling back to "none".` 45 ); 46 platform = 'none'; 47 } 48 49 assertRuntimePlatform(platform); 50 51 // Expo Updates clients explicitly accept "multipart/mixed" responses while browsers implicitly 52 // accept them with "accept: */*". To make it easier to debug manifest responses by visiting their 53 // URLs in a browser, we denote the response as "text/plain" if the user agent appears not to be 54 // an Expo Updates client. 55 const accept = accepts(req); 56 const acceptedType = accept.types([ 57 'unknown/unknown', 58 'multipart/mixed', 59 'application/json', 60 'application/expo+json', 61 'text/plain', 62 ]); 63 64 let responseContentType; 65 switch (acceptedType) { 66 case 'multipart/mixed': 67 responseContentType = ResponseContentType.MULTIPART_MIXED; 68 break; 69 case 'application/json': 70 responseContentType = ResponseContentType.APPLICATION_JSON; 71 break; 72 case 'application/expo+json': 73 responseContentType = ResponseContentType.APPLICATION_EXPO_JSON; 74 break; 75 default: 76 responseContentType = ResponseContentType.TEXT_PLAIN; 77 break; 78 } 79 80 const expectSignature = req.headers['expo-expect-signature']; 81 82 return { 83 responseContentType, 84 platform, 85 expectSignature: expectSignature ? String(expectSignature) : null, 86 hostname: stripPort(req.headers['host']), 87 }; 88 } 89 90 private getDefaultResponseHeaders(): ServerHeaders { 91 const headers = new Map<string, number | string | readonly string[]>(); 92 // set required headers for Expo Updates manifest specification 93 headers.set('expo-protocol-version', 0); 94 headers.set('expo-sfv-version', 0); 95 headers.set('cache-control', 'private, max-age=0'); 96 return headers; 97 } 98 99 public async _getManifestResponseAsync(requestOptions: ExpoGoManifestRequestInfo): Promise<{ 100 body: string; 101 version: string; 102 headers: ServerHeaders; 103 }> { 104 const { exp, hostUri, expoGoConfig, bundleUrl } = await this._resolveProjectSettingsAsync( 105 requestOptions 106 ); 107 108 const runtimeVersion = Updates.getRuntimeVersion( 109 { ...exp, runtimeVersion: exp.runtimeVersion ?? { policy: 'sdkVersion' } }, 110 requestOptions.platform 111 ); 112 if (!runtimeVersion) { 113 throw new CommandError( 114 'MANIFEST_MIDDLEWARE', 115 `Unable to determine runtime version for platform '${requestOptions.platform}'` 116 ); 117 } 118 119 const codeSigningInfo = await getCodeSigningInfoAsync( 120 exp, 121 requestOptions.expectSignature, 122 this.options.privateKeyPath 123 ); 124 125 const easProjectId = exp.extra?.eas?.projectId as string | undefined | null; 126 const scopeKey = await ExpoGoManifestHandlerMiddleware.getScopeKeyAsync({ 127 slug: exp.slug, 128 codeSigningInfo, 129 }); 130 131 const expoUpdatesManifest: ExpoUpdatesManifest = { 132 id: crypto.randomUUID(), 133 createdAt: new Date().toISOString(), 134 runtimeVersion, 135 launchAsset: { 136 key: 'bundle', 137 contentType: 'application/javascript', 138 url: bundleUrl, 139 }, 140 assets: [], // assets are not used in development 141 metadata: {}, // required for the client to detect that this is an expo-updates manifest 142 extra: { 143 eas: { 144 projectId: easProjectId ?? undefined, 145 }, 146 expoClient: { 147 ...exp, 148 hostUri, 149 }, 150 expoGo: expoGoConfig, 151 scopeKey, 152 }, 153 }; 154 155 const stringifiedManifest = JSON.stringify(expoUpdatesManifest); 156 157 let manifestPartHeaders: { 'expo-signature': string } | null = null; 158 let certificateChainBody: string | null = null; 159 if (codeSigningInfo) { 160 const signature = signManifestString(stringifiedManifest, codeSigningInfo); 161 manifestPartHeaders = { 162 'expo-signature': serializeDictionary( 163 convertToDictionaryItemsRepresentation({ 164 keyid: codeSigningInfo.keyId, 165 sig: signature, 166 alg: 'rsa-v1_5-sha256', 167 }) 168 ), 169 }; 170 certificateChainBody = codeSigningInfo.certificateChainForResponse.join('\n'); 171 } 172 173 const headers = this.getDefaultResponseHeaders(); 174 175 switch (requestOptions.responseContentType) { 176 case ResponseContentType.MULTIPART_MIXED: { 177 const form = this.getFormData({ 178 stringifiedManifest, 179 manifestPartHeaders, 180 certificateChainBody, 181 }); 182 headers.set('content-type', `multipart/mixed; boundary=${form.getBoundary()}`); 183 return { 184 body: form.getBuffer().toString(), 185 version: runtimeVersion, 186 headers, 187 }; 188 } 189 case ResponseContentType.APPLICATION_EXPO_JSON: 190 case ResponseContentType.APPLICATION_JSON: 191 case ResponseContentType.TEXT_PLAIN: { 192 headers.set( 193 'content-type', 194 ExpoGoManifestHandlerMiddleware.getContentTypeForResponseContentType( 195 requestOptions.responseContentType 196 ) 197 ); 198 if (manifestPartHeaders) { 199 Object.entries(manifestPartHeaders).forEach(([key, value]) => { 200 headers.set(key, value); 201 }); 202 } 203 204 return { 205 body: stringifiedManifest, 206 version: runtimeVersion, 207 headers, 208 }; 209 } 210 } 211 } 212 213 private static getContentTypeForResponseContentType( 214 responseContentType: ResponseContentType 215 ): string { 216 switch (responseContentType) { 217 case ResponseContentType.MULTIPART_MIXED: 218 return 'multipart/mixed'; 219 case ResponseContentType.APPLICATION_EXPO_JSON: 220 return 'application/expo+json'; 221 case ResponseContentType.APPLICATION_JSON: 222 return 'application/json'; 223 case ResponseContentType.TEXT_PLAIN: 224 return 'text/plain'; 225 } 226 } 227 228 private getFormData({ 229 stringifiedManifest, 230 manifestPartHeaders, 231 certificateChainBody, 232 }: { 233 stringifiedManifest: string; 234 manifestPartHeaders: { 'expo-signature': string } | null; 235 certificateChainBody: string | null; 236 }): FormData { 237 const form = new FormData(); 238 form.append('manifest', stringifiedManifest, { 239 contentType: 'application/json', 240 header: { 241 ...manifestPartHeaders, 242 }, 243 }); 244 if (certificateChainBody && certificateChainBody.length > 0) { 245 form.append('certificate_chain', certificateChainBody, { 246 contentType: 'application/x-pem-file', 247 }); 248 } 249 return form; 250 } 251 252 protected trackManifest(version?: string) { 253 logEventAsync('Serve Expo Updates Manifest', { 254 runtimeVersion: version, 255 }); 256 } 257 258 private static async getScopeKeyAsync({ 259 slug, 260 codeSigningInfo, 261 }: { 262 slug: string; 263 codeSigningInfo: CodeSigningInfo | null; 264 }): Promise<string> { 265 const scopeKeyFromCodeSigningInfo = codeSigningInfo?.scopeKey; 266 if (scopeKeyFromCodeSigningInfo) { 267 return scopeKeyFromCodeSigningInfo; 268 } 269 270 Log.warn( 271 APISettings.isOffline 272 ? 'Using anonymous scope key in manifest for offline mode.' 273 : 'Using anonymous scope key in manifest.' 274 ); 275 return await getAnonymousScopeKeyAsync(slug); 276 } 277} 278 279async function getAnonymousScopeKeyAsync(slug: string): Promise<string> { 280 const userAnonymousIdentifier = await UserSettings.getAnonymousIdentifierAsync(); 281 return `@${ANONYMOUS_USERNAME}/${slug}-${userAnonymousIdentifier}`; 282} 283 284function convertToDictionaryItemsRepresentation(obj: { [key: string]: string }): Dictionary { 285 return new Map( 286 Object.entries(obj).map(([k, v]) => { 287 return [k, [v, new Map()]]; 288 }) 289 ); 290} 291