377 lines
20 KiB
JavaScript
377 lines
20 KiB
JavaScript
// MrTerm Web: die Desktop-Oberfläche im Browser, selbst gehostet (Docker).
|
||
// - Benutzerkonten; jeder Tresor ist mit einem eigenen Datenschlüssel (DEK) verschlüsselt,
|
||
// den nur das Login-Passwort des Benutzers entpackt (scrypt → AES-256-GCM).
|
||
// - Oberfläche ↔ Server über WebSocket (/ws), gleiche Kanäle wie Electron/Android (src/core/backend.js).
|
||
// - SFTP-Up-/Downloads über HTTP (/web/upload, /web/download).
|
||
//
|
||
// Umgebung: PORT (8080), DATA_DIR (/data), TRUST_PROXY=1 (X-Forwarded-Proto/-For auswerten, z. B. hinter Traefik)
|
||
const http = require('http');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
const { WebSocketServer } = require('ws');
|
||
const { Store } = require('../main/store');
|
||
const { createBackend } = require('../core/backend');
|
||
const i18n = require('../i18n');
|
||
|
||
const ROOT = process.env.MRTERM_ROOT || path.resolve(__dirname, '..', '..');
|
||
const DATA = path.resolve(process.env.DATA_DIR || path.join(ROOT, 'web-data'));
|
||
const PORT = Number(process.env.PORT) || 8080;
|
||
const TRUST_PROXY = process.env.TRUST_PROXY === '1';
|
||
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version; } catch { return '0.0.0'; } })();
|
||
const SESSION_IDLE = 12 * 3600e3;
|
||
const SESSION_MAX = 7 * 24 * 3600e3;
|
||
const COOKIE = 'mrterm_sid';
|
||
|
||
process.on('uncaughtException', (e) => console.error('Uncaught exception:', e));
|
||
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
|
||
fs.mkdirSync(path.join(DATA, 'users'), { recursive: true, mode: 0o700 });
|
||
|
||
// ============================================================ Krypto-Helfer
|
||
const scrypt = (pw, salt, N = 2 ** 15) => new Promise((resolve, reject) =>
|
||
crypto.scrypt(String(pw), salt, 32, { N, r: 8, p: 1, maxmem: 256 * N * 8 }, (e, k) => (e ? reject(e) : resolve(k))));
|
||
function box(key, plain) {
|
||
const iv = crypto.randomBytes(12);
|
||
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||
const ct = Buffer.concat([c.update(plain), c.final()]);
|
||
return { iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), ct: ct.toString('base64') };
|
||
}
|
||
function unbox(key, b) {
|
||
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(b.iv, 'base64'));
|
||
d.setAuthTag(Buffer.from(b.tag, 'base64'));
|
||
return Buffer.concat([d.update(Buffer.from(b.ct, 'base64')), d.final()]);
|
||
}
|
||
// Verschlüsselung des Tresors mit dem DEK (Schnittstelle wie Electrons safeStorage)
|
||
const vaultCrypto = (dek) => ({
|
||
isEncryptionAvailable: () => true,
|
||
encryptString(s) {
|
||
const iv = crypto.randomBytes(12);
|
||
const c = crypto.createCipheriv('aes-256-gcm', dek, iv);
|
||
const ct = Buffer.concat([c.update(String(s), 'utf8'), c.final()]);
|
||
return Buffer.concat([iv, c.getAuthTag(), ct]);
|
||
},
|
||
decryptString(buf) {
|
||
const d = crypto.createDecipheriv('aes-256-gcm', dek, buf.subarray(0, 12));
|
||
d.setAuthTag(buf.subarray(12, 28));
|
||
return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString('utf8');
|
||
},
|
||
});
|
||
|
||
// ============================================================ Benutzer
|
||
const USERS_FILE = path.join(DATA, 'users.json');
|
||
let users = (() => { try { return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')).users || []; } catch { return []; } })();
|
||
function saveUsers() {
|
||
fs.writeFileSync(USERS_FILE + '.tmp', JSON.stringify({ users }, null, 2), { mode: 0o600 });
|
||
fs.renameSync(USERS_FILE + '.tmp', USERS_FILE);
|
||
}
|
||
const validName = (n) => /^[A-Za-z0-9._@-]{1,64}$/.test(String(n || ''));
|
||
const validPw = (p) => typeof p === 'string' && p.length >= 8 && p.length <= 1024;
|
||
const publicUser = (u) => ({ id: u.id, name: u.name, admin: !!u.admin });
|
||
|
||
async function createUser(name, password, admin) {
|
||
if (!validName(name)) throw new Error('Usernames may contain letters, digits and . _ @ - (max. 64).');
|
||
if (!validPw(password)) throw new Error('The password must be at least 8 characters long.');
|
||
if (users.some((u) => u.name.toLowerCase() === name.toLowerCase())) throw new Error('This username is already taken.');
|
||
const authSalt = crypto.randomBytes(16), kekSalt = crypto.randomBytes(16);
|
||
const dek = crypto.randomBytes(32);
|
||
const u = {
|
||
id: crypto.randomUUID(), name, admin: !!admin, createdAt: Date.now(),
|
||
auth: { salt: authSalt.toString('base64'), hash: (await scrypt(password, authSalt)).toString('base64') },
|
||
kek: { salt: kekSalt.toString('base64') },
|
||
dek: box(await scrypt(password, kekSalt), dek),
|
||
};
|
||
users.push(u);
|
||
saveUsers();
|
||
return u;
|
||
}
|
||
// Prüft das Passwort und liefert den entpackten DEK (oder null)
|
||
async function verify(u, password) {
|
||
const hash = await scrypt(password, Buffer.from(u.auth.salt, 'base64'));
|
||
if (!crypto.timingSafeEqual(hash, Buffer.from(u.auth.hash, 'base64'))) return null;
|
||
try { return unbox(await scrypt(password, Buffer.from(u.kek.salt, 'base64')), u.dek); } catch { return null; }
|
||
}
|
||
async function setPassword(u, dek, password) {
|
||
if (!validPw(password)) throw new Error('The password must be at least 8 characters long.');
|
||
const authSalt = crypto.randomBytes(16), kekSalt = crypto.randomBytes(16);
|
||
u.auth = { salt: authSalt.toString('base64'), hash: (await scrypt(password, authSalt)).toString('base64') };
|
||
u.kek = { salt: kekSalt.toString('base64') };
|
||
u.dek = box(await scrypt(password, kekSalt), dek);
|
||
saveUsers();
|
||
}
|
||
|
||
// ============================================================ Sitzungen
|
||
const sessions = new Map(); // token -> { userId, dek, created, last }
|
||
function newSession(u, dek) {
|
||
const token = crypto.randomBytes(32).toString('base64url');
|
||
sessions.set(token, { userId: u.id, dek, created: Date.now(), last: Date.now() });
|
||
return token;
|
||
}
|
||
function sessionOf(req) {
|
||
const m = String(req.headers.cookie || '').match(new RegExp(`(?:^|;\\s*)${COOKIE}=([A-Za-z0-9_-]+)`));
|
||
const s = m && sessions.get(m[1]);
|
||
if (!s) return null;
|
||
const now = Date.now();
|
||
if (now - s.last > SESSION_IDLE || now - s.created > SESSION_MAX || !users.some((u) => u.id === s.userId)) { sessions.delete(m[1]); return null; }
|
||
s.last = now;
|
||
return { token: m[1], ...s, user: users.find((u) => u.id === s.userId) };
|
||
}
|
||
setInterval(() => { for (const [t, s] of sessions) if (Date.now() - s.last > SESSION_IDLE || Date.now() - s.created > SESSION_MAX) sessions.delete(t); }, 600e3).unref();
|
||
|
||
const secure = (req) => req.socket.encrypted || (TRUST_PROXY && String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https');
|
||
const cookie = (req, token, maxAge) => `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure(req) ? '; Secure' : ''}`;
|
||
const clientIp = (req) => (TRUST_PROXY && String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()) || req.socket.remoteAddress || '';
|
||
|
||
// Anmeldeversuche begrenzen: 10 Fehlversuche je IP in 15 Minuten
|
||
const failures = new Map();
|
||
const blocked = (ip) => { const f = failures.get(ip); return f && f.n >= 10 && Date.now() - f.first < 900e3; };
|
||
const fail = (ip) => { const f = failures.get(ip); if (!f || Date.now() - f.first > 900e3) failures.set(ip, { n: 1, first: Date.now() }); else f.n++; };
|
||
|
||
// ============================================================ Laufzeit je Benutzer (Tresor + Backend)
|
||
const runtimes = new Map(); // userId -> { store, be, sockets, timer }
|
||
function runtimeFor(sess) {
|
||
let rt = runtimes.get(sess.userId);
|
||
if (rt) { clearTimeout(rt.timer); return rt; }
|
||
const dir = path.join(DATA, 'users', sess.userId);
|
||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||
const store = new Store({ dir, crypto: vaultCrypto(sess.dek) });
|
||
store.load();
|
||
const sockets = new Set();
|
||
const send = (ch, ...args) => { const msg = JSON.stringify({ t: 'evt', ch, args }); for (const ws of sockets) if (ws.readyState === 1) ws.send(msg); };
|
||
const be = createBackend({ store, send, platform: 'web', version: VERSION });
|
||
// Desktop-Funktionen, die es im Browser nicht gibt: neutrale Antworten, damit die Oberfläche nicht stolpert
|
||
be.handle('vpn:status', () => ({}));
|
||
be.handle('fw:active', () => []);
|
||
be.handle('rdp:detect', () => ({}));
|
||
be.handle('rdp:embedSupported', () => ({ ok: false, reason: 'web' }));
|
||
be.handle('update:check', () => ({ available: false }));
|
||
be.handle('sync:status', () => ({ enabled: false, running: false, peers: [], nearby: [], share: { keys: [], hosts: [], vpns: [] } }));
|
||
rt = { store, be, sockets, timer: null };
|
||
runtimes.set(sess.userId, rt);
|
||
return rt;
|
||
}
|
||
// Letzter Browser-Tab zu: SSH-Verbindungen nach kurzer Zeit schließen und den Tresor aus dem Speicher nehmen
|
||
function release(userId) {
|
||
const rt = runtimes.get(userId);
|
||
if (!rt || rt.sockets.size) return;
|
||
rt.timer = setTimeout(() => { if (!rt.sockets.size) { rt.be.close(); runtimes.delete(userId); } }, 60e3);
|
||
}
|
||
function dropUser(userId) {
|
||
for (const [t, s] of sessions) if (s.userId === userId) sessions.delete(t);
|
||
const rt = runtimes.get(userId);
|
||
if (rt) { for (const ws of rt.sockets) ws.close(4001, 'signed out'); rt.be.close(); runtimes.delete(userId); }
|
||
}
|
||
|
||
// ============================================================ HTTP
|
||
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.json': 'application/json', '.ico': 'image/x-icon', '.woff2': 'font/woff2' };
|
||
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'";
|
||
function headers(extra = {}) {
|
||
return { 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'no-referrer', 'Cache-Control': 'no-store', ...extra };
|
||
}
|
||
const json = (res, code, obj, extra = {}) => { res.writeHead(code, headers({ 'Content-Type': 'application/json', ...extra })); res.end(JSON.stringify(obj)); };
|
||
const redirect = (res, to) => { res.writeHead(302, headers({ Location: to })); res.end(); };
|
||
|
||
// Freigegebene statische Dateien: URL-Präfix → Verzeichnis
|
||
const STATIC = [
|
||
['/app/src/renderer/', path.join(ROOT, 'src', 'renderer')],
|
||
['/app/src/i18n.js', path.join(ROOT, 'src', 'i18n.js')],
|
||
['/app/node_modules/@xterm/', path.join(ROOT, 'node_modules', '@xterm')],
|
||
['/web/', path.join(ROOT, 'src', 'web', 'public')],
|
||
];
|
||
function staticFile(urlPath) {
|
||
for (const [prefix, dir] of STATIC) {
|
||
if (urlPath === prefix && !prefix.endsWith('/')) return dir;
|
||
if (!prefix.endsWith('/') || !urlPath.startsWith(prefix)) continue;
|
||
const file = path.resolve(dir, '.' + path.posix.normalize('/' + decodeURIComponent(urlPath.slice(prefix.length))));
|
||
if (!file.startsWith(dir + path.sep)) return null;
|
||
if (dir.endsWith('@xterm') && !/[\\/](lib|css)[\\/][^\\/]+$/.test(file)) return null;
|
||
if (dir.endsWith('renderer') && /\.(rdm|md)$/.test(file)) return null;
|
||
return file;
|
||
}
|
||
return null;
|
||
}
|
||
function serveFile(res, file, transform) {
|
||
fs.stat(file, (err, st) => {
|
||
if (err || !st.isFile()) return json(res, 404, { error: 'Not found' });
|
||
const type = MIME[path.extname(file)] || 'application/octet-stream';
|
||
const cache = type.startsWith('text/html') ? 'no-store' : 'private, max-age=300';
|
||
if (transform) {
|
||
const body = transform(fs.readFileSync(file, 'utf8'));
|
||
res.writeHead(200, headers({ 'Content-Type': type, 'Cache-Control': cache }));
|
||
return res.end(body);
|
||
}
|
||
res.writeHead(200, headers({ 'Content-Type': type, 'Content-Length': st.size, 'Cache-Control': cache }));
|
||
fs.createReadStream(file).pipe(res);
|
||
});
|
||
}
|
||
function readJson(req, limit = 64 * 1024) {
|
||
return new Promise((resolve, reject) => {
|
||
let size = 0; const parts = [];
|
||
req.on('data', (c) => { size += c.length; if (size > limit) { reject(new Error('Request too large')); req.destroy(); } else parts.push(c); });
|
||
req.on('end', () => { try { resolve(parts.length ? JSON.parse(Buffer.concat(parts).toString('utf8')) : {}); } catch { reject(new Error('Invalid JSON')); } });
|
||
req.on('error', reject);
|
||
});
|
||
}
|
||
|
||
const APP_URL = '/app/src/renderer/index.html';
|
||
|
||
async function api(req, res, url, sess) {
|
||
const p = url.pathname;
|
||
// Schreibende Anfragen nur mit eigenem Header (Browser senden ihn nicht seitenübergreifend ohne CORS-Freigabe)
|
||
if (req.method === 'POST' && req.headers['x-mrterm'] !== '1') return json(res, 403, { error: 'Forbidden' });
|
||
if (p === '/api/state' && req.method === 'GET') return json(res, 200, { setup: !users.length, user: sess ? publicUser(sess.user) : null, version: VERSION });
|
||
if (p === '/api/setup' && req.method === 'POST') {
|
||
if (users.length) return json(res, 409, { error: 'Setup is already complete.' });
|
||
const b = await readJson(req);
|
||
const u = await createUser(b.name, b.password, true);
|
||
const dek = await verify(u, b.password);
|
||
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, newSession(u, dek), SESSION_MAX / 1000) });
|
||
}
|
||
if (p === '/api/login' && req.method === 'POST') {
|
||
const ip = clientIp(req);
|
||
if (blocked(ip)) return json(res, 429, { error: 'Too many failed attempts. Try again in 15 minutes.' });
|
||
const b = await readJson(req);
|
||
const u = users.find((x) => x.name.toLowerCase() === String(b.name || '').toLowerCase());
|
||
const dek = u ? await verify(u, String(b.password || '')) : (await scrypt('x', crypto.randomBytes(16)), null);
|
||
if (!dek) { fail(ip); return json(res, 401, { error: 'Wrong username or password.' }); }
|
||
failures.delete(ip);
|
||
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, newSession(u, dek), SESSION_MAX / 1000) });
|
||
}
|
||
if (!sess) return json(res, 401, { error: 'Not signed in' });
|
||
if (p === '/api/me') return json(res, 200, publicUser(sess.user));
|
||
if (p === '/api/logout' && req.method === 'POST') {
|
||
sessions.delete(sess.token);
|
||
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, '', 0) });
|
||
}
|
||
if (p === '/api/password' && req.method === 'POST') {
|
||
const b = await readJson(req);
|
||
if (!(await verify(sess.user, String(b.old || '')))) return json(res, 400, { error: 'The current password is wrong.' });
|
||
await setPassword(sess.user, sess.dek, b.password);
|
||
for (const [t, s] of sessions) if (s.userId === sess.userId && t !== sess.token) sessions.delete(t);
|
||
return json(res, 200, { ok: true });
|
||
}
|
||
if (p.startsWith('/api/users')) {
|
||
if (!sess.user.admin) return json(res, 403, { error: 'Administrators only.' });
|
||
if (p === '/api/users' && req.method === 'GET') return json(res, 200, { users: users.map(publicUser) });
|
||
if (p === '/api/users' && req.method === 'POST') {
|
||
const b = await readJson(req);
|
||
return json(res, 200, publicUser(await createUser(b.name, b.password, !!b.admin)));
|
||
}
|
||
if (p === '/api/users/delete' && req.method === 'POST') {
|
||
const b = await readJson(req);
|
||
const u = users.find((x) => x.id === b.id);
|
||
if (!u) return json(res, 404, { error: 'User not found.' });
|
||
if (u.id === sess.userId) return json(res, 400, { error: 'You cannot delete yourself.' });
|
||
if (u.admin && users.filter((x) => x.admin).length === 1) return json(res, 400, { error: 'The last administrator cannot be deleted.' });
|
||
dropUser(u.id);
|
||
users = users.filter((x) => x.id !== u.id);
|
||
saveUsers();
|
||
fs.rmSync(path.join(DATA, 'users', u.id), { recursive: true, force: true });
|
||
return json(res, 200, { ok: true });
|
||
}
|
||
}
|
||
return json(res, 404, { error: 'Not found' });
|
||
}
|
||
|
||
// SFTP-Dateien des angemeldeten Benutzers streamen
|
||
function sftpFor(sess, url) {
|
||
const rt = runtimes.get(sess.userId);
|
||
const sid = url.searchParams.get('sid') || '';
|
||
const file = url.searchParams.get('path') || '';
|
||
if (!rt || !file.startsWith('/')) throw new Error('Invalid request');
|
||
return { sftp: rt.be.ssh.sftpOf(sid), file };
|
||
}
|
||
function download(req, res, sess, url) {
|
||
let t;
|
||
try { t = sftpFor(sess, url); } catch (e) { return json(res, 400, { error: e.message }); }
|
||
t.sftp.stat(t.file, (err, st) => {
|
||
if (err) return json(res, 404, { error: err.message });
|
||
const name = path.posix.basename(t.file);
|
||
res.writeHead(200, headers({
|
||
'Content-Type': 'application/octet-stream', 'Content-Length': st.size, 'Cache-Control': 'no-store',
|
||
'Content-Disposition': `attachment; filename="${name.replace(/[^\x20-\x7e]|["\\]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(name)}`,
|
||
}));
|
||
const rs = t.sftp.createReadStream(t.file);
|
||
rs.on('error', () => res.destroy());
|
||
rs.pipe(res);
|
||
});
|
||
}
|
||
function upload(req, res, sess, url) {
|
||
let t;
|
||
try { t = sftpFor(sess, url); } catch (e) { return json(res, 400, { error: e.message }); }
|
||
const ws = t.sftp.createWriteStream(t.file);
|
||
let done = false;
|
||
const finish = (err) => { if (done) return; done = true; err ? json(res, 500, { error: err.message }) : json(res, 200, { ok: true }); };
|
||
ws.on('error', finish);
|
||
ws.on('close', () => finish());
|
||
req.on('error', finish);
|
||
req.pipe(ws);
|
||
}
|
||
|
||
const server = http.createServer(async (req, res) => {
|
||
try {
|
||
const url = new URL(req.url, 'http://x');
|
||
const p = url.pathname;
|
||
const sess = sessionOf(req);
|
||
if (p === '/healthz') return json(res, 200, { ok: true });
|
||
if (p.startsWith('/api/')) return await api(req, res, url, sess);
|
||
if (p === '/web/download' && req.method === 'GET') return sess ? download(req, res, sess, url) : json(res, 401, { error: 'Not signed in' });
|
||
if (p === '/web/upload' && req.method === 'POST') {
|
||
if (!sess) return json(res, 401, { error: 'Not signed in' });
|
||
if (req.headers['x-mrterm'] !== '1') return json(res, 403, { error: 'Forbidden' });
|
||
return upload(req, res, sess, url);
|
||
}
|
||
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'Method not allowed' });
|
||
if (p === '/' || p === '/login') return sess && p === '/' ? redirect(res, APP_URL) : serveFile(res, path.join(ROOT, 'src', 'web', 'public', 'login.html'));
|
||
if (p === '/favicon.ico' || p === '/favicon.png') return serveFile(res, path.join(ROOT, 'src', 'renderer', 'logo.png'));
|
||
if (p === APP_URL) {
|
||
if (!sess) return redirect(res, '/');
|
||
// Browser-Anbindung (window.api) vor den übrigen Skripten laden
|
||
return serveFile(res, staticFile(p), (html) => html.replace('<script ', '<script src="/web/web-api.js"></script>\n <script '));
|
||
}
|
||
const file = staticFile(p);
|
||
if (!file) return json(res, 404, { error: 'Not found' });
|
||
// Nur die Anmeldeseite und ihre Dateien sind ohne Sitzung erreichbar
|
||
const PUBLIC = ['/web/login.js', '/web/login.css', '/app/src/i18n.js', '/app/src/renderer/app-themes.css', '/app/src/renderer/logo.png'];
|
||
if (!sess && !PUBLIC.includes(p)) return json(res, 401, { error: 'Not signed in' });
|
||
return serveFile(res, file);
|
||
} catch (e) {
|
||
console.error(e);
|
||
if (!res.headersSent) json(res, 500, { error: e.message || 'Server error' });
|
||
}
|
||
});
|
||
|
||
// ============================================================ WebSocket
|
||
const wss = new WebSocketServer({ noServer: true, maxPayload: 8 * 1024 * 1024 });
|
||
server.on('upgrade', (req, socket, head) => {
|
||
const url = new URL(req.url, 'http://x');
|
||
const sess = sessionOf(req);
|
||
// Nur Verbindungen von der eigenen Seite (Schutz vor Cross-Site-WebSocket-Hijacking)
|
||
const origin = req.headers.origin ? new URL(req.headers.origin).host : '';
|
||
const host = (TRUST_PROXY && req.headers['x-forwarded-host']) || req.headers.host;
|
||
if (url.pathname !== '/ws' || !sess || origin !== host) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); return socket.destroy(); }
|
||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||
const rt = runtimeFor(sess);
|
||
rt.sockets.add(ws);
|
||
const lang = String(req.headers['accept-language'] || 'en').split(',')[0];
|
||
ws.on('message', async (raw) => {
|
||
let m;
|
||
try { m = JSON.parse(raw.toString('utf8')); } catch { return; }
|
||
if (!sessions.has(sess.token)) return ws.close(4001, 'signed out');
|
||
if (m.t === 'send') return rt.be.notify(String(m.ch), Array.isArray(m.args) ? m.args : []);
|
||
if (m.t !== 'call') return;
|
||
try {
|
||
i18n.setLanguage(rt.store.get().settings.language, lang);
|
||
const v = await rt.be.call(String(m.ch), Array.isArray(m.args) ? m.args : []);
|
||
ws.send(JSON.stringify({ t: 'reply', id: m.id, ok: true, v: v === undefined ? null : v }));
|
||
} catch (e) {
|
||
if (ws.readyState === 1) ws.send(JSON.stringify({ t: 'reply', id: m.id, ok: false, v: e?.message || String(e) }));
|
||
}
|
||
});
|
||
ws.on('close', () => { rt.sockets.delete(ws); release(sess.userId); });
|
||
ws.send(JSON.stringify({ t: 'hello', user: publicUser(sess.user), version: VERSION }));
|
||
});
|
||
});
|
||
|
||
server.on('error', (e) => { console.error(`Cannot listen on port ${PORT}: ${e.message}`); process.exit(1); });
|
||
server.listen(PORT, () => console.log(`MrTerm Web ${VERSION} on port ${PORT} (data: ${DATA})${users.length ? '' : ' – open it in the browser to create the admin account'}`));
|