@@ -0,0 +1,376 @@
// 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' } ` ) ) ;