SomeQuestionsAsked/src/main/kotlin/nl/voidcorp/sqa/Main.kt

364 lines
13 KiB
Kotlin

package nl.voidcorp.sqa
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.readValue
import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.CallLogging
import io.ktor.features.ContentNegotiation
import io.ktor.html.respondHtml
import io.ktor.http.ContentType
import io.ktor.http.cio.websocket.*
import io.ktor.jackson.jackson
import io.ktor.response.respondRedirect
import io.ktor.response.respondText
import io.ktor.routing.get
import io.ktor.routing.routing
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.websocket.DefaultWebSocketServerSession
import io.ktor.websocket.WebSockets
import io.ktor.websocket.webSocket
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.mapNotNull
import kotlinx.coroutines.withContext
import kotlinx.html.*
import org.slf4j.event.Level
import redis.clients.jedis.JedisPool
import redis.clients.jedis.Protocol
import java.time.Duration
import java.time.LocalDateTime
/*data class StageCookie(val stage: Int = 0)
fun Application.module() {
install(Sessions) {
header<StageCookie>("stage") {
transform(SessionTransportTransformerMessageAuthentication(SecureRandom.getInstanceStrong().generateSeed(8)))
}
}
routing {
get("/") {
val stage = call.sessions.get<StageCookie>() ?: StageCookie()
call.sessions.set(stage)
}
}
}*/
lateinit var mapper: ObjectMapper
val people = listOf(
"Dany",
"Robbin",
"Laura",
"Jonathan",
"Tim",
"Ricardo",
"Julius",
"Victor"
)
enum class Types {
ask,
answer
}
data class Message(val sender: String, val message: String, val timestamp: LocalDateTime = LocalDateTime.now())
data class RecvMsg(val message: String)
abstract class MessageStore {
private val sockets = mutableMapOf<String, MutableList<DefaultWebSocketServerSession>>()
open fun getSocketsFor(name: String): MutableList<DefaultWebSocketServerSession> {
return sockets.getOrPut(name) { mutableListOf() }
}
open fun setSocketsFor(name: String, list: MutableList<DefaultWebSocketServerSession>) {
sockets[name] = list
}
fun modifySockets(name: String, func: MutableList<DefaultWebSocketServerSession>.() -> Unit) {
val s = getSocketsFor(name)
s.func()
setSocketsFor(name, s)
}
abstract fun addMessageTo(name: String, message: Message)
abstract fun getMessagesFor(name: String): List<Message>
}
class MemoryMessageStore : MessageStore() {
private val l = mutableMapOf<String, MutableList<Message>>()
override fun addMessageTo(name: String, message: Message) {
val msgs = l.getOrPut(name) { mutableListOf() }
msgs += message
l[name] = msgs
}
override fun getMessagesFor(name: String): List<Message> {
return l.getOrPut(name) { mutableListOf() }
}
}
class RedisMessageStore(private val pool: JedisPool) : MessageStore() {
override fun addMessageTo(name: String, message: Message) {
pool.resource.use {
it.rpush(name, mapper.writeValueAsString(message))
}
}
override fun getMessagesFor(name: String): List<Message> {
val r: List<String> = pool.resource.use {
it.lrange(name, 0, -1)
}
return r.map { mapper.readValue<Message>(it) }
}
}
lateinit var mms: MessageStore
fun Application.module() {
install(ContentNegotiation) {
jackson {
this.registerModules(Jdk8Module(), JavaTimeModule())
this.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
mapper = this
}
}
install(WebSockets) {
pingPeriod = Duration.ofSeconds(60)
}
install(CallLogging) {
level = Level.INFO
}
routing {
get("/") {
val missing = call.parameters["missing"] != null
val invalid = call.parameters["invalid"] != null
call.respondHtml {
head {
title("SomeQuestionsAsked")
link("/css.css", "stylesheet")
}
body {
if (missing) {
p {
id = "missing"
+"You were (somehow) missing some parameters, try again..."
}
}
if (invalid) {
p {
id = "invalid"
+"Some parameters were invalid, try again"
}
}
form("/ask") {
label {
htmlFor = "name"
+"Who: "
}
select {
id = "name"
name = "name"
people.forEach {
option {
value = it
+it
}
}
}
div {
radioInput {
name = "type"
value = "ask"
checked = true
id = "askradio"
}
label {
htmlFor = "askradio"
+"Ask Questions"
}
br
radioInput {
name = "type"
value = "answer"
id = "answerradio"
}
label {
htmlFor = "answerradio"
+"Answer Questions"
}
}
button {
type = ButtonType.submit
+"Go!"
}
}
}
}
}
get("/ask") {
val who = call.parameters["name"]
val what = Types.values().find { it.name == call.parameters["type"] }
if (who == null) {
call.respondRedirect("/?missing=true")
return@get
}
if (who !in people || what == null) {
call.respondRedirect("/?invalid=true")
return@get
}
call.respondHtml {
head {
title("${what.name.capitalize()}ing questions: $who")
link("/css.css", "stylesheet")
}
body {
div {
id = "send"
input {
id = "messageText"
}
button {
onClick = "sendMessage()"
+"Send!"
}
}
div {
id = "messages"
}
script {
val wsurl = baseURL.replaceFirst("http", "ws") + "/ws?name=$who&type=${what.name}"
unsafe {
//language=JavaScript
+"""
let ws = new WebSocket("$wsurl");
ws.onmessage = function(event) {
let wsd = JSON.parse(event.data);
if (Array.isArray(wsd)){
wsd.forEach(e=>handleMessage(e))
}else{
handleMessage(wsd)
}
};
String.prototype.isEmpty = function() {
return (this.length === 0 || !this.trim());
};
document.getElementById("messageText").addEventListener("keydown", function (e) {
if (e.key === "Enter") { sendMessage(); }
}, true);
function sendMessage() {
let inp = document.getElementById("messageText").value;
if (inp.isEmpty()) return;
ws.send(JSON.stringify({message: inp}));
document.getElementById("messageText").value = "";
}
function handleMessage(wsd){
let sender = wsd.sender;
let message = wsd.message;
let dt = wsd.timestamp;
let outerP = document.createElement("P");
let senderS = document.createElement("SPAN");
let messageS = document.createElement("SPAN");
senderS.innerText = sender+": ";
messageS.innerText = message;
outerP.title = new Date(dt).toString();
outerP.appendChild(senderS);
outerP.appendChild(messageS);
document.getElementById("messages").appendChild(outerP);
}
""".trimIndent()
}
}
}
}
}
webSocket("/ws") {
val who = call.parameters["name"]
val what = Types.values().find { it.name == call.parameters["type"] }
if (who == null) {
this.close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Missing name and type parameters..."))
return@webSocket
}
if (who !in people || what == null) {
this.close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Missing name and type parameters..."))
return@webSocket
}
mms.modifySockets(who) {
add(this@webSocket)
}
withContext(Dispatchers.IO) {
send(mapper.writeValueAsString(mms.getMessagesFor(who)))
}
for (frame in this.incoming.mapNotNull { it as? Frame.Text }) {
try {
val msg: RecvMsg = mapper.readValue(frame.readText())
println(msg)
val send = Message(what.let { if (it == Types.ask) who else "someone" }, msg.message)
mms.addMessageTo(who, send)
mms.getSocketsFor(who).forEach {
it.send(mapper.writeValueAsString(send))
}
} catch (e: Exception) {
//do nothing, we just ignore wrong messages...
}
}
mms.modifySockets(who) {
remove(this@webSocket)
}
}
get("/css.css") {
call.respondText(ContentType.Text.CSS) {
//language=CSS
"""
@import url('https://fonts.googleapis.com/css?family=Roboto&display=swap');
* {
font-family: 'Roboto', sans-serif !important;
}
""".trimIndent()
}
}
}
}
val baseURL = System.getenv("SQA_BASE_URL") ?: "http://localhost:8080"
fun main() {
val host: String? = System.getenv("SQA_REDIS_HOST")
val port: Int? = System.getenv("SQA_REDIS_PORT")?.toIntOrNull()
mms = if (host != null) {
RedisMessageStore(JedisPool(host, port ?: Protocol.DEFAULT_PORT))
} else {
MemoryMessageStore()
}
embeddedServer(Netty, port = 8080, module = Application::module).start(true)
}