@mmmbuto/nexuscrew 0.4.2 → 0.7.0
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 +37 -5
- package/bin/nexuscrew.js +5 -1
- package/frontend/dist/assets/index-BWhSqR3J.css +32 -0
- package/frontend/dist/assets/index-DQMx2p4x.js +81 -0
- package/frontend/dist/index.html +3 -3
- package/frontend/index.html +1 -1
- package/lib/auth/middleware.js +17 -0
- package/lib/auth/token.js +43 -5
- package/lib/cli/commands.js +198 -0
- package/lib/cli/init.js +149 -0
- package/lib/cli/pidfile.js +100 -0
- package/lib/cli/platform.js +35 -0
- package/lib/cli/service.js +229 -0
- package/lib/config.js +59 -9
- package/lib/files/routes.js +60 -0
- package/lib/files/store.js +127 -0
- package/lib/files/watcher.js +69 -0
- package/lib/fleet/exec.js +32 -0
- package/lib/fleet/index.js +99 -0
- package/lib/fleet/routes.js +34 -0
- package/lib/server.js +90 -9
- package/lib/tmux/actions.js +65 -3
- package/lib/tmux/lifecycle.js +86 -0
- package/lib/tmux/list.js +4 -2
- package/lib/tmux/preview.js +73 -0
- package/lib/voice/transcribe.js +41 -0
- package/lib/ws/bridge.js +2 -1
- package/package.json +12 -8
- package/frontend/dist/assets/index-BZIjqGMo.css +0 -32
- package/frontend/dist/assets/index-Bl1a-2Lf.js +0 -81
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Service generation per-platform: systemd --user (linux), launchd plist (mac),
|
|
3
|
+
// Termux:boot script. Escaping per-platform + install no-symlink atomic. [M2][M3][B1][R2][R3]
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { execFileSync } = require('node:child_process');
|
|
8
|
+
const { detectPlatform, uid } = require('./platform.js');
|
|
9
|
+
|
|
10
|
+
// --- Escaping helpers ---
|
|
11
|
+
|
|
12
|
+
// systemd WorkingDirectory: escape % (percent specifier). Spazi ok.
|
|
13
|
+
function escapeSystemdPath(s) {
|
|
14
|
+
return String(s).replace(/%/g, '%%');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// systemd ExecStart arg: escape backslash, %, spazio.
|
|
18
|
+
function escapeSystemdExec(s) {
|
|
19
|
+
return String(s)
|
|
20
|
+
.replace(/\\/g, '\\\\')
|
|
21
|
+
.replace(/%/g, '%%')
|
|
22
|
+
.replace(/ /g, '\\ ');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// launchd plist XML escape.
|
|
26
|
+
function escapeXml(s) {
|
|
27
|
+
return String(s)
|
|
28
|
+
.replace(/&/g, '&')
|
|
29
|
+
.replace(/</g, '<')
|
|
30
|
+
.replace(/>/g, '>')
|
|
31
|
+
.replace(/"/g, '"');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// POSIX shell single-quote.
|
|
35
|
+
function shellQuote(s) {
|
|
36
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// systemd ExecStart non gestisce bene alcuni char (", $, ;, backtick, newline, apice)
|
|
40
|
+
// nemmeno con escaping ad hoc: reject esplicito con errore chiaro. [M3]
|
|
41
|
+
// space e % sono ok (escaped). Path repo normali non hanno questi char.
|
|
42
|
+
const SYSTEMD_FORBIDDEN = /[";$`\n']/;
|
|
43
|
+
function assertSystemdSafe(label, p) {
|
|
44
|
+
if (SYSTEMD_FORBIDDEN.test(String(p))) {
|
|
45
|
+
throw new Error(`${label} path contiene caratteri non supportati in systemd (", $, ;, backtick, newline, apice): "${p}". Usa un path senza questi caratteri (spazi e % sono ok).`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Templates ---
|
|
50
|
+
|
|
51
|
+
function generateService(platform, ctx) {
|
|
52
|
+
if (platform === 'linux') return generateLinux(ctx);
|
|
53
|
+
if (platform === 'mac') return generateMac(ctx);
|
|
54
|
+
if (platform === 'termux') return generateTermux(ctx);
|
|
55
|
+
throw new Error(`unsupported platform: ${platform}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function generateLinux(ctx) {
|
|
59
|
+
assertSystemdSafe('repoRoot', ctx.repoRoot); // [M3] reject char non gestibili
|
|
60
|
+
assertSystemdSafe('nodeBin', ctx.nodeBin);
|
|
61
|
+
const repo = escapeSystemdPath(ctx.repoRoot);
|
|
62
|
+
const node = escapeSystemdExec(ctx.nodeBin);
|
|
63
|
+
const repoBin = escapeSystemdExec(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
|
|
64
|
+
const nodeDir = escapeSystemdPath(path.dirname(ctx.nodeBin));
|
|
65
|
+
return `# NexusCrew service (systemd --user, loopback, solo tunnel SSH/VPN)
|
|
66
|
+
[Unit]
|
|
67
|
+
Description=NexusCrew - browser tmux client (loopback, solo tunnel SSH/VPN)
|
|
68
|
+
After=network-online.target
|
|
69
|
+
|
|
70
|
+
[Service]
|
|
71
|
+
Type=simple
|
|
72
|
+
WorkingDirectory=${repo}
|
|
73
|
+
Environment=NEXUSCREW_PORT=${ctx.port}
|
|
74
|
+
Environment=PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin
|
|
75
|
+
ExecStart=${node} ${repoBin} serve
|
|
76
|
+
Restart=on-failure
|
|
77
|
+
RestartSec=3
|
|
78
|
+
|
|
79
|
+
[Install]
|
|
80
|
+
WantedBy=default.target
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function generateMac(ctx) {
|
|
85
|
+
const nodeXml = escapeXml(ctx.nodeBin);
|
|
86
|
+
const repoBinXml = escapeXml(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
|
|
87
|
+
const repoXml = escapeXml(ctx.repoRoot);
|
|
88
|
+
const homeXml = escapeXml(ctx.home);
|
|
89
|
+
const port = ctx.port;
|
|
90
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
91
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
92
|
+
<plist version="1.0">
|
|
93
|
+
<dict>
|
|
94
|
+
<key>Label</key>
|
|
95
|
+
<string>com.mmmbuto.nexuscrew</string>
|
|
96
|
+
<key>ProgramArguments</key>
|
|
97
|
+
<array>
|
|
98
|
+
<string>${nodeXml}</string>
|
|
99
|
+
<string>${repoBinXml}</string>
|
|
100
|
+
<string>serve</string>
|
|
101
|
+
</array>
|
|
102
|
+
<key>WorkingDirectory</key>
|
|
103
|
+
<string>${repoXml}</string>
|
|
104
|
+
<key>EnvironmentVariables</key>
|
|
105
|
+
<dict>
|
|
106
|
+
<key>NEXUSCREW_PORT</key>
|
|
107
|
+
<string>${port}</string>
|
|
108
|
+
</dict>
|
|
109
|
+
<key>RunAtLoad</key>
|
|
110
|
+
<true/>
|
|
111
|
+
<key>KeepAlive</key>
|
|
112
|
+
<dict>
|
|
113
|
+
<key>SuccessfulExit</key>
|
|
114
|
+
<false/>
|
|
115
|
+
</dict>
|
|
116
|
+
<key>StandardOutPath</key>
|
|
117
|
+
<string>${homeXml}/.nexuscrew/nexuscrew.log</string>
|
|
118
|
+
<key>StandardErrorPath</key>
|
|
119
|
+
<string>${homeXml}/.nexuscrew/nexuscrew.log</string>
|
|
120
|
+
</dict>
|
|
121
|
+
</plist>
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function generateTermux(ctx) {
|
|
126
|
+
const nodeQ = shellQuote(ctx.nodeBin);
|
|
127
|
+
const repoBinQ = shellQuote(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
|
|
128
|
+
const repoQ = shellQuote(ctx.repoRoot);
|
|
129
|
+
return `#!/data/data/com.termux/files/usr/bin/sh
|
|
130
|
+
# NexusCrew boot (Termux) - loopback, localhost del telefono
|
|
131
|
+
export PATH=/data/data/com.termux/files/usr/bin:$PATH
|
|
132
|
+
export HOME=/data/data/com.termux/files/home
|
|
133
|
+
export NEXUSCREW_PORT=${ctx.port}
|
|
134
|
+
cd -- ${repoQ}
|
|
135
|
+
termux-wake-lock 2>/dev/null || true
|
|
136
|
+
mkdir -p "$HOME/.nexuscrew"
|
|
137
|
+
exec ${nodeQ} ${repoBinQ} serve --pidfile >> "$HOME/.nexuscrew/nexuscrew.log" 2>&1
|
|
138
|
+
`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- Install paths ---
|
|
142
|
+
|
|
143
|
+
function installPath(platform, home) {
|
|
144
|
+
if (platform === 'linux') return path.join(home, '.config', 'systemd', 'user', 'nexuscrew.service');
|
|
145
|
+
if (platform === 'mac') return path.join(home, 'Library', 'LaunchAgents', 'com.mmmbuto.nexuscrew.plist');
|
|
146
|
+
if (platform === 'termux') return path.join(home, '.termux', 'boot', 'nexuscrew.sh');
|
|
147
|
+
throw new Error(`unsupported platform: ${platform}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function fileMode(platform) {
|
|
151
|
+
if (platform === 'termux') return 0o700;
|
|
152
|
+
return 0o644; // systemd unit + launchd plist
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Install no-symlink + atomic rename. [M3]
|
|
156
|
+
// - lstat target: reject symlink (no follow)
|
|
157
|
+
// - write temp file nella stessa dir -> chmod mode -> atomic rename
|
|
158
|
+
// - exec service manager (systemctl/launchctl); skip in dryRun
|
|
159
|
+
// execImpl per test (default execFileSync).
|
|
160
|
+
function installService(platform, content, ctx, { dryRun = false, execImpl = execFileSync } = {}) {
|
|
161
|
+
const home = ctx.home || os.homedir();
|
|
162
|
+
const target = ctx.installPath || installPath(platform, home);
|
|
163
|
+
const mode = fileMode(platform);
|
|
164
|
+
|
|
165
|
+
// lstat: reject symlink preesistente
|
|
166
|
+
try {
|
|
167
|
+
const st = fs.lstatSync(target);
|
|
168
|
+
if (st.isSymbolicLink()) {
|
|
169
|
+
throw new Error(`refusing symlink install target: ${target}`);
|
|
170
|
+
}
|
|
171
|
+
} catch (e) {
|
|
172
|
+
if (e.code !== 'ENOENT') throw e;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (dryRun) {
|
|
176
|
+
return { target, mode, written: false, failures: [], note: 'dry-run: nessuna scrittura' };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
180
|
+
// temp file stessa dir (per atomic rename su stesso filesystem)
|
|
181
|
+
const tmp = target + '.tmp.' + process.pid;
|
|
182
|
+
try {
|
|
183
|
+
fs.writeFileSync(tmp, content, { mode });
|
|
184
|
+
fs.chmodSync(tmp, mode);
|
|
185
|
+
fs.renameSync(tmp, target); // atomic
|
|
186
|
+
} catch (e) {
|
|
187
|
+
try { fs.unlinkSync(tmp); } catch (_) {} // cleanup temp su failure [m1]
|
|
188
|
+
throw e;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// exec service manager — NON ingoiare failure (M1): raccogli per diagnosi
|
|
192
|
+
const cmds = installCommands(platform, target, ctx);
|
|
193
|
+
const failures = [];
|
|
194
|
+
for (const [bin, args] of cmds) {
|
|
195
|
+
try { execImpl(bin, args, { stdio: 'ignore' }); }
|
|
196
|
+
catch (e) { failures.push({ cmd: `${bin} ${args.join(' ')}`, error: e.message }); }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { target, mode, written: true, failures };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function installCommands(platform, target, ctx) {
|
|
203
|
+
if (platform === 'linux') {
|
|
204
|
+
return [
|
|
205
|
+
['systemctl', ['--user', 'daemon-reload']],
|
|
206
|
+
['systemctl', ['--user', 'enable', 'nexuscrew']],
|
|
207
|
+
['systemctl', ['--user', 'restart', 'nexuscrew']], // restart carica nuovo codice (drop-in)
|
|
208
|
+
];
|
|
209
|
+
}
|
|
210
|
+
if (platform === 'mac') {
|
|
211
|
+
const label = `gui/${ctx.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
212
|
+
// bootout (ignore se non esiste) poi bootstrap
|
|
213
|
+
return [
|
|
214
|
+
['launchctl', ['bootout', label]],
|
|
215
|
+
['launchctl', ['bootstrap', label, target]],
|
|
216
|
+
];
|
|
217
|
+
}
|
|
218
|
+
if (platform === 'termux') {
|
|
219
|
+
return []; // nessun service manager; boot script + start manuale (nohup+pidfile)
|
|
220
|
+
}
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = {
|
|
225
|
+
generateService, generateLinux, generateMac, generateTermux,
|
|
226
|
+
installService, installPath, installCommands, fileMode,
|
|
227
|
+
escapeSystemdPath, escapeSystemdExec, escapeXml, shellQuote,
|
|
228
|
+
assertSystemdSafe, SYSTEMD_FORBIDDEN,
|
|
229
|
+
};
|
package/lib/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
const os = require('os');
|
|
3
|
-
const path = require('path');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const fs = require('node:fs');
|
|
4
5
|
|
|
5
6
|
const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
6
7
|
|
|
@@ -11,15 +12,64 @@ function assertLoopback(bind) {
|
|
|
11
12
|
return bind;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
// Defaults PURI (no env, no config.json). voice null = graceful (non configurato).
|
|
16
|
+
function baseDefaults() {
|
|
15
17
|
return {
|
|
16
18
|
bind: '127.0.0.1',
|
|
17
|
-
port:
|
|
18
|
-
tokenPath:
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
port: 41820,
|
|
20
|
+
tokenPath: path.join(os.homedir(), '.nexuscrew', 'token'),
|
|
21
|
+
tmuxBin: 'tmux',
|
|
22
|
+
readonlyDefault: false,
|
|
23
|
+
filesRoot: path.join(os.homedir(), 'NexusFiles'),
|
|
24
|
+
maxUpload: 100 * 1024 * 1024,
|
|
25
|
+
voiceUrl: null,
|
|
26
|
+
voiceToken: '',
|
|
27
|
+
voiceTokenFile: null,
|
|
28
|
+
fleetEnabled: true,
|
|
29
|
+
fleetBin: path.join(os.homedir(), '.local', 'bin', 'fleet'),
|
|
30
|
+
sessionPresets: {},
|
|
22
31
|
};
|
|
23
32
|
}
|
|
24
33
|
|
|
25
|
-
|
|
34
|
+
function configJsonPath() {
|
|
35
|
+
return process.env.NEXUSCREW_CONFIG_FILE || path.join(os.homedir(), '.nexuscrew', 'config.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Legge ~/.nexuscrew/config.json se esiste (nuovo, B2). {} se assente/malformato.
|
|
39
|
+
function readConfigJson() {
|
|
40
|
+
try {
|
|
41
|
+
const raw = fs.readFileSync(configJsonPath(), 'utf8');
|
|
42
|
+
const obj = JSON.parse(raw);
|
|
43
|
+
return (obj && typeof obj === 'object') ? obj : {};
|
|
44
|
+
} catch (_) { return {}; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Override da env (precedence più alta di config.json).
|
|
48
|
+
function envOverrides() {
|
|
49
|
+
const e = {};
|
|
50
|
+
if (process.env.NEXUSCREW_PORT) e.port = Number(process.env.NEXUSCREW_PORT);
|
|
51
|
+
if (process.env.NEXUSCREW_TOKEN_FILE) e.tokenPath = process.env.NEXUSCREW_TOKEN_FILE;
|
|
52
|
+
if (process.env.NEXUSCREW_TMUX) e.tmuxBin = process.env.NEXUSCREW_TMUX;
|
|
53
|
+
if (process.env.NEXUSCREW_READONLY) e.readonlyDefault = process.env.NEXUSCREW_READONLY === '1';
|
|
54
|
+
if (process.env.NEXUSCREW_FILES_ROOT) e.filesRoot = process.env.NEXUSCREW_FILES_ROOT;
|
|
55
|
+
if (process.env.NEXUSCREW_MAX_UPLOAD_MB) e.maxUpload = Number(process.env.NEXUSCREW_MAX_UPLOAD_MB) * 1024 * 1024;
|
|
56
|
+
if (process.env.NEXUSCREW_VOICE_URL) e.voiceUrl = process.env.NEXUSCREW_VOICE_URL;
|
|
57
|
+
if (process.env.NEXUSCREW_VOICE_TOKEN) e.voiceToken = process.env.NEXUSCREW_VOICE_TOKEN;
|
|
58
|
+
if (process.env.NEXUSCREW_VOICE_TOKEN_FILE) e.voiceTokenFile = process.env.NEXUSCREW_VOICE_TOKEN_FILE;
|
|
59
|
+
if (process.env.NEXUSCREW_FLEET) e.fleetEnabled = process.env.NEXUSCREW_FLEET !== '0';
|
|
60
|
+
if (process.env.NEXUSCREW_FLEET_BIN) e.fleetBin = process.env.NEXUSCREW_FLEET_BIN;
|
|
61
|
+
return e;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Precedence: baseDefaults < config.json < env < opts. [B2]
|
|
65
|
+
function loadConfig(opts = {}) {
|
|
66
|
+
return { ...baseDefaults(), ...readConfigJson(), ...envOverrides(), ...opts };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Retrocompat: defaults() = baseDefaults + env (NO config.json — per test isolati
|
|
70
|
+
// che non devono leggere ~/.nexuscrew/config.json del device). server.js usa loadConfig().
|
|
71
|
+
function defaults() {
|
|
72
|
+
return { ...baseDefaults(), ...envOverrides() };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { assertLoopback, baseDefaults, readConfigJson, loadConfig, defaults, LOOPBACK, configJsonPath };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { Router } = require('express');
|
|
3
|
+
const multer = require('multer');
|
|
4
|
+
const store = require('./store.js');
|
|
5
|
+
|
|
6
|
+
// Router file-exchange. Nessuno stato: tutto deriva da cfg + filesystem.
|
|
7
|
+
function filesRoutes({ cfg, sessionExists, paste }) {
|
|
8
|
+
const router = Router();
|
|
9
|
+
const upload = multer({
|
|
10
|
+
storage: multer.memoryStorage(),
|
|
11
|
+
limits: { fileSize: cfg.maxUpload, files: 1 },
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
router.post('/upload', (req, res) => {
|
|
15
|
+
upload.single('file')(req, res, async (err) => {
|
|
16
|
+
if (err) {
|
|
17
|
+
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
|
|
18
|
+
return res.status(status).json({ error: err.message });
|
|
19
|
+
}
|
|
20
|
+
const session = String((req.body && req.body.session) || '');
|
|
21
|
+
if (!req.file) return res.status(400).json({ error: 'file mancante' });
|
|
22
|
+
if (!store.isValidSession(session) || !sessionExists(session)) {
|
|
23
|
+
return res.status(404).json({ error: 'sessione tmux inesistente' });
|
|
24
|
+
}
|
|
25
|
+
const saved = store.saveUpload(cfg.filesRoot, session, req.file.buffer, req.file.originalname);
|
|
26
|
+
const pasted = await paste(session, saved.path);
|
|
27
|
+
res.json({ ...saved, pasted });
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
router.get('/', (req, res) => {
|
|
32
|
+
const session = String(req.query.session || '');
|
|
33
|
+
if (!store.isValidSession(session)) return res.status(400).json({ error: 'sessione invalida' });
|
|
34
|
+
res.json({
|
|
35
|
+
session,
|
|
36
|
+
inbox: store.listBox(cfg.filesRoot, session, 'inbox'),
|
|
37
|
+
outbox: store.listBox(cfg.filesRoot, session, 'outbox'),
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
router.get('/download', (req, res) => {
|
|
42
|
+
const full = store.resolveExisting(
|
|
43
|
+
cfg.filesRoot, String(req.query.session || ''), String(req.query.box || 'outbox'), String(req.query.name || ''),
|
|
44
|
+
);
|
|
45
|
+
if (!full) return res.status(404).json({ error: 'file non trovato' });
|
|
46
|
+
res.download(full);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
router.delete('/', (req, res) => {
|
|
50
|
+
const ok = store.removeFile(
|
|
51
|
+
cfg.filesRoot, String(req.query.session || ''), String(req.query.box || ''), String(req.query.name || ''),
|
|
52
|
+
);
|
|
53
|
+
if (!ok) return res.status(404).json({ error: 'file non trovato' });
|
|
54
|
+
res.json({ deleted: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return router;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { filesRoutes };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// File exchange per sessione: <root>/<sessione>/{inbox,outbox}.
|
|
3
|
+
// Ogni path e' derivato SOLO da input validati: sessione via regex tmux,
|
|
4
|
+
// nome file senza separatori. Mai overwrite in inbox.
|
|
5
|
+
//
|
|
6
|
+
// NOTE: i check su control-char/separatori/backslash usano charCode invece
|
|
7
|
+
// di regex con escape (\u00XX, \w, \\) perche' il layer di scrittura del file
|
|
8
|
+
// corrompe gli escape backslash. Semantica identica al piano.
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
|
|
12
|
+
const BOXES = new Set(['inbox', 'outbox']);
|
|
13
|
+
// \w === [A-Za-z0-9_]; espanso in letterali per evitare escape nel sorgente.
|
|
14
|
+
const SESSION_RE = /^[A-Za-z0-9_.@%:+-]{1,128}$/;
|
|
15
|
+
const BS = String.fromCharCode(0x5c); // backslash
|
|
16
|
+
|
|
17
|
+
function isValidSession(name) {
|
|
18
|
+
return typeof name === 'string' && SESSION_RE.test(name);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Rimuove i control char (0x00-0x1f e 0x7f) — eliminati, non sostituiti.
|
|
22
|
+
function stripControl(s) {
|
|
23
|
+
let out = '';
|
|
24
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
25
|
+
const c = s.charCodeAt(i);
|
|
26
|
+
if (c > 0x1f && c !== 0x7f) out += s[i];
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Sostituisce separatori di path e whitespace con '_' (mai un separatore nel nome).
|
|
32
|
+
function replaceSeparators(s) {
|
|
33
|
+
let out = '';
|
|
34
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
35
|
+
const ch = s[i];
|
|
36
|
+
const c = s.charCodeAt(i);
|
|
37
|
+
if (ch === '/' || ch === BS || c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) out += '_';
|
|
38
|
+
else out += ch;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sanitizeName(name) {
|
|
44
|
+
const base = replaceSeparators(stripControl(path.basename(String(name || ''))));
|
|
45
|
+
// '..' o solo punti -> neutri (mai un nome che il shell interpreta).
|
|
46
|
+
let onlyDots = true;
|
|
47
|
+
for (let i = 0; i < base.length; i += 1) { if (base[i] !== '.') { onlyDots = false; break; } }
|
|
48
|
+
const safe = onlyDots ? '' : base;
|
|
49
|
+
return (safe || 'file').slice(0, 128);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stamp(now = new Date()) {
|
|
53
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
54
|
+
return `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function boxDir(root, session, box) {
|
|
58
|
+
if (!isValidSession(session) || !BOXES.has(box)) return null;
|
|
59
|
+
return path.join(root, session, box);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ensureBox(root, session, box) {
|
|
63
|
+
const dir = boxDir(root, session, box);
|
|
64
|
+
if (!dir) return null;
|
|
65
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
66
|
+
return dir;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function saveUpload(root, session, buffer, origName, now = new Date()) {
|
|
70
|
+
const dir = ensureBox(root, session, 'inbox');
|
|
71
|
+
if (!dir) return null;
|
|
72
|
+
const base = `${stamp(now)}_${sanitizeName(origName)}`;
|
|
73
|
+
let name = base;
|
|
74
|
+
for (let i = 1; fs.existsSync(path.join(dir, name)); i += 1) name = `${i}-${base}`;
|
|
75
|
+
const full = path.join(dir, name);
|
|
76
|
+
fs.writeFileSync(full, buffer, { flag: 'wx' });
|
|
77
|
+
return { name, path: full, size: buffer.length };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function listBox(root, session, box) {
|
|
81
|
+
const dir = boxDir(root, session, box);
|
|
82
|
+
if (!dir) return null;
|
|
83
|
+
let entries;
|
|
84
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
85
|
+
catch (_) { return []; }
|
|
86
|
+
return entries
|
|
87
|
+
.filter((e) => e.isFile())
|
|
88
|
+
.map((e) => {
|
|
89
|
+
const st = fs.statSync(path.join(dir, e.name));
|
|
90
|
+
return { name: e.name, size: st.size, mtime: st.mtimeMs };
|
|
91
|
+
})
|
|
92
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// true se il nome contiene separatori/NUL o e' '.'/'..' (guardia anti-traversal).
|
|
96
|
+
function isUnsafeName(name) {
|
|
97
|
+
if (name === '.' || name === '..') return true;
|
|
98
|
+
for (let i = 0; i < name.length; i += 1) {
|
|
99
|
+
const ch = name[i];
|
|
100
|
+
const c = name.charCodeAt(i);
|
|
101
|
+
if (ch === '/' || ch === BS || c === 0x00) return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveExisting(root, session, box, name) {
|
|
107
|
+
const dir = boxDir(root, session, box);
|
|
108
|
+
if (!dir || typeof name !== 'string' || name === '') return null;
|
|
109
|
+
if (isUnsafeName(name)) return null;
|
|
110
|
+
const full = path.join(dir, name);
|
|
111
|
+
// lstat (non stat): NON segue i symlink. Un symlink ha isFile()=false su lstat,
|
|
112
|
+
// quindi un link nella box che punta fuori NON viene mai servito/scaricato (anti-evasion).
|
|
113
|
+
try { if (!fs.lstatSync(full).isFile()) return null; } catch (_) { return null; }
|
|
114
|
+
return full;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function removeFile(root, session, box, name) {
|
|
118
|
+
const full = resolveExisting(root, session, box, name);
|
|
119
|
+
if (!full) return false;
|
|
120
|
+
fs.unlinkSync(full);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
isValidSession, sanitizeName, stamp, boxDir, ensureBox,
|
|
126
|
+
saveUpload, listBox, resolveExisting, removeFile, BOXES,
|
|
127
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Osserva <root>/<sessione>/outbox per tutte le sessioni presenti su disco.
|
|
3
|
+
// fs.watch dove disponibile (con debounce); il polling periodico e' la rete
|
|
4
|
+
// di sicurezza che copre anche i filesystem dove fs.watch non funziona.
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { EventEmitter } = require('node:events');
|
|
8
|
+
const { listBox } = require('./store.js');
|
|
9
|
+
|
|
10
|
+
function createOutboxWatcher({ root, pollMs = 5000, debounceMs = 300 }) {
|
|
11
|
+
const em = new EventEmitter();
|
|
12
|
+
const watchers = new Map();
|
|
13
|
+
const timers = new Map();
|
|
14
|
+
const summary = {};
|
|
15
|
+
let closed = false;
|
|
16
|
+
|
|
17
|
+
function rescan(session) {
|
|
18
|
+
if (closed) return;
|
|
19
|
+
const files = listBox(root, session, 'outbox') || [];
|
|
20
|
+
const snap = { count: files.length, latest: files.length ? files[0].mtime : 0 };
|
|
21
|
+
const prev = summary[session];
|
|
22
|
+
summary[session] = snap;
|
|
23
|
+
if (!prev || prev.count !== snap.count || prev.latest !== snap.latest) {
|
|
24
|
+
em.emit('change', session, files);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function bump(session) {
|
|
29
|
+
clearTimeout(timers.get(session));
|
|
30
|
+
timers.set(session, setTimeout(() => rescan(session), debounceMs));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ensureWatch(session) {
|
|
34
|
+
if (watchers.has(session)) return;
|
|
35
|
+
try {
|
|
36
|
+
const w = fs.watch(path.join(root, session, 'outbox'), () => bump(session));
|
|
37
|
+
w.on('error', () => { try { w.close(); } catch (_) {} watchers.delete(session); });
|
|
38
|
+
watchers.set(session, w);
|
|
39
|
+
} catch (_) { /* il polling copre */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function scanAll() {
|
|
43
|
+
if (closed) return;
|
|
44
|
+
let names = [];
|
|
45
|
+
try {
|
|
46
|
+
names = fs.readdirSync(root, { withFileTypes: true })
|
|
47
|
+
.filter((d) => d.isDirectory()).map((d) => d.name);
|
|
48
|
+
} catch (_) { return; }
|
|
49
|
+
for (const session of names) { ensureWatch(session); rescan(session); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
scanAll();
|
|
53
|
+
const timer = setInterval(scanAll, pollMs);
|
|
54
|
+
if (timer.unref) timer.unref();
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
on: (ev, fn) => em.on(ev, fn),
|
|
58
|
+
getSummary: () => ({ ...summary }),
|
|
59
|
+
close: () => {
|
|
60
|
+
closed = true;
|
|
61
|
+
clearInterval(timer);
|
|
62
|
+
for (const t of timers.values()) clearTimeout(t);
|
|
63
|
+
for (const w of watchers.values()) { try { w.close(); } catch (_) {} }
|
|
64
|
+
watchers.clear();
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { createOutboxWatcher };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { execFile } = require('node:child_process');
|
|
3
|
+
|
|
4
|
+
// Esecutore serializzato del binario fleet: UNA invocazione alla volta
|
|
5
|
+
// (fleet tocca systemd/tmux reali: due comandi concorrenti = stato incoerente),
|
|
6
|
+
// timeout duro, stderr propagato nell'errore. Argomenti SEMPRE array (no shell).
|
|
7
|
+
function createFleetExec(bin, { timeoutMs = 15000 } = {}) {
|
|
8
|
+
let chain = Promise.resolve();
|
|
9
|
+
|
|
10
|
+
function exec(args) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
execFile(bin, args, { timeout: timeoutMs, killSignal: 'SIGKILL' }, (err, stdout, stderr) => {
|
|
13
|
+
if (err) {
|
|
14
|
+
if (err.killed || err.signal === 'SIGKILL') return reject(new Error('fleet timeout'));
|
|
15
|
+
return reject(new Error(`fleet ${args.join(' ')} failed: ${String(stderr || err.message).trim()}`));
|
|
16
|
+
}
|
|
17
|
+
resolve(String(stdout));
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function run(args) {
|
|
23
|
+
const next = chain.then(() => exec(args));
|
|
24
|
+
// la coda non si spezza sugli errori (catch), ma il chiamante li vede
|
|
25
|
+
chain = next.catch(() => {});
|
|
26
|
+
return next;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { run };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { createFleetExec };
|