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