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 ###
|
### Mac OS ###
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/.idea/misc.xml
|
/.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.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package dev.asdf00.visionfive.hc
|
package dev.asdf00.visionfive.hc.gpio
|
||||||
|
|
||||||
import kotlin.math.abs
|
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