View webapp in progress

This commit is contained in:
Claude Brisson
2023-06-07 17:48:14 +02:00
parent ea02bb3e81
commit b40250c639
30 changed files with 1262 additions and 19 deletions

262
view-webapp/pom.xml Normal file
View File

@@ -0,0 +1,262 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.jeudego.pairgoth</groupId>
<artifactId>engine-parent</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>view-webapp</artifactId>
<packaging>war</packaging>
<name>${project.groupId}:${project.artifactId}</name>
<description>PairGoth pairing system</description>
<url>TODO</url>
<properties>
<kotlin.version>1.8.21</kotlin.version>
<kotlin.code.style>official</kotlin.code.style>
<kotlin.compiler.jvmTarget>10</kotlin.compiler.jvmTarget>
<kotlin.compiler.incremental>true</kotlin.compiler.incremental>
<pac4j.version>5.7.1</pac4j.version>
</properties>
<build>
<defaultGoal>package</defaultGoal>
<sourceDirectory>src/main/kotlin</sourceDirectory>
<testSourceDirectory>src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>test-compile</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-resources</id>
<phase>process-resources</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/${project.build.finalName}/WEB-INF</outputDirectory>
<resources>
<resource>
<directory>${project.basedir}/src/main/config</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<classpathDependencyExcludes>
<classpathDependencyExclude>com.republicate:webapp-slf4j-logger</classpathDependencyExclude>
</classpathDependencyExcludes>
</configuration>
</plugin>
</plugins>
</build>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.9.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- main dependencies -->
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test-junit5</artifactId>
<version>${kotlin.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-datetime-jvm</artifactId>
<version>0.4.0</version>
</dependency>
<!-- servlets and mail APIs -->
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>${servlet.api.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>jakarta.mail</artifactId>
<version>1.6.7</version>
</dependency>
<!-- auth -->
<dependency>
<groupId>org.pac4j</groupId>
<artifactId>pac4j-oauth</artifactId>
<version>${pac4j.version}</version>
</dependency>
<!-- logging -->
<dependency>
<groupId>io.github.microutils</groupId>
<artifactId>kotlin-logging-jvm</artifactId>
<version>3.0.5</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>com.republicate</groupId>
<artifactId>webapp-slf4j-logger</artifactId>
<version>3.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>${slf4j.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.diogonunes</groupId>
<artifactId>JColor</artifactId>
<version>5.0.1</version>
</dependency>
<!-- mailer -->
<dependency>
<groupId>com.republicate</groupId>
<artifactId>simple-mailer</artifactId>
<version>1.6</version>
</dependency>
<!-- json -->
<dependency>
<groupId>com.republicate.kson</groupId>
<artifactId>essential-kson-jvm</artifactId>
<version>2.3</version>
</dependency>
<!-- charset detection
<dependency>
<groupId>com.ibm.icu</groupId>
<artifactId>icu4j</artifactId>
<version>70.1</version>
</dependency>
-->
<!-- net clients
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.13</version>
<exclusions>
<exclusion>
<artifactId>commons-logging</artifactId>
<groupId>commons-logging</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpmime</artifactId>
<version>4.5.13</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
<dependency>
<groupId>commons-net</groupId>
<artifactId>commons-net</artifactId>
<version>3.8.0</version>
</dependency>
-->
<!-- server-side events -->
<dependency>
<groupId>com.republicate</groupId>
<artifactId>jeasse-servlet3</artifactId>
<version>1.2</version>
</dependency>
<!-- templating -->
<dependency>
<groupId>org.apache.velocity.tools</groupId>
<artifactId>velocity-tools-view</artifactId>
<version>3.1</version>
</dependency>
<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
<version>2.4-SNAPSHOT</version>
</dependency>
<!-- tests -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito.kotlin</groupId>
<artifactId>mockito-kotlin</artifactId>
<version>4.1.0</version>
<scope>test</scope>
</dependency>
<!-- test emails -->
<dependency>
<groupId>com.icegreen</groupId>
<artifactId>greenmail</artifactId>
<version>1.6.12</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</exclusion>
<exclusion>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
</exclusion>
<exclusion>
<groupId>com.sun.mail</groupId>
<artifactId>javax.mail</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE Configure PUBLIC "-//Mort Bay Consulting//DTD Configure//EN" "http://www.eclipse.org/jetty/configure.dtd">
<Configure class="org.eclipse.jetty.webapp.WebAppContext">
<!-- https://www.eclipse.org/jetty/documentation/jetty-9/index.html#file-alias-serving -->
<Call name="addAliasCheck">
<Arg>
<New class="org.eclipse.jetty.server.handler.AllowSymLinkAliasChecker" />
</Arg>
</Call>
</Configure>

View File

@@ -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

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
version="3.1">
<!-- Listeners -->
<!-- we're not using @WebListener annotations so that our manager is initialized *after* the webapp logger -->
<listener>
<listener-class>com.republicate.slf4j.impl.ServletContextLoggerListener</listener-class>
</listener>
<listener>
<listener-class>org.jeudego.pairgoth.web.WebappManager</listener-class>
</listener>
<!-- filters -->
<filter>
<filter-name>webapp-slf4j-logger-ip-tag-filter</filter-name>
<filter-class>com.republicate.slf4j.impl.IPTagFilter</filter-class>
<async-supported>true</async-supported>
</filter>
<!-- filters mapping -->
<filter-mapping>
<filter-name>webapp-slf4j-logger-ip-tag-filter</filter-name>
<url-pattern>/*</url-pattern>
<dispatcher>REQUEST</dispatcher>
<dispatcher>FORWARD</dispatcher>
</filter-mapping>
<!-- servlets -->
<servlet>
<servlet-name>view</servlet-name>
<servlet-class>org.jeudego.pairgoth.web.ViewServlet</servlet-class>
</servlet>
<servlet>
<servlet-name>sse</servlet-name>
<servlet-class>org.jeudego.pairgoth.web.SSEServlet</servlet-class>
<load-on-startup>1</load-on-startup>
<async-supported>true</async-supported>
</servlet>
<!-- servlet mappings -->
<servlet-mapping>
<servlet-name>view</servlet-name>
<url-pattern>/*</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>sse</servlet-name>
<url-pattern>/events/*</url-pattern>
</servlet-mapping>
<!-- context params -->
<context-param>
<param-name>webapp-slf4j-logger.format</param-name>
<param-value>%logger [%level] [%ip] %message @%file:%line:%column</param-value>
</context-param>
</web-app>

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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")
}
}
}

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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
}
})

View File

@@ -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)
}
}

View File

@@ -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<String, String?> {
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<NavigableMap<Int, Boolean>, String?> {
val map: NavigableMap<Int, Boolean> = 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<Pair<String, String>, Template> = ConcurrentHashMap<Pair<String, String>, Template>()
private val textExtractor = Pattern.compile(
"<[^>]+\\splaceholder=\"(?<placeholder>[^\"]*)\"[^>]*>|(?<=>)(?:[ \\r\\n\\t\u00A0/-]|&nbsp;|&dash;)*(?<text>[^<>]+?)(?:[ \\r\\n\\t\u00A0/-]|&nbsp;|&dash;)*(?=<|$)|(?<=>|^)(?:[ \\r\\n\\t\u00A0/-]|&nbsp;|&dash;)*(?<text2>[^<>]+?)(?:[ \\r\\n\\t\u00A0/-]|&nbsp;|&dash;)*(?=<)",
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<String, Translator>()
fun getTranslator(iso: String) = translators.getOrPut(iso) { Translator(iso) }
val providedLanguages = setOf("en", "fr")
const val defaultLanguage = "en"
}
}

View File

@@ -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<Translator>()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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.]+))?")
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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"
}
}

View File

@@ -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<TrustManager>(object : X509TrustManager {
override fun getAcceptedIssuers(): Array<X509Certificate>? {
return null
}
@Suppress("TrustAllX509TrustManager")
override fun checkClientTrusted(certs: Array<X509Certificate>, authType: String) {}
@Suppress("TrustAllX509TrustManager")
override fun checkServerTrusted(certs: Array<X509Certificate>, 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<String?, Pair<Runnable, Thread?>> = 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<String, Pair<Runnable, Thread>>()
@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)
}
}
}
}

View File

View File

@@ -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 =====")
}
}