Fri, 18 Dec 2020 16:16:54 +0100
Add mailto link to the display name above comments - fixes #112
/* * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER. * * Copyright 2018 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.dao.DaoProvider; import de.uapcore.lightpit.dao.postgres.PGDaoProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; import java.lang.reflect.*; import java.sql.Connection; import java.sql.SQLException; import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; /** * A special implementation of a HTTPServlet which is focused on implementing * the necessary functionality for LightPIT pages. */ public abstract class AbstractLightPITServlet extends HttpServlet { private static final Logger LOG = LoggerFactory.getLogger(AbstractLightPITServlet.class); private static final String SITE_JSP = jspPath("site"); @FunctionalInterface protected interface SQLFindFunction<K, T> { T apply(K key) throws SQLException; default <V> SQLFindFunction<V, T> compose(Function<? super V, ? extends K> before) throws SQLException { Objects.requireNonNull(before); return (v) -> this.apply(before.apply(v)); } default <V> SQLFindFunction<K, V> andThen(Function<? super T, ? extends V> after) throws SQLException { Objects.requireNonNull(after); return (t) -> after.apply(this.apply(t)); } static <K> Function<K, K> identity() { return (t) -> t; } } /** * Invocation mapping gathered from the {@link RequestMapping} annotations. * <p> * Paths in this map must always start with a leading slash, although * the specification in the annotation must not start with a leading slash. * <p> * The reason for this is the different handling of empty paths in * {@link HttpServletRequest#getPathInfo()}. */ private final Map<HttpMethod, Map<PathPattern, Method>> mappings = new HashMap<>(); /** * Returns the name of the resource bundle associated with this servlet. * * @return the resource bundle base name */ protected abstract String getResourceBundleName(); /** * Creates a set of data access objects for the specified connection. * * @param connection the SQL connection * @return a set of data access objects */ private DaoProvider createDataAccessObjects(Connection connection) throws SQLException { final var df = (DataSourceProvider) getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME()); if (df.getDialect() == DataSourceProvider.Dialect.Postgres) { return new PGDaoProvider(connection); } throw new UnsupportedOperationException("Non-exhaustive if-else - this is a bug."); } private void invokeMapping(Map.Entry<PathPattern, Method> mapping, HttpServletRequest req, HttpServletResponse resp, DaoProvider dao) throws IOException { final var pathPattern = mapping.getKey(); final var method = mapping.getValue(); try { LOG.trace("invoke {}#{}", method.getDeclaringClass().getName(), method.getName()); final var paramTypes = method.getParameterTypes(); final var paramValues = new Object[paramTypes.length]; for (int i = 0; i < paramTypes.length; i++) { if (paramTypes[i].isAssignableFrom(HttpServletRequest.class)) { paramValues[i] = req; } else if (paramTypes[i].isAssignableFrom(HttpServletResponse.class)) { paramValues[i] = resp; } if (paramTypes[i].isAssignableFrom(DaoProvider.class)) { paramValues[i] = dao; } if (paramTypes[i].isAssignableFrom(PathParameters.class)) { paramValues[i] = pathPattern.obtainPathParameters(sanitizeRequestPath(req)); } } method.invoke(this, paramValues); } catch (InvocationTargetException ex) { LOG.error("invocation of method {}::{} failed: {}", method.getDeclaringClass().getName(), method.getName(), ex.getTargetException().getMessage()); LOG.debug("Details: ", ex.getTargetException()); resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getTargetException().getMessage()); } catch (ReflectiveOperationException | ClassCastException ex) { LOG.error("invocation of method {}::{} failed: {}", method.getDeclaringClass().getName(), method.getName(), ex.getMessage()); LOG.debug("Details: ", ex); resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getMessage()); } } @Override public void init() throws ServletException { scanForRequestMappings(); LOG.trace("{} initialized", getServletName()); } private void scanForRequestMappings() { try { Method[] methods = getClass().getDeclaredMethods(); for (Method method : methods) { Optional<RequestMapping> mapping = Optional.ofNullable(method.getAnnotation(RequestMapping.class)); if (mapping.isPresent()) { if (mapping.get().requestPath().isBlank()) { LOG.warn("{} is annotated with {} but request path is empty", method.getName(), RequestMapping.class.getSimpleName() ); continue; } if (!Modifier.isPublic(method.getModifiers())) { LOG.warn("{} is annotated with {} but is not public", method.getName(), RequestMapping.class.getSimpleName() ); continue; } if (Modifier.isAbstract(method.getModifiers())) { LOG.warn("{} is annotated with {} but is abstract", method.getName(), RequestMapping.class.getSimpleName() ); continue; } boolean paramsInjectible = true; for (var param : method.getParameterTypes()) { paramsInjectible &= HttpServletRequest.class.isAssignableFrom(param) || HttpServletResponse.class.isAssignableFrom(param) || PathParameters.class.isAssignableFrom(param) || DaoProvider.class.isAssignableFrom(param); } if (paramsInjectible) { try { PathPattern pathPattern = new PathPattern(mapping.get().requestPath()); final var methodMappings = mappings.computeIfAbsent(mapping.get().method(), k -> new HashMap<>()); final var currentMapping = methodMappings.putIfAbsent(pathPattern, method); if (currentMapping != null) { LOG.warn("Cannot map {} {} to {} in class {} - this would override the mapping to {}", mapping.get().method(), mapping.get().requestPath(), method.getName(), getClass().getSimpleName(), currentMapping.getName() ); } LOG.debug("{} {} maps to {}::{}", mapping.get().method(), mapping.get().requestPath(), getClass().getSimpleName(), method.getName() ); } catch (IllegalArgumentException ex) { LOG.warn("Request mapping for {} failed: path pattern '{}' is syntactically invalid", method.getName(), mapping.get().requestPath() ); } } else { LOG.warn("{} is annotated with {} but has the wrong parameters - only HttpServletRequest, HttpServletResponse, PathParameters, and DataAccessObjects are allowed", method.getName(), RequestMapping.class.getSimpleName() ); } } } } catch (SecurityException ex) { LOG.error("Scan for request mappings on declared methods failed.", ex); } } @Override public void destroy() { mappings.clear(); LOG.trace("{} destroyed", getServletName()); } /** * Sets the name of the content page. * <p> * It is sufficient to specify the name without any extension. The extension * is added automatically if not specified. * * @param req the servlet request object * @param pageName the name of the content page * @see Constants#REQ_ATTR_CONTENT_PAGE */ protected void setContentPage(HttpServletRequest req, String pageName) { req.setAttribute(Constants.REQ_ATTR_CONTENT_PAGE, jspPath(pageName)); } /** * Sets the navigation menu. * * @param req the servlet request object * @param jspName the name of the menu's jsp file * @see Constants#REQ_ATTR_NAVIGATION */ protected void setNavigationMenu(HttpServletRequest req, String jspName) { req.setAttribute(Constants.REQ_ATTR_NAVIGATION, jspPath(jspName)); } /** * @param req the servlet request object * @param location the location where to redirect * @see Constants#REQ_ATTR_REDIRECT_LOCATION */ protected void setRedirectLocation(HttpServletRequest req, String location) { if (location.startsWith("./")) { location = location.replaceFirst("\\./", baseHref(req)); } req.setAttribute(Constants.REQ_ATTR_REDIRECT_LOCATION, location); } /** * Specifies the names of additional stylesheets used by this Servlet. * <p> * It is sufficient to specify the name without any extension. The extension * is added automatically if not specified. * * @param req the servlet request object * @param stylesheets the names of the stylesheets */ public void setStylesheet(HttpServletRequest req, String ... stylesheets) { req.setAttribute(Constants.REQ_ATTR_STYLESHEET, Arrays .stream(stylesheets) .map(s -> enforceExt(s, ".css")) .collect(Collectors.toUnmodifiableList())); } /** * Sets the view model object. * The type must match the expected type in the JSP file. * * @param req the servlet request object * @param viewModel the view model object */ public void setViewModel(HttpServletRequest req, Object viewModel) { req.setAttribute(Constants.REQ_ATTR_VIEWMODEL, viewModel); } private <T> Optional<T> parseParameter(String paramValue, Class<T> clazz) { if (paramValue == null) return Optional.empty(); if (clazz.equals(Boolean.class)) { if (paramValue.toLowerCase().equals("false") || paramValue.equals("0")) { return Optional.of((T) Boolean.FALSE); } else { return Optional.of((T) Boolean.TRUE); } } if (clazz.equals(String.class)) return Optional.of((T) paramValue); if (java.sql.Date.class.isAssignableFrom(clazz)) { try { return Optional.of((T) java.sql.Date.valueOf(paramValue)); } catch (IllegalArgumentException ex) { return Optional.empty(); } } try { final Constructor<T> ctor = clazz.getConstructor(String.class); return Optional.of(ctor.newInstance(paramValue)); } catch (ReflectiveOperationException e) { // does not type check and is not convertible - treat as if the parameter was never set return Optional.empty(); } } /** * Obtains a request parameter of the specified type. * The specified type must have a single-argument constructor accepting a string to perform conversion. * The constructor of the specified type may throw an exception on conversion failures. * * @param req the servlet request object * @param clazz the class object of the expected type * @param name the name of the parameter * @param <T> the expected type * @return the parameter value or an empty optional, if no parameter with the specified name was found */ protected <T> Optional<T> getParameter(HttpServletRequest req, Class<T> clazz, String name) { if (clazz.isArray()) { final String[] paramValues = req.getParameterValues(name); int len = paramValues == null ? 0 : paramValues.length; final var array = (T) Array.newInstance(clazz.getComponentType(), len); for (int i = 0; i < len; i++) { try { final Constructor<?> ctor = clazz.getComponentType().getConstructor(String.class); Array.set(array, i, ctor.newInstance(paramValues[i])); } catch (ReflectiveOperationException e) { throw new RuntimeException(e); } } return Optional.of(array); } else { return parseParameter(req.getParameter(name), clazz); } } /** * Tries to look up an entity with a key obtained from a request parameter. * * @param req the servlet request object * @param clazz the class representing the type of the request parameter * @param name the name of the request parameter * @param find the find function (typically a DAO function) * @param <T> the type of the request parameter * @param <R> the type of the looked up entity * @return the retrieved entity or an empty optional if there is no such entity or the request parameter was missing * @throws SQLException if the find function throws an exception */ protected <T, R> Optional<R> findByParameter(HttpServletRequest req, Class<T> clazz, String name, SQLFindFunction<? super T, ? extends R> find) throws SQLException { final var param = getParameter(req, clazz, name); if (param.isPresent()) { return Optional.ofNullable(find.apply(param.get())); } else { return Optional.empty(); } } private String sanitizeRequestPath(HttpServletRequest req) { return Optional.ofNullable(req.getPathInfo()).orElse("/"); } private Optional<Map.Entry<PathPattern, Method>> findMapping(HttpMethod method, HttpServletRequest req) { return Optional.ofNullable(mappings.get(method)).flatMap(rm -> rm.entrySet().stream().filter( kv -> kv.getKey().matches(sanitizeRequestPath(req)) ).findAny() ); } protected void renderSite(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.getRequestDispatcher(SITE_JSP).forward(req, resp); } protected Optional<String[]> availableLanguages() { return Optional.ofNullable(getServletContext().getInitParameter(Constants.CTX_ATTR_LANGUAGES)).map((x) -> x.split("\\s*,\\s*")); } private static String baseHref(HttpServletRequest req) { return String.format("%s://%s:%d%s/", req.getScheme(), req.getServerName(), req.getServerPort(), req.getContextPath()); } private static String enforceExt(String filename, String ext) { return filename.endsWith(ext) ? filename : filename + ext; } private static String jspPath(String filename) { return enforceExt(Constants.JSP_PATH_PREFIX + filename, ".jsp"); } private void doProcess(HttpMethod method, HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // the very first thing to do is to force UTF-8 req.setCharacterEncoding("UTF-8"); // choose the requested language as session language (if available) or fall back to english, otherwise HttpSession session = req.getSession(); if (session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) == null) { Optional<List<String>> availableLanguages = availableLanguages().map(Arrays::asList); Optional<Locale> reqLocale = Optional.of(req.getLocale()); Locale sessionLocale = reqLocale.filter((rl) -> availableLanguages.map((al) -> al.contains(rl.getLanguage())).orElse(false)).orElse(Locale.ENGLISH); session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, sessionLocale); LOG.debug("Setting language for new session {}: {}", session.getId(), sessionLocale.getDisplayLanguage()); } else { Locale sessionLocale = (Locale) session.getAttribute(Constants.SESSION_ATTR_LANGUAGE); resp.setLocale(sessionLocale); LOG.trace("Continuing session {} with language {}", session.getId(), sessionLocale); } // set some internal request attributes final String fullPath = req.getServletPath() + Optional.ofNullable(req.getPathInfo()).orElse(""); req.setAttribute(Constants.REQ_ATTR_BASE_HREF, baseHref(req)); req.setAttribute(Constants.REQ_ATTR_PATH, fullPath); req.setAttribute(Constants.REQ_ATTR_RESOURCE_BUNDLE, getResourceBundleName()); // if this is an error path, bypass the normal flow if (fullPath.startsWith("/error/")) { final var mapping = findMapping(method, req); if (mapping.isPresent()) { invokeMapping(mapping.get(), req, resp, null); } return; } // obtain a connection and create the data access objects final var db = (DataSourceProvider) req.getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME()); final var ds = db.getDataSource(); if (ds == null) { resp.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "JNDI DataSource lookup failed. See log for details."); return; } try (final var connection = ds.getConnection()) { final var dao = createDataAccessObjects(connection); try { connection.setAutoCommit(false); // call the handler, if available, or send an HTTP 404 error final var mapping = findMapping(method, req); if (mapping.isPresent()) { invokeMapping(mapping.get(), req, resp, dao); } else { resp.sendError(HttpServletResponse.SC_NOT_FOUND); } connection.commit(); } catch (SQLException ex) { LOG.warn("Database transaction failed (Code {}): {}", ex.getErrorCode(), ex.getMessage()); LOG.debug("Details: ", ex); resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unhandled Transaction Error - Code: " + ex.getErrorCode()); connection.rollback(); } } catch (SQLException ex) { LOG.error("Severe Database Exception (Code {}): {}", ex.getErrorCode(), ex.getMessage()); LOG.debug("Details: ", ex); resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Database Error - Code: " + ex.getErrorCode()); } } @Override protected final void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doProcess(HttpMethod.GET, req, resp); } @Override protected final void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doProcess(HttpMethod.POST, req, resp); } }