16a750d06SEvan Bacon/**
26a750d06SEvan Bacon * Copyright © 2022 650 Industries.
36a750d06SEvan Bacon *
46a750d06SEvan Bacon * This source code is licensed under the MIT license found in the
56a750d06SEvan Bacon * LICENSE file in the root directory of this source tree.
66a750d06SEvan Bacon */
7e87e8ea8SEvan Baconimport fs from 'fs';
8e87e8ea8SEvan Baconimport path from 'path';
9e87e8ea8SEvan Bacon
10e87e8ea8SEvan Baconimport { ExpoMiddleware } from './ExpoMiddleware';
11e87e8ea8SEvan Baconimport { ServerRequest, ServerResponse } from './server.types';
12e87e8ea8SEvan Bacon
13e87e8ea8SEvan Baconconst debug = require('debug')('expo:start:server:middleware:createFile') as typeof console.log;
14e87e8ea8SEvan Bacon
15*8010f0caSEvan Baconexport type TouchFileBody = {
16*8010f0caSEvan Bacon  /** @deprecated */
17*8010f0caSEvan Bacon  path: string;
18*8010f0caSEvan Bacon  absolutePath?: string;
19*8010f0caSEvan Bacon  contents: string;
20*8010f0caSEvan Bacon};
21e87e8ea8SEvan Bacon
22e87e8ea8SEvan Bacon/**
23e87e8ea8SEvan Bacon * Middleware for creating a file given a `POST` request with
24e87e8ea8SEvan Bacon * `{ contents: string, path: string }` in the body.
25e87e8ea8SEvan Bacon */
26e87e8ea8SEvan Baconexport class CreateFileMiddleware extends ExpoMiddleware {
27e87e8ea8SEvan Bacon  constructor(protected projectRoot: string) {
28e87e8ea8SEvan Bacon    super(projectRoot, ['/_expo/touch']);
29e87e8ea8SEvan Bacon  }
30e87e8ea8SEvan Bacon
31e87e8ea8SEvan Bacon  protected resolvePath(inputPath: string): string {
32*8010f0caSEvan Bacon    return this.resolveExtension(path.join(this.projectRoot, inputPath));
33*8010f0caSEvan Bacon  }
34*8010f0caSEvan Bacon
35*8010f0caSEvan Bacon  protected resolveExtension(inputPath: string): string {
36*8010f0caSEvan Bacon    let resolvedPath = inputPath;
37*8010f0caSEvan Bacon    const extension = path.extname(inputPath);
38e87e8ea8SEvan Bacon    if (extension === '.js') {
39e87e8ea8SEvan Bacon      // Automatically convert JS files to TS files when added to a project
40e87e8ea8SEvan Bacon      // with TypeScript.
41e87e8ea8SEvan Bacon      const tsconfigPath = path.join(this.projectRoot, 'tsconfig.json');
42e87e8ea8SEvan Bacon      if (fs.existsSync(tsconfigPath)) {
43e87e8ea8SEvan Bacon        resolvedPath = resolvedPath.replace(/\.js$/, '.tsx');
44e87e8ea8SEvan Bacon      }
45e87e8ea8SEvan Bacon    }
46e87e8ea8SEvan Bacon
47e87e8ea8SEvan Bacon    return resolvedPath;
48e87e8ea8SEvan Bacon  }
49e87e8ea8SEvan Bacon
50e87e8ea8SEvan Bacon  protected async parseRawBody(req: ServerRequest): Promise<TouchFileBody> {
51e87e8ea8SEvan Bacon    const rawBody = await new Promise<string>((resolve, reject) => {
52e87e8ea8SEvan Bacon      let body = '';
53e87e8ea8SEvan Bacon      req.on('data', (chunk) => {
54e87e8ea8SEvan Bacon        body += chunk.toString();
55e87e8ea8SEvan Bacon      });
56e87e8ea8SEvan Bacon      req.on('end', () => {
57e87e8ea8SEvan Bacon        resolve(body);
58e87e8ea8SEvan Bacon      });
59e87e8ea8SEvan Bacon      req.on('error', (err) => {
60e87e8ea8SEvan Bacon        reject(err);
61e87e8ea8SEvan Bacon      });
62e87e8ea8SEvan Bacon    });
63e87e8ea8SEvan Bacon
64e87e8ea8SEvan Bacon    const properties = JSON.parse(rawBody);
65e87e8ea8SEvan Bacon    this.assertTouchFileBody(properties);
66e87e8ea8SEvan Bacon
67e87e8ea8SEvan Bacon    return properties;
68e87e8ea8SEvan Bacon  }
69e87e8ea8SEvan Bacon
70e87e8ea8SEvan Bacon  private assertTouchFileBody(body: any): asserts body is TouchFileBody {
71e87e8ea8SEvan Bacon    if (typeof body !== 'object' || body == null) {
72e87e8ea8SEvan Bacon      throw new Error('Expected object');
73e87e8ea8SEvan Bacon    }
74e87e8ea8SEvan Bacon    if (typeof body.path !== 'string') {
75e87e8ea8SEvan Bacon      throw new Error('Expected "path" in body to be string');
76e87e8ea8SEvan Bacon    }
77e87e8ea8SEvan Bacon    if (typeof body.contents !== 'string') {
78e87e8ea8SEvan Bacon      throw new Error('Expected "contents" in body to be string');
79e87e8ea8SEvan Bacon    }
80e87e8ea8SEvan Bacon  }
81e87e8ea8SEvan Bacon
82e87e8ea8SEvan Bacon  async handleRequestAsync(req: ServerRequest, res: ServerResponse): Promise<void> {
83e87e8ea8SEvan Bacon    if (req.method !== 'POST') {
84e87e8ea8SEvan Bacon      res.statusCode = 405;
85e87e8ea8SEvan Bacon      res.end('Method Not Allowed');
86e87e8ea8SEvan Bacon      return;
87e87e8ea8SEvan Bacon    }
88e87e8ea8SEvan Bacon
89e87e8ea8SEvan Bacon    let properties: TouchFileBody;
90e87e8ea8SEvan Bacon
91e87e8ea8SEvan Bacon    try {
92e87e8ea8SEvan Bacon      properties = await this.parseRawBody(req);
93e87e8ea8SEvan Bacon    } catch (e) {
94e87e8ea8SEvan Bacon      debug('Error parsing request body', e);
95e87e8ea8SEvan Bacon      res.statusCode = 400;
96e87e8ea8SEvan Bacon      res.end('Bad Request');
97e87e8ea8SEvan Bacon      return;
98e87e8ea8SEvan Bacon    }
99e87e8ea8SEvan Bacon
100e87e8ea8SEvan Bacon    debug(`Requested: %O`, properties);
101e87e8ea8SEvan Bacon
102*8010f0caSEvan Bacon    const resolvedPath = properties.absolutePath
103*8010f0caSEvan Bacon      ? this.resolveExtension(path.resolve(properties.absolutePath))
104*8010f0caSEvan Bacon      : this.resolvePath(properties.path);
105e87e8ea8SEvan Bacon
106e87e8ea8SEvan Bacon    if (fs.existsSync(resolvedPath)) {
107e87e8ea8SEvan Bacon      res.statusCode = 409;
108e87e8ea8SEvan Bacon      res.end('File already exists.');
109e87e8ea8SEvan Bacon      return;
110e87e8ea8SEvan Bacon    }
111e87e8ea8SEvan Bacon
112e87e8ea8SEvan Bacon    debug(`Resolved path:`, resolvedPath);
113e87e8ea8SEvan Bacon
114e87e8ea8SEvan Bacon    try {
115e87e8ea8SEvan Bacon      await fs.promises.mkdir(path.dirname(resolvedPath), { recursive: true });
116e87e8ea8SEvan Bacon      await fs.promises.writeFile(resolvedPath, properties.contents, 'utf8');
117e87e8ea8SEvan Bacon    } catch (e) {
118e87e8ea8SEvan Bacon      debug('Error writing file', e);
119e87e8ea8SEvan Bacon      res.statusCode = 500;
120e87e8ea8SEvan Bacon      res.end('Error writing file.');
121e87e8ea8SEvan Bacon      return;
122e87e8ea8SEvan Bacon    }
123e87e8ea8SEvan Bacon
124e87e8ea8SEvan Bacon    debug(`File created`);
125e87e8ea8SEvan Bacon    res.statusCode = 200;
126e87e8ea8SEvan Bacon    res.end('OK');
127e87e8ea8SEvan Bacon  }
128e87e8ea8SEvan Bacon}
129