src/main/kotlin/de/uapcore/lightpit/AbstractServlet.kt

Wed, 07 Aug 2024 18:09:04 +0200

author
Mike Becker <universe@uap-core.de>
date
Wed, 07 Aug 2024 18:09:04 +0200
changeset 318
50052f2fbb3f
parent 298
1275eb652008
child 335
1eed60b779da
permissions
-rw-r--r--

changelog entry for issue #375

actually, this serves as a test if it works

/*
 * Copyright 2021 Mike Becker. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright
 * notice, this list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright
 * notice, this list of conditions and the following disclaimer in the
 * documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package de.uapcore.lightpit

import de.uapcore.lightpit.DataSourceProvider.Companion.SC_ATTR_NAME
import de.uapcore.lightpit.dao.DataAccessObject
import de.uapcore.lightpit.dao.createDataAccessObject
import jakarta.servlet.http.Cookie
import jakarta.servlet.http.HttpServlet
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import java.sql.SQLException
import java.util.*

abstract class AbstractServlet : HttpServlet() {

    companion object {
        const val LANGUAGE_COOKIE_NAME = "lpit_language"
    }
    
    protected val logger = MyLogger()

    /**
     * Contains the GET request mappings.
     */
    private val getMappings = mutableMapOf<PathPattern, MappingMethod>()

    /**
     * Contains the POST request mappings.
     */
    private val postMappings = mutableMapOf<PathPattern, MappingMethod>()

    protected fun get(pattern: String, method: MappingMethod) {
        getMappings[PathPattern(pattern)] = method
    }

    protected fun post(pattern: String, method: MappingMethod) {
        postMappings[PathPattern(pattern)] = method
    }

    private fun notFound(http: HttpRequest, dao: DataAccessObject) {
        http.response.sendError(HttpServletResponse.SC_NOT_FOUND)
    }

    private fun findMapping(
        mappings: Map<PathPattern, MappingMethod>,
        req: HttpServletRequest
    ): Pair<PathPattern, MappingMethod> {
        val requestPath = sanitizedRequestPath(req)
        val candidates = mappings.filter { it.key.matches(requestPath) }
        return if (candidates.isEmpty()) {
            Pair(PathPattern(requestPath), ::notFound)
        } else {
            if (candidates.size > 1) {
                logger.warn("Ambiguous mapping for request path '{0}'", requestPath)
            }
            candidates.entries.first().toPair()
        }
    }

    private fun invokeMapping(
        mapping: Pair<PathPattern, MappingMethod>,
        req: HttpServletRequest,
        resp: HttpServletResponse,
        dao: DataAccessObject
    ) {
        val params = mapping.first.obtainPathParameters(sanitizedRequestPath(req))
        val method = mapping.second
        logger.trace("invoke {0}", method)
        method(HttpRequest(req, resp, params), dao)
    }

    private fun sanitizedRequestPath(req: HttpServletRequest) = req.pathInfo ?: "/"

    private fun doProcess(
        req: HttpServletRequest,
        resp: HttpServletResponse,
        mappings: Map<PathPattern, MappingMethod>
    ) {
        val session = req.session

        // the very first thing to do is to force UTF-8
        req.characterEncoding = "UTF-8"

        // set some internal request attributes
        val http = HttpRequest(req, resp)
        val fullPath = req.servletPath + Optional.ofNullable(req.pathInfo).orElse("")
        req.setAttribute(Constants.REQ_ATTR_BASE_HREF, http.baseHref)
        req.setAttribute(Constants.REQ_ATTR_PATH, fullPath)
        req.getHeader("Referer")?.let {
            // TODO: add a sanity check to avoid link injection
            req.setAttribute(Constants.REQ_ATTR_REFERER, it)
        }

        // choose the requested language as session language (if available)
        if (session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) == null) {
            // language selection stored in cookie
            val cookieLocale = cookieLanguage(http)

            // if no cookie, fall back to request locale a.k.a "Browser Language"
            val reqLocale = cookieLocale ?: req.locale

            val availableLanguages = availableLanguages()
            val sessionLocale = if (availableLanguages.contains(reqLocale)) reqLocale else availableLanguages.first()

            // select the language (this will also refresh the cookie max-age)
            selectLanguage(http, sessionLocale)

            logger.debug(
                "Setting language for new session {0}: {1}", session.id, sessionLocale.displayLanguage
            )
        } else {
            val sessionLocale = session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) as Locale
            resp.locale = sessionLocale
            logger.trace("Continuing session {0} with language {1}", session.id, sessionLocale)
        }

        // if this is an error path, bypass the normal flow
        if (fullPath.startsWith("/error/")) {
            http.styleSheets = listOf("error")
            http.render("error")
            return
        }

        // obtain a connection and create the data access objects
        val dsp = req.servletContext.getAttribute(SC_ATTR_NAME) as DataSourceProvider
        val dialect = dsp.dialect
        val ds = dsp.dataSource
        if (ds == null) {
            resp.sendError(
                HttpServletResponse.SC_SERVICE_UNAVAILABLE,
                "JNDI DataSource lookup failed. See log for details."
            )
            return
        }
        try {
            ds.connection.use { connection ->
                val dao = createDataAccessObject(dialect, connection)
                try {
                    connection.autoCommit = false
                    invokeMapping(findMapping(mappings, req), req, resp, dao)
                    connection.commit()
                } catch (ex: SQLException) {
                    logger.warn("Database transaction failed (Code {0}): {1}", ex.errorCode, ex.message)
                    logger.debug("Details: ", ex)
                    resp.sendError(
                        HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
                        "Unhandled Transaction Error - Code: " + ex.errorCode
                    )
                    connection.rollback()
                }
            }
        } catch (ex: SQLException) {
            logger.error("Severe Database Exception (Code {0}): {1}", ex.errorCode, ex.message)
            logger.debug("Details: ", ex)
            resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Database Error - Code: " + ex.errorCode)
        }
    }

    override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
        doProcess(req, resp, getMappings)
    }

    override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) {
        doProcess(req, resp, postMappings)
    }

    protected fun availableLanguages(): List<Locale> {
        val langTags = servletContext.getInitParameter(Constants.CTX_ATTR_LANGUAGES)?.split(",")?.map(String::trim) ?: emptyList()
        val locales = langTags.map(Locale::forLanguageTag).filter { it.language.isNotEmpty() }
        return locales.ifEmpty { listOf(Locale.ENGLISH) }
    }

    private fun cookieLanguage(http: HttpRequest): Locale? =
        http.request.cookies?.firstOrNull { c -> c.name == LANGUAGE_COOKIE_NAME }
            ?.runCatching {Locale.forLanguageTag(this.value)}?.getOrNull()

    protected fun selectLanguage(http: HttpRequest, locale: Locale) {
        http.response.locale = locale
        http.session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, locale)
        // delete cookie if language selection matches request locale, otherwise set cookie
        val cookie = Cookie(LANGUAGE_COOKIE_NAME, "")
        cookie.isHttpOnly = true
        cookie.path = http.request.contextPath
        if (http.request.locale.language == locale.language) {
            cookie.maxAge = 0
        } else {
            cookie.value = locale.language
            cookie.maxAge = 2592000 // 30 days
        }
        http.response.addCookie(cookie)
    }
}

mercurial