From abafed20e195e27f473b0c4abd66d6fea19a5934 Mon Sep 17 00:00:00 2001
From: 00asdf
Date: Mon, 21 Apr 2025 20:44:52 +0200
Subject: [PATCH] working http server
---
.gitignore | 1 +
.../asdf00/visionfive/hc/GlobalDataStore.kt | 10 ++
.../asdf00/visionfive/hc/{ => gpio}/Gpio.kt | 2 +-
.../visionfive/hc/{ => gpio}/MotorControl.kt | 2 +-
.../visionfive/hc/server/LoginBehavior.kt | 74 +++++++++
.../dev/asdf00/visionfive/hc/server/Main.kt | 157 ++++++++++++++++++
.../visionfive/hc/server/WebServerBuilder.kt | 101 +++++++++++
src/test/kotlin/Sender.kt | 11 ++
8 files changed, 356 insertions(+), 2 deletions(-)
create mode 100644 src/main/kotlin/dev/asdf00/visionfive/hc/GlobalDataStore.kt
rename src/main/kotlin/dev/asdf00/visionfive/hc/{ => gpio}/Gpio.kt (98%)
rename src/main/kotlin/dev/asdf00/visionfive/hc/{ => gpio}/MotorControl.kt (98%)
create mode 100644 src/main/kotlin/dev/asdf00/visionfive/hc/server/LoginBehavior.kt
create mode 100644 src/main/kotlin/dev/asdf00/visionfive/hc/server/Main.kt
create mode 100644 src/main/kotlin/dev/asdf00/visionfive/hc/server/WebServerBuilder.kt
create mode 100644 src/test/kotlin/Sender.kt
diff --git a/.gitignore b/.gitignore
index 59477e2..3d901d4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,4 @@ bin/
### Mac OS ###
.DS_Store
/.idea/misc.xml
+/userlist.txt
diff --git a/src/main/kotlin/dev/asdf00/visionfive/hc/GlobalDataStore.kt b/src/main/kotlin/dev/asdf00/visionfive/hc/GlobalDataStore.kt
new file mode 100644
index 0000000..3eed029
--- /dev/null
+++ b/src/main/kotlin/dev/asdf00/visionfive/hc/GlobalDataStore.kt
@@ -0,0 +1,10 @@
+package dev.asdf00.visionfive.hc
+
+import java.util.*
+import java.util.concurrent.CopyOnWriteArraySet
+
+object GlobalDataStore {
+ @Volatile var temperature = 0.0
+ @Volatile var tempUpdated = Date()
+ val allowedTempUpdates = CopyOnWriteArraySet()
+}
\ No newline at end of file
diff --git a/src/main/kotlin/dev/asdf00/visionfive/hc/Gpio.kt b/src/main/kotlin/dev/asdf00/visionfive/hc/gpio/Gpio.kt
similarity index 98%
rename from src/main/kotlin/dev/asdf00/visionfive/hc/Gpio.kt
rename to src/main/kotlin/dev/asdf00/visionfive/hc/gpio/Gpio.kt
index b9d3bff..802978e 100644
--- a/src/main/kotlin/dev/asdf00/visionfive/hc/Gpio.kt
+++ b/src/main/kotlin/dev/asdf00/visionfive/hc/gpio/Gpio.kt
@@ -1,4 +1,4 @@
-package dev.asdf00.visionfive.hc
+package dev.asdf00.visionfive.hc.gpio
import java.nio.file.Files
import java.nio.file.Path
diff --git a/src/main/kotlin/dev/asdf00/visionfive/hc/MotorControl.kt b/src/main/kotlin/dev/asdf00/visionfive/hc/gpio/MotorControl.kt
similarity index 98%
rename from src/main/kotlin/dev/asdf00/visionfive/hc/MotorControl.kt
rename to src/main/kotlin/dev/asdf00/visionfive/hc/gpio/MotorControl.kt
index 7812ab6..727e2c9 100644
--- a/src/main/kotlin/dev/asdf00/visionfive/hc/MotorControl.kt
+++ b/src/main/kotlin/dev/asdf00/visionfive/hc/gpio/MotorControl.kt
@@ -1,4 +1,4 @@
-package dev.asdf00.visionfive.hc
+package dev.asdf00.visionfive.hc.gpio
import kotlin.math.abs
diff --git a/src/main/kotlin/dev/asdf00/visionfive/hc/server/LoginBehavior.kt b/src/main/kotlin/dev/asdf00/visionfive/hc/server/LoginBehavior.kt
new file mode 100644
index 0000000..464cb76
--- /dev/null
+++ b/src/main/kotlin/dev/asdf00/visionfive/hc/server/LoginBehavior.kt
@@ -0,0 +1,74 @@
+package dev.asdf00.visionfive.hc.server
+
+import java.nio.file.Files
+import java.security.SecureRandom
+import java.util.*
+import javax.crypto.SecretKeyFactory
+import javax.crypto.spec.PBEKeySpec
+import kotlin.io.path.Path
+
+data class PwdHash(val hash: String, val salt: String)
+
+object LoginBehavior {
+ object Users {
+ private val FILE_PATH get() = Path("userlist.txt")
+ private val users = loadFromFile()
+
+ operator fun get(userName: String) = users[userName]
+ operator fun set(userName: String, password: String) {
+ val salt = getRandomBase64(16)
+ users[userName] = PwdHash(hashPassword(password, salt), salt)
+ Files.writeString(FILE_PATH, users.map {
+ "${it.key};${it.value.hash};${it.value.salt}"
+ }.joinToString("\n"))
+ }
+
+ private fun loadFromFile(): MutableMap {
+ if (Files.exists(FILE_PATH)) {
+ return Files.readString(FILE_PATH)
+ .split("\n")
+ .map { it.split(";") }
+ .filter { it.size == 3 }
+ .associateBy({ it[0] }, { PwdHash(it[1], it[2]) })
+ .toMutableMap()
+ } else {
+ Files.writeString(FILE_PATH, "")
+ return mutableMapOf()
+ }
+ }
+
+ fun remove(userName: String) = users.remove(userName) == null
+ }
+
+ private val loggedInUsers = mutableMapOf()
+
+ fun login(userName: String, password: String) = Users[userName]?.let {
+ if (it.hash != hashPassword(password, it.salt))
+ null
+ else {
+ var cookie: String
+ do {
+ cookie = getRandomBase64().replace("=", "_")
+ } while (cookie in loggedInUsers.keys)
+ loggedInUsers[cookie] = userName
+ cookie
+ }
+ }
+
+ fun isLoggedIn(cookie: String) = loggedInUsers[cookie]
+
+ fun logout(cookie: String) = loggedInUsers.remove(cookie) != null
+
+ private fun hashPassword(password: String, salt: String, iterations: Int = 8, keyLength: Int = 256): String {
+ val spec = PBEKeySpec(password.toCharArray(), Base64.getDecoder().decode(salt), iterations, keyLength)
+ val skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
+ val hash = skf.generateSecret(spec).encoded
+ return Base64.getEncoder().encodeToString(hash)
+ }
+
+ private fun getRandomBase64(len: Int = 8): String {
+ val bytes = ByteArray(len)
+ SecureRandom.getInstanceStrong().nextBytes(bytes)
+ return Base64.getEncoder().encodeToString(bytes)
+ }
+}
diff --git a/src/main/kotlin/dev/asdf00/visionfive/hc/server/Main.kt b/src/main/kotlin/dev/asdf00/visionfive/hc/server/Main.kt
new file mode 100644
index 0000000..42e916d
--- /dev/null
+++ b/src/main/kotlin/dev/asdf00/visionfive/hc/server/Main.kt
@@ -0,0 +1,157 @@
+package dev.asdf00.visionfive.hc.server
+
+import com.sun.net.httpserver.HttpExchange
+import dev.asdf00.visionfive.hc.GlobalDataStore
+import java.nio.charset.StandardCharsets
+import java.util.*
+
+fun main() {
+ val server = buildWebServer(8030) {
+ // data collection
+ endpoint("temp") handler@{ exchange ->
+ if (exchange.remoteAddress.address.toString() !in GlobalDataStore.allowedTempUpdates) {
+ exchange.replyCat(403)
+ }
+ exchange.requestURI.query.split("&").forEach {
+ if (it.startsWith("t=")) {
+ it.substring(2).toDoubleOrNull()?.let {
+ GlobalDataStore.temperature = it
+ GlobalDataStore.tempUpdated = Date()
+ exchange.replyCat(200)
+ } ?: run {
+ exchange.replyCat(400)
+ }
+ return@handler
+ }
+ }
+ exchange.replyCat(400)
+ }
+
+ // main landing page
+ endpoint("") { exchange ->
+ exchange.responseHeaders.add("Cache-Control", "no-store")
+ val token = getLoginToken(exchange)
+ LoginBehavior.isLoggedIn(token)?.let { uname ->
+ // logged in
+ exchange.sendReply(
+ //@formatter:off
+ 200, ContentType.HTML, """
+
+
+ VFHeatControl Landing Page
+
+
+ Welcome to HeatControl
+ You are logged in as ${uname}. To log out, click here.
+
+
+ The current recorded temperature is currently ${String.format("%.1f",GlobalDataStore.temperature)} C
+
+
+
+ """.trimIndent().uft8()
+ //@formatter:on
+ )
+ } ?: run {
+ // logged out
+ exchange.sendReply(
+ 200, ContentType.HTML, """
+
+
+ VFHeatControl Landing Page
+
+
+ Welcome to HeatControl
+ Please log in to view the current temperature
+
+
+ Login Form
+
+
+
+
+
+ """.trimIndent().uft8()
+ )
+ }
+ }
+
+ // login logic
+ path("login") {
+ endpoint("in") handler@{ exchange ->
+ if (exchange.requestMethod != "POST") {
+ exchange.sendReply(400, ContentType.PLAIN, "wrong request method".uft8())
+ return@handler
+ }
+ val (userName, pwd) = exchange.requestBody.readAllBytes().utf8().split("&").map {
+ it.split("=").let {
+ if (it.size == 2) it[1] else ""
+ }
+ }.let {
+ if (it.size == 2) Pair(it[0], it[1]) else Pair("", "")
+ }
+ LoginBehavior.login(userName, pwd)?.let {
+ exchange.responseHeaders.add("Set-Cookie", "auth=${it}; Max-Age=600; Path=/")
+ exchange.sendReply(
+ 200,
+ ContentType.HTML,
+ "".uft8()
+ )
+ } ?: run {
+ exchange.sendReply(
+ 400,
+ ContentType.HTML,
+ "".uft8()
+ )
+ }
+ }
+
+ endpoint("out") { exchange ->
+ val token = getLoginToken(exchange)
+ if (LoginBehavior.logout(token)) {
+ exchange.responseHeaders.add("Set-Cookie", "auth=; Path=/")
+ exchange.sendReply(
+ 200,
+ ContentType.HTML,
+ "".uft8()
+ )
+ } else {
+ exchange.sendReply(
+ 400,
+ ContentType.HTML,
+ "".uft8()
+ )
+ }
+ }
+ }
+ }
+
+ server.start()
+ println("HttpServer is running, press CTRL+C to exit")
+ print("IP address of temperature sensor: ")
+ GlobalDataStore.allowedTempUpdates += "/${readln()}"
+ while (!Thread.interrupted()) {
+ println()
+ print("Username to add: ")
+ val userName = readln()
+ print("Password for '$userName': ")
+ val pwd = readln()
+ LoginBehavior.Users[userName] = pwd
+ }
+}
+
+fun String.uft8() = toByteArray(StandardCharsets.UTF_8)
+
+fun ByteArray.utf8() = String(this, StandardCharsets.UTF_8)
+
+private fun getLoginToken(exchange: HttpExchange) =
+ exchange.requestHeaders["Cookie"]
+ ?.let { if (it.size > 0) it[0] else null }
+ ?.let { it.split("=").let { if (it.size == 2) it[1] else "" } }
+ ?: ""
diff --git a/src/main/kotlin/dev/asdf00/visionfive/hc/server/WebServerBuilder.kt b/src/main/kotlin/dev/asdf00/visionfive/hc/server/WebServerBuilder.kt
new file mode 100644
index 0000000..09c1dbf
--- /dev/null
+++ b/src/main/kotlin/dev/asdf00/visionfive/hc/server/WebServerBuilder.kt
@@ -0,0 +1,101 @@
+package dev.asdf00.visionfive.hc.server
+
+import com.sun.net.httpserver.HttpExchange
+import com.sun.net.httpserver.HttpServer
+import java.net.InetSocketAddress
+import java.nio.charset.StandardCharsets
+import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+
+fun buildWebServer(port: Int, rootPath: String = "", executor: Executor = Executors.newSingleThreadExecutor(), bld: WebServerBuilder.() -> Unit): HttpServer {
+ val bdr = WebServerBuilder(rootPath)
+ bdr.bld()
+
+ val server = HttpServer.create(InetSocketAddress(port), 0)
+ server.executor = executor
+
+ fun WebServerBuilder.collect(prevPath: String = "") {
+ val curPath =
+ if (ownPath == "")
+ prevPath
+ else
+ "$prevPath/$ownPath"
+ if (curPath.endsWith("/")) {
+ throw IllegalStateException("Invalid path '$curPath' encountered while building server!")
+ }
+ for (ep in endpoints) {
+ server.createContext("$curPath/${ep.path}", ep.code)
+ }
+ for (inner in innerPaths) {
+ inner.collect(curPath)
+ }
+ }
+
+ bdr.collect()
+ return server
+}
+
+// =====================================================================================================================
+// ServerBuilder
+// =====================================================================================================================
+
+class WebServerBuilder(internal val ownPath: String) {
+ internal val endpoints = mutableListOf()
+ internal val innerPaths = mutableListOf()
+ internal val usedPaths = mutableSetOf()
+
+ fun path(innerPath: String, bld: WebServerBuilder.() -> Unit) {
+ if (!usedPaths.add(innerPath)){
+ throw IllegalStateException("'$innerPath' already exists within '$ownPath'!")
+ }
+ val bdr = WebServerBuilder(innerPath)
+ bdr.bld()
+ innerPaths.add(bdr)
+ }
+
+ fun endpoint(location: String, code: (HttpExchange) -> Unit) {
+ if (!usedPaths.add(location)) {
+ throw IllegalStateException("'$location' already exists within '$ownPath'!")
+ }
+ usedPaths.add(location)
+ endpoints.add(Endpoint(location, code))
+ }
+
+ fun String.endpoint(location: String, type: ContentType = ContentType.HTML, rCode: Int = 200) {
+ endpoint(location) {
+ it.sendReply(rCode, type, toByteArray(StandardCharsets.UTF_8))
+ }
+ }
+
+}
+
+// =====================================================================================================================
+// Helper Types
+// =====================================================================================================================
+
+enum class ContentType(val text: String) {
+ PLAIN("text/plain"),
+ HTML("text/html"),
+ JS("application/javascript"),
+ JSON("application/json")
+}
+
+data class Endpoint(
+ val path: String,
+ val code: (HttpExchange) -> Unit)
+
+// =====================================================================================================================
+// Helper Methods
+// =====================================================================================================================
+
+fun HttpExchange.sendReply(code: Int, type: ContentType, data: ByteArray) {
+ responseHeaders.add("Content-type", type.text)
+ sendResponseHeaders(code, data.size.toLong())
+ responseBody.use {
+ it.write(data)
+ }
+}
+
+fun HttpExchange.replyCat(code: Int) {
+ this.sendReply(code, ContentType.HTML, "
".uft8())
+}
diff --git a/src/test/kotlin/Sender.kt b/src/test/kotlin/Sender.kt
new file mode 100644
index 0000000..eb68f77
--- /dev/null
+++ b/src/test/kotlin/Sender.kt
@@ -0,0 +1,11 @@
+import java.net.URI
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+
+fun main() {
+ val client = HttpClient.newHttpClient()
+ // val req = HttpRequest.newBuilder(URI.create("http://192.168.178.71:8030/temp?t=34.5")).GET().build()
+ val req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:8030/temp?t=34.5")).GET().build()
+ println("response: ${client.send(req, HttpResponse.BodyHandlers.ofString()).statusCode()}")
+}
\ No newline at end of file