nodebb-plugin-safe-updater 1.1.0 → 2.0.1

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.
package/README.md CHANGED
@@ -2,49 +2,49 @@
2
2
 
3
3
  A conservative utility that keeps a NodeBB forum up to date safely.
4
4
 
5
- This repository now includes both:
6
- - a CLI update tool
7
- - a NodeBB plugin-style integration that exposes a one-click update action from the admin area
5
+ This repository includes a CLI update tool and a NodeBB admin plugin with a one-click update action.
8
6
 
9
7
  ## Features
10
- - Validates that the target directory is a Git repository
11
- - Refuses to run on a dirty working tree unless explicitly allowed
12
- - Creates a backup before updating
13
- - Fetches and fast-forwards the selected branch
14
- - Runs `npm install`, `yarn install`, or `pnpm install` when a project uses dependencies
15
- - Exposes an admin route and an API endpoint suitable for a NodeBB plugin integration
16
8
 
17
- ## Install as a NodeBB plugin
18
-
19
- 1. Copy the repository contents into your NodeBB plugin directory, for example:
9
+ - Verifies the target directory using Git itself (including linked worktrees).
10
+ - Refuses to run on a dirty working tree unless explicitly allowed.
11
+ - Creates a backup outside the target repository before updating.
12
+ - Fetches the selected branch and only fast-forwards it; existing local commits are not reset.
13
+ - Runs `npm install`, `yarn install`, or `pnpm install` when the project has a `package.json`.
14
+ - Restricts the NodeBB update endpoint to administrators and prevents overlapping updates.
20
15
 
21
- ```bash
22
- plugins/nodebb-safe-updater/
23
- ```
16
+ ## Install as a NodeBB plugin
24
17
 
25
- 2. Make sure the NodeBB server has environment variables configured, for example:
18
+ 1. Copy the repository into your NodeBB plugin directory, for example `plugins/nodebb-safe-updater/`.
19
+ 2. Configure the environment variables for the NodeBB server as needed:
26
20
 
27
- ```bash
28
- export NODEBB_DIRECTORY=/srv/nodebb
29
- export NODEBB_BRANCH=main
30
- export NODEBB_REMOTE=origin
31
- export NODEBB_ALLOW_DIRTY=false
32
- export NODEBB_SKIP_INSTALL=false
33
- ```
21
+ ```bash
22
+ export NODEBB_DIRECTORY=/srv/nodebb
23
+ export NODEBB_BRANCH=main
24
+ export NODEBB_REMOTE=origin
25
+ export NODEBB_ALLOW_DIRTY=false
26
+ export NODEBB_SKIP_INSTALL=false
27
+ # Optional; must be outside NODEBB_DIRECTORY
28
+ export NODEBB_BACKUP_ROOT=/var/backups/nodebb-safe-updater
29
+ ```
34
30
 
35
- 3. Restart NodeBB.
31
+ 3. Restart NodeBB, then open **Admin → Plugins → NodeBB Safe Updater**.
36
32
 
37
- 4. Open the admin area and use the new "NodeBB Safe Updater" item.
33
+ The admin page is `/admin/plugins/nodebb-safe-updater`; its update endpoint is `/api/v3/plugins/nodebb-safe-updater/update`. Both are protected by NodeBB's admin middleware.
38
34
 
39
- 5. Trigger the update from the browser by clicking the button in the plugin page.
35
+ ## Standalone web updater
40
36
 
41
- ## Browser update flow
37
+ Run `npm start` to start the optional standalone updater. It binds to `127.0.0.1` by default, serves the UI at `/`, and accepts updates at `/api/update`. The UI uses a per-process token and cross-origin requests are rejected. Do not expose this service publicly; use a trusted reverse proxy and appropriate access controls if remote access is required.
42
38
 
43
- The plugin registers:
44
- - `/admin/plugins/nodebb-safe-updater` as the admin page
45
- - `/api/v3/plugins/nodebb-safe-updater/update` as the POST endpoint used by the button
39
+ Configuration:
46
40
 
47
- The page executes a safe update using the configured repo directory and then prints the JSON result.
41
+ ```bash
42
+ export NODEBB_DIRECTORY=/srv/nodebb
43
+ export NODEBB_UPDATER_PORT=3000
44
+ export NODEBB_UPDATER_HOST=127.0.0.1
45
+ # Optional; an ephemeral token is generated when omitted
46
+ export NODEBB_UPDATER_TOKEN='use-a-long-random-secret'
47
+ ```
48
48
 
