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