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