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 +42 -51
- package/package.json +1 -1
- package/src/index.js +11 -6
- package/src/nodebbPlugin.js +19 -12
- package/src/safeUpdater.js +72 -32
- package/src/webUpdater.js +80 -75
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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
plugins/nodebb-safe-updater/
|
|
23
|
-
```
|
|
16
|
+
## Install as a NodeBB plugin
|
|
24
17
|
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
+
## Standalone web updater
|
|
40
36
|
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
70
|
+
## Safety notes
|
|
76
71
|
|
|
77
|
-
|
|
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
|
|
74
|
+
## Example result
|
|
84
75
|
|
|
85
76
|
```json
|
|
86
77
|
{
|
|
87
78
|
"ok": true,
|
|
88
|
-
"
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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": "
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/src/nodebbPlugin.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
30
|
-
skipInstall:
|
|
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
|
};
|
package/src/safeUpdater.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (
|
|
132
|
-
|
|
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 (
|
|
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', [
|
|
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
|
-
|
|
169
|
-
const installCommand =
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
|
1
|
+
const { safeUpdate } = require('./safeUpdater');
|
|
2
2
|
const http = require('node:http');
|
|
3
|
-
const
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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', {
|
|
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
|
|
113
|
-
|
|
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, {
|
|
121
|
-
|
|
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
|
|
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 };
|