49
49
  ## CLI usage
50
50
 
@@ -60,37 +60,28 @@ Update a specific installation:
60
60
  node src/index.js --directory /srv/nodebb --branch main --remote origin
61
61
  ```
62
62
 
63
- Allow dirty working trees:
63
+ Allow a dirty working tree or skip dependency installation:
64
64
 
65
65
  ```bash
66
66
  node src/index.js --directory /srv/nodebb --allow-dirty
67
- ```
68
-
69
- Skip post-update install:
70
-
71
- ```bash
72
67
  node src/index.js --directory /srv/nodebb --skip-install
73
68
  ```
74
69
 
75
- ## Safety checks
70
+ ## Safety notes
76
71
 
77
- This updater intentionally prefers a cautious workflow:
78
- - it aborts if the repo is dirty,
79
- - it creates a backup before the change,
80
- - it uses a fast-forward pull strategy,
81
- - and it keeps the update process explicit and inspectable.
72
+ The updater aborts on a dirty repository by default, creates a backup before updating, and uses `git pull --ff-only`. `--allow-dirty` permits the update to proceed with local changes; review the backup and working tree carefully before using it. Backup folders must be outside the target repository.
82
73
 
83
- ## Example response
74
+ ## Example result
84
75
 
85
76
  ```json
86
77
  {
87
78
  "ok": true,
88
- "result": {
89
- "directory": "/srv/nodebb",
90
- "branch": "main",
91
- "remote": "origin",
92
- "backupDir": "/tmp/nodebb-safe-updater/nodebb-backup-2026-10-24T12-00-00.000Z",
93
- "statusAfter": ""
94
- }
79
+ "directory": "/srv/nodebb",
80
+ "branch": "main",
81
+ "remote": "origin",
82
+ "backupDir": "/tmp/nodebb-safe-updater/nodebb-backup-2026-10-24T12-00-00-000Z",
83
+ "statusBefore": "",
84
+ "statusAfter": "",
85
+ "packageManager": "npm"
95
86
  }
96
87
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-safe-updater",
3
- "version": "1.1.0",
3
+ "version": "2.0.1",
4
4
  "description": "Safe NodeBB updater plugin with a one-click update endpoint and CLI fallback.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/amer-raba/nodebb-plugin-safe-updater",
package/src/index.js CHANGED
@@ -2,21 +2,26 @@
2
2
 
3
3
  const { safeUpdate, parseArgs } = require('./safeUpdater');
4
4
 
