Add browser support with Socket.io transport, authentication, performance-optimized IPC, and CI/CD (#925)
This commit is contained in:
94
src/AuthManager.ts
Normal file
94
src/AuthManager.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as bcrypt from 'bcryptjs'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export interface Credentials {
|
||||
username: string
|
||||
passwordHash: string
|
||||
}
|
||||
|
||||
export class AuthManager {
|
||||
private credentialsPath: string
|
||||
private credentials: Credentials | undefined
|
||||
|
||||
constructor(credentialsPath: string) {
|
||||
this.credentialsPath = credentialsPath
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
// Try to get credentials from environment variables
|
||||
const envUsername = process.env.MQTT_EXPLORER_USERNAME
|
||||
const envPassword = process.env.MQTT_EXPLORER_PASSWORD
|
||||
|
||||
if (envUsername && envPassword) {
|
||||
// Use environment credentials
|
||||
console.log('Using credentials from environment variables')
|
||||
console.log('Username:', envUsername)
|
||||
this.credentials = {
|
||||
username: envUsername,
|
||||
passwordHash: await bcrypt.hash(envPassword, 10),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Try to load from file
|
||||
if (fs.existsSync(this.credentialsPath)) {
|
||||
try {
|
||||
const data = fs.readFileSync(this.credentialsPath, 'utf8')
|
||||
this.credentials = JSON.parse(data)
|
||||
console.log('Loaded credentials from', this.credentialsPath)
|
||||
console.log('Username:', this.credentials!.username)
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('Failed to load credentials from file:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new credentials
|
||||
const username = `user-${uuidv4().substring(0, 8)}`
|
||||
const password = uuidv4()
|
||||
|
||||
console.log('='.repeat(60))
|
||||
console.log('Generated new credentials:')
|
||||
console.log('Username:', username)
|
||||
console.log('Password:', password)
|
||||
console.log('='.repeat(60))
|
||||
console.log('Please save these credentials. They will be persisted to:')
|
||||
console.log(this.credentialsPath)
|
||||
console.log('='.repeat(60))
|
||||
|
||||
this.credentials = {
|
||||
username,
|
||||
passwordHash: await bcrypt.hash(password, 10),
|
||||
}
|
||||
|
||||
// Save to file
|
||||
try {
|
||||
const dir = path.dirname(this.credentialsPath)
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
fs.writeFileSync(this.credentialsPath, JSON.stringify(this.credentials, null, 2))
|
||||
console.log('Credentials saved successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to save credentials:', error)
|
||||
}
|
||||
}
|
||||
|
||||
public async verifyCredentials(username: string, password: string): Promise<boolean> {
|
||||
if (!this.credentials) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (username !== this.credentials.username) {
|
||||
return false
|
||||
}
|
||||
|
||||
return bcrypt.compare(password, this.credentials.passwordHash)
|
||||
}
|
||||
|
||||
public getUsername(): string | undefined {
|
||||
return this.credentials?.username
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
import { shouldAutoUpdate, handleAutoUpdate } from './autoUpdater'
|
||||
import { registerCrashReporter } from './registerCrashReporter'
|
||||
import { makeOpenDialogRpc, makeSaveDialogRpc } from '../events/OpenDialogRequest'
|
||||
import { backendRpc, getAppVersion, writeToFile, readFromFile } from '../events'
|
||||
import { backendRpc, backendEvents, getAppVersion, writeToFile, readFromFile } from '../events'
|
||||
import { RpcEvents } from '../events/EventsV2'
|
||||
|
||||
registerCrashReporter()
|
||||
|
||||
@@ -49,21 +50,35 @@ app.whenReady().then(() => {
|
||||
backendRpc.on(getAppVersion, async () => app.getVersion())
|
||||
|
||||
backendRpc.on(writeToFile, async ({ filePath, data, encoding }) => {
|
||||
await fsPromise.writeFile(filePath, Buffer.from(data, 'base64'), { encoding })
|
||||
await fsPromise.writeFile(filePath, Buffer.from(data, 'base64'), { encoding: encoding as BufferEncoding })
|
||||
})
|
||||
|
||||
backendRpc.on(readFromFile, async ({ filePath, encoding }) => {
|
||||
return fsPromise.readFile(filePath, { encoding })
|
||||
if (encoding) {
|
||||
const content = await fsPromise.readFile(filePath, { encoding: encoding as BufferEncoding })
|
||||
return Buffer.from(content)
|
||||
}
|
||||
return fsPromise.readFile(filePath)
|
||||
})
|
||||
|
||||
// Certificate upload handler - works for both Electron and browser mode via IPC
|
||||
backendRpc.on(RpcEvents.uploadCertificate, async ({ filename, data }) => {
|
||||
// In Electron, we just return the data as-is since it's already read
|
||||
// The client will use it directly
|
||||
return {
|
||||
name: filename,
|
||||
data,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
autoUpdater.logger = log
|
||||
log.info('App starting...')
|
||||
|
||||
const connectionManager = new ConnectionManager()
|
||||
const connectionManager = new ConnectionManager(backendEvents)
|
||||
connectionManager.manageConnections()
|
||||
|
||||
const configStorage = new ConfigStorage(path.join(app.getPath('userData'), 'settings.json'))
|
||||
const configStorage = new ConfigStorage(path.join(app.getPath('userData'), 'settings.json'), backendRpc)
|
||||
configStorage.init()
|
||||
|
||||
// Keep a global reference of the window object, if you don't, the window will
|
||||
|
||||
177
src/server.ts
Normal file
177
src/server.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import express from 'express'
|
||||
import * as http from 'http'
|
||||
import * as path from 'path'
|
||||
import { Server } from 'socket.io'
|
||||
import { promises as fsPromise } from 'fs'
|
||||
import { Request, Response } from 'express'
|
||||
import { AuthManager } from './AuthManager'
|
||||
import { ConnectionManager } from '../backend/src/index'
|
||||
import ConfigStorage from '../backend/src/ConfigStorage'
|
||||
import { SocketIOServerEventBus } from '../events/EventSystem/SocketIOServerEventBus'
|
||||
import { Rpc } from '../events/EventSystem/Rpc'
|
||||
import { makeOpenDialogRpc, makeSaveDialogRpc } from '../events/OpenDialogRequest'
|
||||
import { getAppVersion, writeToFile, readFromFile } from '../events'
|
||||
import { RpcEvents } from '../events/EventsV2'
|
||||
|
||||
const PORT = process.env.PORT || 3000
|
||||
const CREDENTIALS_PATH = path.join(process.cwd(), 'data', 'credentials.json')
|
||||
|
||||
async function startServer() {
|
||||
// Initialize authentication
|
||||
const authManager = new AuthManager(CREDENTIALS_PATH)
|
||||
await authManager.initialize()
|
||||
|
||||
// Create Express app
|
||||
const app = express()
|
||||
const server = http.createServer(app)
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST'],
|
||||
},
|
||||
allowEIO3: true, // Allow Engine.IO v3 clients (backwards compatibility)
|
||||
transports: ['websocket', 'polling'], // Support both transports
|
||||
pingTimeout: 60000, // Increase ping timeout
|
||||
pingInterval: 25000, // Ping interval
|
||||
})
|
||||
|
||||
// Authentication middleware for Socket.io
|
||||
io.use(async (socket, next) => {
|
||||
const { username, password } = socket.handshake.auth
|
||||
|
||||
if (!username || !password) {
|
||||
return next(new Error('Authentication required'))
|
||||
}
|
||||
|
||||
const isValid = await authManager.verifyCredentials(username, password)
|
||||
if (!isValid) {
|
||||
return next(new Error('Invalid credentials'))
|
||||
}
|
||||
|
||||
console.log('Client authenticated:', username)
|
||||
next()
|
||||
})
|
||||
|
||||
// Initialize backend event bus with Socket.io
|
||||
const backendEvents = new SocketIOServerEventBus(io)
|
||||
const backendRpc = new Rpc(backendEvents)
|
||||
|
||||
// Initialize connection manager
|
||||
const connectionManager = new ConnectionManager(backendEvents)
|
||||
connectionManager.manageConnections()
|
||||
|
||||
// Initialize config storage
|
||||
const configStorage = new ConfigStorage(path.join(process.cwd(), 'data', 'settings.json'), backendRpc)
|
||||
configStorage.init()
|
||||
|
||||
// Setup RPC handlers for file operations
|
||||
backendRpc.on(makeOpenDialogRpc(), async request => {
|
||||
// In browser mode, file selection is handled client-side via upload
|
||||
// Return empty result as this will be handled differently
|
||||
return { canceled: true, filePaths: [] }
|
||||
})
|
||||
|
||||
backendRpc.on(makeSaveDialogRpc(), async request => {
|
||||
// In browser mode, file saving is handled client-side via download
|
||||
return { canceled: true, filePath: undefined }
|
||||
})
|
||||
|
||||
backendRpc.on(getAppVersion, async () => {
|
||||
// Return version from package.json
|
||||
try {
|
||||
const packageJsonPath = path.join(__dirname, '..', '..', 'package.json')
|
||||
const packageJsonData = await fsPromise.readFile(packageJsonPath, 'utf8')
|
||||
const packageJson = JSON.parse(packageJsonData)
|
||||
return packageJson.version
|
||||
} catch (e) {
|
||||
return '0.0.0'
|
||||
}
|
||||
})
|
||||
|
||||
backendRpc.on(writeToFile, async ({ filePath, data, encoding }) => {
|
||||
// In browser mode, we store files in the server's data directory
|
||||
const dataDir = path.join(process.cwd(), 'data', 'uploads')
|
||||
const safePath = path.join(dataDir, path.basename(filePath))
|
||||
|
||||
try {
|
||||
await fsPromise.mkdir(dataDir, { recursive: true })
|
||||
if (encoding) {
|
||||
await fsPromise.writeFile(safePath, Buffer.from(data, 'base64'), { encoding: encoding as BufferEncoding })
|
||||
} else {
|
||||
await fsPromise.writeFile(safePath, Buffer.from(data, 'base64'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error writing file:', error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
backendRpc.on(readFromFile, async ({ filePath, encoding }) => {
|
||||
// In browser mode, files are read from the server's data directory
|
||||
const dataDir = path.join(process.cwd(), 'data', 'uploads')
|
||||
const safePath = path.join(dataDir, path.basename(filePath))
|
||||
|
||||
try {
|
||||
if (encoding) {
|
||||
const content = await fsPromise.readFile(safePath, { encoding: encoding as BufferEncoding })
|
||||
return Buffer.from(content)
|
||||
}
|
||||
return await fsPromise.readFile(safePath)
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
// Certificate upload handler - via IPC for consistency
|
||||
backendRpc.on(RpcEvents.uploadCertificate, async ({ filename, data }) => {
|
||||
// Store certificate on server for browser mode
|
||||
const dataDir = path.join(process.cwd(), 'data', 'certificates')
|
||||
await fsPromise.mkdir(dataDir, { recursive: true })
|
||||
|
||||
const safePath = path.join(dataDir, path.basename(filename))
|
||||
await fsPromise.writeFile(safePath, Buffer.from(data, 'base64'))
|
||||
|
||||
console.log('Certificate uploaded:', filename)
|
||||
|
||||
// Return the certificate data for client to use
|
||||
return {
|
||||
name: filename,
|
||||
data,
|
||||
}
|
||||
})
|
||||
|
||||
// Serve static files
|
||||
app.use(express.static(path.join(__dirname, '..', '..', 'app', 'build')))
|
||||
|
||||
// Serve index.html for all other routes (SPA)
|
||||
app.use((req: Request, res: Response) => {
|
||||
res.sendFile(path.join(__dirname, '..', '..', 'app', 'index.html'))
|
||||
})
|
||||
|
||||
// Start server
|
||||
server.listen(PORT, () => {
|
||||
console.log('='.repeat(60))
|
||||
console.log(`MQTT Explorer server running on http://localhost:${PORT}`)
|
||||
console.log('='.repeat(60))
|
||||
})
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGTERM' as any, () => {
|
||||
console.log('SIGTERM received, closing connections...')
|
||||
connectionManager.closeAllConnections()
|
||||
server.close()
|
||||
})
|
||||
|
||||
process.on('SIGINT' as any, () => {
|
||||
console.log('SIGINT received, closing connections...')
|
||||
connectionManager.closeAllConnections()
|
||||
server.close()
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
startServer().catch(error => {
|
||||
console.error('Failed to start server:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -32,7 +32,7 @@ const cleanUp = async (scenes: SceneBuilder, electronApp: ElectronApplication) =
|
||||
await electronApp.close()
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', (error: Error | any) => {
|
||||
process.on('unhandledRejection' as any, (error: Error | any) => {
|
||||
console.error('unhandledRejection', error.message, error.stack)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { clearSearch, searchTree } from './scenarios/searchTree'
|
||||
import { connectTo } from './scenarios/connect'
|
||||
import { reconnect } from './scenarios/reconnect'
|
||||
|
||||
process.on('unhandledRejection', (error: Error | any) => {
|
||||
process.on('unhandledRejection' as any, (error: Error | any) => {
|
||||
console.error('unhandledRejection', error.message, error.stack)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user