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

368 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.URLProtocol
import io.ktor.http.cio.websocket.*
import io.ktor.http.isSecure
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.util.url
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.net.URI
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 = call.url {
protocol = if (protocol.isSecure()) URLProtocol.WSS else URLProtocol.WS
path("/ws")
}
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())
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()
}
}
}
}
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)
}