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