@naviyra/cloud-panel 0.0.3-beta → 0.0.3-beta3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,8 @@ import { fileURLToPath } from "node:url";
12
12
  import path from "node:path";
13
13
  import os from "node:os";
14
14
  import net from "node:net";
15
+ import { ensurePhpTools } from "./host-tools.mjs";
16
+ import { reservedEnv } from "@naviyra/shared";
15
17
  const windows = process.platform === "win32";
16
18
  const binaries = {
17
19
  node: process.execPath,
@@ -45,11 +47,48 @@ export function safeEnvironment(extra = {}) {
45
47
  if (process.env[key]) env[key] = process.env[key];
46
48
  return { ...env, GIT_TERMINAL_PROMPT: "0", PYTHONUNBUFFERED: "1", ...extra };
47
49
  }
50
+ export function applicationEnvironment(config) {
51
+ const env = {};
52
+ for (const [key, value] of Object.entries(config?.env || {})) {
53
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(key)) continue;
54
+ if (reservedEnv.has(key.toUpperCase()) || key === "Path") continue;
55
+ if (typeof value !== "string") continue;
56
+ env[key] = value.slice(0, 4000);
57
+ }
58
+ return env;
59
+ }
60
+ export function pythonTarget(config) {
61
+ const file = String(config?.startup_file || "").replaceAll("\\", "/");
62
+ if (!file) return config?.entrypoint || "app:app";
63
+ const dir = String(config?.working_dir || ".")
64
+ .replaceAll("\\", "/")
65
+ .replace(/\/$/, "");
66
+ let relative = file;
67
+ if (dir && dir !== "." && (file === dir || file.startsWith(`${dir}/`)))
68
+ relative = file.slice(dir.length).replace(/^\//, "");
69
+ const mod = relative.replace(/\.py$/i, "").replaceAll("/", ".");
70
+ if (
71
+ !file.toLowerCase().endsWith(".py") ||
72
+ !/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$/.test(mod)
73
+ )
74
+ throw Error("Python startup file must be a relative .py path such as app.py");
75
+ return `${mod}:app`;
76
+ }
48
77
  export async function nativeCommand(name, args, options = {}) {
49
78
  const file = binaries[name];
50
79
  if (!file) throw Error("Native command is not allowed");
51
80
  return run(file, args, options);
52
81
  }
82
+ export function parseCommandJson(text) {
83
+ const source = String(text ?? "");
84
+ try {
85
+ return JSON.parse(source);
86
+ } catch (error) {
87
+ const index = Number(String(error.message).match(/position (\d+)/)?.[1]);
88
+ if (!Number.isInteger(index) || index <= 0) throw error;
89
+ return JSON.parse(source.slice(0, index));
90
+ }
91
+ }
53
92
  export function run(
54
93
  file,
55
94
  args,
@@ -61,19 +100,22 @@ export function run(
61
100
  [],
62
101
  { env: safeEnvironment(), silent: true, windowsHide: true },
63
102
  );
64
- let output = "",
103
+ let stdout = "",
104
+ stderr = "",
65
105
  settled = false;
66
106
  const timer = setTimeout(() => {
67
107
  settled = true;
68
108
  worker.send({ stop: true });
69
109
  reject(Error("Native command timed out"));
70
110
  }, timeout);
71
- const collect = (chunk) => {
72
- output = (output + chunk.toString()).slice(-100000);
73
- log(chunk.toString());
111
+ const collect = (target) => (chunk) => {
112
+ const text = chunk.toString();
113
+ if (target === "stdout") stdout = (stdout + text).slice(-100000);
114
+ else stderr = (stderr + text).slice(-100000);
115
+ log(text);
74
116
  };
75
- worker.stdout.on("data", collect);
76
- worker.stderr.on("data", collect);
117
+ worker.stdout.on("data", collect("stdout"));
118
+ worker.stderr.on("data", collect("stderr"));
77
119
  worker.on("error", (error) => {
78
120
  clearTimeout(timer);
79
121
  if (!settled) reject(error);
@@ -82,9 +124,11 @@ export function run(
82
124
  clearTimeout(timer);
83
125
  if (!settled) {
84
126
  code === 0
85
- ? resolve(output)
127
+ ? resolve(stdout)
86
128
  : reject(
87
- Error(`${path.basename(file)} failed: ${output.slice(-3000)}`),
129
+ Error(
130
+ `${path.basename(file)} failed: ${(stderr || stdout).slice(-3000)}`,
131
+ ),
88
132
  );
89
133
  }
90
134
  });
@@ -188,14 +232,44 @@ export function npmCommand(args) {
188
232
  args: [cli, ...args],
189
233
  };
190
234
  }
191
- export async function prepareNative(p, source, log = () => {}) {
235
+ export function phpFpmPool(port, directory, uid = process.getuid?.()) {
236
+ const listen = Number(port);
237
+ if (!Number.isInteger(listen) || listen < 1 || listen > 65535)
238
+ throw Error("PHP port is invalid");
239
+ const lines = [
240
+ "[global]",
241
+ "daemonize = no",
242
+ `error_log = ${JSON.stringify(path.join(directory, "php-fpm.log"))}`,
243
+ `pid = ${JSON.stringify(path.join(directory, "php-fpm.pid"))}`,
244
+ "",
245
+ "[www]",
246
+ `listen = 127.0.0.1:${listen}`,
247
+ "listen.allowed_clients = 127.0.0.1",
248
+ "pm = ondemand",
249
+ "pm.max_children = 8",
250
+ "pm.process_idle_timeout = 10s",
251
+ ];
252
+ if (uid === 0) {
253
+ const user = process.env.PHP_FPM_USER || "";
254
+ const group = process.env.PHP_FPM_GROUP || user;
255
+ if (!/^[a-zA-Z0-9._-]+$/.test(user) || !/^[a-zA-Z0-9._-]+$/.test(group))
256
+ throw Error(
257
+ "PHP-FPM cannot run workers as root. Set PHP_FPM_USER and PHP_FPM_GROUP, or run the panel as a normal user.",
258
+ );
259
+ lines.push(`user = ${user}`, `group = ${group}`);
260
+ }
261
+ return `${lines.join("\n")}\n`;
262
+ }
263
+ export async function prepareNative(p, source, log = () => {}, toolsDir) {
192
264
  const config = p.runtime_config || {};
193
265
  const env = safeEnvironment({
194
266
  PORT: String(p.port),
195
267
  HOST: "127.0.0.1",
196
268
  HOSTNAME: "127.0.0.1",
197
269
  NODE_ENV: "production",
270
+ ...applicationEnvironment(config),
198
271
  });
272
+ const cwd = await containedDirectory(source, config.working_dir || ".");
199
273
  const execute = (file, args) => run(file, args, { cwd: source, env, log });
200
274
  const npm = async (args) => {
201
275
  const spec = npmCommand(args);
@@ -207,8 +281,10 @@ export async function prepareNative(p, source, log = () => {}) {
207
281
  const manifest = JSON.parse(
208
282
  await readFile(path.join(source, "package.json"), "utf8"),
209
283
  );
210
- if (type === "nodejs" && !manifest.scripts?.start)
211
- throw Error("Node.js projects require a package.json start script");
284
+ if (type === "nodejs" && !manifest.scripts?.start && !config.startup_file)
285
+ throw Error(
286
+ "Node.js projects require a package.json start script or a startup file",
287
+ );
212
288
  await npm([
213
289
  (await exists(path.join(source, "package-lock.json")))
214
290
  ? "ci"
@@ -218,12 +294,24 @@ export async function prepareNative(p, source, log = () => {}) {
218
294
  "--no-fund",
219
295
  ]);
220
296
  if (manifest.scripts?.build) await npm(["run", "build"]);
221
- } else if (type === "nodejs")
297
+ } else if (type === "nodejs" && !config.startup_file)
222
298
  throw Error("package.json is missing at the source root");
223
- if (type === "nodejs")
224
- return [
225
- { ...npmCommand(["run", "start"]), cwd: source, env, key: "app" },
226
- ];
299
+ if (type === "nodejs") {
300
+ if (config.startup_file) {
301
+ let startFile;
302
+ try {
303
+ startFile = await containedDirectory(source, config.startup_file);
304
+ } catch (error) {
305
+ if (error.code === "ENOENT")
306
+ throw Error(`Startup file not found: ${config.startup_file}`);
307
+ throw error;
308
+ }
309
+ return [
310
+ { key: "app", file: process.execPath, args: [startFile], cwd, env },
311
+ ];
312
+ }
313
+ return [{ ...npmCommand(["run", "start"]), cwd, env, key: "app" }];
314
+ }
227
315
  const root = await containedDirectory(source, config.public_dir || ".");
228
316
  if (!(await exists(path.join(root, "index.html"))))
229
317
  throw Error("Static public directory must contain index.html");
@@ -250,6 +338,7 @@ export async function prepareNative(p, source, log = () => {}) {
250
338
  if (await exists(path.join(source, "requirements.txt")))
251
339
  await execute(python, ["-m", "pip", "install", "-r", "requirements.txt"]);
252
340
  const wsgi = config.python_server === "wsgi";
341
+ const entry = pythonTarget(config);
253
342
  await execute(python, [
254
343
  "-m",
255
344
  "pip",
@@ -265,18 +354,18 @@ export async function prepareNative(p, source, log = () => {}) {
265
354
  "-m",
266
355
  "waitress",
267
356
  `--listen=127.0.0.1:${p.port}`,
268
- config.entrypoint || "app:app",
357
+ entry,
269
358
  ]
270
359
  : [
271
360
  "-m",
272
361
  "uvicorn",
273
- config.entrypoint || "app:app",
362
+ entry,
274
363
  "--host",
275
364
  "127.0.0.1",
276
365
  "--port",
277
366
  String(p.port),
278
367
  ],
279
- cwd: source,
368
+ cwd,
280
369
  env,
281
370
  },
282
371
  ];
@@ -286,14 +375,30 @@ export async function prepareNative(p, source, log = () => {}) {
286
375
  p.folder_path,
287
376
  windows ? "application.exe" : "application",
288
377
  );
289
- await execute(binaries.go, ["build", "-o", target, "."]);
290
- return [{ key: "app", file: target, args: [], cwd: source, env }];
378
+ await run(binaries.go, ["build", "-o", target, "."], { cwd, env, log });
379
+ return [{ key: "app", file: target, args: [], cwd, env }];
291
380
  }
292
381
  if (type === "php") {
382
+ const php = await ensurePhpTools(
383
+ toolsDir || path.resolve(".runtime", "host-tools"),
384
+ { log },
385
+ );
386
+ const fastcgi = php.fpm || php.phpcgi;
387
+ const phpDir = path.dirname(fastcgi);
388
+ const phpEnv = {
389
+ ...env,
390
+ Path: `${phpDir}${path.delimiter}${env.Path || env.PATH || ""}`,
391
+ PATH: `${phpDir}${path.delimiter}${env.PATH || env.Path || ""}`,
392
+ PHP_FCGI_MAX_REQUESTS: "0",
393
+ };
293
394
  if (await exists(path.join(source, "composer.json"))) {
294
395
  if (!process.env.COMPOSER_PHAR)
295
396
  throw Error("Set COMPOSER_PHAR to composer.phar for Composer projects");
296
- await execute(binaries.php, [
397
+ if (php.fpm && path.resolve(php.php) === path.resolve(php.fpm))
398
+ throw Error(
399
+ "Set PHP_BIN to a PHP CLI binary before deploying a Composer project",
400
+ );
401
+ await execute(php.php, [
297
402
  process.env.COMPOSER_PHAR,
298
403
  "install",
299
404
  "--no-dev",
@@ -307,17 +412,34 @@ export async function prepareNative(p, source, log = () => {}) {
307
412
  caddy,
308
413
  `{\n admin off\n auto_https off\n}\nhttp://127.0.0.1:${p.port} {\n root * ${JSON.stringify(root.replaceAll("\\", "/"))}\n @private path /.git/* /.env /composer.json /composer.lock\n respond @private 404\n php_fastcgi 127.0.0.1:${p.php_port}\n file_server {\n hide .* composer.json composer.lock\n }\n}\n`,
309
414
  );
415
+ const phpProcess = php.fpm
416
+ ? {
417
+ key: "php",
418
+ file: php.fpm,
419
+ args: ["-F", "-y", path.join(p.folder_path, "php-fpm.conf")],
420
+ cwd: source,
421
+ env: phpEnv,
422
+ }
423
+ : {
424
+ key: "php",
425
+ file: php.phpcgi,
426
+ args: ["-b", `127.0.0.1:${p.php_port}`],
427
+ cwd: source,
428
+ env: phpEnv,
429
+ };
430
+ if (php.fpm) {
431
+ await writeFile(
432
+ path.join(p.folder_path, "php-fpm.conf"),
433
+ phpFpmPool(p.php_port, p.folder_path),
434
+ );
435
+ if (php.ini && (await exists(php.ini)))
436
+ phpProcess.args.push("-c", php.ini);
437
+ }
310
438
  return [
311
- {
312
- key: "php",
313
- file: binaries.phpcgi,
314
- args: ["-b", `127.0.0.1:${p.php_port}`],
315
- cwd: source,
316
- env: { ...env, PHP_FCGI_MAX_REQUESTS: "0" },
317
- },
439
+ phpProcess,
318
440
  {
319
441
  key: "app",
320
- file: binaries.caddy,
442
+ file: php.caddy,
321
443
  args: ["run", "--config", caddy, "--adapter", "caddyfile"],
322
444
  cwd: source,
323
445
  env,
@@ -338,7 +460,7 @@ export async function prepareNative(p, source, log = () => {}) {
338
460
  args: (config.args || []).map((arg) =>
339
461
  arg.replaceAll("{PORT}", String(p.port)),
340
462
  ),
341
- cwd: source,
463
+ cwd,
342
464
  env,
343
465
  },
344
466
  ];
@@ -416,15 +538,18 @@ export async function provisionNativeTunnel(runtime, p) {
416
538
  `tunnel: ${credential.TunnelID}\ncredentials-file: ${JSON.stringify(credentials)}\ningress:\n - hostname: ${p.subdomain}\n service: http://127.0.0.1:${p.port}\n - service: http_status:404\n`,
417
539
  { mode: 0o600 },
418
540
  );
541
+ runtime.log(p, `Updating DNS for ${p.subdomain}`);
419
542
  await nativeCommand("cloudflared", [
420
543
  "tunnel",
421
544
  "--origincert",
422
545
  cert,
423
546
  "route",
424
547
  "dns",
548
+ "--overwrite-dns",
425
549
  credential.TunnelID,
426
550
  p.subdomain,
427
551
  ]);
552
+ runtime.log(p, `DNS updated: ${p.subdomain} points at the tunnel`);
428
553
  const spec = {
429
554
  key: "tunnel",
430
555
  file: binaries.cloudflared,
@@ -445,7 +570,7 @@ export async function deleteNativeTunnel(p) {
445
570
  process.env.CLOUDFLARE_CERT ||
446
571
  path.join(os.homedir(), ".cloudflared", "cert.pem"),
447
572
  ];
448
- const tunnels = JSON.parse(
573
+ const tunnels = parseCommandJson(
449
574
  await nativeCommand("cloudflared", [...prefix, "list", "--output", "json"]),
450
575
  );
451
576
  if (tunnels.some((t) => t.id === p.tunnel.tunnel_id))
@@ -1,4 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { createWriteStream } from "node:fs";
3
+ import { pipeline } from "node:stream/promises";
2
4
  import {
3
5
  mkdir,
4
6
  readFile,
@@ -16,6 +18,7 @@ import yauzl from "yauzl";
16
18
  import {
17
19
  NativeSupervisor,
18
20
  nativeCommand,
21
+ parseCommandJson,
19
22
  prepareNative,
20
23
  ready,
21
24
  provisionNativeTunnel,
@@ -41,7 +44,8 @@ export function command(name, args, options = {}) {
41
44
  stdio: ["ignore", "pipe", "pipe"],
42
45
  detached: process.platform !== "win32",
43
46
  });
44
- let output = "";
47
+ let stdout = "";
48
+ let stderr = "";
45
49
  let timedOut = false;
46
50
  const stop = () => {
47
51
  try {
@@ -53,12 +57,14 @@ export function command(name, args, options = {}) {
53
57
  timedOut = true;
54
58
  stop();
55
59
  }, options.timeout || 120000);
56
- const collect = (data) => {
57
- output = (output + data.toString()).slice(-100000);
58
- options.onOutput?.(data.toString());
60
+ const collect = (target) => (data) => {
61
+ const text = data.toString();
62
+ if (target === "stdout") stdout = (stdout + text).slice(-100000);
63
+ else stderr = (stderr + text).slice(-100000);
64
+ options.onOutput?.(text);
59
65
  };
60
- child.stdout.on("data", collect);
61
- child.stderr.on("data", collect);
66
+ child.stdout.on("data", collect("stdout"));
67
+ child.stderr.on("data", collect("stderr"));
62
68
  child.on("error", (error) => {
63
69
  clearTimeout(timer);
64
70
  reject(error);
@@ -66,26 +72,22 @@ export function command(name, args, options = {}) {
66
72
  child.on("close", (code) => {
67
73
  clearTimeout(timer);
68
74
  code === 0 && !timedOut
69
- ? resolve(output)
75
+ ? resolve(stdout)
70
76
  : reject(
71
77
  Error(
72
- `${name} ${timedOut ? "timed out" : `exited ${code}`}: ${output.slice(-3000)}`,
78
+ `${name} ${timedOut ? "timed out" : `exited ${code}`}: ${(stderr || stdout).slice(-3000)}`,
73
79
  ),
74
80
  );
75
81
  });
76
82
  });
77
83
  }
78
84
 
79
- export async function extractZip(buffer, destination) {
85
+ export async function extractZip(source, destination) {
80
86
  await mkdir(destination, { recursive: true });
81
87
  return new Promise((resolve, reject) => {
82
- yauzl.fromBuffer(
83
- buffer,
84
- { lazyEntries: true, validateEntrySizes: true },
85
- (error, zip) => {
88
+ const options = { lazyEntries: true, validateEntrySizes: true };
89
+ const opened = (error, zip) => {
86
90
  if (error) return reject(error);
87
- let total = 0,
88
- count = 0;
89
91
  const fail = (error) => {
90
92
  zip.close();
91
93
  reject(error);
@@ -112,11 +114,6 @@ export async function extractZip(buffer, destination) {
112
114
  .some((p) => ["node_modules", ".git"].includes(p))
113
115
  )
114
116
  throw Error("Remove node_modules and .git from the archive");
115
- if (
116
- ++count > 10000 ||
117
- (total += entry.uncompressedSize) > 200 * 1024 * 1024
118
- )
119
- throw Error("ZIP exceeds extraction limit");
120
117
  if (filename.endsWith("/")) {
121
118
  await mkdir(target, { recursive: true });
122
119
  zip.readEntry();
@@ -142,8 +139,9 @@ export async function extractZip(buffer, destination) {
142
139
  }
143
140
  });
144
141
  zip.readEntry();
145
- },
146
- );
142
+ };
143
+ if (Buffer.isBuffer(source)) yauzl.fromBuffer(source, options, opened);
144
+ else yauzl.open(source, options, opened);
147
145
  });
148
146
  }
149
147
 
@@ -223,10 +221,22 @@ export class Runtime {
223
221
  ))
224
222
  await this.supervisor.start(`${p.id}:${spec.key}`, spec);
225
223
  await ready(p.port, this.supervisor, `${p.id}:app`);
224
+ if (
225
+ process.env.NATIVE_TUNNELS !== "false" &&
226
+ !p.native_specs.some((spec) => spec.key === "tunnel")
227
+ ) {
228
+ await provisionNativeTunnel(this, p);
229
+ p.error = null;
230
+ }
226
231
  } catch (error) {
227
- p.status = "failed";
228
- p.error = error.message;
229
- await this.supervisor.stopProject(p.id);
232
+ if (this.supervisor.active(`${p.id}:app`)) {
233
+ this.log(p, `Cloudflare DNS was not updated: ${error.message}`);
234
+ p.error = error.message;
235
+ } else {
236
+ p.status = "failed";
237
+ p.error = error.message;
238
+ await this.supervisor.stopProject(p.id);
239
+ }
230
240
  }
231
241
  await this.save();
232
242
  });
@@ -261,7 +271,13 @@ export class Runtime {
261
271
  ).slice(-100000);
262
272
  }
263
273
  async create(id, input) {
264
- if (this.state.projects[id]) return this.get(id);
274
+ if (this.state.projects[id]) {
275
+ const current = this.get(id);
276
+ if (input.type) current.type = input.type;
277
+ if (input.runtime_config) current.runtime_config = input.runtime_config;
278
+ await this.save();
279
+ return current;
280
+ }
265
281
  if (
266
282
  Object.values(this.state.projects).some(
267
283
  (p) => p.name === input.name || p.subdomain === input.subdomain,
@@ -308,6 +324,23 @@ export class Runtime {
308
324
  p.has_upload = true;
309
325
  await this.save();
310
326
  }
327
+ async uploadStream(id, source) {
328
+ const p = this.get(id);
329
+ if (["queued", "deploying"].includes(p.status))
330
+ throw Object.assign(Error("Deployment in progress"), { status: 409 });
331
+ const file = path.join(this.root, `${id}.zip`);
332
+ const temporary = `${file}.${process.pid}.partial`;
333
+ try {
334
+ await pipeline(source, createWriteStream(temporary, { mode: 0o600 }));
335
+ await rm(file, { force: true });
336
+ await rename(temporary, file);
337
+ } catch (error) {
338
+ await rm(temporary, { force: true });
339
+ throw error;
340
+ }
341
+ p.has_upload = true;
342
+ await this.save();
343
+ }
311
344
  async deploy(id, deploymentId) {
312
345
  const p = this.get(id);
313
346
  if (this.mode === "host" && p.type && p.type !== "nodejs")
@@ -394,10 +427,7 @@ export class Runtime {
394
427
  await rm(source, { recursive: true, force: true });
395
428
  await mkdir(source, { recursive: true });
396
429
  if (p.source_type === "zip")
397
- await extractZip(
398
- await readFile(path.join(this.root, `${p.id}.zip`)),
399
- source,
400
- );
430
+ await extractZip(path.join(this.root, `${p.id}.zip`), source);
401
431
  else
402
432
  await nativeCommand(
403
433
  "git",
@@ -418,8 +448,11 @@ export class Runtime {
418
448
  p,
419
449
  `Preparing native ${p.type || "nodejs"} runtime on ${process.platform}`,
420
450
  );
421
- p.native_specs = await prepareNative(p, source, (message) =>
422
- this.log(p, message),
451
+ p.native_specs = await prepareNative(
452
+ p,
453
+ source,
454
+ (message) => this.log(p, message),
455
+ path.join(this.root, "host-tools"),
423
456
  );
424
457
  for (const spec of p.native_specs)
425
458
  await this.supervisor.start(`${p.id}:${spec.key}`, spec);
@@ -475,10 +508,7 @@ export class Runtime {
475
508
  folder,
476
509
  );
477
510
  else {
478
- await extractZip(
479
- await readFile(path.join(this.root, `${p.id}.zip`)),
480
- release,
481
- );
511
+ await extractZip(path.join(this.root, `${p.id}.zip`), release);
482
512
  await command("chown", ["-R", `${user}:${group}`, release]);
483
513
  }
484
514
  const manifest = JSON.parse(
@@ -541,15 +571,18 @@ export class Runtime {
541
571
  `tunnel: ${p.tunnel.tunnel_id}\ncredentials-file: ${credentials}\ningress:\n - hostname: ${p.subdomain}\n service: http://127.0.0.1:${p.port}\n - service: http_status:404\n`,
542
572
  { mode: 0o600 },
543
573
  );
574
+ this.log(p, `Updating DNS for ${p.subdomain}`);
544
575
  await command("cloudflared", [
545
576
  "tunnel",
546
577
  "--origincert",
547
578
  cert,
548
579
  "route",
549
580
  "dns",
581
+ "--overwrite-dns",
550
582
  p.tunnel.tunnel_id,
551
583
  p.subdomain,
552
584
  ]);
585
+ this.log(p, `DNS updated: ${p.subdomain} points at the tunnel`);
553
586
  const tunnelUnit = `[Unit]\nDescription=Naviyra tunnel ${p.name}\nAfter=network-online.target\nWants=network-online.target\n[Service]\nExecStart=${binaries.cloudflared} tunnel --config ${config} run ${p.tunnel.tunnel_id}\nRestart=on-failure\nRestartSec=5\n[Install]\nWantedBy=multi-user.target\n`;
554
587
  await writeFile(`/etc/systemd/system/${this.unit(p, true)}`, tunnelUnit);
555
588
  await command("systemctl", ["daemon-reload"]);
@@ -653,7 +686,7 @@ export class Runtime {
653
686
  "--origincert",
654
687
  process.env.CLOUDFLARE_CERT || "/root/.cloudflared/cert.pem",
655
688
  ];
656
- const tunnels = JSON.parse(
689
+ const tunnels = parseCommandJson(
657
690
  await command("cloudflared", [...prefix, "list", "--output", "json"]),
658
691
  );
659
692
  if (tunnels.some((t) => t.id === p.tunnel.tunnel_id))
@@ -1,12 +1,22 @@
1
1
  import http from "node:http";
2
+ import { createReadStream } from "node:fs";
2
3
  import { timingSafeEqual, createHash } from "node:crypto";
3
4
  import { pathToFileURL } from "node:url";
4
5
  import { Runtime } from "./runtime.mjs";
6
+ import {
7
+ projectAppRoot,
8
+ listFiles,
9
+ readFileText,
10
+ writeFileText,
11
+ createDirectory,
12
+ removePath,
13
+ saveUpload,
14
+ openDownload,
15
+ } from "./files.mjs";
5
16
  import {
6
17
  projectInput,
7
18
  idSchema,
8
19
  deploymentInput,
9
- MAX_ZIP_BYTES,
10
20
  } from "@naviyra/shared";
11
21
  import {
12
22
  cloudflareStatus,
@@ -26,6 +36,17 @@ import {
26
36
  deleteDestination,
27
37
  } from "./cloudflare.mjs";
28
38
 
39
+ async function readBody(req, limit) {
40
+ let size = 0;
41
+ const chunks = [];
42
+ for await (const chunk of req) {
43
+ size += chunk.length;
44
+ if (size > limit)
45
+ throw Object.assign(Error("Payload too large"), { status: 413 });
46
+ chunks.push(chunk);
47
+ }
48
+ return Buffer.concat(chunks);
49
+ }
29
50
  export function createServer(runtime, token) {
30
51
  if (!token || token.length < 32)
31
52
  throw Error("AGENT_TOKEN must contain at least 32 characters");
@@ -114,6 +135,40 @@ export function createServer(runtime, token) {
114
135
  return send(200, await deleteDestination(emailId));
115
136
  return send(405, { error: "Method not allowed" });
116
137
  }
138
+ const fileMatch = url.pathname.match(/^\/projects\/([^/]+)\/files$/);
139
+ if (fileMatch) {
140
+ const id = idSchema.parse(fileMatch[1]);
141
+ const root = await projectAppRoot(runtime.get(id).folder_path);
142
+ const queryPath = url.searchParams.get("path") || "";
143
+ if (req.method === "GET" && url.searchParams.get("download") === "1") {
144
+ const file = await openDownload(root, queryPath);
145
+ res.writeHead(200, {
146
+ "Content-Type": "application/octet-stream",
147
+ "Content-Disposition": `attachment; filename="${file.name}"`,
148
+ "Cache-Control": "no-store",
149
+ });
150
+ createReadStream(file.file).pipe(res);
151
+ return;
152
+ }
153
+ if (req.method === "GET")
154
+ return send(
155
+ 200,
156
+ url.searchParams.get("read") === "1"
157
+ ? await readFileText(root, queryPath)
158
+ : await listFiles(root, queryPath),
159
+ );
160
+ if (req.method === "POST")
161
+ return send(200, await saveUpload(root, queryPath, req));
162
+ const body = await readBody(req, 2 * 1024 * 1024);
163
+ const input = body.length ? JSON.parse(body.toString()) : {};
164
+ if (req.method === "PUT" && input.action === "mkdir")
165
+ return send(200, await createDirectory(root, input.path));
166
+ if (req.method === "PUT")
167
+ return send(200, await writeFileText(root, input.path, input.content));
168
+ if (req.method === "DELETE")
169
+ return send(200, await removePath(root, queryPath));
170
+ return send(405, { error: "Method not allowed" });
171
+ }
117
172
  const match = url.pathname.match(
118
173
  /^\/projects(?:\/([^/]+))?(?:\/(deploy|source|restart|logs|metrics))?$/,
119
174
  );
@@ -125,9 +180,13 @@ export function createServer(runtime, token) {
125
180
  if (action === "metrics") return send(200, await runtime.metrics(id));
126
181
  if (!action) return send(200, runtime.get(id));
127
182
  }
183
+ if (req.method === "PUT" && action === "source") {
184
+ await runtime.serial(() => runtime.uploadStream(id, req));
185
+ return send(200, { ok: true });
186
+ }
128
187
  let size = 0;
129
188
  const chunks = [];
130
- const limit = action === "source" ? MAX_ZIP_BYTES : 8192;
189
+ const limit = 8192;
131
190
  for await (const chunk of req) {
132
191
  size += chunk.length;
133
192
  if (size > limit) return send(413, { error: "Payload too large" });
@@ -143,10 +202,6 @@ export function createServer(runtime, token) {
143
202
  ),
144
203
  );
145
204
  }
146
- if (req.method === "PUT" && action === "source") {
147
- await runtime.serial(() => runtime.upload(id, body));
148
- return send(200, { ok: true });
149
- }
150
205
  if (req.method === "POST" && action === "deploy") {
151
206
  const input = deploymentInput.parse(JSON.parse(body.toString()));
152
207
  return send(