Sat, 06 Jan 2024 20:31:14 +0100
add language selection cookie - fixes #352
1 /*
2 * Copyright 2021 Mike Becker. All rights reserved.
3 *
4 * Redistribution and use in source and binary forms, with or without
5 * modification, are permitted provided that the following conditions are met:
6 *
7 * 1. Redistributions of source code must retain the above copyright
8 * notice, this list of conditions and the following disclaimer.
9 *
10 * 2. Redistributions in binary form must reproduce the above copyright
11 * notice, this list of conditions and the following disclaimer in the
12 * documentation and/or other materials provided with the distribution.
13 *
14 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
15 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
18 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
20 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
23 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24 */
26 package de.uapcore.lightpit
28 import de.uapcore.lightpit.DataSourceProvider.Companion.SC_ATTR_NAME
29 import de.uapcore.lightpit.dao.DataAccessObject
30 import de.uapcore.lightpit.dao.createDataAccessObject
31 import jakarta.servlet.http.Cookie
32 import jakarta.servlet.http.HttpServlet
33 import jakarta.servlet.http.HttpServletRequest
34 import jakarta.servlet.http.HttpServletResponse
35 import java.sql.SQLException
36 import java.util.*
38 abstract class AbstractServlet : HttpServlet() {
40 companion object {
41 const val LANGUAGE_COOKIE_NAME = "lpit_language"
42 }
44 protected val logger = MyLogger()
46 /**
47 * Contains the GET request mappings.
48 */
49 private val getMappings = mutableMapOf<PathPattern, MappingMethod>()
51 /**
52 * Contains the POST request mappings.
53 */
54 private val postMappings = mutableMapOf<PathPattern, MappingMethod>()
56 protected fun get(pattern: String, method: MappingMethod) {
57 getMappings[PathPattern(pattern)] = method
58 }
60 protected fun post(pattern: String, method: MappingMethod) {
61 postMappings[PathPattern(pattern)] = method
62 }
64 private fun notFound(http: HttpRequest, dao: DataAccessObject) {
65 http.response.sendError(HttpServletResponse.SC_NOT_FOUND)
66 }
68 private fun findMapping(
69 mappings: Map<PathPattern, MappingMethod>,
70 req: HttpServletRequest
71 ): Pair<PathPattern, MappingMethod> {
72 val requestPath = sanitizedRequestPath(req)
73 val candidates = mappings.filter { it.key.matches(requestPath) }
74 return if (candidates.isEmpty()) {
75 Pair(PathPattern(requestPath), ::notFound)
76 } else {
77 if (candidates.size > 1) {
78 logger.warn("Ambiguous mapping for request path '{0}'", requestPath)
79 }
80 candidates.entries.first().toPair()
81 }
82 }
84 private fun invokeMapping(
85 mapping: Pair<PathPattern, MappingMethod>,
86 req: HttpServletRequest,
87 resp: HttpServletResponse,
88 dao: DataAccessObject
89 ) {
90 val params = mapping.first.obtainPathParameters(sanitizedRequestPath(req))
91 val method = mapping.second
92 logger.trace("invoke {0}", method)
93 method(HttpRequest(req, resp, params), dao)
94 }
96 private fun sanitizedRequestPath(req: HttpServletRequest) = req.pathInfo ?: "/"
98 private fun doProcess(
99 req: HttpServletRequest,
100 resp: HttpServletResponse,
101 mappings: Map<PathPattern, MappingMethod>
102 ) {
103 val session = req.session
105 // the very first thing to do is to force UTF-8
106 req.characterEncoding = "UTF-8"
108 // set some internal request attributes
109 val http = HttpRequest(req, resp)
110 val fullPath = req.servletPath + Optional.ofNullable(req.pathInfo).orElse("")
111 req.setAttribute(Constants.REQ_ATTR_BASE_HREF, http.baseHref)
112 req.setAttribute(Constants.REQ_ATTR_PATH, fullPath)
113 req.getHeader("Referer")?.let {
114 // TODO: add a sanity check to avoid link injection
115 req.setAttribute(Constants.REQ_ATTR_REFERER, it)
116 }
118 // choose the requested language as session language (if available)
119 if (session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) == null) {
120 // language selection stored in cookie
121 val cookieLocale = cookieLanguage(http)
123 // if no cookie, fall back to request locale a.k.a "Browser Language"
124 val reqLocale = cookieLocale ?: req.locale
126 val availableLanguages = availableLanguages()
127 val sessionLocale = if (availableLanguages.contains(reqLocale)) reqLocale else availableLanguages.first()
129 // select the language (this will also refresh the cookie max-age)
130 selectLanguage(http, sessionLocale)
132 logger.debug(
133 "Setting language for new session {0}: {1}", session.id, sessionLocale.displayLanguage
134 )
135 } else {
136 val sessionLocale = session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) as Locale
137 resp.locale = sessionLocale
138 logger.trace("Continuing session {0} with language {1}", session.id, sessionLocale)
139 }
141 // if this is an error path, bypass the normal flow
142 if (fullPath.startsWith("/error/")) {
143 http.styleSheets = listOf("error")
144 http.render("error")
145 return
146 }
148 // obtain a connection and create the data access objects
149 val dsp = req.servletContext.getAttribute(SC_ATTR_NAME) as DataSourceProvider
150 val dialect = dsp.dialect
151 val ds = dsp.dataSource
152 if (ds == null) {
153 resp.sendError(
154 HttpServletResponse.SC_SERVICE_UNAVAILABLE,
155 "JNDI DataSource lookup failed. See log for details."
156 )
157 return
158 }
159 try {
160 ds.connection.use { connection ->
161 val dao = createDataAccessObject(dialect, connection)
162 try {
163 connection.autoCommit = false
164 invokeMapping(findMapping(mappings, req), req, resp, dao)
165 connection.commit()
166 } catch (ex: SQLException) {
167 logger.warn("Database transaction failed (Code {0}): {1}", ex.errorCode, ex.message)
168 logger.debug("Details: ", ex)
169 resp.sendError(
170 HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
171 "Unhandled Transaction Error - Code: " + ex.errorCode
172 )
173 connection.rollback()
174 }
175 }
176 } catch (ex: SQLException) {
177 logger.error("Severe Database Exception (Code {0}): {1}", ex.errorCode, ex.message)
178 logger.debug("Details: ", ex)
179 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Database Error - Code: " + ex.errorCode)
180 }
181 }
183 override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
184 doProcess(req, resp, getMappings)
185 }
187 override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) {
188 doProcess(req, resp, postMappings)
189 }
191 protected fun availableLanguages(): List<Locale> {
192 val langTags = servletContext.getInitParameter(Constants.CTX_ATTR_LANGUAGES)?.split(",")?.map(String::trim) ?: emptyList()
193 val locales = langTags.map(Locale::forLanguageTag).filter { it.language.isNotEmpty() }
194 return locales.ifEmpty { listOf(Locale.ENGLISH) }
195 }
197 private fun cookieLanguage(http: HttpRequest): Locale? =
198 http.request.cookies?.firstOrNull { c -> c.name == LANGUAGE_COOKIE_NAME }
199 ?.runCatching {Locale.forLanguageTag(this.value)}?.getOrNull()
201 protected fun selectLanguage(http: HttpRequest, locale: Locale) {
202 http.response.locale = locale
203 http.session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, locale)
204 // delete cookie if language selection matches request locale, otherwise set cookie
205 val cookie = Cookie(LANGUAGE_COOKIE_NAME, "")
206 cookie.isHttpOnly = true
207 cookie.path = http.request.contextPath
208 if (http.request.locale.language == locale.language) {
209 cookie.maxAge = 0
210 } else {
211 cookie.value = locale.language
212 cookie.maxAge = 2592000 // 30 days
213 }
214 http.response.addCookie(cookie)
215 }
216 }