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