1 /* |
|
2 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER. |
|
3 * |
|
4 * Copyright 2021 Mike Becker. All rights reserved. |
|
5 * |
|
6 * Redistribution and use in source and binary forms, with or without |
|
7 * modification, are permitted provided that the following conditions are met: |
|
8 * |
|
9 * 1. Redistributions of source code must retain the above copyright |
|
10 * notice, this list of conditions and the following disclaimer. |
|
11 * |
|
12 * 2. Redistributions in binary form must reproduce the above copyright |
|
13 * notice, this list of conditions and the following disclaimer in the |
|
14 * documentation and/or other materials provided with the distribution. |
|
15 * |
|
16 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" |
|
17 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE |
|
18 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE |
|
19 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE |
|
20 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR |
|
21 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF |
|
22 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS |
|
23 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN |
|
24 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) |
|
25 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE |
|
26 * POSSIBILITY OF SUCH DAMAGE. |
|
27 * |
|
28 */ |
|
29 package de.uapcore.lightpit; |
|
30 |
|
31 import de.uapcore.lightpit.dao.DataAccessObject; |
|
32 import de.uapcore.lightpit.dao.PostgresDataAccessObject; |
|
33 import org.slf4j.Logger; |
|
34 import org.slf4j.LoggerFactory; |
|
35 |
|
36 import javax.servlet.ServletException; |
|
37 import javax.servlet.http.HttpServlet; |
|
38 import javax.servlet.http.HttpServletRequest; |
|
39 import javax.servlet.http.HttpServletResponse; |
|
40 import javax.servlet.http.HttpSession; |
|
41 import java.io.IOException; |
|
42 import java.lang.reflect.*; |
|
43 import java.sql.Connection; |
|
44 import java.sql.SQLException; |
|
45 import java.util.*; |
|
46 import java.util.function.Function; |
|
47 import java.util.stream.Collectors; |
|
48 |
|
49 /** |
|
50 * A special implementation of a HTTPServlet which is focused on implementing |
|
51 * the necessary functionality for LightPIT pages. |
|
52 */ |
|
53 public abstract class AbstractServlet extends HttpServlet { |
|
54 |
|
55 private static final Logger LOG = LoggerFactory.getLogger(AbstractServlet.class); |
|
56 |
|
57 /** |
|
58 * Invocation mapping gathered from the {@link RequestMapping} annotations. |
|
59 * <p> |
|
60 * Paths in this map must always start with a leading slash, although |
|
61 * the specification in the annotation must not start with a leading slash. |
|
62 * <p> |
|
63 * The reason for this is the different handling of empty paths in |
|
64 * {@link HttpServletRequest#getPathInfo()}. |
|
65 */ |
|
66 private final Map<HttpMethod, Map<PathPattern, Method>> mappings = new HashMap<>(); |
|
67 |
|
68 /** |
|
69 * Creates a set of data access objects for the specified connection. |
|
70 * |
|
71 * @param connection the SQL connection |
|
72 * @return a set of data access objects |
|
73 */ |
|
74 private DataAccessObject createDataAccessObjects(Connection connection) { |
|
75 final var df = (DataSourceProvider) getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME()); |
|
76 if (df.getDialect() == DataSourceProvider.Dialect.Postgres) { |
|
77 return new PostgresDataAccessObject(connection); |
|
78 } |
|
79 throw new UnsupportedOperationException("Non-exhaustive if-else - this is a bug."); |
|
80 } |
|
81 |
|
82 private void invokeMapping(Map.Entry<PathPattern, Method> mapping, HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws IOException { |
|
83 final var pathPattern = mapping.getKey(); |
|
84 final var method = mapping.getValue(); |
|
85 try { |
|
86 LOG.trace("invoke {}#{}", method.getDeclaringClass().getName(), method.getName()); |
|
87 final var paramTypes = method.getParameterTypes(); |
|
88 final var paramValues = new Object[paramTypes.length]; |
|
89 for (int i = 0; i < paramTypes.length; i++) { |
|
90 if (paramTypes[i].isAssignableFrom(HttpServletRequest.class)) { |
|
91 paramValues[i] = req; |
|
92 } else if (paramTypes[i].isAssignableFrom(HttpServletResponse.class)) { |
|
93 paramValues[i] = resp; |
|
94 } |
|
95 if (paramTypes[i].isAssignableFrom(DataAccessObject.class)) { |
|
96 paramValues[i] = dao; |
|
97 } |
|
98 if (paramTypes[i].isAssignableFrom(PathParameters.class)) { |
|
99 paramValues[i] = pathPattern.obtainPathParameters(sanitizeRequestPath(req)); |
|
100 } |
|
101 } |
|
102 method.invoke(this, paramValues); |
|
103 } catch (InvocationTargetException ex) { |
|
104 LOG.error("invocation of method {}::{} failed: {}", |
|
105 method.getDeclaringClass().getName(), method.getName(), ex.getTargetException().getMessage()); |
|
106 LOG.debug("Details: ", ex.getTargetException()); |
|
107 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getTargetException().getMessage()); |
|
108 } catch (ReflectiveOperationException | ClassCastException ex) { |
|
109 LOG.error("invocation of method {}::{} failed: {}", |
|
110 method.getDeclaringClass().getName(), method.getName(), ex.getMessage()); |
|
111 LOG.debug("Details: ", ex); |
|
112 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getMessage()); |
|
113 } |
|
114 } |
|
115 |
|
116 @Override |
|
117 public void init() throws ServletException { |
|
118 scanForRequestMappings(); |
|
119 |
|
120 LOG.trace("{} initialized", getServletName()); |
|
121 } |
|
122 |
|
123 private void scanForRequestMappings() { |
|
124 try { |
|
125 Method[] methods = getClass().getDeclaredMethods(); |
|
126 for (Method method : methods) { |
|
127 Optional<RequestMapping> mapping = Optional.ofNullable(method.getAnnotation(RequestMapping.class)); |
|
128 if (mapping.isPresent()) { |
|
129 if (mapping.get().requestPath().isBlank()) { |
|
130 LOG.warn("{} is annotated with {} but request path is empty", |
|
131 method.getName(), RequestMapping.class.getSimpleName() |
|
132 ); |
|
133 continue; |
|
134 } |
|
135 |
|
136 if (!Modifier.isPublic(method.getModifiers())) { |
|
137 LOG.warn("{} is annotated with {} but is not public", |
|
138 method.getName(), RequestMapping.class.getSimpleName() |
|
139 ); |
|
140 continue; |
|
141 } |
|
142 if (Modifier.isAbstract(method.getModifiers())) { |
|
143 LOG.warn("{} is annotated with {} but is abstract", |
|
144 method.getName(), RequestMapping.class.getSimpleName() |
|
145 ); |
|
146 continue; |
|
147 } |
|
148 |
|
149 boolean paramsInjectible = true; |
|
150 for (var param : method.getParameterTypes()) { |
|
151 paramsInjectible &= HttpServletRequest.class.isAssignableFrom(param) |
|
152 || HttpServletResponse.class.isAssignableFrom(param) |
|
153 || PathParameters.class.isAssignableFrom(param) |
|
154 || DataAccessObject.class.isAssignableFrom(param); |
|
155 } |
|
156 if (paramsInjectible) { |
|
157 try { |
|
158 PathPattern pathPattern = new PathPattern(mapping.get().requestPath()); |
|
159 |
|
160 final var methodMappings = mappings.computeIfAbsent(mapping.get().method(), k -> new HashMap<>()); |
|
161 final var currentMapping = methodMappings.putIfAbsent(pathPattern, method); |
|
162 if (currentMapping != null) { |
|
163 LOG.warn("Cannot map {} {} to {} in class {} - this would override the mapping to {}", |
|
164 mapping.get().method(), |
|
165 mapping.get().requestPath(), |
|
166 method.getName(), |
|
167 getClass().getSimpleName(), |
|
168 currentMapping.getName() |
|
169 ); |
|
170 } |
|
171 |
|
172 LOG.debug("{} {} maps to {}::{}", |
|
173 mapping.get().method(), |
|
174 mapping.get().requestPath(), |
|
175 getClass().getSimpleName(), |
|
176 method.getName() |
|
177 ); |
|
178 } catch (IllegalArgumentException ex) { |
|
179 LOG.warn("Request mapping for {} failed: path pattern '{}' is syntactically invalid", |
|
180 method.getName(), mapping.get().requestPath() |
|
181 ); |
|
182 } |
|
183 } else { |
|
184 LOG.warn("{} is annotated with {} but has the wrong parameters - only HttpServletRequest, HttpServletResponse, PathParameters, and DataAccessObjects are allowed", |
|
185 method.getName(), RequestMapping.class.getSimpleName() |
|
186 ); |
|
187 } |
|
188 } |
|
189 } |
|
190 } catch (SecurityException ex) { |
|
191 LOG.error("Scan for request mappings on declared methods failed.", ex); |
|
192 } |
|
193 } |
|
194 |
|
195 @Override |
|
196 public void destroy() { |
|
197 mappings.clear(); |
|
198 LOG.trace("{} destroyed", getServletName()); |
|
199 } |
|
200 |
|
201 /** |
|
202 * Sets the name of the content page. |
|
203 * <p> |
|
204 * It is sufficient to specify the name without any extension. The extension |
|
205 * is added automatically if not specified. |
|
206 * |
|
207 * @param req the servlet request object |
|
208 * @param pageName the name of the content page |
|
209 * @see Constants#REQ_ATTR_CONTENT_PAGE |
|
210 */ |
|
211 protected void setContentPage(HttpServletRequest req, String pageName) { |
|
212 req.setAttribute(Constants.REQ_ATTR_CONTENT_PAGE, jspPath(pageName)); |
|
213 } |
|
214 |
|
215 /** |
|
216 * Sets the navigation menu. |
|
217 * |
|
218 * @param req the servlet request object |
|
219 * @param jspName the name of the menu's jsp file |
|
220 * @see Constants#REQ_ATTR_NAVIGATION |
|
221 */ |
|
222 protected void setNavigationMenu(HttpServletRequest req, String jspName) { |
|
223 req.setAttribute(Constants.REQ_ATTR_NAVIGATION, jspPath(jspName)); |
|
224 } |
|
225 |
|
226 /** |
|
227 * @param req the servlet request object |
|
228 * @param location the location where to redirect |
|
229 * @see Constants#REQ_ATTR_REDIRECT_LOCATION |
|
230 */ |
|
231 protected void setRedirectLocation(HttpServletRequest req, String location) { |
|
232 if (location.startsWith("./")) { |
|
233 location = location.replaceFirst("\\./", baseHref(req)); |
|
234 } |
|
235 req.setAttribute(Constants.REQ_ATTR_REDIRECT_LOCATION, location); |
|
236 } |
|
237 |
|
238 /** |
|
239 * Specifies the names of additional stylesheets used by this Servlet. |
|
240 * <p> |
|
241 * It is sufficient to specify the name without any extension. The extension |
|
242 * is added automatically if not specified. |
|
243 * |
|
244 * @param req the servlet request object |
|
245 * @param stylesheets the names of the stylesheets |
|
246 */ |
|
247 public void setStylesheet(HttpServletRequest req, String ... stylesheets) { |
|
248 req.setAttribute(Constants.REQ_ATTR_STYLESHEET, Arrays |
|
249 .stream(stylesheets) |
|
250 .map(s -> enforceExt(s, ".css")) |
|
251 .collect(Collectors.toUnmodifiableList())); |
|
252 } |
|
253 |
|
254 /** |
|
255 * Sets the view model object. |
|
256 * The type must match the expected type in the JSP file. |
|
257 * |
|
258 * @param req the servlet request object |
|
259 * @param viewModel the view model object |
|
260 */ |
|
261 public void setViewModel(HttpServletRequest req, Object viewModel) { |
|
262 req.setAttribute(Constants.REQ_ATTR_VIEWMODEL, viewModel); |
|
263 } |
|
264 |
|
265 private <T> Optional<T> parseParameter(String paramValue, Class<T> clazz) { |
|
266 if (paramValue == null) return Optional.empty(); |
|
267 if (clazz.equals(Boolean.class)) { |
|
268 if (paramValue.equalsIgnoreCase("false") || paramValue.equals("0")) { |
|
269 return Optional.of((T) Boolean.FALSE); |
|
270 } else { |
|
271 return Optional.of((T) Boolean.TRUE); |
|
272 } |
|
273 } |
|
274 if (clazz.equals(String.class)) return Optional.of((T) paramValue); |
|
275 if (java.sql.Date.class.isAssignableFrom(clazz)) { |
|
276 try { |
|
277 return Optional.of((T) java.sql.Date.valueOf(paramValue)); |
|
278 } catch (IllegalArgumentException ex) { |
|
279 return Optional.empty(); |
|
280 } |
|
281 } |
|
282 try { |
|
283 final Constructor<T> ctor = clazz.getConstructor(String.class); |
|
284 return Optional.of(ctor.newInstance(paramValue)); |
|
285 } catch (ReflectiveOperationException e) { |
|
286 // does not type check and is not convertible - treat as if the parameter was never set |
|
287 return Optional.empty(); |
|
288 } |
|
289 } |
|
290 |
|
291 /** |
|
292 * Obtains a request parameter of the specified type. |
|
293 * The specified type must have a single-argument constructor accepting a string to perform conversion. |
|
294 * The constructor of the specified type may throw an exception on conversion failures. |
|
295 * |
|
296 * @param req the servlet request object |
|
297 * @param clazz the class object of the expected type |
|
298 * @param name the name of the parameter |
|
299 * @param <T> the expected type |
|
300 * @return the parameter value or an empty optional, if no parameter with the specified name was found |
|
301 */ |
|
302 protected <T> Optional<T> getParameter(HttpServletRequest req, Class<T> clazz, String name) { |
|
303 if (clazz.isArray()) { |
|
304 final String[] paramValues = req.getParameterValues(name); |
|
305 int len = paramValues == null ? 0 : paramValues.length; |
|
306 final var array = (T) Array.newInstance(clazz.getComponentType(), len); |
|
307 for (int i = 0; i < len; i++) { |
|
308 try { |
|
309 final Constructor<?> ctor = clazz.getComponentType().getConstructor(String.class); |
|
310 Array.set(array, i, ctor.newInstance(paramValues[i])); |
|
311 } catch (ReflectiveOperationException e) { |
|
312 throw new RuntimeException(e); |
|
313 } |
|
314 } |
|
315 return Optional.of(array); |
|
316 } else { |
|
317 return parseParameter(req.getParameter(name), clazz); |
|
318 } |
|
319 } |
|
320 |
|
321 /** |
|
322 * Tries to look up an entity with a key obtained from a request parameter. |
|
323 * |
|
324 * @param req the servlet request object |
|
325 * @param clazz the class representing the type of the request parameter |
|
326 * @param name the name of the request parameter |
|
327 * @param find the find function (typically a DAO function) |
|
328 * @param <T> the type of the request parameter |
|
329 * @param <R> the type of the looked up entity |
|
330 * @return the retrieved entity or an empty optional if there is no such entity or the request parameter was missing |
|
331 * @throws SQLException if the find function throws an exception |
|
332 */ |
|
333 protected <T, R> Optional<R> findByParameter(HttpServletRequest req, Class<T> clazz, String name, Function<? super T, ? extends R> find) { |
|
334 final var param = getParameter(req, clazz, name); |
|
335 if (param.isPresent()) { |
|
336 return Optional.ofNullable(find.apply(param.get())); |
|
337 } else { |
|
338 return Optional.empty(); |
|
339 } |
|
340 } |
|
341 |
|
342 protected void setAttributeFromParameter(HttpServletRequest req, String name) { |
|
343 final var parm = req.getParameter(name); |
|
344 if (parm != null) { |
|
345 req.setAttribute(name, parm); |
|
346 } |
|
347 } |
|
348 |
|
349 private String sanitizeRequestPath(HttpServletRequest req) { |
|
350 return Optional.ofNullable(req.getPathInfo()).orElse("/"); |
|
351 } |
|
352 |
|
353 private Optional<Map.Entry<PathPattern, Method>> findMapping(HttpMethod method, HttpServletRequest req) { |
|
354 return Optional.ofNullable(mappings.get(method)).flatMap(rm -> |
|
355 rm.entrySet().stream().filter( |
|
356 kv -> kv.getKey().matches(sanitizeRequestPath(req)) |
|
357 ).findAny() |
|
358 ); |
|
359 } |
|
360 |
|
361 protected void renderSite(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { |
|
362 req.getRequestDispatcher(jspPath("site")).forward(req, resp); |
|
363 } |
|
364 |
|
365 protected Optional<String[]> availableLanguages() { |
|
366 return Optional.ofNullable(getServletContext().getInitParameter(Constants.CTX_ATTR_LANGUAGES)).map((x) -> x.split("\\s*,\\s*")); |
|
367 } |
|
368 |
|
369 private static String baseHref(HttpServletRequest req) { |
|
370 return String.format("%s://%s:%d%s/", |
|
371 req.getScheme(), |
|
372 req.getServerName(), |
|
373 req.getServerPort(), |
|
374 req.getContextPath()); |
|
375 } |
|
376 |
|
377 private static String enforceExt(String filename, String ext) { |
|
378 return filename.endsWith(ext) ? filename : filename + ext; |
|
379 } |
|
380 |
|
381 private static String jspPath(String filename) { |
|
382 return enforceExt(Constants.JSP_PATH_PREFIX + filename, ".jsp"); |
|
383 } |
|
384 |
|
385 private void doProcess(HttpMethod method, HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { |
|
386 // the very first thing to do is to force UTF-8 |
|
387 req.setCharacterEncoding("UTF-8"); |
|
388 |
|
389 // choose the requested language as session language (if available) or fall back to english, otherwise |
|
390 HttpSession session = req.getSession(); |
|
391 if (session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) == null) { |
|
392 Optional<List<String>> availableLanguages = availableLanguages().map(Arrays::asList); |
|
393 Optional<Locale> reqLocale = Optional.of(req.getLocale()); |
|
394 Locale sessionLocale = reqLocale.filter((rl) -> availableLanguages.map((al) -> al.contains(rl.getLanguage())).orElse(false)).orElse(Locale.ENGLISH); |
|
395 session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, sessionLocale); |
|
396 LOG.debug("Setting language for new session {}: {}", session.getId(), sessionLocale.getDisplayLanguage()); |
|
397 } else { |
|
398 Locale sessionLocale = (Locale) session.getAttribute(Constants.SESSION_ATTR_LANGUAGE); |
|
399 resp.setLocale(sessionLocale); |
|
400 LOG.trace("Continuing session {} with language {}", session.getId(), sessionLocale); |
|
401 } |
|
402 |
|
403 // set some internal request attributes |
|
404 final String fullPath = req.getServletPath() + Optional.ofNullable(req.getPathInfo()).orElse(""); |
|
405 req.setAttribute(Constants.REQ_ATTR_BASE_HREF, baseHref(req)); |
|
406 req.setAttribute(Constants.REQ_ATTR_PATH, fullPath); |
|
407 |
|
408 // if this is an error path, bypass the normal flow |
|
409 if (fullPath.startsWith("/error/")) { |
|
410 final var mapping = findMapping(method, req); |
|
411 if (mapping.isPresent()) { |
|
412 invokeMapping(mapping.get(), req, resp, null); |
|
413 } |
|
414 return; |
|
415 } |
|
416 |
|
417 // obtain a connection and create the data access objects |
|
418 final var db = (DataSourceProvider) req.getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME()); |
|
419 final var ds = db.getDataSource(); |
|
420 if (ds == null) { |
|
421 resp.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "JNDI DataSource lookup failed. See log for details."); |
|
422 return; |
|
423 } |
|
424 try (final var connection = ds.getConnection()) { |
|
425 final var dao = createDataAccessObjects(connection); |
|
426 try { |
|
427 connection.setAutoCommit(false); |
|
428 // call the handler, if available, or send an HTTP 404 error |
|
429 final var mapping = findMapping(method, req); |
|
430 if (mapping.isPresent()) { |
|
431 invokeMapping(mapping.get(), req, resp, dao); |
|
432 } else { |
|
433 resp.sendError(HttpServletResponse.SC_NOT_FOUND); |
|
434 } |
|
435 connection.commit(); |
|
436 } catch (SQLException ex) { |
|
437 LOG.warn("Database transaction failed (Code {}): {}", ex.getErrorCode(), ex.getMessage()); |
|
438 LOG.debug("Details: ", ex); |
|
439 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unhandled Transaction Error - Code: " + ex.getErrorCode()); |
|
440 connection.rollback(); |
|
441 } |
|
442 } catch (SQLException ex) { |
|
443 LOG.error("Severe Database Exception (Code {}): {}", ex.getErrorCode(), ex.getMessage()); |
|
444 LOG.debug("Details: ", ex); |
|
445 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Database Error - Code: " + ex.getErrorCode()); |
|
446 } |
|
447 } |
|
448 |
|
449 @Override |
|
450 protected final void doGet(HttpServletRequest req, HttpServletResponse resp) |
|
451 throws ServletException, IOException { |
|
452 doProcess(HttpMethod.GET, req, resp); |
|
453 } |
|
454 |
|
455 @Override |
|
456 protected final void doPost(HttpServletRequest req, HttpServletResponse resp) |
|
457 throws ServletException, IOException { |
|
458 doProcess(HttpMethod.POST, req, resp); |
|
459 } |
|
460 } |
|