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