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