diff --git a/package.json b/package.json index 3b53e36..43e6388 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ }, "homepage": "https://github.com/amikhalev/sprinklers3#readme", "dependencies": { + "bcrypt": "^2.0.1", + "body-parser": "^1.18.3", "chalk": "^2.4.1", "express": "^4.16.3", "express-pino-logger": "^3.0.2", @@ -51,6 +53,7 @@ }, "devDependencies": { "@types/async": "^2.0.49", + "@types/bcrypt": "^2.0.0", "@types/classnames": "^2.2.4", "@types/core-js": "^2.5.0", "@types/express": "^4.16.0", diff --git a/server/app/index.ts b/server/app/index.ts deleted file mode 100644 index aeac4ce..0000000 --- a/server/app/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as express from "express"; - -import * as schema from "@common/sprinklers/schema"; -import {serialize} from "serializr"; -import { ServerState } from "../state"; -import logger from "./logger"; -import serveApp from "./serveApp"; - -export function createApp(state: ServerState) { - const app = express(); - - app.use(logger); - - app.get("/api/grinklers", (req, res) => { - const j = serialize(schema.sprinklersDevice, state.device); - res.send(j); - }); - - serveApp(app); - - return app; -} diff --git a/server/express/index.ts b/server/express/index.ts new file mode 100644 index 0000000..8a26010 --- /dev/null +++ b/server/express/index.ts @@ -0,0 +1,66 @@ +import * as bodyParser from "body-parser"; +import * as express from "express"; +import { serialize, serializeAll } from "serializr"; + +import * as schema from "@common/sprinklers/schema"; +import { ServerState } from "../state"; +import logger from "./logger"; +import serveApp from "./serveApp"; + +import log from "@common/logger"; + +import { User } from "../models/User"; + +export function createApp(state: ServerState) { + const app = express(); + + app.use(logger); + app.use(bodyParser.json()); + + app.get("/api/grinklers", (req, res) => { + const j = serialize(schema.sprinklersDevice, state.device); + res.send(j); + }); + + app.get("/api/users", (req, res) => { + User.loadAll(state.database) + .then((users) => { + res.json({ + data: users, + }); + }); + }); + + app.get("/api/users/:username", (req, res, next) => { + User.loadByUsername(state.database, req.params.username) + .then((user) => { + res.json({ + data: user, + }); + }) + .catch(next); + }); + + app.post("/api/authenticate", (req, res, next) => { + const body = req.body; + log.info({body}, "/api/authenticate: " + req.body); + if (!body || !body.username || !body.password) { + return next(new Error("must specify username and password")); + } + User.loadByUsername(state.database, body.username) + .then((user) => { + if (!user) { + throw new Error("user does not exist"); + } + return user.comparePassword(body.password); + }).then((passwordMatches) => { + res.json({ + passwordMatches, + }); + }).catch(next); + }); + + serveApp(app); + + return app; +} diff --git a/server/app/logger.ts b/server/express/logger.ts similarity index 100% rename from server/app/logger.ts rename to server/express/logger.ts diff --git a/server/app/serveApp.ts b/server/express/serveApp.ts similarity index 68% rename from server/app/serveApp.ts rename to server/express/serveApp.ts index fd8b111..3ef546c 100644 --- a/server/app/serveApp.ts +++ b/server/express/serveApp.ts @@ -1,11 +1,14 @@ import { Express } from "express"; +import * as path from "path"; import * as serveStatic from "serve-static"; import * as paths from "paths"; +const index = path.join(paths.publicDir, "index.html"); + export default function serveApp(app: Express) { app.use(serveStatic(paths.appBuildDir)); app.get("/*", (req, res) => { - res.sendFile(path.join(paths.publicDir, "index.html")); + res.sendFile(index); }); } diff --git a/server/index.ts b/server/index.ts index 5438381..e83b5d6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -8,7 +8,7 @@ import {Server} from "http"; import * as WebSocket from "ws"; import { ServerState } from "./state"; -import { createApp } from "./app"; +import { createApp } from "./express"; import { WebSocketApi } from "./websocket"; const state = new ServerState(); @@ -20,9 +20,14 @@ const host = process.env.HOST || "0.0.0.0"; const server = new Server(app); const webSocketServer = new WebSocket.Server({server}); -webSocketServer.on("connection", webSocketApi.handleConnection); +webSocketApi.listen(webSocketServer); -state.start(); -server.listen(port, host, () => { - log.info(`listening at ${host}:${port}`); -}); +state.start() + .then(() => { + server.listen(port, host, () => { + log.info(`listening at ${host}:${port}`); + }); + }) + .catch((err) => { + log.error({ err }, "error starting server"); + }); diff --git a/server/models/Database.ts b/server/models/Database.ts new file mode 100644 index 0000000..fa5520a --- /dev/null +++ b/server/models/Database.ts @@ -0,0 +1,57 @@ +import * as r from "rethinkdb"; + +import { User } from "./User"; +import logger from "@common/logger"; + +export class Database { + static readonly databaseName = "sprinklers3"; + + db: r.Db; + private _conn: r.Connection | null = null; + + get conn(): r.Connection { + if (this._conn == null) { + throw new Error("Not connected to rethinkDB"); + } + return this._conn; + } + + constructor() { + this.db = r.db(Database.databaseName); + } + + async connect() { + this._conn = await r.connect("localhost"); + } + + async disconnect() { + if (this._conn) { + return this._conn.close(); + } + } + + async createAll() { + const dbs = await r.dbList().run(this.conn); + if (dbs.indexOf(Database.databaseName) === -1) { + await r.dbCreate(Database.databaseName).run(this.conn); + } + await this.createTables(); + } + + async createTables() { + const tables = await this.db.tableList().run(this.conn); + if (tables.indexOf(User.tableName) === -1) { + await User.createTable(this); + } + const alex = new User(this, { + name: "Alex Mikhalev", + username: "alex", + }); + await alex.setPassword("kakashka"); + const created = await alex.createOrUpdate(); + logger.info((created ? "created" : "updated") + " user alex"); + + const alex2 = await User.loadByUsername(this, "alex"); + logger.info("password valid: " + await alex2!.comparePassword("kakashka")); + } +} \ No newline at end of file diff --git a/server/models/User.ts b/server/models/User.ts new file mode 100644 index 0000000..ec9db21 --- /dev/null +++ b/server/models/User.ts @@ -0,0 +1,105 @@ +import * as r from "rethinkdb"; +import { createModelSchema, deserialize, primitive, serialize, update } from "serializr"; +import { Database } from "./Database"; +import * as bcrypt from "bcrypt"; + +export interface IUser { + id: string | undefined; + username: string; + name: string; + passwordHash: string; +} + +const HASH_ROUNDS = 10; + +export class User implements IUser { + static readonly tableName = "users"; + + id: string | undefined; + username: string = ""; + name: string = ""; + passwordHash: string = ""; + + private db: Database; + + private get _db() { + return this.db.db; + } + + private get table() { + return this.db.db.table(User.tableName); + } + + constructor(db: Database, data?: Partial) { + this.db = db; + if (data) { + update(this, data); + } + } + + static async createTable(db: Database) { + await db.db.tableCreate(User.tableName).run(db.conn); + await db.db.table(User.tableName).indexCreate("username").run(db.conn); + } + + static async loadAll(db: Database): Promise { + const cursor = await db.db.table(User.tableName) + .run(db.conn); + const users = await cursor.toArray(); + return users.map((data) => { + return new User(db, data); + }); + } + + static async loadByUsername(db: Database, username: string): Promise { + const seq = await db.db.table(User.tableName) + .filter(r.row("username").eq(username)) + .run(db.conn); + const data = await seq.toArray(); + if (data.length === 0) { + return null; + } + return new User(db, data[0]); + } + + async create() { + const data = serialize(this); + delete data.id; + const a = this.table + .insert(data) + .run(this.db.conn); + } + + async createOrUpdate() { + const data = serialize(this); + delete data.id; + const user = this.table.filter(r.row("username").eq(this.username)); + const usernameDoesNotExist = user.isEmpty(); + const a: r.WriteResult = await r.branch(usernameDoesNotExist, + this.table.insert(data) as r.Expression, + user.nth(0).update(data) as r.Expression) + .run(this.db.conn); + return a.inserted > 0; + } + + async setPassword(newPassword: string): Promise { + this.passwordHash = await bcrypt.hash(newPassword, HASH_ROUNDS); + } + + async comparePassword(password: string): Promise { + return bcrypt.compare(password, this.passwordHash); + } + + toJSON(): any { + const data = serialize(this); + delete data.passwordHash; + return data; + } +} + +createModelSchema(User, { + id: primitive(), + username: primitive(), + name: primitive(), + passwordHash: primitive(), +}); diff --git a/server/state.ts b/server/state.ts index 38ab1f6..82a0f4a 100644 --- a/server/state.ts +++ b/server/state.ts @@ -1,17 +1,27 @@ +import logger from "@common/logger"; import {SprinklersDevice} from "@common/sprinklers"; import * as mqtt from "@common/sprinklers/mqtt"; +import { Database } from "./models/Database"; export class ServerState { - mqttClient!: mqtt.MqttApiClient; - device!: SprinklersDevice; + mqttClient: mqtt.MqttApiClient; + device: SprinklersDevice; + database: Database; - start() { + constructor() { const mqttUrl = process.env.MQTT_URL; if (!mqttUrl) { throw new Error("Must specify a MQTT_URL to connect to"); } this.mqttClient = new mqtt.MqttApiClient(mqttUrl); this.device = this.mqttClient.getDevice("grinklers"); + this.database = new Database(); + } + + async start() { + await this.database.connect(); + await this.database.createAll(); + logger.info("created database and tables"); this.mqttClient.start(); } diff --git a/server/websocket/index.ts b/server/websocket/index.ts index c4fdda7..d9b6d7a 100644 --- a/server/websocket/index.ts +++ b/server/websocket/index.ts @@ -14,6 +14,10 @@ export class WebSocketApi { this.state = state; } + listen(webSocketServer: WebSocket.Server) { + webSocketServer.on("connection", this.handleConnection); + } + handleConnection = (socket: WebSocket) => { const disposers = [ autorun(() => { diff --git a/yarn.lock b/yarn.lock index 18f900a..fc5ccc5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27,6 +27,10 @@ version "2.0.49" resolved "https://registry.yarnpkg.com/@types/async/-/async-2.0.49.tgz#92e33d13f74c895cb9a7f38ba97db8431ed14bc0" +"@types/bcrypt@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/bcrypt/-/bcrypt-2.0.0.tgz#74cccef82026341fd786cf2eb9c912c7f9107c55" + "@types/body-parser@*": version "1.17.0" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.17.0.tgz#9f5c9d9bd04bb54be32d5eb9fc0d8c974e6cf58c" @@ -655,6 +659,13 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +bcrypt@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-2.0.1.tgz#229c5afe09379789f918efe86e5e5b682e509f85" + dependencies: + nan "2.10.0" + node-pre-gyp "0.9.1" + better-assert@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522" @@ -725,6 +736,21 @@ body-parser@1.18.2: raw-body "2.3.2" type-is "~1.6.15" +body-parser@^1.18.3: + version "1.18.3" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.3.tgz#5b292198ffdd553b3a0f20ded0592b956955c8b4" + dependencies: + bytes "3.0.0" + content-type "~1.0.4" + debug "2.6.9" + depd "~1.1.2" + http-errors "~1.6.3" + iconv-lite "0.4.23" + on-finished "~2.3.0" + qs "6.5.2" + raw-body "2.3.3" + type-is "~1.6.16" + bonjour@^3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/bonjour/-/bonjour-3.5.0.tgz#8e890a183d8ee9a2393b3844c691a42bcf7bc9f5" @@ -2896,7 +2922,7 @@ http-errors@1.6.2: setprototypeof "1.0.3" statuses ">= 1.3.1 < 2" -http-errors@~1.6.2: +http-errors@1.6.3, http-errors@~1.6.2, http-errors@~1.6.3: version "1.6.3" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" dependencies: @@ -2942,7 +2968,7 @@ iconv-lite@0.4.19: version "0.4.19" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" -iconv-lite@^0.4.17, iconv-lite@^0.4.22, iconv-lite@^0.4.4, iconv-lite@~0.4.13: +iconv-lite@0.4.23, iconv-lite@^0.4.17, iconv-lite@^0.4.22, iconv-lite@^0.4.4, iconv-lite@~0.4.13: version "0.4.23" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63" dependencies: @@ -4108,7 +4134,7 @@ mute-stream@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" -nan@^2.10.0, nan@^2.9.2: +nan@2.10.0, nan@^2.10.0, nan@^2.9.2: version "2.10.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f" @@ -4215,6 +4241,21 @@ node-libs-browser@^2.0.0: util "^0.10.3" vm-browserify "0.0.4" +node-pre-gyp@0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.9.1.tgz#f11c07516dd92f87199dbc7e1838eab7cd56c9e0" + dependencies: + detect-libc "^1.0.2" + mkdirp "^0.5.1" + needle "^2.2.0" + nopt "^4.0.1" + npm-packlist "^1.1.6" + npmlog "^4.0.2" + rc "^1.1.7" + rimraf "^2.6.1" + semver "^5.3.0" + tar "^4" + node-pre-gyp@^0.10.0: version "0.10.2" resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.2.tgz#e8945c20ef6795a20aac2b44f036eb13cf5146e3" @@ -5431,6 +5472,10 @@ qs@6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" +qs@6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + qs@~6.3.0: version "6.3.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.2.tgz#e75bd5f6e268122a2a0e0bda630b2550c166502c" @@ -5498,7 +5543,16 @@ raw-body@2.3.2: iconv-lite "0.4.19" unpipe "1.0.0" -rc@^1.0.1, rc@^1.1.6, rc@^1.2.7: +raw-body@2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.3.tgz#1b324ece6b5706e153855bc1148c65bb7f6ea0c3" + dependencies: + bytes "3.0.0" + http-errors "1.6.3" + iconv-lite "0.4.23" + unpipe "1.0.0" + +rc@^1.0.1, rc@^1.1.6, rc@^1.1.7, rc@^1.2.7: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" dependencies: