From b40250c639c2a9f074d651cd261de89360b17b7c Mon Sep 17 00:00:00 2001 From: Claude Brisson Date: Wed, 7 Jun 2023 17:48:14 +0200 Subject: [PATCH] View webapp in progress --- api-webapp/pom.xml | 15 +- .../{webapp/WEB-INF => config}/jetty-web.xml | 0 .../pairgoth.default.properties | 0 .../main/{webapp/WEB-INF => config}/web.xml | 0 .../org/jeudego/pairgoth/model/Pairable.kt | 3 - pom.xml | 4 +- view-webapp/pom.xml | 262 ++++++++++++++++++ view-webapp/src/main/config/jetty-web.xml | 10 + .../main/config/pairgoth.default.properties | 18 ++ view-webapp/src/main/config/translations/fr | 0 view-webapp/src/main/config/web.xml | 56 ++++ .../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/util/Colorizer.kt | 18 ++ .../org/jeudego/pairgoth/util/JsonIO.kt | 25 ++ .../pairgoth/util/TranslateDirective.kt | 20 ++ .../org/jeudego/pairgoth/util/Translator.kt | 169 +++++++++++ .../jeudego/pairgoth/view/TranslationTool.kt | 17 ++ .../jeudego/pairgoth/web/DispatchingFilter.kt | 38 +++ .../jeudego/pairgoth/web/LanguageFilter.kt | 65 +++++ .../org/jeudego/pairgoth/web/Logging.kt | 71 +++++ .../org/jeudego/pairgoth/web/SSEServlet.kt | 30 ++ .../org/jeudego/pairgoth/web/ViewServlet.kt | 93 +++++++ .../org/jeudego/pairgoth/web/WebappManager.kt | 167 +++++++++++ view-webapp/src/test/kotlin/.gitkeep | 0 view-webapp/src/test/kotlin/TestBase.kt | 24 ++ 30 files changed, 1262 insertions(+), 19 deletions(-) rename api-webapp/src/main/{webapp/WEB-INF => config}/jetty-web.xml (100%) rename api-webapp/src/main/{webapp/WEB-INF => config}/pairgoth.default.properties (100%) rename api-webapp/src/main/{webapp/WEB-INF => config}/web.xml (100%) create mode 100644 view-webapp/pom.xml create mode 100644 view-webapp/src/main/config/jetty-web.xml create mode 100644 view-webapp/src/main/config/pairgoth.default.properties create mode 100644 view-webapp/src/main/config/translations/fr create mode 100644 view-webapp/src/main/config/web.xml create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/FacebookHelper.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/GoogleHelper.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/InstagramHelper.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OAuthHelper.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OauthHelperFactory.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/TwitterHelper.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/Colorizer.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/JsonIO.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/TranslateDirective.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/Translator.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/view/TranslationTool.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/DispatchingFilter.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/LanguageFilter.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/Logging.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/SSEServlet.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/ViewServlet.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/WebappManager.kt create mode 100644 view-webapp/src/test/kotlin/.gitkeep create mode 100644 view-webapp/src/test/kotlin/TestBase.kt 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("") + 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 =====") + } +}