1import * as crypto from 'crypto';
2import express from 'express';
3import FormData from 'form-data';
4import * as fs from 'fs';
5import * as path from 'path';
6import { serializeDictionary } from 'structured-headers';
7import { setTimeout } from 'timers/promises';
8
9const app = express();
10
11let server: { close: () => void } | null;
12
13let messages: any[] = [];
14let responsesToServe: any[] = [];
15
16let updateRequest: null = null;
17
18let manifestToServe: null = null;
19let manifestHeadersToServe: { [x: string]: any } | null = null;
20
21let multipartResponseToServe: any = null;
22let requestedStaticFiles: string[] = [];
23
24let protocolVersion: number = 1;
25
26function start(port: any, protocol: number = 1) {
27  if (!server) {
28    server = app.listen(port);
29    protocolVersion = protocol;
30  }
31}
32
33function stop() {
34  if (server) {
35    server.close();
36    server = null;
37  }
38  messages = [];
39  responsesToServe = [];
40  updateRequest = null;
41  manifestToServe = null;
42  manifestHeadersToServe = null;
43  multipartResponseToServe = null;
44  requestedStaticFiles = [];
45}
46
47function consumeRequestedStaticFiles() {
48  const returnArray = requestedStaticFiles;
49  requestedStaticFiles = [];
50  return returnArray;
51}
52
53app.use(express.json());
54app.use('/static', (req: { url: string }, res: any, next: () => void) => {
55  requestedStaticFiles.push(path.basename(req.url));
56  next();
57});
58app.use('/static', express.static(path.resolve(__dirname, '..', '.static')));
59
60app.get(
61  '/notify/:string',
62  (
63    req: { params: { string: any } },
64    res: {
65      set: (arg0: string, arg1: string) => void;
66      json: (arg0: any) => void;
67      send: (arg0: string) => void;
68    }
69  ) => {
70    messages.push(req.params.string);
71    res.set('Cache-Control', 'no-store');
72    if (responsesToServe[0]) {
73      res.json(responsesToServe.shift());
74    } else {
75      res.send('Received request');
76    }
77  }
78);
79
80app.post(
81  '/post',
82  (
83    req: { body: any },
84    res: {
85      set: (arg0: string, arg1: string) => void;
86      json: (arg0: any) => void;
87      send: (arg0: string) => void;
88    }
89  ) => {
90    messages.push(req.body);
91    res.set('Cache-Control', 'no-store');
92    if (responsesToServe[0]) {
93      res.json(responsesToServe.shift());
94    } else {
95      res.send('Received request');
96    }
97  }
98);
99
100app.get('/update', (req: any, res: any) => {
101  updateRequest = req;
102  if (multipartResponseToServe) {
103    // Protocol 1: multipart and rollbacks supported
104    const form = new FormData();
105
106    if (multipartResponseToServe.manifest) {
107      form.append('manifest', JSON.stringify(multipartResponseToServe.manifest), {
108        contentType: 'application/json',
109        header: {
110          'content-type': 'application/json; charset=utf-8',
111          'expo-signature': multipartResponseToServe.manifestSignature,
112        },
113      });
114    }
115
116    if (multipartResponseToServe.directive) {
117      form.append('directive', JSON.stringify(multipartResponseToServe.directive), {
118        contentType: 'application/json',
119        header: {
120          'content-type': 'application/json; charset=utf-8',
121          'expo-signature': multipartResponseToServe.directiveSignature,
122        },
123      });
124    }
125
126    res.statusCode = 200;
127    res.setHeader('expo-protocol-version', 1);
128    res.setHeader('expo-sfv-version', 0);
129    res.setHeader('cache-control', 'private, max-age=0');
130    res.setHeader('content-type', `multipart/mixed; boundary=${form.getBoundary()}`);
131    res.write(form.getBuffer());
132    res.end();
133  } else {
134    // Protocol 0
135    if (manifestToServe) {
136      if (manifestHeadersToServe) {
137        Object.keys(manifestHeadersToServe).forEach((headerName) => {
138          res.set(headerName, manifestHeadersToServe ? manifestHeadersToServe[headerName] : '');
139        });
140      }
141      res.json(manifestToServe);
142    } else {
143      res.status(404).send('No update available');
144    }
145    res.status(404).send('No update available');
146  }
147});
148
149async function waitForUpdateRequest(timeout: number): Promise<{ headers: any }> {
150  const finishTime = new Date().getTime() + timeout;
151  while (!updateRequest && server) {
152    const currentTime = new Date().getTime();
153    if (currentTime >= finishTime) {
154      throw new Error('Timed out waiting for update request');
155    }
156    if (!server) {
157      throw new Error('Server killed while waiting for update');
158    }
159    await setTimeout(50);
160  }
161
162  const request: { headers: any } = updateRequest || { headers: {} };
163  updateRequest = null;
164  return request;
165}
166
167async function getPrivateKeyAsync(projectRoot: string) {
168  const codeSigningPrivateKeyPath = path.join(projectRoot, 'keys', 'private-key.pem');
169  const pemBuffer = fs.readFileSync(path.resolve(codeSigningPrivateKeyPath));
170  return pemBuffer.toString('utf8');
171}
172
173function signRSASHA256(
174  data: string,
175  privateKey: crypto.KeyLike | crypto.SignKeyObjectInput | crypto.SignPrivateKeyInput
176) {
177  const sign = crypto.createSign('RSA-SHA256');
178  sign.update(data, 'utf8');
179  sign.end();
180  return sign.sign(privateKey, 'base64');
181}
182
183function convertToDictionaryItemsRepresentation(
184  obj: { [s: string]: unknown } | ArrayLike<unknown>
185): any {
186  return new Map(
187    Object.entries(obj).map(([k, v]) => {
188      return [k, [v, new Map()]];
189    })
190  );
191}
192
193function serveManifest(manifest: any, headers: any = null) {
194  manifestToServe = manifest;
195  manifestHeadersToServe = headers;
196}
197
198async function serveSignedManifest(manifest: any, projectRoot: any) {
199  if (protocolVersion === 0) {
200    serveSignedManifest0(manifest, projectRoot);
201  } else {
202    serveSignedManifest1(manifest, projectRoot);
203  }
204}
205
206// Protocol 0
207async function serveSignedManifest0(manifest: any, projectRoot: any) {
208  const privateKey = await getPrivateKeyAsync(projectRoot);
209  const manifestString = JSON.stringify(manifest);
210  const hashSignature = signRSASHA256(manifestString, privateKey);
211  const dictionary = convertToDictionaryItemsRepresentation({
212    sig: hashSignature,
213    keyid: 'main',
214  });
215  const signature = serializeDictionary(dictionary);
216  serveManifest(manifest, { 'expo-protocol-version': '0', 'expo-signature': signature });
217}
218
219// Protocol 1 multipart response
220async function serveSignedManifest1(manifest: any, projectRoot: any) {
221  const privateKey = await getPrivateKeyAsync(projectRoot);
222  const manifestString = JSON.stringify(manifest);
223  const hashSignature = signRSASHA256(manifestString, privateKey);
224  const dictionary = convertToDictionaryItemsRepresentation({
225    sig: hashSignature,
226    keyid: 'main',
227  });
228  const signature = serializeDictionary(dictionary);
229  multipartResponseToServe = {
230    manifest,
231    manifestSignature: signature,
232  };
233}
234
235// Protocol 1 directive response (including rollback)
236async function serveSignedDirective(directive: any, projectRoot: string) {
237  const privateKey = await getPrivateKeyAsync(projectRoot);
238  const directiveString = JSON.stringify(directive);
239  const hashSignature = signRSASHA256(directiveString, privateKey);
240  const dictionary = convertToDictionaryItemsRepresentation({
241    sig: hashSignature,
242    keyid: 'main',
243  });
244  const signature = serializeDictionary(dictionary);
245  multipartResponseToServe = {
246    directive,
247    directiveSignature: signature,
248  };
249}
250
251const Server = {
252  start,
253  stop,
254  waitForUpdateRequest,
255  serveManifest,
256  serveSignedManifest,
257  serveSignedDirective,
258  consumeRequestedStaticFiles,
259};
260
261export default Server;
262