diff --git a/api-webapp/pom.xml b/api-webapp/pom.xml
index bd4cc47..ef8e7ca 100644
--- a/api-webapp/pom.xml
+++ b/api-webapp/pom.xml
@@ -48,10 +48,6 @@
-
- org.apache.maven.plugins
- maven-enforcer-plugin
-
org.apache.maven.plugins
maven-resources-plugin
@@ -66,9 +62,7 @@
${project.build.directory}/${project.build.finalName}/WEB-INF
- ${basedir}/..
- pairgoth.properties
-
+ ${project.basedir}/src/main/config
@@ -161,13 +155,6 @@
${slf4j.version}
test
-
com.diogonunes
JColor
diff --git a/api-webapp/src/main/webapp/WEB-INF/jetty-web.xml b/api-webapp/src/main/config/jetty-web.xml
similarity index 100%
rename from api-webapp/src/main/webapp/WEB-INF/jetty-web.xml
rename to api-webapp/src/main/config/jetty-web.xml
diff --git a/api-webapp/src/main/webapp/WEB-INF/pairgoth.default.properties b/api-webapp/src/main/config/pairgoth.default.properties
similarity index 100%
rename from api-webapp/src/main/webapp/WEB-INF/pairgoth.default.properties
rename to api-webapp/src/main/config/pairgoth.default.properties
diff --git a/api-webapp/src/main/webapp/WEB-INF/web.xml b/api-webapp/src/main/config/web.xml
similarity index 100%
rename from api-webapp/src/main/webapp/WEB-INF/web.xml
rename to api-webapp/src/main/config/web.xml
diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt
index 711d8c0..369b749 100644
--- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt
+++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt
@@ -1,11 +1,8 @@
package org.jeudego.pairgoth.model
import com.republicate.kson.Json
-import com.republicate.kson.toJsonArray
-import org.jeudego.pairgoth.api.ApiHandler
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.store.Store
-import kotlin.math.roundToInt
// Pairable
diff --git a/pom.xml b/pom.xml
index 88720b9..14177a4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -23,7 +23,7 @@
republicate.com
https://republicate.com/maven2
- true
+ false
true
@@ -60,7 +60,7 @@
api-webapp
-
+ view-webapp
container
bootstrap
application
diff --git a/view-webapp/pom.xml b/view-webapp/pom.xml
new file mode 100644
index 0000000..f6afb56
--- /dev/null
+++ b/view-webapp/pom.xml
@@ -0,0 +1,262 @@
+
+
+ 4.0.0
+
+
+ org.jeudego.pairgoth
+ engine-parent
+ 1.0-SNAPSHOT
+
+ view-webapp
+
+ war
+ ${project.groupId}:${project.artifactId}
+ PairGoth pairing system
+ TODO
+
+ 1.8.21
+ official
+ 10
+ true
+ 5.7.1
+
+
+ package
+ src/main/kotlin
+ src/test/kotlin
+
+
+ org.jetbrains.kotlin
+ kotlin-maven-plugin
+ ${kotlin.version}
+
+
+ compile
+ compile
+
+ compile
+
+
+
+ test-compile
+ test-compile
+
+ test-compile
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-resources-plugin
+
+
+ copy-resources
+ process-resources
+
+ copy-resources
+
+
+ ${project.build.directory}/${project.build.finalName}/WEB-INF
+
+
+ ${project.basedir}/src/main/config
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ com.republicate:webapp-slf4j-logger
+
+
+
+
+
+
+
+
+ org.junit
+ junit-bom
+ 5.9.3
+ pom
+ import
+
+
+
+
+
+
+ org.jetbrains.kotlin
+ kotlin-test-junit5
+ ${kotlin.version}
+ test
+
+
+ org.jetbrains.kotlin
+ kotlin-stdlib-jdk8
+ ${kotlin.version}
+
+
+ org.jetbrains.kotlin
+ kotlin-reflect
+ ${kotlin.version}
+
+
+ org.jetbrains.kotlinx
+ kotlinx-datetime-jvm
+ 0.4.0
+
+
+
+ jakarta.servlet
+ jakarta.servlet-api
+ ${servlet.api.version}
+ provided
+
+
+ com.sun.mail
+ jakarta.mail
+ 1.6.7
+
+
+
+ org.pac4j
+ pac4j-oauth
+ ${pac4j.version}
+
+
+
+ io.github.microutils
+ kotlin-logging-jvm
+ 3.0.5
+
+
+ org.slf4j
+ slf4j-api
+ ${slf4j.version}
+
+
+ com.republicate
+ webapp-slf4j-logger
+ 3.0
+ runtime
+
+
+ org.slf4j
+ slf4j-simple
+ ${slf4j.version}
+ test
+
+
+ com.diogonunes
+ JColor
+ 5.0.1
+
+
+
+ com.republicate
+ simple-mailer
+ 1.6
+
+
+
+ com.republicate.kson
+ essential-kson-jvm
+ 2.3
+
+
+
+
+
+ com.republicate
+ jeasse-servlet3
+ 1.2
+
+
+
+ org.apache.velocity.tools
+ velocity-tools-view
+ 3.1
+
+
+ org.apache.velocity
+ velocity-engine-core
+ 2.4-SNAPSHOT
+
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ ${junit.jupiter.version}
+ test
+
+
+ org.mockito.kotlin
+ mockito-kotlin
+ 4.1.0
+ test
+
+
+
+ com.icegreen
+ greenmail
+ 1.6.12
+ test
+
+
+ junit
+ junit
+
+
+ javax.activation
+ activation
+
+
+ com.sun.mail
+ javax.mail
+
+
+
+
+
diff --git a/view-webapp/src/main/config/jetty-web.xml b/view-webapp/src/main/config/jetty-web.xml
new file mode 100644
index 0000000..b4f78e5
--- /dev/null
+++ b/view-webapp/src/main/config/jetty-web.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/view-webapp/src/main/config/pairgoth.default.properties b/view-webapp/src/main/config/pairgoth.default.properties
new file mode 100644
index 0000000..b0ebec3
--- /dev/null
+++ b/view-webapp/src/main/config/pairgoth.default.properties
@@ -0,0 +1,18 @@
+# webapp
+webapp.env = dev
+webapp.url = http://localhost:8080
+
+# store
+store = file
+store.file.path = tournamentfiles
+
+# smtp
+smtp.sender =
+smtp.host =
+smtp.port = 587
+smtp.user =
+smtp.password =
+
+# logging
+logger.level = trace
+logger.format = [%level] %ip [%logger] %message
diff --git a/view-webapp/src/main/config/translations/fr b/view-webapp/src/main/config/translations/fr
new file mode 100644
index 0000000..e69de29
diff --git a/view-webapp/src/main/config/web.xml b/view-webapp/src/main/config/web.xml
new file mode 100644
index 0000000..b9e50de
--- /dev/null
+++ b/view-webapp/src/main/config/web.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+ 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
+
+
+
+
+ view
+ org.jeudego.pairgoth.web.ViewServlet
+
+
+ sse
+ org.jeudego.pairgoth.web.SSEServlet
+ 1
+ true
+
+
+
+
+ view
+ /*
+
+
+ sse
+ /events/*
+
+
+
+
+ webapp-slf4j-logger.format
+ %logger [%level] [%ip] %message @%file:%line:%column
+
+
diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/FacebookHelper.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/FacebookHelper.kt
new file mode 100644
index 0000000..9b0ab7f
--- /dev/null
+++ b/view-webapp/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/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/GoogleHelper.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/GoogleHelper.kt
new file mode 100644
index 0000000..db3c30d
--- /dev/null
+++ b/view-webapp/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/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/InstagramHelper.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/InstagramHelper.kt
new file mode 100644
index 0000000..20f5089
--- /dev/null
+++ b/view-webapp/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/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OAuthHelper.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OAuthHelper.kt
new file mode 100644
index 0000000..453a7a4
--- /dev/null
+++ b/view-webapp/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.getMandatoryProperty("oauth." + name + ".client_id")
+ protected val secret: String
+ protected get() = WebappManager.getMandatoryProperty("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/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OauthHelperFactory.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OauthHelperFactory.kt
new file mode 100644
index 0000000..45568da
--- /dev/null
+++ b/view-webapp/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/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/TwitterHelper.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/TwitterHelper.kt
new file mode 100644
index 0000000..d0bf47a
--- /dev/null
+++ b/view-webapp/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/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/Colorizer.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/Colorizer.kt
new file mode 100644
index 0000000..f3d2a09
--- /dev/null
+++ b/view-webapp/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/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/JsonIO.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/JsonIO.kt
new file mode 100644
index 0000000..99177ea
--- /dev/null
+++ b/view-webapp/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/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/TranslateDirective.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/TranslateDirective.kt
new file mode 100644
index 0000000..3a30187
--- /dev/null
+++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/TranslateDirective.kt
@@ -0,0 +1,20 @@
+package org.jeudego.pairgoth.util
+
+import org.apache.velocity.Template
+import org.apache.velocity.exception.ResourceNotFoundException
+import org.apache.velocity.runtime.directive.Parse
+import org.jeudego.pairgoth.view.TranslationTool
+import org.jeudego.pairgoth.web.LanguageFilter
+
+class TranslateDirective : Parse() {
+ override fun getName(): String {
+ return "translate"
+ }
+
+ override fun getTemplate(path: String, encoding: String): Template? {
+ val template = super.getTemplate(path, encoding)
+ val translator = TranslationTool.translator.get()
+ ?: throw RuntimeException("no current active translator")
+ return translator.translate(path, template)
+ }
+}
\ No newline at end of file
diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/Translator.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/Translator.kt
new file mode 100644
index 0000000..4a0957e
--- /dev/null
+++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/Translator.kt
@@ -0,0 +1,169 @@
+package org.jeudego.pairgoth.util
+
+import org.apache.commons.lang3.StringEscapeUtils
+import org.apache.commons.lang3.StringUtils
+import org.apache.velocity.Template
+import org.apache.velocity.runtime.parser.node.ASTText
+import org.apache.velocity.runtime.parser.node.SimpleNode
+import org.jeudego.pairgoth.web.WebappManager
+import org.slf4j.LoggerFactory
+import java.io.PrintWriter
+import java.io.StringWriter
+import java.nio.charset.StandardCharsets
+import java.nio.file.Path
+import java.util.*
+import java.util.concurrent.ConcurrentHashMap
+import java.util.regex.Pattern
+import kotlin.io.path.readLines
+import kotlin.io.path.useDirectoryEntries
+
+class Translator private constructor(private val iso: String) {
+
+ fun translate(enText: String) = translations[iso]?.get(enText) ?: enText.also {
+ reportMissingTranslation(enText)
+ }
+
+ fun translate(uri: String, template: Template): Template? {
+ if (iso == "en") return template
+ val key = Pair(uri, iso)
+ var translated = translatedTemplates[key]
+ if (translated != null && translated.lastModified < template.lastModified) {
+ translatedTemplates.remove(key)
+ translated = null
+ }
+ if (translated == null) {
+ synchronized(translatedTemplates) {
+ translated = translatedTemplates[key]
+ if (translated == null) {
+ translated = template.clone() as Template
+ val data: SimpleNode = translated!!.data as SimpleNode
+ translateNode(data)
+ translatedTemplates[key] = translated!!
+ }
+ }
+ }
+ return translated
+ }
+
+ private fun translateNode(node: SimpleNode, ignoringInput: String? = null): String? {
+ var ignoring = ignoringInput
+ if (node is ASTText) translateFragments(node.text, ignoring).let {
+ node.text = it.first
+ ignoring = it.second
+ }
+ else for (i in 0 until node.jjtGetNumChildren()) {
+ ignoring = translateNode(node.jjtGetChild(i) as SimpleNode, ignoring)
+ }
+ return ignoring
+ }
+
+ private fun translateFragments(text: String, ignoringInput: String?): Pair {
+ var ignoring = ignoringInput
+ val ignoreMap = buildIgnoreMap(text, ignoring).also {
+ ignoring = it.second
+ }.first
+ val sw = StringWriter()
+ val output = PrintWriter(sw)
+ val matcher = textExtractor.matcher(text)
+ var pos = 0
+ while (matcher.find(pos)) {
+ val start = matcher.start()
+ val end = matcher.end()
+ if (start > pos) output.print(text.substring(pos, start))
+ val ignore: Boolean = ignoreMap.floorEntry(start).value
+ if (ignore) output.print(text.substring(start, end)) else {
+ var group = 1
+ var groupStart = matcher.start(group)
+ while (groupStart == -1 && group < matcher.groupCount()) groupStart = matcher.start(++group)
+ if (groupStart == -1) throw RuntimeException("unexpected case")
+ if (groupStart > start) output.print(text.substring(start, groupStart))
+ val capture = matcher.group(group)
+ var token: String = StringEscapeUtils.unescapeHtml4(capture)
+ if (StringUtils.containsOnly(token, "\r\n\t -;:.\"/<>\u00A00123456789€!")) output.print(capture) else {
+ token = normalize(token)
+ token = translate(token)
+ output.print(StringEscapeUtils.escapeHtml4(token))
+ }
+ val groupEnd = matcher.end(group)
+ if (groupEnd < end) output.print(text.substring(groupEnd, end))
+ }
+ pos = end
+ }
+ if (pos < text.length) output.print(text.substring(pos))
+ return Pair(sw.toString(), ignoring)
+ }
+
+ private fun normalize(str: String): String {
+ return str.replace(Regex("\\s+"), " ")
+ }
+
+ private fun buildIgnoreMap(text: String, ignoringInput: String?): Pair, String?> {
+ val map: NavigableMap = TreeMap()
+ var ignoring = ignoringInput
+ var pos = 0
+ map[0] = (ignoring != null)
+ while (pos < text.length) {
+ if (ignoring == null) {
+ val nextIgnore = ignoredTags.map { tag ->
+ Pair(tag, text.indexOf("<$tag(?:>\\s)"))
+ }.filter {
+ it.second != -1
+ }.sortedBy {
+ it.second
+ }.firstOrNull()
+ if (nextIgnore == null) pos = text.length
+ else {
+ ignoring = nextIgnore.first
+ pos += nextIgnore.first.length + 2
+ }
+ } else {
+ val closingTag = text.indexOf("$ignoring>")
+ if (closingTag == -1) pos = text.length
+ else {
+ pos += ignoring.length + 3
+ ignoring = null
+ }
+ }
+ }
+ return Pair(map, ignoring)
+ }
+
+ private var ASTText.text: String
+ get() = textAccessor[this] as String
+ set(value: String) { textAccessor[this] = value }
+
+ private fun reportMissingTranslation(enText: String) {
+ logger.warn("missing translation towards {}: {}", iso, enText)
+ // CB TODO - create file
+ }
+
+ companion object {
+ private val textAccessor = ASTText::class.java.getDeclaredField("ctext").apply { isAccessible = true }
+ private val logger = LoggerFactory.getLogger("translation")
+ private val translatedTemplates: MutableMap, Template> = ConcurrentHashMap, Template>()
+ private val textExtractor = Pattern.compile(
+ "<[^>]+\\splaceholder=\"(?[^\"]*)\"[^>]*>|(?<=>)(?:[ \\r\\n\\t\u00A0/–-]| |‐)*(?[^<>]+?)(?:[ \\r\\n\\t\u00A0/–-]| |‐)*(?=<|$)|(?<=>|^)(?:[ \\r\\n\\t\u00A0/–-]| |‐)*(?[^<>]+?)(?:[ \\r\\n\\t\u00A0/–-]| |‐)*(?=<)",
+ Pattern.DOTALL
+ )
+ private val ignoredTags = setOf("head", "script", "style")
+
+ private val translations = Path.of(WebappManager.context.getRealPath("WEB-INF/translations")).useDirectoryEntries("??") { entries ->
+ entries.map { file ->
+ Pair(
+ file.fileName.toString(),
+ file.readLines(StandardCharsets.UTF_8).filter {
+ it.isNotEmpty() && it.contains('\t') && !it.startsWith('#')
+ }.map {
+ Pair(it.substringBefore('\t'), it.substringAfter('\t'))
+ }.toMap()
+ )
+ }.toMap()
+ }
+
+ private val translators = ConcurrentHashMap()
+ fun getTranslator(iso: String) = translators.getOrPut(iso) { Translator(iso) }
+
+ val providedLanguages = setOf("en", "fr")
+ const val defaultLanguage = "en"
+ }
+}
diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/view/TranslationTool.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/view/TranslationTool.kt
new file mode 100644
index 0000000..c9299ed
--- /dev/null
+++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/view/TranslationTool.kt
@@ -0,0 +1,17 @@
+package org.jeudego.pairgoth.view
+
+import org.apache.velocity.tools.config.ValidScope
+import org.jeudego.pairgoth.util.Translator
+import javax.servlet.http.HttpServletRequest
+
+@ValidScope("request")
+class TranslationTool {
+
+ fun translate(enText: String): String {
+ return translator.get().translate(enText)
+ }
+
+ companion object {
+ val translator = ThreadLocal()
+ }
+}
\ No newline at end of file
diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/DispatchingFilter.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/DispatchingFilter.kt
new file mode 100644
index 0000000..ca3bcea
--- /dev/null
+++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/DispatchingFilter.kt
@@ -0,0 +1,38 @@
+package org.jeudego.egc2024.web
+
+import org.slf4j.LoggerFactory
+import javax.servlet.Filter
+import javax.servlet.FilterChain
+import javax.servlet.FilterConfig
+import javax.servlet.RequestDispatcher
+import javax.servlet.ServletException
+import javax.servlet.ServletRequest
+import javax.servlet.ServletResponse
+import javax.servlet.http.HttpServletRequest
+import javax.servlet.http.HttpServletResponse
+
+class DispatchingFilter : Filter {
+
+ protected val defaultRequestDispatcher: RequestDispatcher by lazy {
+ filterConfig.servletContext.getNamedDispatcher("default")
+ }
+
+ private lateinit var filterConfig: FilterConfig
+
+ override fun init(filterConfig: FilterConfig) {
+ this.filterConfig = filterConfig
+ }
+
+ override fun destroy() {}
+
+ override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
+ val req = request as HttpServletRequest
+ val resp = response as HttpServletResponse
+ val uri = req.requestURI
+ when {
+ uri.endsWith('/') -> response.sendRedirect("${uri}index")
+ uri.contains('.') -> defaultRequestDispatcher.forward(request, response)
+ else -> chain.doFilter(request, response)
+ }
+ }
+}
\ No newline at end of file
diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/LanguageFilter.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/LanguageFilter.kt
new file mode 100644
index 0000000..6b4cab0
--- /dev/null
+++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/LanguageFilter.kt
@@ -0,0 +1,65 @@
+package org.jeudego.pairgoth.web
+
+import org.jeudego.pairgoth.util.Translator
+import org.jeudego.pairgoth.util.Translator.Companion.defaultLanguage
+import org.jeudego.pairgoth.util.Translator.Companion.getTranslator
+import org.jeudego.pairgoth.util.Translator.Companion.providedLanguages
+import org.jeudego.pairgoth.view.TranslationTool
+import javax.servlet.Filter
+import javax.servlet.FilterChain
+import javax.servlet.FilterConfig
+import javax.servlet.ServletRequest
+import javax.servlet.ServletResponse
+import javax.servlet.http.HttpServletRequest
+import javax.servlet.http.HttpServletResponse
+
+class LanguageFilter : Filter {
+ private var filterConfig: FilterConfig? = null
+
+ override fun init(filterConfig: FilterConfig) {
+ this.filterConfig = filterConfig
+ }
+
+ override fun doFilter(req: ServletRequest, resp: ServletResponse, chain: FilterChain) {
+ val request = req as HttpServletRequest
+ val response = resp as HttpServletResponse
+
+ val uri = request.requestURI
+ val match = langPattern.matchEntire(uri)
+ val lang = match?.groupValues?.get(1)
+ val target = match?.groupValues?.get(2) ?: uri
+
+ if (lang != null && providedLanguages.contains(lang)) {
+ // the target URI contains a language we provide
+ request.setAttribute("lang", lang)
+ request.setAttribute("target", target)
+ TranslationTool.translator.set(Translator.getTranslator(lang))
+ chain.doFilter(request, response)
+ } else {
+ // the request must be redirected
+ val preferredLanguage = getPreferredLanguage(request)
+ val destination = if (lang != null) target else uri
+ response.sendRedirect("${preferredLanguage}${destination}")
+ }
+ }
+
+ private fun getPreferredLanguage(request: HttpServletRequest): String {
+ return (request.session.getAttribute("lang") as String?) ?:
+ ( langHeaderParser.findAll(request.getHeader("Accept-Language") ?: "").filter {
+ providedLanguages.contains(it.groupValues[1])
+ }.sortedByDescending {
+ it.groupValues[2].toDoubleOrNull() ?: 1.0
+ }.firstOrNull()?.let {
+ it.groupValues[1]
+ } ?: defaultLanguage ).also {
+ request.session.setAttribute("lang", it)
+ }
+ }
+
+ override fun destroy() {}
+
+ companion object {
+ private val langPattern = Regex("/([a-z]{2})(/.+)")
+ private val langHeaderParser = Regex("(?:\\b(\\*|[a-z]{2})(?:_\\w+)?)(?:;q=([0-9.]+))?")
+ }
+}
diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/Logging.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/Logging.kt
new file mode 100644
index 0000000..22ba455
--- /dev/null
+++ b/view-webapp/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 (isTraceEnabled && 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)
+ trace(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/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/SSEServlet.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/SSEServlet.kt
new file mode 100644
index 0000000..cb8b02c
--- /dev/null
+++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/SSEServlet.kt
@@ -0,0 +1,30 @@
+package org.jeudego.pairgoth.web
+
+import info.macias.sse.EventBroadcast
+import info.macias.sse.events.MessageEvent
+import info.macias.sse.servlet3.ServletEventTarget
+import org.slf4j.LoggerFactory
+import javax.servlet.http.HttpServlet
+import javax.servlet.http.HttpServletRequest
+import javax.servlet.http.HttpServletResponse
+
+
+class SSEServlet: HttpServlet() {
+ companion object {
+ private val logger = LoggerFactory.getLogger("sse")
+ private var zeInstance: SSEServlet? = null
+ internal fun getInstance(): SSEServlet = zeInstance ?: throw Error("SSE servlet not ready")
+ }
+ init {
+ if (zeInstance != null) throw Error("Multiple instances of SSE servlet found!")
+ zeInstance = this
+ }
+ private val broadcast = EventBroadcast()
+
+ override fun doGet(req: HttpServletRequest, resp: HttpServletResponse?) {
+ logger.trace("<< new channel")
+ broadcast.addSubscriber(ServletEventTarget(req), req.getHeader("Last-Event-Id"))
+ }
+
+ internal fun broadcast(message: MessageEvent) = broadcast.broadcast(message)
+}
diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/ViewServlet.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/ViewServlet.kt
new file mode 100644
index 0000000..5a11e25
--- /dev/null
+++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/ViewServlet.kt
@@ -0,0 +1,93 @@
+package org.jeudego.pairgoth.web
+
+import org.apache.commons.lang3.tuple.Pair
+import org.apache.velocity.Template
+import org.apache.velocity.context.Context
+import org.apache.velocity.tools.view.ServletUtils
+import org.apache.velocity.tools.view.VelocityViewServlet
+import org.jeudego.pairgoth.util.Translator
+import org.jeudego.pairgoth.web.WebappManager
+import java.io.File
+import java.io.IOException
+import java.io.InputStreamReader
+import java.io.Serializable
+import java.io.UnsupportedEncodingException
+import java.net.URLDecoder
+import java.nio.charset.StandardCharsets
+import java.text.DateFormat
+import java.util.*
+import java.util.function.Function
+import java.util.stream.Collectors
+import javax.servlet.http.HttpServletRequest
+import javax.servlet.http.HttpServletResponse
+
+class ViewServlet : VelocityViewServlet() {
+ private fun fileExists(path: String): Boolean {
+ return File(servletContext.getRealPath(path)).exists()
+ }
+
+ private fun decodeURI(request: HttpServletRequest): String {
+ var uri = request.requestURI
+ uri = try {
+ URLDecoder.decode(uri, "UTF-8")
+ } catch (use: UnsupportedEncodingException) {
+ throw RuntimeException("could not decode URI $uri", use)
+ }
+ return uri
+ }
+
+ override fun getTemplate(request: HttpServletRequest, response: HttpServletResponse?): Template = getTemplate(STANDARD_LAYOUT)
+
+ override fun fillContext(context: Context, request: HttpServletRequest) {
+ super.fillContext(context, request)
+ var uri = decodeURI(request)
+ context.put("page", uri)
+ val base = uri.replaceFirst(".html$".toRegex(), "")
+ val suffixes = Arrays.asList("js", "css")
+ for (suffix in suffixes) {
+ val resource = "/$suffix$base.$suffix"
+ if (fileExists(resource)) {
+ context.put(suffix, resource)
+ }
+ }
+ val lang = request.getAttribute("lang") as String
+ /*
+ val menu = menuEntries!![uri]
+ var title: String? = null
+ if (lang != null && menu != null) title = menu.getString(lang)
+ if (title != null) context.put("title", title)
+ if (lang != null) context.put(
+ "dateformat",
+ DateFormat.getDateInstance(DateFormat.LONG, Locale.forLanguageTag(lang))
+ )
+ */
+ }
+
+ override fun error(
+ request: HttpServletRequest?,
+ response: HttpServletResponse,
+ e: Throwable?
+ ) {
+ val path: String = ServletUtils.getPath(request)
+ if (response.isCommitted) {
+ log.error("An error occured but the response headers have already been sent.")
+ log.error("Error processing a template for path '{}'", path, e)
+ return
+ }
+ try {
+ log.error("Error processing a template for path '{}'", path, e)
+ response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
+ } catch (e2: Exception) {
+ // clearly something is quite wrong.
+ // let's log the new exception then give up and
+ // throw a runtime exception that wraps the first one
+ val msg = "Exception while printing error screen"
+ log.error(msg, e2)
+ throw RuntimeException(msg, e)
+ }
+ }
+
+ companion object {
+ private const val STANDARD_LAYOUT = "/WEB-INF/layouts/standard.html"
+ }
+}
\ No newline at end of file
diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/WebappManager.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/WebappManager.kt
new file mode 100644
index 0000000..1688dc9
--- /dev/null
+++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/WebappManager.kt
@@ -0,0 +1,167 @@
+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.lang.IllegalAccessError
+import java.security.SecureRandom
+import java.security.cert.X509Certificate
+import java.util.*
+import java.util.IllegalFormatCodePointException
+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) {
+ context = sce.servletContext
+ logger.info("---------- Starting Web Application ----------")
+ context.setAttribute("manager", this)
+ webappRoot = context.getRealPath("/")
+ try {
+ // load default properties
+ properties.load(context.getResourceAsStream("/WEB-INF/pairgoth.default.properties"))
+ // override with system properties after stripping off the 'pairgoth.' prefix
+ System.getProperties().filter { (key, value) -> key is String && key.startsWith(PAIRGOTH_PROPERTIES_PREFIX)
+ }.forEach { (key, value) ->
+ properties[(key as String).removePrefix(PAIRGOTH_PROPERTIES_PREFIX)] = 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 ----------")
+
+ 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 {
+ const val PAIRGOTH_PROPERTIES_PREFIX = "pairgoth."
+ 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)
+ }
+ fun getMandatoryProperty(prop: String): String {
+ return properties.getProperty(prop) ?: throw Error("missing property: ${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/view-webapp/src/test/kotlin/.gitkeep b/view-webapp/src/test/kotlin/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/view-webapp/src/test/kotlin/TestBase.kt b/view-webapp/src/test/kotlin/TestBase.kt
new file mode 100644
index 0000000..7c6e99f
--- /dev/null
+++ b/view-webapp/src/test/kotlin/TestBase.kt
@@ -0,0 +1,24 @@
+package org.jeudego.pairgoth.test
+
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.TestInfo
+import org.slf4j.LoggerFactory
+import java.io.File
+
+abstract class TestBase {
+ companion object {
+ val logger = LoggerFactory.getLogger("test")
+
+ @BeforeAll
+ @JvmStatic
+ fun prepare() {
+ }
+ }
+
+ @BeforeEach
+ fun before(testInfo: TestInfo) {
+ val testName = testInfo.displayName.removeSuffix("()")
+ logger.info("===== Running $testName =====")
+ }
+}