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