1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/src/main/java/de/uapcore/lightpit/AbstractServlet.java Tue Jan 05 19:19:31 2021 +0100 1.3 @@ -0,0 +1,471 @@ 1.4 +/* 1.5 + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER. 1.6 + * 1.7 + * Copyright 2018 Mike Becker. All rights reserved. 1.8 + * 1.9 + * Redistribution and use in source and binary forms, with or without 1.10 + * modification, are permitted provided that the following conditions are met: 1.11 + * 1.12 + * 1. Redistributions of source code must retain the above copyright 1.13 + * notice, this list of conditions and the following disclaimer. 1.14 + * 1.15 + * 2. Redistributions in binary form must reproduce the above copyright 1.16 + * notice, this list of conditions and the following disclaimer in the 1.17 + * documentation and/or other materials provided with the distribution. 1.18 + * 1.19 + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 1.20 + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 1.21 + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 1.22 + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 1.23 + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 1.24 + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 1.25 + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 1.26 + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 1.27 + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 1.28 + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 1.29 + * POSSIBILITY OF SUCH DAMAGE. 1.30 + * 1.31 + */ 1.32 +package de.uapcore.lightpit; 1.33 + 1.34 +import de.uapcore.lightpit.dao.DataAccessObject; 1.35 +import de.uapcore.lightpit.dao.PostgresDataAccessObject; 1.36 +import org.slf4j.Logger; 1.37 +import org.slf4j.LoggerFactory; 1.38 + 1.39 +import javax.servlet.ServletException; 1.40 +import javax.servlet.http.HttpServlet; 1.41 +import javax.servlet.http.HttpServletRequest; 1.42 +import javax.servlet.http.HttpServletResponse; 1.43 +import javax.servlet.http.HttpSession; 1.44 +import java.io.IOException; 1.45 +import java.lang.reflect.*; 1.46 +import java.sql.Connection; 1.47 +import java.sql.SQLException; 1.48 +import java.util.*; 1.49 +import java.util.function.Function; 1.50 +import java.util.stream.Collectors; 1.51 + 1.52 +/** 1.53 + * A special implementation of a HTTPServlet which is focused on implementing 1.54 + * the necessary functionality for LightPIT pages. 1.55 + */ 1.56 +public abstract class AbstractServlet extends HttpServlet { 1.57 + 1.58 + private static final Logger LOG = LoggerFactory.getLogger(AbstractServlet.class); 1.59 + 1.60 + private static final String SITE_JSP = jspPath("site"); 1.61 + 1.62 + /** 1.63 + * Invocation mapping gathered from the {@link RequestMapping} annotations. 1.64 + * <p> 1.65 + * Paths in this map must always start with a leading slash, although 1.66 + * the specification in the annotation must not start with a leading slash. 1.67 + * <p> 1.68 + * The reason for this is the different handling of empty paths in 1.69 + * {@link HttpServletRequest#getPathInfo()}. 1.70 + */ 1.71 + private final Map<HttpMethod, Map<PathPattern, Method>> mappings = new HashMap<>(); 1.72 + 1.73 + /** 1.74 + * Returns the name of the resource bundle associated with this servlet. 1.75 + * 1.76 + * @return the resource bundle base name 1.77 + */ 1.78 + protected abstract String getResourceBundleName(); 1.79 + 1.80 + 1.81 + /** 1.82 + * Creates a set of data access objects for the specified connection. 1.83 + * 1.84 + * @param connection the SQL connection 1.85 + * @return a set of data access objects 1.86 + */ 1.87 + private DataAccessObject createDataAccessObjects(Connection connection) { 1.88 + final var df = (DataSourceProvider) getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME()); 1.89 + if (df.getDialect() == DataSourceProvider.Dialect.Postgres) { 1.90 + return new PostgresDataAccessObject(connection); 1.91 + } 1.92 + throw new UnsupportedOperationException("Non-exhaustive if-else - this is a bug."); 1.93 + } 1.94 + 1.95 + private void invokeMapping(Map.Entry<PathPattern, Method> mapping, HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws IOException { 1.96 + final var pathPattern = mapping.getKey(); 1.97 + final var method = mapping.getValue(); 1.98 + try { 1.99 + LOG.trace("invoke {}#{}", method.getDeclaringClass().getName(), method.getName()); 1.100 + final var paramTypes = method.getParameterTypes(); 1.101 + final var paramValues = new Object[paramTypes.length]; 1.102 + for (int i = 0; i < paramTypes.length; i++) { 1.103 + if (paramTypes[i].isAssignableFrom(HttpServletRequest.class)) { 1.104 + paramValues[i] = req; 1.105 + } else if (paramTypes[i].isAssignableFrom(HttpServletResponse.class)) { 1.106 + paramValues[i] = resp; 1.107 + } 1.108 + if (paramTypes[i].isAssignableFrom(DataAccessObject.class)) { 1.109 + paramValues[i] = dao; 1.110 + } 1.111 + if (paramTypes[i].isAssignableFrom(PathParameters.class)) { 1.112 + paramValues[i] = pathPattern.obtainPathParameters(sanitizeRequestPath(req)); 1.113 + } 1.114 + } 1.115 + method.invoke(this, paramValues); 1.116 + } catch (InvocationTargetException ex) { 1.117 + LOG.error("invocation of method {}::{} failed: {}", 1.118 + method.getDeclaringClass().getName(), method.getName(), ex.getTargetException().getMessage()); 1.119 + LOG.debug("Details: ", ex.getTargetException()); 1.120 + resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getTargetException().getMessage()); 1.121 + } catch (ReflectiveOperationException | ClassCastException ex) { 1.122 + LOG.error("invocation of method {}::{} failed: {}", 1.123 + method.getDeclaringClass().getName(), method.getName(), ex.getMessage()); 1.124 + LOG.debug("Details: ", ex); 1.125 + resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getMessage()); 1.126 + } 1.127 + } 1.128 + 1.129 + @Override 1.130 + public void init() throws ServletException { 1.131 + scanForRequestMappings(); 1.132 + 1.133 + LOG.trace("{} initialized", getServletName()); 1.134 + } 1.135 + 1.136 + private void scanForRequestMappings() { 1.137 + try { 1.138 + Method[] methods = getClass().getDeclaredMethods(); 1.139 + for (Method method : methods) { 1.140 + Optional<RequestMapping> mapping = Optional.ofNullable(method.getAnnotation(RequestMapping.class)); 1.141 + if (mapping.isPresent()) { 1.142 + if (mapping.get().requestPath().isBlank()) { 1.143 + LOG.warn("{} is annotated with {} but request path is empty", 1.144 + method.getName(), RequestMapping.class.getSimpleName() 1.145 + ); 1.146 + continue; 1.147 + } 1.148 + 1.149 + if (!Modifier.isPublic(method.getModifiers())) { 1.150 + LOG.warn("{} is annotated with {} but is not public", 1.151 + method.getName(), RequestMapping.class.getSimpleName() 1.152 + ); 1.153 + continue; 1.154 + } 1.155 + if (Modifier.isAbstract(method.getModifiers())) { 1.156 + LOG.warn("{} is annotated with {} but is abstract", 1.157 + method.getName(), RequestMapping.class.getSimpleName() 1.158 + ); 1.159 + continue; 1.160 + } 1.161 + 1.162 + boolean paramsInjectible = true; 1.163 + for (var param : method.getParameterTypes()) { 1.164 + paramsInjectible &= HttpServletRequest.class.isAssignableFrom(param) 1.165 + || HttpServletResponse.class.isAssignableFrom(param) 1.166 + || PathParameters.class.isAssignableFrom(param) 1.167 + || DataAccessObject.class.isAssignableFrom(param); 1.168 + } 1.169 + if (paramsInjectible) { 1.170 + try { 1.171 + PathPattern pathPattern = new PathPattern(mapping.get().requestPath()); 1.172 + 1.173 + final var methodMappings = mappings.computeIfAbsent(mapping.get().method(), k -> new HashMap<>()); 1.174 + final var currentMapping = methodMappings.putIfAbsent(pathPattern, method); 1.175 + if (currentMapping != null) { 1.176 + LOG.warn("Cannot map {} {} to {} in class {} - this would override the mapping to {}", 1.177 + mapping.get().method(), 1.178 + mapping.get().requestPath(), 1.179 + method.getName(), 1.180 + getClass().getSimpleName(), 1.181 + currentMapping.getName() 1.182 + ); 1.183 + } 1.184 + 1.185 + LOG.debug("{} {} maps to {}::{}", 1.186 + mapping.get().method(), 1.187 + mapping.get().requestPath(), 1.188 + getClass().getSimpleName(), 1.189 + method.getName() 1.190 + ); 1.191 + } catch (IllegalArgumentException ex) { 1.192 + LOG.warn("Request mapping for {} failed: path pattern '{}' is syntactically invalid", 1.193 + method.getName(), mapping.get().requestPath() 1.194 + ); 1.195 + } 1.196 + } else { 1.197 + LOG.warn("{} is annotated with {} but has the wrong parameters - only HttpServletRequest, HttpServletResponse, PathParameters, and DataAccessObjects are allowed", 1.198 + method.getName(), RequestMapping.class.getSimpleName() 1.199 + ); 1.200 + } 1.201 + } 1.202 + } 1.203 + } catch (SecurityException ex) { 1.204 + LOG.error("Scan for request mappings on declared methods failed.", ex); 1.205 + } 1.206 + } 1.207 + 1.208 + @Override 1.209 + public void destroy() { 1.210 + mappings.clear(); 1.211 + LOG.trace("{} destroyed", getServletName()); 1.212 + } 1.213 + 1.214 + /** 1.215 + * Sets the name of the content page. 1.216 + * <p> 1.217 + * It is sufficient to specify the name without any extension. The extension 1.218 + * is added automatically if not specified. 1.219 + * 1.220 + * @param req the servlet request object 1.221 + * @param pageName the name of the content page 1.222 + * @see Constants#REQ_ATTR_CONTENT_PAGE 1.223 + */ 1.224 + protected void setContentPage(HttpServletRequest req, String pageName) { 1.225 + req.setAttribute(Constants.REQ_ATTR_CONTENT_PAGE, jspPath(pageName)); 1.226 + } 1.227 + 1.228 + /** 1.229 + * Sets the navigation menu. 1.230 + * 1.231 + * @param req the servlet request object 1.232 + * @param jspName the name of the menu's jsp file 1.233 + * @see Constants#REQ_ATTR_NAVIGATION 1.234 + */ 1.235 + protected void setNavigationMenu(HttpServletRequest req, String jspName) { 1.236 + req.setAttribute(Constants.REQ_ATTR_NAVIGATION, jspPath(jspName)); 1.237 + } 1.238 + 1.239 + /** 1.240 + * @param req the servlet request object 1.241 + * @param location the location where to redirect 1.242 + * @see Constants#REQ_ATTR_REDIRECT_LOCATION 1.243 + */ 1.244 + protected void setRedirectLocation(HttpServletRequest req, String location) { 1.245 + if (location.startsWith("./")) { 1.246 + location = location.replaceFirst("\\./", baseHref(req)); 1.247 + } 1.248 + req.setAttribute(Constants.REQ_ATTR_REDIRECT_LOCATION, location); 1.249 + } 1.250 + 1.251 + /** 1.252 + * Specifies the names of additional stylesheets used by this Servlet. 1.253 + * <p> 1.254 + * It is sufficient to specify the name without any extension. The extension 1.255 + * is added automatically if not specified. 1.256 + * 1.257 + * @param req the servlet request object 1.258 + * @param stylesheets the names of the stylesheets 1.259 + */ 1.260 + public void setStylesheet(HttpServletRequest req, String ... stylesheets) { 1.261 + req.setAttribute(Constants.REQ_ATTR_STYLESHEET, Arrays 1.262 + .stream(stylesheets) 1.263 + .map(s -> enforceExt(s, ".css")) 1.264 + .collect(Collectors.toUnmodifiableList())); 1.265 + } 1.266 + 1.267 + /** 1.268 + * Sets the view model object. 1.269 + * The type must match the expected type in the JSP file. 1.270 + * 1.271 + * @param req the servlet request object 1.272 + * @param viewModel the view model object 1.273 + */ 1.274 + public void setViewModel(HttpServletRequest req, Object viewModel) { 1.275 + req.setAttribute(Constants.REQ_ATTR_VIEWMODEL, viewModel); 1.276 + } 1.277 + 1.278 + private <T> Optional<T> parseParameter(String paramValue, Class<T> clazz) { 1.279 + if (paramValue == null) return Optional.empty(); 1.280 + if (clazz.equals(Boolean.class)) { 1.281 + if (paramValue.equalsIgnoreCase("false") || paramValue.equals("0")) { 1.282 + return Optional.of((T) Boolean.FALSE); 1.283 + } else { 1.284 + return Optional.of((T) Boolean.TRUE); 1.285 + } 1.286 + } 1.287 + if (clazz.equals(String.class)) return Optional.of((T) paramValue); 1.288 + if (java.sql.Date.class.isAssignableFrom(clazz)) { 1.289 + try { 1.290 + return Optional.of((T) java.sql.Date.valueOf(paramValue)); 1.291 + } catch (IllegalArgumentException ex) { 1.292 + return Optional.empty(); 1.293 + } 1.294 + } 1.295 + try { 1.296 + final Constructor<T> ctor = clazz.getConstructor(String.class); 1.297 + return Optional.of(ctor.newInstance(paramValue)); 1.298 + } catch (ReflectiveOperationException e) { 1.299 + // does not type check and is not convertible - treat as if the parameter was never set 1.300 + return Optional.empty(); 1.301 + } 1.302 + } 1.303 + 1.304 + /** 1.305 + * Obtains a request parameter of the specified type. 1.306 + * The specified type must have a single-argument constructor accepting a string to perform conversion. 1.307 + * The constructor of the specified type may throw an exception on conversion failures. 1.308 + * 1.309 + * @param req the servlet request object 1.310 + * @param clazz the class object of the expected type 1.311 + * @param name the name of the parameter 1.312 + * @param <T> the expected type 1.313 + * @return the parameter value or an empty optional, if no parameter with the specified name was found 1.314 + */ 1.315 + protected <T> Optional<T> getParameter(HttpServletRequest req, Class<T> clazz, String name) { 1.316 + if (clazz.isArray()) { 1.317 + final String[] paramValues = req.getParameterValues(name); 1.318 + int len = paramValues == null ? 0 : paramValues.length; 1.319 + final var array = (T) Array.newInstance(clazz.getComponentType(), len); 1.320 + for (int i = 0; i < len; i++) { 1.321 + try { 1.322 + final Constructor<?> ctor = clazz.getComponentType().getConstructor(String.class); 1.323 + Array.set(array, i, ctor.newInstance(paramValues[i])); 1.324 + } catch (ReflectiveOperationException e) { 1.325 + throw new RuntimeException(e); 1.326 + } 1.327 + } 1.328 + return Optional.of(array); 1.329 + } else { 1.330 + return parseParameter(req.getParameter(name), clazz); 1.331 + } 1.332 + } 1.333 + 1.334 + /** 1.335 + * Tries to look up an entity with a key obtained from a request parameter. 1.336 + * 1.337 + * @param req the servlet request object 1.338 + * @param clazz the class representing the type of the request parameter 1.339 + * @param name the name of the request parameter 1.340 + * @param find the find function (typically a DAO function) 1.341 + * @param <T> the type of the request parameter 1.342 + * @param <R> the type of the looked up entity 1.343 + * @return the retrieved entity or an empty optional if there is no such entity or the request parameter was missing 1.344 + * @throws SQLException if the find function throws an exception 1.345 + */ 1.346 + protected <T, R> Optional<R> findByParameter(HttpServletRequest req, Class<T> clazz, String name, Function<? super T, ? extends R> find) { 1.347 + final var param = getParameter(req, clazz, name); 1.348 + if (param.isPresent()) { 1.349 + return Optional.ofNullable(find.apply(param.get())); 1.350 + } else { 1.351 + return Optional.empty(); 1.352 + } 1.353 + } 1.354 + 1.355 + protected void setAttributeFromParameter(HttpServletRequest req, String name) { 1.356 + final var parm = req.getParameter(name); 1.357 + if (parm != null) { 1.358 + req.setAttribute(name, parm); 1.359 + } 1.360 + } 1.361 + 1.362 + private String sanitizeRequestPath(HttpServletRequest req) { 1.363 + return Optional.ofNullable(req.getPathInfo()).orElse("/"); 1.364 + } 1.365 + 1.366 + private Optional<Map.Entry<PathPattern, Method>> findMapping(HttpMethod method, HttpServletRequest req) { 1.367 + return Optional.ofNullable(mappings.get(method)).flatMap(rm -> 1.368 + rm.entrySet().stream().filter( 1.369 + kv -> kv.getKey().matches(sanitizeRequestPath(req)) 1.370 + ).findAny() 1.371 + ); 1.372 + } 1.373 + 1.374 + protected void renderSite(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { 1.375 + req.getRequestDispatcher(SITE_JSP).forward(req, resp); 1.376 + } 1.377 + 1.378 + protected Optional<String[]> availableLanguages() { 1.379 + return Optional.ofNullable(getServletContext().getInitParameter(Constants.CTX_ATTR_LANGUAGES)).map((x) -> x.split("\\s*,\\s*")); 1.380 + } 1.381 + 1.382 + private static String baseHref(HttpServletRequest req) { 1.383 + return String.format("%s://%s:%d%s/", 1.384 + req.getScheme(), 1.385 + req.getServerName(), 1.386 + req.getServerPort(), 1.387 + req.getContextPath()); 1.388 + } 1.389 + 1.390 + private static String enforceExt(String filename, String ext) { 1.391 + return filename.endsWith(ext) ? filename : filename + ext; 1.392 + } 1.393 + 1.394 + private static String jspPath(String filename) { 1.395 + return enforceExt(Constants.JSP_PATH_PREFIX + filename, ".jsp"); 1.396 + } 1.397 + 1.398 + private void doProcess(HttpMethod method, HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { 1.399 + // the very first thing to do is to force UTF-8 1.400 + req.setCharacterEncoding("UTF-8"); 1.401 + 1.402 + // choose the requested language as session language (if available) or fall back to english, otherwise 1.403 + HttpSession session = req.getSession(); 1.404 + if (session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) == null) { 1.405 + Optional<List<String>> availableLanguages = availableLanguages().map(Arrays::asList); 1.406 + Optional<Locale> reqLocale = Optional.of(req.getLocale()); 1.407 + Locale sessionLocale = reqLocale.filter((rl) -> availableLanguages.map((al) -> al.contains(rl.getLanguage())).orElse(false)).orElse(Locale.ENGLISH); 1.408 + session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, sessionLocale); 1.409 + LOG.debug("Setting language for new session {}: {}", session.getId(), sessionLocale.getDisplayLanguage()); 1.410 + } else { 1.411 + Locale sessionLocale = (Locale) session.getAttribute(Constants.SESSION_ATTR_LANGUAGE); 1.412 + resp.setLocale(sessionLocale); 1.413 + LOG.trace("Continuing session {} with language {}", session.getId(), sessionLocale); 1.414 + } 1.415 + 1.416 + // set some internal request attributes 1.417 + final String fullPath = req.getServletPath() + Optional.ofNullable(req.getPathInfo()).orElse(""); 1.418 + req.setAttribute(Constants.REQ_ATTR_BASE_HREF, baseHref(req)); 1.419 + req.setAttribute(Constants.REQ_ATTR_PATH, fullPath); 1.420 + req.setAttribute(Constants.REQ_ATTR_RESOURCE_BUNDLE, getResourceBundleName()); 1.421 + 1.422 + // if this is an error path, bypass the normal flow 1.423 + if (fullPath.startsWith("/error/")) { 1.424 + final var mapping = findMapping(method, req); 1.425 + if (mapping.isPresent()) { 1.426 + invokeMapping(mapping.get(), req, resp, null); 1.427 + } 1.428 + return; 1.429 + } 1.430 + 1.431 + // obtain a connection and create the data access objects 1.432 + final var db = (DataSourceProvider) req.getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME()); 1.433 + final var ds = db.getDataSource(); 1.434 + if (ds == null) { 1.435 + resp.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "JNDI DataSource lookup failed. See log for details."); 1.436 + return; 1.437 + } 1.438 + try (final var connection = ds.getConnection()) { 1.439 + final var dao = createDataAccessObjects(connection); 1.440 + try { 1.441 + connection.setAutoCommit(false); 1.442 + // call the handler, if available, or send an HTTP 404 error 1.443 + final var mapping = findMapping(method, req); 1.444 + if (mapping.isPresent()) { 1.445 + invokeMapping(mapping.get(), req, resp, dao); 1.446 + } else { 1.447 + resp.sendError(HttpServletResponse.SC_NOT_FOUND); 1.448 + } 1.449 + connection.commit(); 1.450 + } catch (SQLException ex) { 1.451 + LOG.warn("Database transaction failed (Code {}): {}", ex.getErrorCode(), ex.getMessage()); 1.452 + LOG.debug("Details: ", ex); 1.453 + resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unhandled Transaction Error - Code: " + ex.getErrorCode()); 1.454 + connection.rollback(); 1.455 + } 1.456 + } catch (SQLException ex) { 1.457 + LOG.error("Severe Database Exception (Code {}): {}", ex.getErrorCode(), ex.getMessage()); 1.458 + LOG.debug("Details: ", ex); 1.459 + resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Database Error - Code: " + ex.getErrorCode()); 1.460 + } 1.461 + } 1.462 + 1.463 + @Override 1.464 + protected final void doGet(HttpServletRequest req, HttpServletResponse resp) 1.465 + throws ServletException, IOException { 1.466 + doProcess(HttpMethod.GET, req, resp); 1.467 + } 1.468 + 1.469 + @Override 1.470 + protected final void doPost(HttpServletRequest req, HttpServletResponse resp) 1.471 + throws ServletException, IOException { 1.472 + doProcess(HttpMethod.POST, req, resp); 1.473 + } 1.474 +}