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