From a42d591d036ab493ee5608ae5876b7fc613c01cf Mon Sep 17 00:00:00 2001 From: Claude Brisson Date: Thu, 11 May 2023 16:05:33 +0200 Subject: [PATCH] Engine initial commit --- docker/clear.sh | 3 + docker/docker-compose.yml | 37 ++ docker/run.sh | 8 + pom.xml | 480 ++++++++++++++++++ src/main/config/jetty/jetty-env.xml | 5 + src/main/config/jetty/jetty-http.xml | 26 + src/main/config/jetty/jetty-https.xml | 24 + src/main/config/jetty/jetty-ssl-context.xml | 16 + src/main/config/jetty/jetty-ssl.xml | 46 ++ src/main/config/web.xml | 55 ++ src/main/config/webapp.properties | 8 + .../org/jeudego/pairgoth/api/ApiHandler.kt | 54 ++ .../org/jeudego/pairgoth/api/PlayerHandler.kt | 4 + .../jeudego/pairgoth/api/TournamentHandler.kt | 44 ++ .../org/jeudego/pairgoth/model/Pairable.kt | 24 + .../org/jeudego/pairgoth/model/Pairing.kt | 48 ++ .../org/jeudego/pairgoth/model/Player.kt | 15 + .../org/jeudego/pairgoth/model/Rules.kt | 8 + .../kotlin/org/jeudego/pairgoth/model/Team.kt | 6 + .../org/jeudego/pairgoth/model/TimeSystem.kt | 92 ++++ .../org/jeudego/pairgoth/model/Tournament.kt | 74 +++ .../jeudego/pairgoth/oauth/FacebookHelper.kt | 28 + .../jeudego/pairgoth/oauth/GoogleHelper.kt | 18 + .../jeudego/pairgoth/oauth/InstagramHelper.kt | 18 + .../org/jeudego/pairgoth/oauth/OAuthHelper.kt | 77 +++ .../pairgoth/oauth/OauthHelperFactory.kt | 17 + .../jeudego/pairgoth/oauth/TwitterHelper.kt | 18 + .../org/jeudego/pairgoth/store/Store.kt | 24 + .../org/jeudego/pairgoth/util/Colorizer.kt | 18 + .../org/jeudego/pairgoth/util/JsonIO.kt | 25 + .../org/jeudego/pairgoth/web/ApiException.kt | 36 ++ .../org/jeudego/pairgoth/web/ApiServlet.kt | 222 ++++++++ .../org/jeudego/pairgoth/web/Logging.kt | 71 +++ .../org/jeudego/pairgoth/web/WebappManager.kt | 184 +++++++ src/main/webapp/WEB-INF/jetty-web.xml | 10 + src/main/webapp/WEB-INF/logger.properties | 2 + src/main/webapp/WEB-INF/web.xml | 1 + src/main/webapp/WEB-INF/webapp.properties | 1 + src/test/kotlin/.gitkeep | 0 39 files changed, 1847 insertions(+) create mode 100755 docker/clear.sh create mode 100644 docker/docker-compose.yml create mode 100755 docker/run.sh create mode 100644 pom.xml create mode 100644 src/main/config/jetty/jetty-env.xml create mode 100644 src/main/config/jetty/jetty-http.xml create mode 100644 src/main/config/jetty/jetty-https.xml create mode 100644 src/main/config/jetty/jetty-ssl-context.xml create mode 100644 src/main/config/jetty/jetty-ssl.xml create mode 100644 src/main/config/web.xml create mode 100644 src/main/config/webapp.properties create mode 100644 src/main/kotlin/org/jeudego/pairgoth/api/ApiHandler.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/model/Pairing.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/model/Player.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/model/Rules.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/model/Team.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/model/TimeSystem.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/oauth/FacebookHelper.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/oauth/GoogleHelper.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/oauth/InstagramHelper.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/oauth/OAuthHelper.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/oauth/OauthHelperFactory.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/oauth/TwitterHelper.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/store/Store.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/util/Colorizer.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/util/JsonIO.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/web/ApiException.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/web/ApiServlet.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/web/Logging.kt create mode 100644 src/main/kotlin/org/jeudego/pairgoth/web/WebappManager.kt create mode 100644 src/main/webapp/WEB-INF/jetty-web.xml create mode 100644 src/main/webapp/WEB-INF/logger.properties create mode 120000 src/main/webapp/WEB-INF/web.xml create mode 120000 src/main/webapp/WEB-INF/webapp.properties create mode 100644 src/test/kotlin/.gitkeep diff --git a/docker/clear.sh b/docker/clear.sh new file mode 100755 index 0000000..53bac83 --- /dev/null +++ b/docker/clear.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-compose rm -fsv diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..4dc9fff --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3' +services: + pairgoth: + container_name: pairgoth-engine + image: maven:3.6.3-openjdk-11-slim + working_dir: /home/app/pairgoth + user: "${APP_UID}:${APP_GID}" + entrypoint: bash -c + command: '"mvn -e -Duser.home=/home/app clean package jetty:run"' + # command: mvn -X -e -Pdev clean verify -Dit.test=ApiTokenIT#test01NoVersionHeader -Dorg.slf4j.simpleLogger.log.level=trace jetty:run + volumes: + - ${HOME}/.m2:/home/app/.m2 + - ./pairgoth.properties:/var/lib/pairgoth/pairgoth.properties + - ..:/home/app/pairgoth + - ./data/jetty:/var/lib/pairgoth/jetty + networks: + - pairgoth-network + ports: + - '5006:5006' + - '8080:8080' + environment: + HOME: "/home/app" + USER: "app" + MAVEN_CONFIG: "/home/app/.m2" + MAVEN_OPTS: "-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5006" + SMTP_HOST: ${SMTP_HOST} + SMTP_PORT: ${SMTP_PORT} + SMTP_USER: ${SMTP_USER} + SMTP_PASSWORD: ${SMTP_PASSWORD} + # restart: unless-stopped + stdin_open: true + tty: true +networks: + pairgoth-network: + driver: bridge + + diff --git a/docker/run.sh b/docker/run.sh new file mode 100755 index 0000000..844212e --- /dev/null +++ b/docker/run.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +grep -r '^smtp\.host' webapp.properties | sed -r -e 's/smtp\.host/SMTP_HOST/' -e 's/ //g' > .env +grep -r '^smtp\.port' webapp.properties | sed -r -e 's/smtp\.port/SMTP_PORT/' -e 's/ //g' >> .env +grep -r '^smtp\.user' webapp.properties | sed -r -e 's/smtp\.user/SMTP_USER/' -e 's/ //g' >> .env +grep -r '^smtp\.password' webapp.properties | sed -r -e 's/smtp\.password/SMTP_PASSWORD/' -e 's/ //g' >> .env + +APP_UID=$(id -u) APP_GID=$(id -g) docker-compose up diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..2a45e92 --- /dev/null +++ b/pom.xml @@ -0,0 +1,480 @@ + + + 4.0.0 + + org.jeudego + pairgoth + 1.0-SNAPSHOT + + war + ${project.groupId}:${project.artifactId} + PairGoth pairing system + TODO + + UTF-8 + UTF-8 + 4.13.2 + 1.7.36 + 3.1.0 + 1.8.21 + official + 10 + true + 5.7.1 + 10.0.12 + ${project.build.directory}/${project.build.finalName}/WEB-INF/jetty + 8080 + + + + pairgoth + + true + + + /var/lib/pairgoth/pairgoth.properties + + + + + package + src/main/kotlin + src/test/kotlin + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.1.0 + + + enforce-maven + + enforce + + + + + 3.6.3 + + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + compile + + compile + + + + test-compile + test-compile + + test-compile + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M7 + + true + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.0.0-M7 + + + run-tests + integration-test + + integration-test + verify + + + + + + **/Test* + + false + + ${project.build.testOutputDirectory} + dev + + + + project.version + ${project.version} + + + project.dir + ${project.basedir} + + + build.dir + ${project.build.directory} + + + test.resources.dir + ${project.build.testOutputDirectory} + + + test.run.dir + ${project.build.directory}/run + + + test.result.dir + ${project.build.directory}/results + + + org.slf4j.simpleLogger.defaultLogLevel + debug + + + org.slf4j.simpleLogger.log.com.icegreen.greenmail.util.LineLoggingBuffer + info + + + + + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${webapp.properties} + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.2.2 + + + + true + ./ + com.performance.easyedi.MainKt + + + + + + org.apache.maven.plugins + maven-resources-plugin + + 2.7 + + + org.apache.maven.plugins + maven-compiler-plugin + 3.10.1 + + 10 + 10 + + + + org.apache.maven.plugins + maven-war-plugin + 3.2.2 + + true + + + ${basedir}/src/main/config + true + WEB-INF + + webapp.properties + web.xml + tools.xml + + + + ${basedir}/src/main/config/jetty + true + WEB-INF/jetty + + + + + + default-war + none + + + war-exploded + + + exploded + + + + + + org.codehaus.mojo + templating-maven-plugin + 1.0.0 + + + filter-src + + filter-test-sources + + + + + + org.eclipse.jetty + jetty-maven-plugin + ${jetty.version} + + 3 + + ${jetty.files}/jetty-http.xml,${jetty.files}/jetty-env.xml + 9966 + STOP + + + + start-jetty + pre-integration-test + + start + + + + stop-jetty + post-integration-test + + stop + + + + + + org.eclipse.jetty.http2 + http2-server + ${jetty.version} + + + + + + + + + org.jetbrains.kotlin + kotlin-test-junit5 + ${kotlin.version} + test + + + org.junit.jupiter + junit-jupiter-engine + 5.6.0 + test + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + + + org.jetbrains.kotlinx + kotlinx-datetime-jvm + 0.3.3 + + + + javax.servlet + javax.servlet-api + ${servlet.version} + provided + + + com.sun.mail + jakarta.mail + 1.6.5 + + + + org.pac4j + pac4j-oauth + ${pac4j.version} + + + + io.github.microutils + kotlin-logging-jvm + 2.1.23 + + + org.slf4j + slf4j-api + ${slf4j.version} + + + com.republicate + webapp-slf4j-logger + 1.6 + runtime + + + org.slf4j + jcl-over-slf4j + ${slf4j.version} + + + com.diogonunes + JColor + 5.0.1 + + + + com.republicate + simple-mailer + 1.6 + + + + com.republicate.kson + essential-kson-jvm + 2.3 + + + + + org.apache.httpcomponents + httpclient + 4.5.13 + + + commons-logging + commons-logging + + + + + org.apache.httpcomponents + httpmime + 4.5.13 + + + commons-io + commons-io + 2.11.0 + + + commons-net + commons-net + 3.8.0 + + + + org.apache.poi + poi + 5.2.2 + + + org.apache.poi + poi-ooxml + 5.2.2 + + + + org.apache.pdfbox + pdfbox + 2.0.28 + + + + com.sealwu + kscript-tools + 1.0.3 + + + + com.icegreen + greenmail + 1.6.12 + test + + + junit + junit + + + javax.activation + activation + + + com.sun.mail + javax.mail + + + + + diff --git a/src/main/config/jetty/jetty-env.xml b/src/main/config/jetty/jetty-env.xml new file mode 100644 index 0000000..1f82f54 --- /dev/null +++ b/src/main/config/jetty/jetty-env.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/config/jetty/jetty-http.xml b/src/main/config/jetty/jetty-http.xml new file mode 100644 index 0000000..49c6123 --- /dev/null +++ b/src/main/config/jetty/jetty-http.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/config/jetty/jetty-https.xml b/src/main/config/jetty/jetty-https.xml new file mode 100644 index 0000000..99abd18 --- /dev/null +++ b/src/main/config/jetty/jetty-https.xml @@ -0,0 +1,24 @@ + + + + + + + + + http/1.1 + + + + + + + + + + + + + + + diff --git a/src/main/config/jetty/jetty-ssl-context.xml b/src/main/config/jetty/jetty-ssl-context.xml new file mode 100644 index 0000000..121a644 --- /dev/null +++ b/src/main/config/jetty/jetty-ssl-context.xml @@ -0,0 +1,16 @@ + + + true + / + + + / + + + + + + + + + diff --git a/src/main/config/jetty/jetty-ssl.xml b/src/main/config/jetty/jetty-ssl.xml new file mode 100644 index 0000000..1ee1118 --- /dev/null +++ b/src/main/config/jetty/jetty-ssl.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/config/web.xml b/src/main/config/web.xml new file mode 100644 index 0000000..fa11af6 --- /dev/null +++ b/src/main/config/web.xml @@ -0,0 +1,55 @@ + + + + + + com.republicate.slf4j.impl.ServletContextLoggerListener + + + org.jeudego.pairgoth.web.WebappManager + + + + + webapp-slf4j-logger-ip-tag-filter + com.republicate.slf4j.impl.IPTagFilter + true + + + + + webapp-slf4j-logger-ip-tag-filter + /* + REQUEST + FORWARD + + + + + api + org.jeudego.pairgoth.web.ApiServlet + + + + + api + /api/* + + + + + webapp-slf4j-logger.format + %logger [%level] [%ip] %message @%file:%line:%column + + + webapp-slf4j-logger.level + ${logger.level} + + + webapp-slf4j-logger.notification + ${logger.notification} + + + diff --git a/src/main/config/webapp.properties b/src/main/config/webapp.properties new file mode 100644 index 0000000..ed8aeee --- /dev/null +++ b/src/main/config/webapp.properties @@ -0,0 +1,8 @@ +# webapp +webapp.env = ${webapp.env} +webapp.url = ${webapp.url} + +# smtp + +# Logging +logger.level = ${logger.level} diff --git a/src/main/kotlin/org/jeudego/pairgoth/api/ApiHandler.kt b/src/main/kotlin/org/jeudego/pairgoth/api/ApiHandler.kt new file mode 100644 index 0000000..8a27be0 --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/api/ApiHandler.kt @@ -0,0 +1,54 @@ +package org.jeudego.pairgoth.api + +import com.republicate.kson.Json +import org.jeudego.pairgoth.web.ApiException +import org.slf4j.LoggerFactory +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +interface ApiHandler { + + fun route(request: HttpServletRequest, response: HttpServletResponse) = + when (request.method) { + "GET" -> get(request) + "POST" -> post(request) + "PUT" -> put(request) + "DELETE" -> delete(request) + else -> notImplemented() + } + + fun get(request: HttpServletRequest): Json { + notImplemented() + } + + fun post(request: HttpServletRequest): Json { + notImplemented() + } + + fun put(request: HttpServletRequest): Json { + notImplemented() + } + + fun delete(request: HttpServletRequest): Json { + notImplemented() + } + + fun notImplemented(): Nothing { + throw ApiException(HttpServletResponse.SC_BAD_REQUEST) + } + + fun getPayload(request: HttpServletRequest): Json { + return request.getAttribute(PAYLOAD_KEY) as Json ?: throw ApiException(HttpServletResponse.SC_BAD_REQUEST, "no payload") + } + + fun getSelector(request: HttpServletRequest): String? { + return request.getAttribute(SELECTOR_KEY) as String? + } + + companion object { + const val PAYLOAD_KEY = "PAYLOAD" + const val SELECTOR_KEY = "SELECTOR" + val logger = LoggerFactory.getLogger("api") + fun badRequest(msg: String = "bad request"): Nothing = throw ApiException(HttpServletResponse.SC_BAD_REQUEST, msg) + } +} diff --git a/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt b/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt new file mode 100644 index 0000000..e62ce4d --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt @@ -0,0 +1,4 @@ +package org.jeudego.pairgoth.api + +class PlayerHandler: ApiHandler { +} \ No newline at end of file diff --git a/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt b/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt new file mode 100644 index 0000000..fb894b7 --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt @@ -0,0 +1,44 @@ +package org.jeudego.pairgoth.api + +import com.republicate.kson.Json +import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest +import org.jeudego.pairgoth.model.CanadianByoyomi +import org.jeudego.pairgoth.model.FisherTime +import org.jeudego.pairgoth.model.MacMahon +import org.jeudego.pairgoth.model.Rules +import org.jeudego.pairgoth.model.StandardByoyomi +import org.jeudego.pairgoth.model.SuddenDeath +import org.jeudego.pairgoth.model.TimeSystem +import org.jeudego.pairgoth.model.Tournament +import org.jeudego.pairgoth.model.TournamentType +import org.jeudego.pairgoth.model.fromJson +import org.jeudego.pairgoth.model.toJson +import org.jeudego.pairgoth.store.Store +import javax.servlet.http.HttpServletRequest + +class TournamentHandler(): ApiHandler { + + override fun post(request: HttpServletRequest): Json { + val json = getPayload(request) + if (!json.isObject) badRequest("expecting a json object") + val payload = json.asObject() + + // tournament parsing + val tournament = Tournament.fromJson(payload) + + Store.addTournament(tournament) + return Json.Object("success" to true, "id" to tournament.id) + } + + override fun get(request: HttpServletRequest): Json { + return when (val id = getSelector(request)?.toIntOrNull()) { + null -> Json.Array(Store.getTournamentsIDs()) + else -> Store.getTournament(id)?.toJson() ?: badRequest("no tournament with id #${id}") + } + } + + override fun put(request: HttpServletRequest): Json { + val id = getSelector(request)?.toIntOrNull() ?: badRequest("missing or invalid tournament selector") + TODO() + } +} diff --git a/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt b/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt new file mode 100644 index 0000000..18f7e28 --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt @@ -0,0 +1,24 @@ +package org.jeudego.pairgoth.model + +sealed class Pairable(val id: Int, val name: String, val rating: Double, val rank: Int) + +fun Pairable.displayRank(): String = when { + rank < 0 -> "${-rank}k" + rank >= 0 && rank < 10 -> "${rank + 1}d" + rank >= 10 -> "${rank - 9}p" + else -> throw Error("impossible") +} + +private val rankRegex = Regex("(\\d+)([kdp])", RegexOption.IGNORE_CASE) + +fun Pairable.setRank(rankStr: String): Int { + val (level, letter) = rankRegex.matchEntire(rankStr)?.destructured ?: throw Error("invalid rank: $rankStr") + val num = level.toInt() + if (num < 0 || num > 9) throw Error("invalid rank: $rankStr") + return when (letter.lowercase()) { + "k" -> -num + "d" -> num - 1 + "p" -> num + 9 + else -> throw Error("impossible") + } +} diff --git a/src/main/kotlin/org/jeudego/pairgoth/model/Pairing.kt b/src/main/kotlin/org/jeudego/pairgoth/model/Pairing.kt new file mode 100644 index 0000000..6473bba --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/model/Pairing.kt @@ -0,0 +1,48 @@ +package org.jeudego.pairgoth.model + +import com.republicate.kson.Json +import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest +import org.jeudego.pairgoth.model.Pairing.PairingType.* + +// TODO - this is only an early draft + +sealed class Pairing(val type: PairingType) { + companion object {} + enum class PairingType { SWISS, MACMAHON, ROUNDROBIN } +} + +class Swiss( + var method: Method, + var firstRoundMethod: Method = method +): Pairing(SWISS) { + enum class Method { FOLD, RANDOM, SLIP } +} + +class MacMahon( + var bar: Int = 0, + var minLevel: Int = -30 +): Pairing(MACMAHON) { + val groups = mutableListOf() +} + +class RoundRobin: Pairing(ROUNDROBIN) + +// Serialization + +fun Pairing.Companion.fromJson(json: Json.Object) = when (json.getString("type")?.let { Pairing.PairingType.valueOf(it) } ?: badRequest("missing pairing type")) { + SWISS -> Swiss( + method = json.getString("method")?.let { Swiss.Method.valueOf(it) } ?: badRequest("missing pairing method"), + firstRoundMethod = json.getString("firstRoundMethod")?.let { Swiss.Method.valueOf(it) } ?: json.getString("method")!!.let { Swiss.Method.valueOf(it) } + ) + MACMAHON -> MacMahon( + bar = json.getInt("bar") ?: 0, + minLevel = json.getInt("minLevel") ?: -30 + ) + ROUNDROBIN -> RoundRobin() +} + +fun Pairing.toJson() = when (this) { + is Swiss -> Json.Object("type" to type.name, "method" to method.name, "firstRoundMethod" to firstRoundMethod.name) + is MacMahon -> Json.Object("type" to type.name, "bar" to bar, "minLevel" to minLevel) + is RoundRobin -> Json.Object("type" to type.name) +} diff --git a/src/main/kotlin/org/jeudego/pairgoth/model/Player.kt b/src/main/kotlin/org/jeudego/pairgoth/model/Player.kt new file mode 100644 index 0000000..1a57d4f --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/model/Player.kt @@ -0,0 +1,15 @@ +package org.jeudego.pairgoth.model + +class Player( + id: Int, + name: String, + var firstname: String, + rating: Double, + rank: Int, + var country: String, + var club: String +): Pairable(id, name, rating, rank) { + companion object + // used to store external IDs ("FFG" => FFG ID, "EGF" => EGF PIN, "AGA" => AGA ID ...) + val externalIds = mutableMapOf() +} diff --git a/src/main/kotlin/org/jeudego/pairgoth/model/Rules.kt b/src/main/kotlin/org/jeudego/pairgoth/model/Rules.kt new file mode 100644 index 0000000..169f2a1 --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/model/Rules.kt @@ -0,0 +1,8 @@ +package org.jeudego.pairgoth.model + +enum class Rules { + FRENCH, + JAPANESE, + CHINESE + // ... +} diff --git a/src/main/kotlin/org/jeudego/pairgoth/model/Team.kt b/src/main/kotlin/org/jeudego/pairgoth/model/Team.kt new file mode 100644 index 0000000..d1bcac3 --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/model/Team.kt @@ -0,0 +1,6 @@ +package org.jeudego.pairgoth.model + +class Team(id: Int, name: String, rating: Double, rank: Int): Pairable(id, name, rating, rank) { + companion object {} + val players = mutableSetOf() +} diff --git a/src/main/kotlin/org/jeudego/pairgoth/model/TimeSystem.kt b/src/main/kotlin/org/jeudego/pairgoth/model/TimeSystem.kt new file mode 100644 index 0000000..4508708 --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/model/TimeSystem.kt @@ -0,0 +1,92 @@ +package org.jeudego.pairgoth.model + +import com.republicate.kson.Json +import org.jeudego.pairgoth.api.ApiHandler + + +sealed class TimeSystem( + val type: TimeSystemType, + val mainTime: Int, + val increment: Int, + val maxTime: Int = Int.MAX_VALUE, + val byoyomi: Int, + val periods: Int, + val stones: Int +) { + companion object {} + enum class TimeSystemType { CANADIAN, STANDARD, FISHER, SUDDEN_DEATH } +} + +class CanadianByoyomi(mainTime: Int, byoyomi: Int, stones: Int): + TimeSystem( + type = TimeSystemType.CANADIAN, + mainTime = mainTime, + increment = 0, + byoyomi = byoyomi, + periods = 1, + stones = stones + ) + +class StandardByoyomi(mainTime: Int, byoyomi: Int, periods: Int): + TimeSystem( + type = TimeSystemType.STANDARD, + mainTime = mainTime, + increment = 0, + byoyomi = byoyomi, + periods = periods, + stones = 1 + ) + +class FisherTime(mainTime: Int, increment: Int, maxTime: Int = Int.MAX_VALUE): + TimeSystem( + type = TimeSystemType.FISHER, + mainTime = mainTime, + increment = increment, + maxTime = maxTime, + byoyomi = 0, + periods = 0, + stones = 0 + ) + +class SuddenDeath(mainTime: Int): + TimeSystem( + type = TimeSystemType.SUDDEN_DEATH, + mainTime = mainTime, + increment = 0, + byoyomi = 0, + periods = 0, + stones = 0 + ) + +// Serialization + +fun TimeSystem.Companion.fromJson(json: Json.Object) = + when (json.getString("type")?.uppercase() ?: ApiHandler.badRequest("missing timeSystem type")) { + "CANADIAN" -> CanadianByoyomi( + mainTime = json.getInt("mainTime") ?: ApiHandler.badRequest("missing timeSystem mainTime"), + byoyomi = json.getInt("byoyomi") ?: ApiHandler.badRequest("missing timeSystem byoyomi"), + stones = json.getInt("stones") ?: ApiHandler.badRequest("missing timeSystem stones") + ) + "STANDARD" -> StandardByoyomi( + mainTime = json.getInt("mainTime") ?: ApiHandler.badRequest("missing timeSystem mainTime"), + byoyomi = json.getInt("byoyomi") ?: ApiHandler.badRequest("missing timeSystem byoyomi"), + periods = json.getInt("periods") ?: ApiHandler.badRequest("missing timeSystem periods") + ) + "FISHER" -> FisherTime( + mainTime = json.getInt("mainTime") ?: ApiHandler.badRequest("missing timeSystem mainTime"), + increment = json.getInt("increment") ?: ApiHandler.badRequest("missing timeSystem increment"), + maxTime = json.getInt("increment") ?: Integer.MAX_VALUE + ) + "SUDDEN_DEATH" -> SuddenDeath( + mainTime = json.getInt("mainTime") ?: ApiHandler.badRequest("missing timeSystem mainTime"), + ) + else -> ApiHandler.badRequest("invalid or missing timeSystem type") + } + +fun TimeSystem.toJson() = when (type) { + TimeSystem.TimeSystemType.CANADIAN -> Json.Object("mainTime" to mainTime, "byoyomi" to byoyomi, "stones" to stones) + TimeSystem.TimeSystemType.STANDARD -> Json.Object("mainTime" to mainTime, "byoyomi" to byoyomi, "periods" to periods) + TimeSystem.TimeSystemType.FISHER -> Json.Object("mainTime" to mainTime, "increment" to increment, "maxTime" to maxTime) + TimeSystem.TimeSystemType.SUDDEN_DEATH -> Json.Object("mainTime" to mainTime) +} + diff --git a/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt b/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt new file mode 100644 index 0000000..6ef34ba --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt @@ -0,0 +1,74 @@ +package org.jeudego.pairgoth.model + +import com.republicate.kson.Json +import kotlinx.datetime.LocalDate +import org.jeudego.pairgoth.api.ApiHandler +import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest +import org.jeudego.pairgoth.store.Store + +enum class TournamentType(val playersNumber: Int) { + INDIVIDUAL(1), + PAIRGO(2), + RENGO2(2), + RENGO3(3), + TEAM2(2), + TEAM3(3), + TEAM4(4), + TEAM5(5); +} + +data class Tournament( + var id: Int, + var type: TournamentType, + var name: String, + var shortName: String, + var startDate: LocalDate, + var endDate: LocalDate, + var country: String, + var location: String, + var online: Boolean, + var timeSystem: TimeSystem, + var pairing: Pairing, + var rules: Rules = Rules.FRENCH, + var gobanSize: Int = 19, + var komi: Double = 7.5 +) { + companion object {} + val pairables = mutableMapOf() +} + +// Serialization + +fun Tournament.Companion.fromJson(json: Json.Object) = Tournament( + id = json.getInt("id") ?: Store.nextTournamentId, + type = json.getString("type")?.uppercase()?.let { TournamentType.valueOf(it) } ?: badRequest("missing type"), + name = json.getString("name") ?: ApiHandler.badRequest("missing name"), + shortName = json.getString("shortName") ?: ApiHandler.badRequest("missing shortName"), + startDate = json.getLocalDate("startDate") ?: ApiHandler.badRequest("missing startDate"), + endDate = json.getLocalDate("endDate") ?: ApiHandler.badRequest("missing endDate"), + country = json.getString("country") ?: ApiHandler.badRequest("missing country"), + location = json.getString("location") ?: ApiHandler.badRequest("missing location"), + online = json.getBoolean("online") ?: false, + komi = json.getDouble("komi") ?: 7.5, + rules = json.getString("rules")?.let { Rules.valueOf(it) } ?: Rules.FRENCH, + gobanSize = json.getInt("gobanSize") ?: 19, + timeSystem = TimeSystem.fromJson(json.getObject("timeSystem") ?: badRequest("missing timeSystem")), + pairing = MacMahon() +) + +fun Tournament.toJson() = Json.Object( + "id" to id, + "type" to type.name, + "name" to name, + "shortName" to shortName, + "startDate" to startDate.toString(), + "endDate" to endDate.toString(), + "country" to country, + "location" to location, + "online" to online, + "komi" to komi, + "rules" to rules.name, + "gobanSize" to gobanSize, + "timeSystem" to timeSystem.toJson(), + "pairing" to pairing.toJson() +) diff --git a/src/main/kotlin/org/jeudego/pairgoth/oauth/FacebookHelper.kt b/src/main/kotlin/org/jeudego/pairgoth/oauth/FacebookHelper.kt new file mode 100644 index 0000000..9b0ab7f --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/oauth/FacebookHelper.kt @@ -0,0 +1,28 @@ +package org.jeudego.pairgoth.oauth + +class FacebookHelper : OAuthHelper() { + override val name: String + get() = "facebook" + + override fun getLoginURL(sessionId: String?): String { + return "https://www.facebook.com/v14.0/dialog/oauth?" + + "client_id=" + clientId + + "&redirect_uri=" + redirectURI + + "&scope=email" + + "&state=" + getState(sessionId!!) + } + + override fun getAccessTokenURL(code: String): String? { + return "https://graph.facebook.com/v14.0/oauth/access_token?" + + "client_id=" + clientId + + "&redirect_uri=" + redirectURI + + "&client_secret=" + secret + + "&code=" + code + } + + override fun getUserInfosURL(accessToken: String): String? { + return "https://graph.facebook.com/me?" + + "field=email" + + "&access_token=" + accessToken + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/jeudego/pairgoth/oauth/GoogleHelper.kt b/src/main/kotlin/org/jeudego/pairgoth/oauth/GoogleHelper.kt new file mode 100644 index 0000000..db3c30d --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/oauth/GoogleHelper.kt @@ -0,0 +1,18 @@ +package org.jeudego.pairgoth.oauth + +class GoogleHelper : OAuthHelper() { + override val name: String + get() = "google" + + override fun getLoginURL(sessionId: String?): String { + return "" + } + + override fun getAccessTokenURL(code: String): String? { + return null + } + + override fun getUserInfosURL(accessToken: String): String? { + return null + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/jeudego/pairgoth/oauth/InstagramHelper.kt b/src/main/kotlin/org/jeudego/pairgoth/oauth/InstagramHelper.kt new file mode 100644 index 0000000..20f5089 --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/oauth/InstagramHelper.kt @@ -0,0 +1,18 @@ +package org.jeudego.pairgoth.oauth + +class InstagramHelper : OAuthHelper() { + override val name: String + get() = "instagram" + + override fun getLoginURL(sessionId: String?): String { + return "" + } + + override fun getAccessTokenURL(code: String): String? { + return null + } + + override fun getUserInfosURL(accessToken: String): String? { + return null + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/jeudego/pairgoth/oauth/OAuthHelper.kt b/src/main/kotlin/org/jeudego/pairgoth/oauth/OAuthHelper.kt new file mode 100644 index 0000000..9f92561 --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/oauth/OAuthHelper.kt @@ -0,0 +1,77 @@ +package org.jeudego.pairgoth.oauth + +// In progress + +import com.republicate.kson.Json +import org.jeudego.pairgoth.web.WebappManager +//import com.republicate.modality.util.AESCryptograph +//import com.republicate.modality.util.Cryptograph +import org.apache.commons.codec.binary.Base64 +import org.slf4j.LoggerFactory +import java.io.IOException +import java.io.UnsupportedEncodingException +import java.net.URLEncoder + +abstract class OAuthHelper { + abstract val name: String + abstract fun getLoginURL(sessionId: String?): String + protected val clientId: String + protected get() = WebappManager.getProperty("oauth." + name + ".client_id") + protected val secret: String + protected get() = WebappManager.getProperty("oauth." + name + ".secret") + protected val redirectURI: String? + protected get() = try { + val uri: String = WebappManager.Companion.getProperty("webapp.url") + "/oauth.html" + URLEncoder.encode(uri, "UTF-8") + } catch (uee: UnsupportedEncodingException) { + logger.error("could not encode redirect URI", uee) + null + } + + protected fun getState(sessionId: String): String { + return name + ":" + encrypt(sessionId) + } + + fun checkState(state: String, expectedSessionId: String): Boolean { + val foundSessionId = decrypt(state) + return expectedSessionId == foundSessionId + } + + protected abstract fun getAccessTokenURL(code: String): String? + @Throws(IOException::class) + fun getAccessToken(code: String): String { + val json: Json.Object = Json.Object() // TODO - apiClient.get(getAccessTokenURL(code)) + return json.getString("access_token")!! // ?! + } + + protected abstract fun getUserInfosURL(accessToken: String): String? + + @Throws(IOException::class) + fun getUserEmail(accessToken: String): String { + val json: Json.Object = Json.Object() + // TODO + // apiClient.get(getUserInfosURL(accessToken)) + return json.getString("email") ?: throw IOException("could not fetch email") + } + + companion object { + protected var logger = LoggerFactory.getLogger("oauth") + private const val salt = "0efd28fb53cbac42" +// private val sessionIdCrypto: Cryptograph = AESCryptograph().apply { +// init(salt) +// } + + private fun encrypt(input: String): String { + return "TODO" +// return Base64.encodeBase64URLSafeString(sessionIdCrypto.encrypt(input)) + } + + private fun decrypt(input: String): String { + return "TODO" +// return sessionIdCrypto.decrypt(Base64.decodeBase64(input)) + } + + // TODO + // private val apiClient: ApiClient = ApiClient() + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/jeudego/pairgoth/oauth/OauthHelperFactory.kt b/src/main/kotlin/org/jeudego/pairgoth/oauth/OauthHelperFactory.kt new file mode 100644 index 0000000..45568da --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/oauth/OauthHelperFactory.kt @@ -0,0 +1,17 @@ +package org.jeudego.pairgoth.oauth + +object OauthHelperFactory { + private val facebook: OAuthHelper = FacebookHelper() + private val google: OAuthHelper = GoogleHelper() + private val instagram: OAuthHelper = InstagramHelper() + private val twitter: OAuthHelper = TwitterHelper() + fun getHelper(provider: String?): OAuthHelper { + return when (provider) { + "facebook" -> facebook + "google" -> google + "instagram" -> instagram + "twitter" -> twitter + else -> throw RuntimeException("wrong provider") + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/jeudego/pairgoth/oauth/TwitterHelper.kt b/src/main/kotlin/org/jeudego/pairgoth/oauth/TwitterHelper.kt new file mode 100644 index 0000000..d0bf47a --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/oauth/TwitterHelper.kt @@ -0,0 +1,18 @@ +package org.jeudego.pairgoth.oauth + +class TwitterHelper : OAuthHelper() { + override val name: String + get() = "twitter" + + override fun getLoginURL(sessionId: String?): String { + return "" + } + + override fun getAccessTokenURL(code: String): String? { + return null + } + + override fun getUserInfosURL(accessToken: String): String? { + return null + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/jeudego/pairgoth/store/Store.kt b/src/main/kotlin/org/jeudego/pairgoth/store/Store.kt new file mode 100644 index 0000000..a4ba452 --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/store/Store.kt @@ -0,0 +1,24 @@ +package org.jeudego.pairgoth.store + +import org.jeudego.pairgoth.model.Player +import org.jeudego.pairgoth.model.Tournament +import java.util.concurrent.atomic.AtomicInteger + +object Store { + private val _nextTournamentId = AtomicInteger() + private val _nextPlayerId = AtomicInteger() + val nextTournamentId get() = _nextTournamentId.incrementAndGet() + val nextPlayerId get() = _nextPlayerId.incrementAndGet() + + private val tournaments = mutableMapOf() + private val players = mutableMapOf() + + fun addTournament(tournament: Tournament) { + if (tournaments.containsKey(tournament.id)) throw Error("tournament id #${tournament.id} already exists") + tournaments[tournament.id] = tournament + } + + fun getTournament(id: Int) = tournaments[id] + + fun getTournamentsIDs(): Set = tournaments.keys +} diff --git a/src/main/kotlin/org/jeudego/pairgoth/util/Colorizer.kt b/src/main/kotlin/org/jeudego/pairgoth/util/Colorizer.kt new file mode 100644 index 0000000..f3d2a09 --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/util/Colorizer.kt @@ -0,0 +1,18 @@ +package org.jeudego.pairgoth.util + +import com.diogonunes.jcolor.Ansi +import com.diogonunes.jcolor.AnsiFormat +import com.diogonunes.jcolor.Attribute + +private val blue = AnsiFormat(Attribute.BRIGHT_BLUE_TEXT()) +private val green = AnsiFormat(Attribute.BRIGHT_GREEN_TEXT()) +private val red = AnsiFormat(Attribute.BRIGHT_RED_TEXT()) +private val bold = AnsiFormat(Attribute.BOLD()) + +object Colorizer { + + fun blue(str: String) = Ansi.colorize(str, blue) + fun green(str: String) = Ansi.colorize(str, green) + fun red(str: String) = Ansi.colorize(str, red) + fun bold(str: String) = Ansi.colorize(str, bold) +} diff --git a/src/main/kotlin/org/jeudego/pairgoth/util/JsonIO.kt b/src/main/kotlin/org/jeudego/pairgoth/util/JsonIO.kt new file mode 100644 index 0000000..99177ea --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/util/JsonIO.kt @@ -0,0 +1,25 @@ +package org.jeudego.pairgoth.util + +import com.republicate.kson.Json +import java.io.Reader +import java.io.Writer + + +fun Json.Companion.parse(reader: Reader) = Json.Companion.parse(object: Json.Input { + override fun read() = reader.read().toChar() +}) + +fun Json.toString(writer: Writer) = toString(object: Json.Output { + override fun writeChar(c: Char): Json.Output { + writer.write(c.code) + return this + } + override fun writeString(s: String): Json.Output { + writer.write(s) + return this + } + override fun writeString(s: String, from: Int, to: Int): Json.Output { + writer.write(s, from, to) + return this + } +}) diff --git a/src/main/kotlin/org/jeudego/pairgoth/web/ApiException.kt b/src/main/kotlin/org/jeudego/pairgoth/web/ApiException.kt new file mode 100644 index 0000000..13d2a9d --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/web/ApiException.kt @@ -0,0 +1,36 @@ +package org.jeudego.pairgoth.web + +import com.republicate.kson.Json +import java.io.IOException + +class ApiException : IOException { + var code: Int + private set + var details: Json.Object + private set + + constructor(code: Int) : super("error") { + this.code = code + details = Json.Object("message" to message) + } + + constructor(code: Int, message: String?) : super(message) { + this.code = code + details = Json.Object("message" to message) + } + + constructor(code: Int, cause: Exception) : super(cause) { + this.code = code + details = Json.Object("message" to "Erreur interne du serveur : " + cause.message) + } + + constructor(code: Int, message: String, cause: Exception) : super(message, cause) { + this.code = code + details = Json.Object("message" to message + " : " + cause.message) + } + + constructor(code: Int, details: Json.Object) : super(details.getString("message")) { + this.code = code + this.details = details + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/jeudego/pairgoth/web/ApiServlet.kt b/src/main/kotlin/org/jeudego/pairgoth/web/ApiServlet.kt new file mode 100644 index 0000000..8a5d8b7 --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/web/ApiServlet.kt @@ -0,0 +1,222 @@ +package org.jeudego.pairgoth.web + +import com.republicate.kson.Json +import org.jeudego.pairgoth.api.ApiHandler +import org.jeudego.pairgoth.api.PlayerHandler +import org.jeudego.pairgoth.api.TournamentHandler +import org.jeudego.pairgoth.util.Colorizer +import org.jeudego.pairgoth.util.Colorizer.green +import org.jeudego.pairgoth.util.Colorizer.red +import org.jeudego.pairgoth.util.parse +import org.jeudego.pairgoth.util.toString +import org.jeudego.pairgoth.web.ApiException +import org.slf4j.LoggerFactory +import java.io.IOException +import java.io.StringWriter +import java.util.* +import javax.servlet.ServletException +import javax.servlet.http.HttpServlet +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class ApiServlet : HttpServlet() { + + val tournamentHandler = TournamentHandler() + val playerHandler = PlayerHandler() + + public override fun doGet(request: HttpServletRequest, response: HttpServletResponse) { + doRequest(request, response) + } + + public override fun doPost(request: HttpServletRequest, response: HttpServletResponse) { + doRequest(request, response) + } + + public override fun doPut(request: HttpServletRequest, response: HttpServletResponse) { + doRequest(request, response) + } + + public override fun doDelete(request: HttpServletRequest, response: HttpServletResponse) { + doRequest(request, response) + } + + private fun doRequest(request: HttpServletRequest, response: HttpServletResponse) { + var payload: Json? = null + var reason = "OK" + try { + if ("dev" == WebappManager.getProperty("webapp.env")) { + response.addHeader("Access-Control-Allow-Origin", "*") + } + validateContentType(request) + validateAccept(request); + val uri = request.requestURI + logger.logRequest(request, !uri.contains(".") && uri.length > 1) + + val parts = uri.split("/".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + if (parts.size < 3 || parts.size > 5) throw ApiException(HttpServletResponse.SC_BAD_REQUEST) + if (parts.size >= 4) { + request.setAttribute(ApiHandler.SELECTOR_KEY, parts[3]) + } + val entity = parts[2] + val handler = when (entity) { + "tournament" -> tournamentHandler + "player" -> playerHandler + else -> ApiHandler.badRequest("unknown entity") + } + payload = handler.route(request, response) + // if payload is null, it means the handler already sent the response + if (payload != null) { + setContentType(response) + payload.toString(response.writer) + } + } catch (apiException: ApiException) { + reason = apiException.message ?: "unknown API error" + if (reason == null) error(response, apiException.code) else error( + request, + response, + apiException.code, + reason, + apiException + ) + } catch (ioe: IOException) { + logger.error(red("could not process call"), ioe) + reason = ioe.message ?: "unknown i/o exception" + error(request, response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, reason, ioe) + } finally { + val builder = StringBuilder() + builder.append(response.status).append(' ') + .append(reason) + if (response.status == HttpServletResponse.SC_INTERNAL_SERVER_ERROR) { + logger.info(red(">> {}"), builder.toString()) + } else { + logger.info(green(">> {}"), builder.toString()) + } + + // CB TODO - should be bufferized and asynchronously written in synchronous chunks + // so that header lines from parallel requests are not mixed up in the logs ; + // synchronizing the whole request log is not desirable + for (header in response.headerNames) { + val value = response.getHeader(header) + logger.trace(green(">> {}: {}"), header, value) + } + if (payload != null) { + try { + logger.logPayload(green(">> "), payload, false) + } catch (ioe: IOException) { + } + } + } + } + + @Throws(ApiException::class) + protected fun validateContentType(request: HttpServletRequest) { + // extract content type parts + val contentType = request.contentType + if (contentType == null) { + if (request.method == "GET") return + throw ApiException( + HttpServletResponse.SC_BAD_REQUEST, + "no content type header" + ) + } + val sep = contentType.indexOf(';') + val mimeType: String + var charset: String? = null + if (sep == -1) mimeType = contentType else { + mimeType = contentType.substring(0, sep).trim { it <= ' ' } + val params = + contentType.substring(sep + 1).split("=".toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray() + if (params.size == 2 && params[0].lowercase(Locale.getDefault()) + .trim { it <= ' ' } == "charset" + ) { + charset = params[1].lowercase(Locale.getDefault()).trim { it <= ' ' } + .replace("-".toRegex(), "") + } + } + + // check charset + if (charset != null && EXPECTED_CHARSET != charset) throw ApiException( + HttpServletResponse.SC_BAD_REQUEST, + "UTF-8 content expected" + ) + + // check content type + if (!isJson(mimeType)) throw ApiException( + HttpServletResponse.SC_BAD_REQUEST, + "JSON content expected" + ) + + // put Json body as request attribute + try { + Json.parse(request.reader)?.let { payload: Json -> + request.setAttribute(ApiHandler.PAYLOAD_KEY, payload) + if (logger.isInfoEnabled) { + logger.logPayload("<< ", payload, true) + } + } + } catch (ioe: IOException) { + throw ApiException(HttpServletResponse.SC_BAD_REQUEST, ioe) + } + } + + @Throws(ApiException::class) + protected fun validateAccept(request: HttpServletRequest) { + val accept = request.getHeader("Accept") + ?: throw ApiException( + HttpServletResponse.SC_BAD_REQUEST, + "Missing 'Accept' header" + ) + if (!isJson(accept)) throw ApiException( + HttpServletResponse.SC_BAD_REQUEST, + "Invalid 'Accept' header" + ) + } + + protected fun setContentType(response: HttpServletResponse) { + response.contentType = "application/json; charset=UTF-8" + } + + protected fun error( + request: HttpServletRequest, + response: HttpServletResponse, + code: Int, + message: String?, + cause: Throwable? = null + ) { + try { + if (code == 500) { + logger.error( + "Request {} {} gave error {} {}", + request.method, + request.requestURI, + code, + message, + cause + ) + } + response.sendError(code, message) + } catch (ioe: IOException) { + logger.error("Could not send back error", ioe) + } + } + + protected fun error(response: HttpServletResponse, code: Int) { + try { + response.sendError(code) + } catch (ioe: IOException) { + logger.error("Could not send back error", ioe) + } + } + + companion object { + protected var logger = LoggerFactory.getLogger("api") + protected const val EXPECTED_CHARSET = "utf8" + const val AUTH_HEADER = "Authorization" + const val AUTH_PREFIX = "Bearer" + private fun isJson(mimeType: String): Boolean { + return "text/json" == mimeType || "application/json" == mimeType || + mimeType.endsWith("+json") + } + } +} diff --git a/src/main/kotlin/org/jeudego/pairgoth/web/Logging.kt b/src/main/kotlin/org/jeudego/pairgoth/web/Logging.kt new file mode 100644 index 0000000..d9fce48 --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/web/Logging.kt @@ -0,0 +1,71 @@ +package org.jeudego.pairgoth.web + +import com.republicate.kson.Json +import org.jeudego.pairgoth.util.Colorizer.blue +import org.jeudego.pairgoth.util.Colorizer.green +import org.jeudego.pairgoth.util.toString +import org.slf4j.Logger +import java.io.StringWriter +import javax.servlet.http.HttpServletRequest + +fun Logger.logRequest(req: HttpServletRequest, logHeaders: Boolean = false) { + val builder = StringBuilder() + builder.append(req.method).append(' ') + .append(req.scheme).append("://") + .append(req.localName) + val port = req.localPort + if (port != 80) builder.append(':').append(port) + if (!req.contextPath.isEmpty()) { + builder.append(req.contextPath) + } + builder.append(req.requestURI) + if (req.method == "GET") { + val qs = req.queryString + if (qs != null) builder.append('?').append(qs) + } + // builder.append(' ').append(req.getProtocol()); + info(blue("<< {}"), builder.toString()) + if (logHeaders) { + // CB TODO - should be bufferized and asynchronously written in synchronous chunks + // so that header lines from parallel requests are not mixed up in the logs ; + // synchronizing the whole request log is not desirable + val headerNames = req.headerNames + while (headerNames.hasMoreElements()) { + val name = headerNames.nextElement() + val value = req.getHeader(name) + info(blue("<< {}: {}"), name, value) + } + } +} + +fun Logger.logPayload(prefix: String?, payload: Json, upstream: Boolean) { + val writer = StringWriter() + //payload.toPrettyString(writer, ""); + payload.toString(writer) + if (isTraceEnabled) { + for (line in writer.toString().split("\n".toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray()) { + trace(if (upstream) blue("{}{}") else green("{}{}"), prefix, line) + } + } else { + var line = writer.toString() + val pos = line.indexOf('\n') + if (pos != -1) line = line.substring(0, pos) + if (line.length > 50) line = line.substring(0, 50) + "..." + debug(if (upstream) blue("{}{}") else green("{}{}"), prefix, line) + } +} + + +fun HttpServletRequest.getRemoteAddress(): String? { + var ip = getHeader("X-Forwarded-For") + if (ip == null) { + ip = remoteAddr + } else { + val comma = ip.indexOf(',') + if (comma != -1) { + ip = ip.substring(0, comma).trim { it <= ' ' } // keep the left-most IP address + } + } + return ip +} diff --git a/src/main/kotlin/org/jeudego/pairgoth/web/WebappManager.kt b/src/main/kotlin/org/jeudego/pairgoth/web/WebappManager.kt new file mode 100644 index 0000000..25bfe49 --- /dev/null +++ b/src/main/kotlin/org/jeudego/pairgoth/web/WebappManager.kt @@ -0,0 +1,184 @@ +package org.jeudego.pairgoth.web + +import com.republicate.mailer.SmtpLoop +import org.apache.commons.lang3.tuple.Pair +import org.slf4j.LoggerFactory +import java.io.IOException +import java.security.SecureRandom +import java.security.cert.X509Certificate +import java.util.* +import javax.net.ssl.* +import javax.servlet.* +import javax.servlet.annotation.WebListener +import javax.servlet.http.HttpSessionEvent +import javax.servlet.http.HttpSessionListener + +@WebListener +class WebappManager : ServletContextListener, ServletContextAttributeListener, HttpSessionListener { + private fun disableSSLCertificateChecks() { + // see http://www.nakov.com/blog/2009/07/16/disable-certificate-validation-in-java-ssl-connections/ + try { + // Create a trust manager that does not validate certificate chains + val trustAllCerts = arrayOf(object : X509TrustManager { + override fun getAcceptedIssuers(): Array? { + return null + } + + @Suppress("TrustAllX509TrustManager") + override fun checkClientTrusted(certs: Array, authType: String) {} + @Suppress("TrustAllX509TrustManager") + override fun checkServerTrusted(certs: Array, authType: String) {} + } + ) + + // Install the all-trusting trust manager + val sc = SSLContext.getInstance("SSL") + sc.init(null, trustAllCerts, SecureRandom()) + HttpsURLConnection.setDefaultSSLSocketFactory(sc.socketFactory) + + // Create all-trusting host name verifier + val allHostsValid = HostnameVerifier { hostname, session -> true } + + // Install the all-trusting host verifier + HttpsURLConnection.setDefaultHostnameVerifier(allHostsValid) + } catch (e: Exception) { + logger.error("could not disable SSL certificate checks", e) + } + } + + /* ServletContextListener interface */ + override fun contextInitialized(sce: ServletContextEvent) { + // overcome a Jetty's bug (v9.4.10.v20180503) whereas if a @WebListener is also listed in the descriptor + // it will be instanciated twice... + context = sce.servletContext + logger.info("---------- Starting Web Application ----------") + context.setAttribute("manager", this) + webappRoot = context.getRealPath("/") + try { + properties.load(context.getResourceAsStream("/WEB-INF/webapp.properties")) + val submaps: MutableMap> = HashMap() + for (prop in properties.stringPropertyNames()) { + val value = properties.getProperty(prop) + + // filter out missing values and passwords + if (value.startsWith("\${") || prop.contains("password")) continue + context.setAttribute(prop, value) + + // also support one level of submaps (TODO - more) + val dot = prop.indexOf('.') + if (dot != -1) { + val topKey = prop.substring(0, dot) + val subKey = prop.substring(dot + 1) + if ("password" == subKey) continue + var submap = submaps[topKey] + if (submap == null) { + submap = HashMap() + submaps[topKey] = submap + } + submap[subKey] = value + } + } + for ((key, value) in submaps) { + context.setAttribute(key, value) + } + logger.info("Using profile {}", properties.getProperty("webapp.env")) + + // set system user agent string to empty string + System.setProperty("http.agent", "") + + // disable (for now ?) the SSL certificate checks, because many sites + // fail to correctly implement SSL... + disableSSLCertificateChecks() + + // start smtp loop + if (properties.containsKey("smtp.host")) { + registerService("smtp", SmtpLoop(properties)) + startService("smtp") + } + + } catch (ioe: IOException) { + logger.error("webapp initialization error", ioe) + } + } + + override fun contextDestroyed(sce: ServletContextEvent) { + logger.info("---------- Stopping Web Application ----------") + + // overcome a Jetty's bug (v9.4.10.v20180503) whereas if a @WebListener is also listed in the descriptor + // it will be instanciated twice... + if (context == null) return + val context = sce.servletContext + for (service in webServices.keys) stopService(service, true) + // ??? DriverManager.deregisterDriver(com.mysql.cj.jdbc.Driver ...); + } + + /* ServletContextAttributeListener interface */ + override fun attributeAdded(event: ServletContextAttributeEvent) {} + override fun attributeRemoved(event: ServletContextAttributeEvent) {} + override fun attributeReplaced(event: ServletContextAttributeEvent) {} + + /* HttpSessionListener interface */ + override fun sessionCreated(se: HttpSessionEvent) {} + override fun sessionDestroyed(se: HttpSessionEvent) {} + + companion object { + lateinit var webappRoot: String + lateinit var context: ServletContext + private val webServices: MutableMap> = TreeMap() + var logger = LoggerFactory.getLogger(WebappManager::class.java) + val properties = Properties() + fun getProperty(prop: String?): String { + return properties.getProperty(prop) + } + + val webappURL by lazy { getProperty("webapp.url") } + + private val services = mutableMapOf>() + + @JvmOverloads + fun registerService(name: String?, task: Runnable, initialStatus: Boolean? = null) { + if (webServices.containsKey(name)) { + logger.warn("service {} already registered") + return + } + logger.debug("registered service {}", name) + webServices[name] = + Pair.of(task, null) + } + + fun startService(name: String?) { + val service = webServices[name]!! + if (service.right != null && service.right!!.isAlive) { + logger.warn("service {} is already running", name) + return + } + logger.debug("starting service {}", name) + val thread = Thread(service.left, name) + thread.start() + webServices[name] = + Pair.of( + service.left, + thread + ) + } + + @JvmOverloads + fun stopService(name: String?, webappClosing: Boolean = false) { + val service = webServices[name]!! + val thread = service.right + if (thread == null || !thread.isAlive) { + logger.warn("service {} is already stopped", name) + return + } + logger.debug("stopping service {}", name) + thread.interrupt() + try { + thread.join() + } catch (ie: InterruptedException) { + } + if (!webappClosing) { + webServices[name] = Pair.of(service.left, null) + } + } + } +} diff --git a/src/main/webapp/WEB-INF/jetty-web.xml b/src/main/webapp/WEB-INF/jetty-web.xml new file mode 100644 index 0000000..b4f78e5 --- /dev/null +++ b/src/main/webapp/WEB-INF/jetty-web.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/main/webapp/WEB-INF/logger.properties b/src/main/webapp/WEB-INF/logger.properties new file mode 100644 index 0000000..c88eccb --- /dev/null +++ b/src/main/webapp/WEB-INF/logger.properties @@ -0,0 +1,2 @@ +format = %date [%level] %ip [%logger] %message (@%file:%line:%column) +level = trace diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml new file mode 120000 index 0000000..98643eb --- /dev/null +++ b/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1 @@ +../../../../target/pairgoth-1.0-SNAPSHOT/WEB-INF/web.xml \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/webapp.properties b/src/main/webapp/WEB-INF/webapp.properties new file mode 120000 index 0000000..c04dfb6 --- /dev/null +++ b/src/main/webapp/WEB-INF/webapp.properties @@ -0,0 +1 @@ +../../../../target/pairgoth-1.0-SNAPSHOT/WEB-INF/webapp.properties \ No newline at end of file diff --git a/src/test/kotlin/.gitkeep b/src/test/kotlin/.gitkeep new file mode 100644 index 0000000..e69de29