import { autorun } from "mobx"; import { serialize } from "serializr"; import * as WebSocket from "ws"; import { ErrorCode } from "@common/ErrorCode"; import * as rpc from "@common/jsonRpc"; import log from "@common/logger"; import * as deviceRequests from "@common/sprinklersRpc/deviceRequests"; import * as schema from "@common/sprinklersRpc/schema"; import * as ws from "@common/sprinklersRpc/websocketData"; import { TokenClaims, verifyToken } from "../express/authentication"; import { ServerState } from "../state"; // tslint:disable:member-ordering export class WebSocketClient { api: WebSocketApi; socket: WebSocket; disposers: Array<() => void> = []; deviceSubscriptions: string[] = []; /// This shall be the user id if the client has been authenticated, null otherwise userId: number | null = null; get state() { return this.api.state; } constructor(api: WebSocketApi, socket: WebSocket) { this.api = api; this.socket = socket; } start() { this.socket.on("message", this.handleSocketMessage); this.socket.on("close", this.stop); } stop = () => { this.disposers.forEach((disposer) => disposer()); this.api.removeClient(this); } private subscribeBrokerConnection() { this.disposers.push(autorun(() => { const updateData: ws.IBrokerConnectionUpdate = { brokerConnected: this.state.mqttClient.connected, }; this.sendNotification("brokerConnectionUpdate", updateData); })); } private checkAuthorization() { if (!this.userId) { throw new ws.RpcError("this WebSocket session has not been authenticated", ErrorCode.Unauthorized); } } private requestHandlers: ws.ClientRequestHandlers = { authenticate: async (data: ws.IAuthenticateRequest) => { if (!data.accessToken) { throw new ws.RpcError("no token specified", ErrorCode.BadRequest); } let decoded: TokenClaims; try { decoded = await verifyToken(data.accessToken); } catch (e) { throw new ws.RpcError("invalid token", ErrorCode.BadToken, e); } if (decoded.type !== "access") { throw new ws.RpcError("not an access token", ErrorCode.BadToken); } this.userId = decoded.aud; log.info({ userId: decoded.aud, name: decoded.name }, "authenticated websocket client"); this.subscribeBrokerConnection(); return { result: "success", data: { authenticated: true, message: "authenticated" }, }; }, deviceSubscribe: async (data: ws.IDeviceSubscribeRequest) => { this.checkAuthorization(); const userId = this.userId!; const deviceId = data.deviceId; const userDevice = await this.state.database.sprinklersDevices .findUserDevice(userId, deviceId as any); // TODO: should be number device id if (userDevice !== "grinklers") { return { result: "error", error: { code: ErrorCode.NoPermission, message: "you do not have permission to subscribe to this device", }, }; } if (this.deviceSubscriptions.indexOf(deviceId) === -1) { this.deviceSubscriptions.push(deviceId); const device = this.state.mqttClient.getDevice(deviceId); log.debug({ deviceId, userId: this.userId }, "websocket client subscribed to device"); this.disposers.push(autorun(() => { const json = serialize(schema.sprinklersDevice, device); log.trace({ device: json }); const updateData: ws.IDeviceUpdate = { deviceId, data: json }; this.sendNotification("deviceUpdate", updateData); }, { delay: 100 })); } const response: ws.IDeviceSubscribeResponse = { deviceId, }; return { result: "success", data: response }; }, deviceCall: async (data: ws.IDeviceCallRequest) => { this.checkAuthorization(); try { const response = await this.doDeviceCallRequest(data); const resData: ws.IDeviceCallResponse = { data: response, }; return { result: "success", data: resData }; } catch (err) { const e: deviceRequests.ErrorResponseData = err; throw new ws.RpcError(e.message, e.code, e); } }, }; private sendMessage(data: ws.ServerMessage) { this.socket.send(JSON.stringify(data)); } private sendNotification( method: Method, data: ws.IServerNotificationTypes[Method]) { this.sendMessage({ type: "notification", method, data }); } private sendResponse( method: Method, id: number, data: ws.ServerResponseData) { this.sendMessage({ type: "response", method, id, ...data }); } private handleSocketMessage = (socketData: WebSocket.Data) => { this.doHandleSocketMessage(socketData) .catch((err) => { this.onError({ err }, "unhandled error on handling socket message"); }); } private async doHandleSocketMessage(socketData: WebSocket.Data) { if (typeof socketData !== "string") { return this.onError({ type: typeof socketData }, "received invalid socket data type from client", ErrorCode.Parse); } let data: ws.ClientMessage; try { data = JSON.parse(socketData); } catch (err) { return this.onError({ socketData, err }, "received invalid websocket message from client", ErrorCode.Parse); } switch (data.type) { case "request": await this.handleRequest(data); break; default: return this.onError({ data }, "received invalid message type from client", ErrorCode.BadRequest); } } private async handleRequest(request: ws.ClientRequest) { let response: ws.ServerResponseData; try { if (!this.requestHandlers[request.method]) { // noinspection ExceptionCaughtLocallyJS throw new ws.RpcError("received invalid client request method"); } response = await rpc.handleRequest(this.requestHandlers, request); } catch (err) { if (err instanceof ws.RpcError) { log.debug({ err }, "rpc error"); response = { result: "error", error: err.toJSON() }; } else { log.error({ method: request.method, err }, "unhandled error during processing of client request"); response = { result: "error", error: { code: ErrorCode.Internal, message: "unhandled error during processing of client request", data: err.toString(), }, }; } } this.sendResponse(request.method, request.id, response); } private onError(data: any, message: string, code: number = ErrorCode.Internal) { log.error(data, message); const errorData: ws.IError = { code, message, data }; this.sendNotification("error", errorData); } private async doDeviceCallRequest(requestData: ws.IDeviceCallRequest): Promise { const { deviceId, data } = requestData; const device = this.state.mqttClient.getDevice(deviceId); // TODO: authorize the requests const request = schema.requests.deserializeRequest(data); return device.makeRequest(request); } } export class WebSocketApi { state: ServerState; clients: WebSocketClient[] = []; constructor(state: ServerState) { this.state = state; } listen(webSocketServer: WebSocket.Server) { webSocketServer.on("connection", this.handleConnection); } handleConnection = (socket: WebSocket) => { const client = new WebSocketClient(this, socket); client.start(); this.clients.push(client); } removeClient(client: WebSocketClient) { const idx = this.clients.indexOf(client); if (idx !== -1) { this.clients.splice(idx, 1); } } }