working http server
This commit is contained in:
parent
754275d7b2
commit
abafed20e1
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -31,3 +31,4 @@ bin/
|
|||
### Mac OS ###
|
||||
.DS_Store
|
||||
/.idea/misc.xml
|
||||
/userlist.txt
|
||||
|
|
|
|||
10
src/main/kotlin/dev/asdf00/visionfive/hc/GlobalDataStore.kt
Normal file
10
src/main/kotlin/dev/asdf00/visionfive/hc/GlobalDataStore.kt
Normal file
|
|
@ -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<String>()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package dev.asdf00.visionfive.hc
|
||||
package dev.asdf00.visionfive.hc.gpio
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
|
|
@ -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<String, PwdHash> {
|
||||
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<String, String>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
157
src/main/kotlin/dev/asdf00/visionfive/hc/server/Main.kt
Normal file
157
src/main/kotlin/dev/asdf00/visionfive/hc/server/Main.kt
Normal file
|
|
@ -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, """
|
||||
<html>
|
||||
<head>
|
||||
<title>VFHeatControl Landing Page</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to HeatControl</h1>
|
||||
<p>You are logged in as ${uname}. To log out, click <a href="/login/out">here</a>.</p>
|
||||
<br />
|
||||
<p>
|
||||
The current recorded temperature is currently ${String.format("%.1f",GlobalDataStore.temperature)} C
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent().uft8()
|
||||
//@formatter:on
|
||||
)
|
||||
} ?: run {
|
||||
// logged out
|
||||
exchange.sendReply(
|
||||
200, ContentType.HTML, """
|
||||
<html>
|
||||
<head>
|
||||
<title>VFHeatControl Landing Page</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to HeatControl</h1>
|
||||
<p>Please log in to view the current temperature</p>
|
||||
<br />
|
||||
<p>
|
||||
Login Form
|
||||
<br />
|
||||
<form action="/login/in" method="post">
|
||||
<label for="uname">user name:</label><br />
|
||||
<input type="text" id="uname" name="uname"><br />
|
||||
<label for="pwd">password:</label><br />
|
||||
<input type="password" id="pwd" name="pwd"><br />
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
""".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,
|
||||
"<meta http-equiv=\"refresh\" content=\"0; url=/\">".uft8()
|
||||
)
|
||||
} ?: run {
|
||||
exchange.sendReply(
|
||||
400,
|
||||
ContentType.HTML,
|
||||
"<meta http-equiv=\"refresh\" content=\"0; url=/\">".uft8()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
endpoint("out") { exchange ->
|
||||
val token = getLoginToken(exchange)
|
||||
if (LoginBehavior.logout(token)) {
|
||||
exchange.responseHeaders.add("Set-Cookie", "auth=; Path=/")
|
||||
exchange.sendReply(
|
||||
200,
|
||||
ContentType.HTML,
|
||||
"<meta http-equiv=\"refresh\" content=\"0; url=/\">".uft8()
|
||||
)
|
||||
} else {
|
||||
exchange.sendReply(
|
||||
400,
|
||||
ContentType.HTML,
|
||||
"<meta http-equiv=\"refresh\" content=\"0; url=/\">".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 "" } }
|
||||
?: ""
|
||||
|
|
@ -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<Endpoint>()
|
||||
internal val innerPaths = mutableListOf<WebServerBuilder>()
|
||||
internal val usedPaths = mutableSetOf<String>()
|
||||
|
||||
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, "<img src=\"https://http.cat/${code}\" />".uft8())
|
||||
}
|
||||
11
src/test/kotlin/Sender.kt
Normal file
11
src/test/kotlin/Sender.kt
Normal file
|
|
@ -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()}")
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user