5
- function main() {
5
+ function main(argv = process.argv.slice(2)) {
6
6
  try {
7
- const options = parseArgs(process.argv.slice(2));
7
+ const options = parseArgs(argv);
8
8
 
9
9
  if (options.help) {
10
- console.log('Usage: nodebb-safe-updater [--directory /path/to/nodebb] [--branch main] [--remote origin] [--allow-dirty] [--skip-install]');
11
- process.exit(0);
10
+ console.log('Usage: nodebb-safe-updater [--directory /path/to/nodebb] [--branch main] [--remote origin] [--allow-dirty] [--skip-install] [--backup-root /path/to/backups]');
11
+ return 0;
12
12
  }
13
13
 
14
14
  const result = safeUpdate(options);
15
15
  console.log(JSON.stringify(result, null, 2));
16
+ return 0;
16
17
  } catch (error) {
17
18
  console.error(error.message || error);
18
- process.exit(1);
19
+ return 1;
19
20
  }
20
21
  }
21
22
 
22
- main();
23
+ if (require.main === module) {
24
+ process.exitCode = main();
25
+ }
26
+
27
+ module.exports = { main };
@@ -2,37 +2,45 @@
2
2
 
3
3
  const { safeUpdate } = require('./safeUpdater');
4
4
 
5
+ let updateInProgress = false;
6
+
5
7
  const plugin = {
6
8
  async init(params) {
7
9
  const { router, middleware } = params;
8
-
9
10
  const adminRoute = '/admin/plugins/nodebb-safe-updater';
10
11
  const apiRoute = '/api/v3/plugins/nodebb-safe-updater/update';
11
12
 
12
13
  router.get(adminRoute, middleware.admin.build, async (req, res) => {
13
- const context = {
14
+ res.render('admin/plugins/nodebb-safe-updater', {
14
15
  title: 'NodeBB Safe Updater',
15
16
  directory: process.env.NODEBB_DIRECTORY || process.cwd(),
16
17
  branch: process.env.NODEBB_BRANCH || 'main',
17
18
  remote: process.env.NODEBB_REMOTE || 'origin'
18
- };
19
-
20
- res.render('admin/plugins/nodebb-safe-updater', context);
19
+ });
21
20
  });
22
21
 
23
- router.post(apiRoute, middleware.ensureLoggedIn, async (req, res) => {
22
+ // Updating changes the server's code and runs its package manager, so this
23
+ // endpoint must be restricted to administrators, not merely logged-in users.
24
+ router.post(apiRoute, middleware.admin.build, async (req, res) => {
25
+ if (updateInProgress) {
26
+ return res.status(409).json({ ok: false, error: 'An update is already in progress.' });
27
+ }
28
+
29
+ updateInProgress = true;
24
30
  try {
25
31
  const result = safeUpdate({
26
32
  directory: process.env.NODEBB_DIRECTORY || process.cwd(),
27
33
  branch: process.env.NODEBB_BRANCH || 'main',
28
34
  remote: process.env.NODEBB_REMOTE || 'origin',
29
- allowDirty: Boolean(process.env.NODEBB_ALLOW_DIRTY === 'true'),
30
- skipInstall: Boolean(process.env.NODEBB_SKIP_INSTALL === 'true')
35
+ allowDirty: process.env.NODEBB_ALLOW_DIRTY === 'true',
36
+ skipInstall: process.env.NODEBB_SKIP_INSTALL === 'true',
37
+ backupRoot: process.env.NODEBB_BACKUP_ROOT
31
38
  });
32
-
33
- res.json({ ok: true, result });
39
+ return res.json({ ok: true, result });
34
40
  } catch (error) {
35
- res.status(500).json({ ok: false, error: error.message || String(error) });
41
+ return res.status(500).json({ ok: false, error: error.message || String(error) });
42
+ } finally {
43
+ updateInProgress = false;
36
44
  }
37
45
  });
38
46
  },
@@ -43,7 +51,6 @@ const plugin = {
43
51
  icon: 'fa-rocket',
44
52
  name: 'NodeBB Safe Updater'
45
53
  });
46
-
47
54
  callback(null, header);
48
55
  }
49
56
  };
@@ -7,6 +7,7 @@ function runCommand(command, args, options = {}) {
7
7
  const result = spawnSync(command, args, {
8
8
  cwd: options.cwd || process.cwd(),
9
9
  encoding: 'utf8',
10
+ maxBuffer: 10 * 1024 * 1024,
10
11
  stdio: ['inherit', 'pipe', 'pipe']
11
12
  });
12
13
 
@@ -16,31 +17,41 @@ function runCommand(command, args, options = {}) {
16
17
 
17
18
  if (result.status !== 0) {
18
19
  const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
19
- throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}. ${output}`);
20
+ const reason = result.signal ? `signal ${result.signal}` : `exit code ${result.status}`;
21
+ throw new Error(`${command} ${args.join(' ')} failed with ${reason}. ${output}`);
20
22
  }
21
23
 
22
24
  return (result.stdout || '').trim();
23
25
  }
24
26
 
25
27
  function ensureGitRepository(directory) {
26
- const gitDir = path.join(directory, '.git');
27
- if (!fs.existsSync(directory)) {
28
+ if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
28
29
  throw new Error(`Directory does not exist: ${directory}`);
29
30
  }
30
31
 
31
- if (!fs.existsSync(gitDir)) {
32
+ try {
33
+ runCommand('git', ['-C', directory, 'rev-parse', '--is-inside-work-tree']);
34
+ } catch {
32
35
  throw new Error(`Not a git repository: ${directory}`);
33
36
  }
34
37
  }
35
38
 
36
39
  function getGitStatus(directory) {
37
- return runCommand('git', ['-C', directory, 'status', '--porcelain']);
40
+ return runCommand('git', ['-C', directory, 'status', '--porcelain', '--untracked-files=all']);
41
+ }
42
+
43
+ function isPathInside(parent, candidate) {
44
+ const relative = path.relative(path.resolve(parent), path.resolve(candidate));
45
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
38
46
  }
39
47
 
40
48
  function createBackup(directory, backupRoot) {
49
+ if (isPathInside(directory, backupRoot)) {
50
+ throw new Error('Backup directory must be outside the target repository.');
51
+ }
52
+
41
53
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
42
54
  const backupDir = path.join(backupRoot, `nodebb-backup-${timestamp}`);
43
-
44
55
  fs.mkdirSync(backupDir, { recursive: true });
45
56
 
46
57
  const entries = fs.readdirSync(directory);
@@ -51,10 +62,7 @@ function createBackup(directory, backupRoot) {
51
62
 
52
63
  const sourcePath = path.join(directory, entry);
53
64
  const targetPath = path.join(backupDir, entry);
54
-
55
- if (fs.existsSync(sourcePath)) {
56
- fs.cpSync(sourcePath, targetPath, { recursive: true, force: true });
57
- }
65
+ fs.cpSync(sourcePath, targetPath, { recursive: true, force: true, errorOnExist: false });
58
66
  }
59
67
 
60
68
  return backupDir;
@@ -81,30 +89,39 @@ function parseArgs(argv = []) {
81
89
  skipInstall: false,
82
90
  backupRoot: path.join(os.tmpdir(), 'nodebb-safe-updater')
83
91
  };
92
+ let positionalDirectorySet = false;
93
+
94
+ function readValue(arg, index) {
95
+ const value = argv[index + 1];
96
+ if (!value || value.startsWith('-')) {
97
+ throw new Error(`Missing value for ${arg}`);
98
+ }
99
+ return value;
100
+ }
84
101
 
85
102
  for (let index = 0; index < argv.length; index += 1) {
86
103
  const arg = argv[index];
87
104
 
88
105
  if (arg === '--directory' || arg === '-d') {
89
- options.directory = argv[index + 1];
106
+ options.directory = readValue(arg, index);
90
107
  index += 1;
91
108
  continue;
92
109
  }
93
110
 
94
111
  if (arg === '--branch' || arg === '-b') {
95
- options.branch = argv[index + 1];
112
+ options.branch = readValue(arg, index);
96
113
  index += 1;
97
114
  continue;
98
115
  }
99
116
 
100
117
  if (arg === '--remote' || arg === '-r') {
101
- options.remote = argv[index + 1];
118
+ options.remote = readValue(arg, index);
102
119
  index += 1;
103
120
  continue;
104
121
  }
105
122
 
106
123
  if (arg === '--backup-root') {
107
- options.backupRoot = argv[index + 1];
124
+ options.backupRoot = readValue(arg, index);
108
125
  index += 1;
109
126
  continue;
110
127
  }
@@ -128,56 +145,79 @@ function parseArgs(argv = []) {
128
145
  throw new Error(`Unknown option: ${arg}`);
129
146
  }
130
147
 
131
- if (!options.directory || options.directory === process.cwd()) {
132
- options.directory = arg;
148
+ if (positionalDirectorySet) {
149
+ throw new Error(`Unexpected argument: ${arg}`);
133
150
  }
151
+ options.directory = arg;
152
+ positionalDirectorySet = true;
134
153
  }
135
154
 
136
- if (options.directory && options.directory !== process.cwd() && !path.isAbsolute(options.directory)) {
155
+ if (!path.isAbsolute(options.directory)) {
137
156
  options.directory = path.resolve(options.directory);
138
157
  }
158
+ if (!path.isAbsolute(options.backupRoot)) {
159
+ options.backupRoot = path.resolve(options.backupRoot);
160
+ }
139
161
 
140
162
  return options;
141
163
  }
142
164
 
143
165
  function safeUpdate(options = {}) {
144
166
  const finalOptions = {
145
- directory: options.directory || process.cwd(),
167
+ directory: path.resolve(options.directory || process.cwd()),
146
168
  branch: options.branch || 'main',
147
169
  remote: options.remote || 'origin',
148
170
  allowDirty: Boolean(options.allowDirty),
149
171
  skipInstall: Boolean(options.skipInstall),
150
- backupRoot: options.backupRoot || path.join(os.tmpdir(), 'nodebb-safe-updater')
172
+ backupRoot: path.resolve(options.backupRoot || path.join(os.tmpdir(), 'nodebb-safe-updater'))
151
173
  };
152
174
 
153
175
  ensureGitRepository(finalOptions.directory);
176
+ runCommand('git', ['check-ref-format', '--branch', finalOptions.branch]);
154
177
 
155
178
  const statusBefore = getGitStatus(finalOptions.directory);
156
179
  if (statusBefore && !finalOptions.allowDirty) {
157
180
  throw new Error(`Working tree is not clean. Commit or stash your changes before updating, or use --allow-dirty.\n${statusBefore}`);
158
181
  }
159
182
 
183
+ if (isPathInside(finalOptions.directory, finalOptions.backupRoot)) {
184
+ throw new Error('Backup directory must be outside the target repository.');
185
+ }
186
+
160
187
  fs.mkdirSync(finalOptions.backupRoot, { recursive: true });
161
188
  const backupDir = createBackup(finalOptions.directory, finalOptions.backupRoot);
162
189
 
163
190
  runCommand('git', ['-C', finalOptions.directory, 'fetch', finalOptions.remote, finalOptions.branch]);
164
- runCommand('git', ['-C', finalOptions.directory, 'checkout', finalOptions.branch]);
191
+ const localBranches = runCommand('git', [
192
+ '-C', finalOptions.directory,
193
+ 'branch', '--list', '--format=%(refname:short)'
194
+ ]).split('\n').filter(Boolean);
195
+
196
+ if (localBranches.includes(finalOptions.branch)) {
197
+ runCommand('git', ['-C', finalOptions.directory, 'checkout', finalOptions.branch]);
198
+ } else {
199
+ runCommand('git', [
200
+ '-C', finalOptions.directory,
201
+ 'checkout', '--track', '-b', finalOptions.branch,
202
+ `${finalOptions.remote}/${finalOptions.branch}`
203
+ ]);
204
+ }
205
+
165
206
  runCommand('git', ['-C', finalOptions.directory, 'pull', '--ff-only', finalOptions.remote, finalOptions.branch]);
166
207
 
208
+ let packageManager = null;
167
209
  if (!finalOptions.skipInstall && fs.existsSync(path.join(finalOptions.directory, 'package.json'))) {
168
- const packageManager = detectPackageManager(finalOptions.directory);
169
- const installCommand =
170
- packageManager === 'pnpm'
171
- ? ['pnpm', 'install']
172
- : packageManager === 'yarn'
173
- ? ['yarn', 'install']
174
- : ['npm', 'install'];
175
-
210
+ packageManager = detectPackageManager(finalOptions.directory);
211
+ const installCommand = packageManager === 'pnpm'
212
+ ? ['pnpm', 'install']
213
+ : packageManager === 'yarn'
214
+ ? ['yarn', 'install']
215
+ : ['npm', 'install'];
176
216
  runCommand(installCommand[0], installCommand.slice(1), { cwd: finalOptions.directory });
217
+ } else if (fs.existsSync(path.join(finalOptions.directory, 'package.json'))) {
218
+ packageManager = detectPackageManager(finalOptions.directory);
177
219
  }
178
220
 
179
- const statusAfter = getGitStatus(finalOptions.directory);
180
-
181
221
  return {
182
222
  ok: true,
183
223
  directory: finalOptions.directory,
@@ -185,8 +225,8 @@ function safeUpdate(options = {}) {
185
225
  remote: finalOptions.remote,
186
226
  backupDir,
187
227
  statusBefore,
188
- statusAfter,
189
- packageManager: fs.existsSync(path.join(finalOptions.directory, 'package.json')) ? detectPackageManager(finalOptions.directory) : null
228
+ statusAfter: getGitStatus(finalOptions.directory),
229
+ packageManager
190
230
  };
191
231
  }
192
232
 
package/src/webUpdater.js CHANGED
@@ -1,6 +1,6 @@
1
- const { safeUpdate, parseArgs } = require('./safeUpdater');
1
+ const { safeUpdate } = require('./safeUpdater');
2
2
  const http = require('node:http');
3
- const fs = require('node:fs');
3
+ const crypto = require('node:crypto');
4
4
  const path = require('node:path');
5
5
 
6
6
  const HTML_PAGE = `<!doctype html>
@@ -10,87 +10,33 @@ const HTML_PAGE = `<!doctype html>
10
10
  <meta name="viewport" content="width=device-width, initial-scale=1" />
11
11
  <title>NodeBB Safe Updater</title>
12
12
  <style>
13
- body {
14
- font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
15
- background: #0f172a;
16
- color: #e2e8f0;
17
- display: grid;
18
- place-items: center;
19
- min-height: 100vh;
20
- margin: 0;
21
- }
22
-
23
- .panel {
24
- width: min(520px, 90vw);
25
- background: rgba(15, 23, 42, 0.8);
26
- border: 1px solid rgba(148, 163, 184, 0.25);
27
- border-radius: 14px;
28
- padding: 28px;
29
- box-shadow: 0 20px 45px rgba(15, 23, 42, 0.5);
30
- }
31
-
32
- h1 {
33
- margin-top: 0;
34
- font-size: 1.8rem;
35
- }
36
-
37
- button {
38
- background: #22c55e;
39
- color: #052e16;
40
- border: none;
41
- border-radius: 10px;
42
- padding: 14px 22px;
43
- font-size: 1rem;
44
- font-weight: 700;
45
- cursor: pointer;
46
- transition: transform 0.15s ease;
47
- }
48
-
49
- button:hover {
50
- transform: translateY(-1px);
51
- }
52
-
53
- button:disabled {
54
- opacity: 0.6;
55
- cursor: wait;
56
- }
57
-
58
- #result {
59
- margin-top: 20px;
60
- min-height: 80px;
61
- background: rgba(15, 118, 110, 0.1);
62
- border: 1px solid rgba(45, 212, 191, 0.2);
63
- border-radius: 10px;
64
- padding: 12px 14px;
65
- white-space: pre-wrap;
66
- word-break: break-word;
67
- }
13
+ body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; display: grid; place-items: center; min-height: 100vh; margin: 0; }
14
+ .panel { width: min(520px, 90vw); background: rgba(15, 23, 42, .8); border: 1px solid rgba(148, 163, 184, .25); border-radius: 14px; padding: 28px; }
15
+ button { background: #22c55e; color: #052e16; border: 0; border-radius: 10px; padding: 14px 22px; font-size: 1rem; font-weight: 700; cursor: pointer; }
16
+ button:disabled { opacity: .6; cursor: wait; }
17
+ #result { margin-top: 20px; min-height: 80px; white-space: pre-wrap; word-break: break-word; }
68
18
  </style>
69
19
  </head>
70
20
  <body>
71
21
  <div class="panel">
72
22
  <h1>NodeBB Safe Updater</h1>
73
- <p>Click the button below to update the forum to the latest version safely.</p>
23
+ <p>Click below to update the configured NodeBB installation.</p>
74
24
  <button id="updateButton">Update to latest version</button>
75
25
  <div id="result">Waiting for action...</div>
76
26
  </div>
77
-
78
27
  <script>
79
28
  const button = document.getElementById('updateButton');
80
29
  const result = document.getElementById('result');
81
-
82
30
  button.addEventListener('click', async () => {
83
31
  button.disabled = true;
84
32
  result.textContent = 'Updating...';
85
-
86
33
  try {
87
- const response = await fetch('/api/update', { method: 'POST' });
34
+ const response = await fetch('/api/update', {
35
+ method: 'POST',
36
+ headers: { 'x-updater-token': '__UPDATER_TOKEN__' }
37
+ });
88
38
  const data = await response.json();
89
-
90
- if (!response.ok) {
91
- throw new Error(data.error || 'Update failed');
92
- }
93
-
39
+ if (!response.ok) throw new Error(data.error || 'Update failed');
94
40
  result.textContent = JSON.stringify(data, null, 2);
95
41
  } catch (error) {
96
42
  result.textContent = 'ERROR: ' + (error.message || String(error));
@@ -103,26 +49,83 @@ const HTML_PAGE = `<!doctype html>
103
49
  </html>`;
104
50
 
105
51
  function parseOptionsFromEnv() {
52
+ const port = Number(process.env.NODEBB_UPDATER_PORT || 3000);
53
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
54
+ throw new Error('NODEBB_UPDATER_PORT must be an integer between 1 and 65535.');
55
+ }
56
+
106
57
  return {
107
58
  directory: process.env.NODEBB_DIRECTORY || process.cwd(),
108
59
  branch: process.env.NODEBB_BRANCH || 'main',
109
60
  remote: process.env.NODEBB_REMOTE || 'origin',
110
61
  allowDirty: (process.env.NODEBB_ALLOW_DIRTY || '').toLowerCase() === 'true',
111
62
  skipInstall: (process.env.NODEBB_SKIP_INSTALL || '').toLowerCase() === 'true',
112
- port: Number(process.env.NODEBB_UPDATER_PORT || 3000),
113
- backupRoot: process.env.NODEBB_BACKUP_ROOT || path.join(process.cwd(), '.nodebb-safe-updater-backups')
63
+ port,
64
+ host: process.env.NODEBB_UPDATER_HOST || '127.0.0.1',
65
+ token: process.env.NODEBB_UPDATER_TOKEN || crypto.randomBytes(32).toString('hex'),
66
+ backupRoot: process.env.NODEBB_BACKUP_ROOT || path.join(require('node:os').tmpdir(), 'nodebb-safe-updater')
114
67
  };
115
68
  }
116
69
 
70
+ function isLoopbackHost(hostHeader) {
71
+ if (!hostHeader) return false;
72
+ try {
73
+ const hostname = new URL(`http://${hostHeader}`).hostname.toLowerCase();
74
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
117
80
  function createServer(options) {
81
+ const token = options.token || crypto.randomBytes(32).toString('hex');
82
+ let updateInProgress = false;
83
+
118
84
  const server = http.createServer(async (req, res) => {
85
+ if (!isLoopbackHost(req.headers.host)) {
86
+ res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
87
+ res.end(JSON.stringify({ ok: false, error: 'Requests must use a loopback host.' }));
88
+ return;
89
+ }
90
+
119
91
  if (req.method === 'GET' && req.url === '/') {
120
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
121
- res.end(HTML_PAGE);
92
+ res.writeHead(200, {
93
+ 'Content-Type': 'text/html; charset=utf-8',
94
+ 'Cache-Control': 'no-store',
95
+ 'X-Content-Type-Options': 'nosniff'
96
+ });
97
+ res.end(HTML_PAGE.replace('__UPDATER_TOKEN__', token));
122
98
  return;
123
99
  }
124
100
 
125
101
  if (req.method === 'POST' && req.url === '/api/update') {
102
+ const origin = req.headers.origin;
103
+ if (origin) {
104
+ try {
105
+ if (new URL(origin).host !== req.headers.host) throw new Error('Origin mismatch');
106
+ } catch {
107
+ res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
108
+ res.end(JSON.stringify({ ok: false, error: 'Cross-origin requests are not allowed.' }));
109
+ return;
110
+ }
111
+ }
112
+
113
+ const suppliedToken = req.headers['x-updater-token'];
114
+ const expected = Buffer.from(token);
115
+ const supplied = Buffer.from(typeof suppliedToken === 'string' ? suppliedToken : '');
116
+ if (supplied.length !== expected.length || !crypto.timingSafeEqual(supplied, expected)) {
117
+ res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
118
+ res.end(JSON.stringify({ ok: false, error: 'Invalid updater token.' }));
119
+ return;
120
+ }
121
+
122
+ if (updateInProgress) {
123
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
124
+ res.end(JSON.stringify({ ok: false, error: 'An update is already in progress.' }));
125
+ return;
126
+ }
127
+
128
+ updateInProgress = true;
126
129
  try {
127
130
  const result = safeUpdate(options);
128
131
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -130,6 +133,8 @@ function createServer(options) {
130
133
  } catch (error) {
131
134
  res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
132
135
  res.end(JSON.stringify({ ok: false, error: error.message || String(error) }, null, 2));
136
+ } finally {
137
+ updateInProgress = false;
133
138
  }
134
139
  return;
135
140
  }
@@ -144,13 +149,13 @@ function createServer(options) {
144
149
  function main() {
145
150
  const options = parseOptionsFromEnv();
146
151
  const server = createServer(options);
147
-
148
- server.listen(options.port, () => {
152
+ server.listen(options.port, options.host, () => {
149
153
  console.log(JSON.stringify({
150
154
  status: 'ready',
155
+ host: options.host,
151
156
  port: options.port,
152
157
  directory: options.directory,
153
- endpoint: `http://localhost:${options.port}/`
158
+ endpoint: `http://${options.host}:${options.port}/`
154
159
  }, null, 2));
155
160
  });
156
161
  }
@@ -159,4 +164,4 @@ if (require.main === module) {
159
164
  main();
160
165
  }
161
166
 
162
- module.exports = { parseOptionsFromEnv, createServer, main };
167
+ module.exports = { parseOptionsFromEnv, createServer, main, isLoopbackHost };