commit 09794b1ef5e8f95168258fc6121266a5aba8e31d Author: Julius de Jeu Date: Wed Dec 11 18:58:50 2019 +0100 Initial Commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7c8ca3b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +build/ +.idea/ +.gradle/ +Dockerfile +.dockerignore \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09bdf3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/ +.idea/ +.gradle/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..a8afcf4 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,26 @@ +image: docker:latest + +variables: + DOCKER_BUILDKIT: 1 + +services: + - docker:dind + +before_script: + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY + +build-master: + stage: build + script: + - docker build --pull -t "$CI_REGISTRY_IMAGE" . + - docker push "$CI_REGISTRY_IMAGE" + only: + - master + +build: + stage: build + script: + - docker build --pull -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" . + - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" + except: + - master diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c9cf36f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM gradle:jdk11 as build + +COPY --chown=gradle:gradle . /home/gradle/src + +WORKDIR /home/gradle/src +RUN gradle shadowJar --no-daemon + +FROM openjdk:11-jre + +COPY --from=build /home/gradle/src/build/libs/*.jar /app/app.jar + +ENTRYPOINT ["java","-jar","/app/app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..622b658 --- /dev/null +++ b/build.gradle @@ -0,0 +1,65 @@ +plugins { + id 'java' + id 'org.jetbrains.kotlin.jvm' version '1.3.61' + id 'com.github.johnrengelman.shadow' version '5.2.0' +} + +group 'nl.voidcorp.sqa' +version '1.0-SNAPSHOT' + +sourceCompatibility = 1.8 + +def ktor_version = "1.2.6" + + +repositories { + jcenter() +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" + + implementation 'ch.qos.logback:logback-classic:1.2.3' + implementation "io.ktor:ktor-server-netty:$ktor_version" + implementation "io.ktor:ktor-server-core:$ktor_version" + implementation "io.ktor:ktor-auth:$ktor_version" + implementation "io.ktor:ktor-auth-jwt:$ktor_version" + implementation "io.ktor:ktor-jackson:$ktor_version" + implementation 'org.jetbrains.kotlinx:kotlinx-html:0.6.12' + implementation "io.ktor:ktor-html-builder:$ktor_version" + implementation "io.ktor:ktor-websockets:$ktor_version" + implementation "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.9.+" + implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.9.+" + + implementation 'redis.clients:jedis:3.1.0' + + testImplementation "io.ktor:ktor-server-test-host:$ktor_version" + testImplementation("org.junit.jupiter:junit-jupiter-api:5.5.2") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.5.2") +} + +test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + } +} + +shadowJar { + baseName = 'app' + classifier = '' + archiveVersion = '' +} + +jar { + manifest { + attributes 'Main-Class': 'nl.voidcorp.sqa.MainKt' + } +} + +compileKotlin { + kotlinOptions.jvmTarget = "1.8" +} +compileTestKotlin { + kotlinOptions.jvmTarget = "1.8" +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..29e08e8 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..87b738c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..44e7c4d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.2.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..af6708f --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..0f8d593 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..5a038d4 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'SomeQuestionsAsked' + diff --git a/src/main/kotlin/nl/voidcorp/sqa/Main.kt b/src/main/kotlin/nl/voidcorp/sqa/Main.kt new file mode 100644 index 0000000..d516aa1 --- /dev/null +++ b/src/main/kotlin/nl/voidcorp/sqa/Main.kt @@ -0,0 +1,368 @@ +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("stage") { + transform(SessionTransportTransformerMessageAuthentication(SecureRandom.getInstanceStrong().generateSeed(8))) + } + } + + routing { + get("/") { + val stage = call.sessions.get() ?: 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>() + open fun getSocketsFor(name: String): MutableList { + return sockets.getOrPut(name) { mutableListOf() } + } + + open fun setSocketsFor(name: String, list: MutableList) { + sockets[name] = list + } + + fun modifySockets(name: String, func: MutableList.() -> Unit) { + val s = getSocketsFor(name) + s.func() + setSocketsFor(name, s) + } + + abstract fun addMessageTo(name: String, message: Message) + + abstract fun getMessagesFor(name: String): List +} + +class MemoryMessageStore : MessageStore() { + private val l = mutableMapOf>() + override fun addMessageTo(name: String, message: Message) { + val msgs = l.getOrPut(name) { mutableListOf() } + msgs += message + l[name] = msgs + } + + override fun getMessagesFor(name: String): List { + 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 { + val r: List = pool.resource.use { + it.lrange(name, 0, -1) + } + return r.map { mapper.readValue(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) +} \ No newline at end of file diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..8f53404 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,19 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %highlight(%-5level) %logger{0} - %msg%n + + + + + + + + + + + + + + + \ No newline at end of file