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 } = await this._resolveProjectSettingsAsync(
103      requestOptions
104    );
105
106    const runtimeVersion = await Updates.getRuntimeVersionAsync(
107      this.projectRoot,
108      { ...exp, runtimeVersion: exp.runtimeVersion ?? { policy: 'sdkVersion' } },
109      requestOptions.platform
110    );
111    if (!runtimeVersion) {
112      throw new CommandError(
113        'MANIFEST_MIDDLEWARE',
114        `Unable to determine runtime version for platform '${requestOptions.platform}'`
115      );
116    }
117
118    const codeSigningInfo = await getCodeSigningInfoAsync(
119      exp,
120      requestOptions.expectSignature,
121      this.options.privateKeyPath
122    );
123
124    const easProjectId = exp.extra?.eas?.projectId as string | undefined | null;
125    const scopeKey = await ExpoGoManifestHandlerMiddleware.getScopeKeyAsync({
126      slug: exp.slug,
127      codeSigningInfo,
128    });
129
130    const expoUpdatesManifest: ExpoUpdatesManifest = {
131      id: crypto.randomUUID(),
132      createdAt: new Date().toISOString(),
133      runtimeVersion,
134      launchAsset: {
135        key: 'bundle',
136        contentType: 'application/javascript',
137        url: bundleUrl,
138      },
139      assets: [], // assets are not used in development
140      metadata: {}, // required for the client to detect that this is an expo-updates manifest
141      extra: {
142        eas: {
143          projectId: easProjectId ?? undefined,
144        },
145        expoClient: {
146          ...exp,
147          hostUri,
148        },
149        expoGo: expoGoConfig,
150        scopeKey,
151      },
152    };
153
154    const stringifiedManifest = JSON.stringify(expoUpdatesManifest);
155
156    let manifestPartHeaders: { 'expo-signature': string } | null = null;
157    let certificateChainBody: string | null = null;
158    if (codeSigningInfo) {
159      const signature = signManifestString(stringifiedManifest, codeSigningInfo);
160      manifestPartHeaders = {
161        'expo-signature': serializeDictionary(
162          convertToDictionaryItemsRepresentation({
163            keyid: codeSigningInfo.keyId,
164            sig: signature,
165            alg: 'rsa-v1_5-sha256',
166          })
167        ),
168      };
169      certificateChainBody = codeSigningInfo.certificateChainForResponse.join('\n');
170    }
171
172    const headers = this.getDefaultResponseHeaders();
173
174    switch (requestOptions.responseContentType) {
175      case ResponseContentType.MULTIPART_MIXED: {
176        const form = this.getFormData({
177          stringifiedManifest,
178          manifestPartHeaders,
179          certificateChainBody,
180        });
181        headers.set('content-type', `multipart/mixed; boundary=${form.getBoundary()}`);
182        return {
183          body: form.getBuffer().toString(),
184          version: runtimeVersion,
185          headers,
186        };
187      }
188      case ResponseContentType.APPLICATION_EXPO_JSON:
189      case ResponseContentType.APPLICATION_JSON:
190      case ResponseContentType.TEXT_PLAIN: {
191        headers.set(
192          'content-type',
193          ExpoGoManifestHandlerMiddleware.getContentTypeForResponseContentType(
194            requestOptions.responseContentType
195          )
196        );
197        if (manifestPartHeaders) {
198          Object.entries(manifestPartHeaders).forEach(([key, value]) => {
199            headers.set(key, value);
200          });
201        }
202
203        return {
204          body: stringifiedManifest,
205          version: runtimeVersion,
206          headers,
207        };
208      }
209    }
210  }
211
212  private static getContentTypeForResponseContentType(
213    responseContentType: ResponseContentType
214  ): string {
215    switch (responseContentType) {
216      case ResponseContentType.MULTIPART_MIXED:
217        return 'multipart/mixed';
218      case ResponseContentType.APPLICATION_EXPO_JSON:
219        return 'application/expo+json';
220      case ResponseContentType.APPLICATION_JSON:
221        return 'application/json';
222      case ResponseContentType.TEXT_PLAIN:
223        return 'text/plain';
224    }
225  }
226
227  private getFormData({
228    stringifiedManifest,
229    manifestPartHeaders,
230    certificateChainBody,
231  }: {
232    stringifiedManifest: string;
233    manifestPartHeaders: { 'expo-signature': string } | null;
234    certificateChainBody: string | null;
235  }): FormData {
236    const form = new FormData();
237    form.append('manifest', stringifiedManifest, {
238      contentType: 'application/json',
239      header: {
240        ...manifestPartHeaders,
241      },
242    });
243    if (certificateChainBody && certificateChainBody.length > 0) {
244      form.append('certificate_chain', certificateChainBody, {
245        contentType: 'application/x-pem-file',
246      });
247    }
248    return form;
249  }
250
251  protected trackManifest(version?: string) {
252    logEventAsync('Serve Expo Updates Manifest', {
253      runtimeVersion: version,
254    });
255  }
256
257  private static async getScopeKeyAsync({
258    slug,
259    codeSigningInfo,
260  }: {
261    slug: string;
262    codeSigningInfo: CodeSigningInfo | null;
263  }): Promise<string> {
264    const scopeKeyFromCodeSigningInfo = codeSigningInfo?.scopeKey;
265    if (scopeKeyFromCodeSigningInfo) {
266      return scopeKeyFromCodeSigningInfo;
267    }
268
269    // Log.warn(
270    //   env.EXPO_OFFLINE
271    //     ? 'Using anonymous scope key in manifest for offline mode.'
272    //     : 'Using anonymous scope key in manifest.'
273    // );
274    return await getAnonymousScopeKeyAsync(slug);
275  }
276}
277
278async function getAnonymousScopeKeyAsync(slug: string): Promise<string> {
279  const userAnonymousIdentifier = await UserSettings.getAnonymousIdentifierAsync();
280  return `@${ANONYMOUS_USERNAME}/${slug}-${userAnonymousIdentifier}`;
281}
282
283function convertToDictionaryItemsRepresentation(obj: { [key: string]: string }): Dictionary {
284  return new Map(
285    Object.entries(obj).map(([k, v]) => {
286      return [k, [v, new Map()]];
287    })
288  );
289}
290