homebridge-smartsystem
Version:
SmartServer (Proxy Websockets to TCP sockets, Smappee MQTT, Duotecno IP Nodes, Homekit interface)
527 lines (450 loc) • 17.4 kB
text/typescript
// 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));
});
});
}
}