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