UNPKG

homebridge-smartsystem

Version:

SmartServer (Proxy Websockets to TCP sockets, Smappee MQTT, Duotecno IP Nodes, Homekit interface)

527 lines (450 loc) 17.4 kB
// Generic Webapp implementation // Johan Coppieters, Feb-Apr 2019. // import * as ejs from "ejs"; import * as http from "http"; import * as fs from "fs"; import * as url from "url"; import * as qs from "querystring"; import { Base } from './base'; import * as types from "../duotecno/types"; import * as logger from "../duotecno/logger"; import { log } from "../duotecno/logger"; import { exec } from "child_process"; export interface Params {[key: string]: string | Array<string>}; export interface Spec { name: string; type?: "string" | "integer" | "strings" | "integers"; default?: string | number | Array<string> | Array<number> } // Dynamic type mapping. Thanks to Ruben type TypeMap = { 'integer': number; 'integers': Array<number>; 'string': string; 'strings': Array<string>; }; export type SpecType<T extends Spec> = TypeMap[T["type"]]; // output for a web request export interface HttpResponse { status: number; // http status 200, 404, ... type: string; // mime type data: string | Buffer; header?: {[key: string]: string | number}; } // use to store the entries in our hashmap export interface WebAppFile { content: string; type: string; // "text/html", "text/css", "application/javascript", ... filename: string; } const kLogoutHeader = {"Set-Cookie": "ticket=x" }; ////////////////////// // Helper functions // ////////////////////// // move most of them to types.ts export function single(val: string | Array<string>): string { return (val instanceof Array) ? val[0] : val; } export const kVersion = require("../package.json").version; console.log("stolen version: " + kVersion); /////////////////// // Context Class // /////////////////// export class Context { request: string; // second part of URL or through urlparams/body method: string; // post, get, put, ... action: string; // first part of URL id: string; // could be number, but let's be open... params: Params; // body and urlparams path: string; // the url path part cookies: {[name: string]: string}; // parsed cookies nums: number[]; // the URL in number if they exist req: http.IncomingMessage; // from http request res: http.ServerResponse; // from http request jsonrequest: boolean; // from http request message = ""; // display if not empty today: Date; year: number; version: string; time = types.time; hex = types.hex; watt = types.watt; date = types.date; datetime = types.datetime; makeInt = types.makeInt; constructor(body: string, req: http.IncomingMessage, res: http.ServerResponse) { const bodyParams = qs.parse(body) const urlParts = url.parse(req.url, true); this.params = {...bodyParams, ...urlParts.query}; // url = /[request]/[action]/[id] let path = this.path = urlParts.pathname; if (path[0] === "/") path = path.substring(1); const parts = path.split("/"); // get action and id from the params or if not specified from the url when available this.action = single(this.params.action) || ((parts.length > 1) ? parts[1] : "") || single(this.params.daction); this.id = single(this.params.id) || ((parts.length > 2) ? parts[2] : ""); // some easy to use stuff this.method = req.method; this.request = parts[0]; this.req = req; this.res = res; this.parseCookies(); // can be used for sending different answers this.jsonrequest = !!(req.headers['content-type']?.toLowerCase().indexOf('application/json')> -1); // helpers for ejs this.today = new Date(); this.year = this.today.getFullYear(); this.version = kVersion; this.nums = [0,0,0]; // check short urls with numbers a parts const p1 = parseInt(this.request); if (! isNaN(p1)) this.nums[0] = p1; const p2 = parseInt(this.action); if (! isNaN(p2)) this.nums[1] = p2; const p3 = parseInt(this.id); if (! isNaN(p3)) this.nums[2] = p3; } parseCookies () { this.cookies = {}; const cookieHeader = this.req.headers?.cookie; if (!cookieHeader) return; cookieHeader.split(`;`).forEach((cookie) => { let [ name, ...rest] = cookie.split(`=`); name = name?.trim(); if (!name) return; const value = rest.join(`=`).trim(); if (!value) return; this.cookies[name] = decodeURIComponent(value); }); } getCookie(name: string): string { return this.cookies[name]; } addr(a: string): {logicalNodeAddress: number, logicalAddress: number } { const parts = a.split(";"); return { logicalNodeAddress: parseInt(parts[0], 16), logicalAddress: parseInt((parts.length > 1) ? parts[1] : "0", 16) }; } parseAddress(suggestion): boolean { if (suggestion) { const parts = suggestion.split(":"); if (parts.length > 1) { this["masterAddress"] = parts[0]; this["masterPort"] = parseInt(parts[1]); return true; } } return false; } getMaster(tryName: string, newAction?: string) { const suggestion = this[tryName] || <string>this.params[tryName]; // try "in 1 variable" in the "action" part of the URL if (this.parseAddress(suggestion)) { // replace action if one was suggested if (newAction) this.action = newAction; } else { const master = (this.params["masterAddress"]) ? this.getParam({name: "masterAddress", type: "string"}) : this.getParam({name: "master", type: "string"}); // try "in 1 variable" in the "masterAddress" or "master" if (! this.parseAddress(master)) { // old fashion way -> in 2 variables this["masterAddress"] = master; this["masterPort"] = (this.params["masterPort"]) ? this.getParam({name: "masterPort", type: "integer", default: 5001}) : this.getParam({name: "port", type: "integer", default: 5001}) } } } getUnit(): {logicalNodeAddress: number, logicalAddress: number } { const unitStr = this.getParam({name: "unit", type: "string"}); if (unitStr.indexOf(";") > 0) { // unit=0x23;0x12 return this.addr(unitStr); } else { // node=0x23, unit=0x12 or node=35, unit=17 return { logicalNodeAddress: this.getParam({name: "node", type: "integer"}), logicalAddress: this.getParam({name: "unit", type: "integer"}) }; } } getParam<T extends Spec>(spec: T): any /* SpecType<T> */ { // infer a type if non is given if (typeof spec.type === "undefined") { if (typeof spec.default === "number") spec.type = "integer"; else if (typeof spec.default === "string") spec.type = "string"; else if (spec.default instanceof Array) { if (spec.default.length > 0) spec.type = (typeof spec.default[0] === "number") ? "integers" : "strings"; else spec.type = "string"; } else spec.type = "string"; } let val = this.params[spec.name]; if (spec.type === "integer") { let num = this.makeInt(single(val)); return (isNaN(num) ? spec.default : num); } else if ((typeof val === "undefined") || (val === null)) { return spec.default; } else if (spec.type === "string") { return single(val); } else if (spec.type === "integers") { if (val instanceof Array) return val.map(s => parseInt(s)); else { let num = this.makeInt(single(val)); return (isNaN(num) ? spec.default : [num]); } } else if (spec.type === "strings") { if (val instanceof Array) return val; else { return [val]; } } else { // no type nor default specified return val; } } } /* add later: https -> let webServer: http.Server | https.Server; try { // try https const cert = fs.readFileSync('ssh.cert'); const key = fs.readFileSync('ssh.key'); webServer = https.createServer({cert, key}, http_request); log("server", " - Running in encrypted HTTPS (wss://) mode using: ssh.cert, ssh.key"); } catch(e) { try { // fallback to http webServer = http.createServer(http_request); log("server", " - Running in unencrypted HTTP (ws://) mode"); } catch(e) { // give up err("server", " - Can't start up: " + e.message); } */ export class WebApp extends Base { files: {[filename: string]: WebAppFile}; // hashmap for serving files, including ejs files for rendering server: http.Server; port: number; token = ""; // "$#@!" + new Date().getTime() + "!@#$"; // should never match user = ""; password = ""; constructor(type: string) { super(type); this.port = 80; log("server", "Creating http server"); this.files = {}; this.server = http.createServer( (req: http.IncomingMessage, res: http.ServerResponse) => { this.doServe(req, res).then( result => { res.writeHead(result.status, {'Content-Type': result.type || 'text/html', ...result.header}); res.write(result.data); res.end(); } ).catch( reason => { res.writeHead(501, {'Content-Type': 'text/html'}); res.write(reason.toString()); res.end(); } ); }); } serve(onConnected?: () => void) { log("server", "WebApp - Start serving http on port " + this.port); this.server.listen(this.port, onConnected); } addFile(name: string, filename: string, type?: string, enc?: BufferEncoding) { this.addContent(name, fs.readFileSync(filename, {encoding: enc || 'utf-8'}), type || "text/html", filename); } addContent(name: string, content: string, type: string, filename: string) { this.files[name] = { content, type, filename }; } getFile(name: string): WebAppFile { return this.files[name]; } image(filename: string, type?: string): HttpResponse { type = type || "image/jpeg"; if (type.indexOf("/") < 0) type = "image/" + type; const data = fs.readFileSync(filename); return { status: 200, type: type, data } } file(name: string): HttpResponse { const file = this.getFile(name); return { status: 200, data: file.content, type: file.type }; } html(html: string): HttpResponse { return { status: 200, data: html, type: "text/html" } } ejs(name: string, context: Context, objects: object, header = {}): HttpResponse { // types.debug("webapp", context); !! circular ref in socket/http // try to get cached file let file = this.getFile(name); // for non cached files if (!file) { try { const filename = __dirname + "/views/" + name; file = { content: fs.readFileSync(filename, {encoding: 'utf-8'}), type: "text/html", filename}; } catch(e) { return this.error(context, e.toString()); } } if (file) { // copy objects into context for (const key in objects) context[key] = objects[key]; try { const html = ejs.render(file.content, context, { root: __dirname, filename: file.filename }); return { status: 200, type: file.type, data: html, header }; } catch(e) { return this.error(context, e.toString()); } } else { return this.notFound(); } } json(objects: object): HttpResponse { return { status: 200, data: JSON.stringify(objects), type: "application/json" }; } needsLogin(context: Context): boolean { return (context.request != "login"); } async doServe(req: http.IncomingMessage, res: http.ServerResponse): Promise<HttpResponse> { const context = await this.parseRequest(req, res); let prevRequest = ""; try { if (this.needsLogin(context)) { logger.debug("webapp", "checking ticket: " + context.cookies["DTicket"] + " = " + this.token + " for " + context.request+"/"+context.action + " => " + (context.cookies["DTicket"] == this.token)); // if a password is set, check if the tickets is valid, if not: redirect to login page if (!this.token || (context.cookies["DTicket"] != this.token)) { context.request = "login"; context.action = ""; } } const httpResult = await this.doRequest(context); // see if we can go to the original request... if ((context.request === "login") && (context.path != "/login")) { httpResult.header = {...httpResult.header, "Set-Cookie": "DPath="+context.path}; } return httpResult; } catch (e) { return this.error(context, e); } } async doRequest(context: Context): Promise<HttpResponse> { if ((context.request === "favicon.ico") || (context.request === "404")) { return this.notFound(); } else if (context.request === "restart") { return this.doRestart(context.jsonrequest); } else if (context.request === "reboot") { this.doRestart(context.jsonrequest); return this.doReboot(context, context.jsonrequest); } else if (context.request === "login") { return this.doLogin(context); } else { return {status: 200, type: "text/html", data: "hello there ! " + context.method + ":" + context.request + "/" + (context.action||"")}; } } notFound(filename = ""): HttpResponse { return { status: 404, type: "text/html", data: "<html><head><title>File " + filename + " not found</title></head><body>These are not the droids your are looking for</body></html>" }; } redirect(url: string, header = {}): HttpResponse { return { status: 303, type: "text/html", header: {"Location": url, ...header}, data: "<html><head><meta http-equiv=\"Refresh\" content=\"0; URL="+url+"\"></head></html>" }; } error(context: Context, msg = "", json = false): HttpResponse { return json ? { status: 202, type: "json/application", data: '{"error": "'+msg+'"}' } : { status: 202, type: "text/html", data: "<html><head><title>Error in " + context.request + "/" + context.action + "</title></head>" + "<body><h1>Error</h1>During " + context.method + " of " + context.request + "/" + context.action + " :</br>" + msg + "</body></html>" }; } async pwOK(context: Context, user: string, pw: string): Promise<boolean> { // to be overriden return true; } async doLogin(context: Context): Promise<HttpResponse> { if (context.action === "logout") { return this.doLogout(context); } else if (context.action === "login") { return this.doTryLogin(context); } else { return this.ejs("login", context, {user: this.user}); } } async doTryLogin(context: Context): Promise<HttpResponse> { const pw = context.getParam({name: "password", type: "string"}); const user = context.getParam({name: "user", type: "string"}); if (await this.pwOK(context, user, pw)) { const path = context.getCookie("DPath") || "/" return this.redirect(path, {"Set-Cookie": "DTicket="+this.token}); } else { return this.ejs("login", context, {message: "authentication failed", user}, {"Set-Cookie": "DTicket=x"}); } } async doLogout(context: Context): Promise<HttpResponse> { this.token = "" return this.redirect("/", {"Set-Cookie": "DTicket=x"}) } doRestart(json: boolean): HttpResponse { // stop the process, pm2 will restart it setTimeout(() => { process.exit(-1) }, 500); if (json) return { status: 200, type: "application/json", data: "Restarting App" }; else return { status: 200, type: "text/html", data: '<html><meta http-equiv="refresh" content="10; url=/">Restarting app... please wait</html>' }; } async doReboot(context: Context, json: boolean): Promise <HttpResponse> { // make sure the nodejs process is sudo-er // 1) create nodesudo << pi // ALL=/sbin/shutdown // pi ALL=NOPASSWD: /sbin/shutdown // 2) add file to sudo // sudo /etc/sudoers.d/mysudoersfile return new Promise((resolve, reject) => { exec("sudo shutdown -r now", (error, stdout, stderr) => { if (error) { // context.message = stderr; // console.log("error shutting down: ", error, stdout, stderr); resolve({ status: 200, type: "text/html", header: kLogoutHeader, data: '<html><meta http-equiv="refresh" content="10; url=/settings"><body><h1>Failed to restart OS</h1><p>' + stderr.replace("\n", "<br>") +'</p></body></html>' }) } else { if (json) resolve({ status: 200, type: "application/json", data: "Rebooting OS" }); else resolve({ status: 200, type: "text/html", data: '<html><meta http-equiv="refresh" content="20; url=/">Restarting OS... please wait</html>' }); } }); }); } async parseRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<Context> { return new Promise((resolve, reject) => { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('error', error => { reject(error) }); req.on('end', () => { resolve(new Context(body, req, res)); }); }); } }