universe@7: /* universe@7: * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER. universe@34: * universe@180: * Copyright 2021 Mike Becker. All rights reserved. universe@34: * universe@7: * Redistribution and use in source and binary forms, with or without universe@7: * modification, are permitted provided that the following conditions are met: universe@7: * universe@7: * 1. Redistributions of source code must retain the above copyright universe@7: * notice, this list of conditions and the following disclaimer. universe@7: * universe@7: * 2. Redistributions in binary form must reproduce the above copyright universe@7: * notice, this list of conditions and the following disclaimer in the universe@7: * documentation and/or other materials provided with the distribution. universe@7: * universe@7: * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" universe@7: * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE universe@7: * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE universe@7: * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE universe@7: * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR universe@7: * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF universe@7: * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS universe@7: * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN universe@7: * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) universe@7: * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE universe@7: * POSSIBILITY OF SUCH DAMAGE. universe@34: * universe@7: */ universe@7: package de.uapcore.lightpit; universe@7: universe@167: import de.uapcore.lightpit.dao.DataAccessObject; universe@167: import de.uapcore.lightpit.dao.PostgresDataAccessObject; universe@33: import org.slf4j.Logger; universe@33: import org.slf4j.LoggerFactory; universe@33: universe@7: import javax.servlet.ServletException; universe@7: import javax.servlet.http.HttpServlet; universe@7: import javax.servlet.http.HttpServletRequest; universe@7: import javax.servlet.http.HttpServletResponse; universe@13: import javax.servlet.http.HttpSession; universe@33: import java.io.IOException; universe@83: import java.lang.reflect.*; universe@38: import java.sql.Connection; universe@38: import java.sql.SQLException; universe@33: import java.util.*; universe@63: import java.util.function.Function; universe@163: import java.util.stream.Collectors; universe@7: universe@7: /** universe@7: * A special implementation of a HTTPServlet which is focused on implementing universe@79: * the necessary functionality for LightPIT pages. universe@7: */ universe@179: public abstract class AbstractServlet extends HttpServlet { universe@34: universe@179: private static final Logger LOG = LoggerFactory.getLogger(AbstractServlet.class); universe@34: universe@158: private static final String SITE_JSP = jspPath("site"); universe@33: universe@10: /** universe@11: * Invocation mapping gathered from the {@link RequestMapping} annotations. universe@34: *

universe@18: * Paths in this map must always start with a leading slash, although universe@18: * the specification in the annotation must not start with a leading slash. universe@34: *

universe@34: * The reason for this is the different handling of empty paths in universe@18: * {@link HttpServletRequest#getPathInfo()}. universe@11: */ universe@130: private final Map> mappings = new HashMap<>(); universe@11: universe@11: /** universe@38: * Creates a set of data access objects for the specified connection. universe@33: * universe@38: * @param connection the SQL connection universe@38: * @return a set of data access objects universe@17: */ universe@167: private DataAccessObject createDataAccessObjects(Connection connection) { universe@151: final var df = (DataSourceProvider) getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME()); universe@158: if (df.getDialect() == DataSourceProvider.Dialect.Postgres) { universe@167: return new PostgresDataAccessObject(connection); universe@38: } universe@151: throw new UnsupportedOperationException("Non-exhaustive if-else - this is a bug."); universe@17: } universe@33: universe@167: private void invokeMapping(Map.Entry mapping, HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws IOException { universe@130: final var pathPattern = mapping.getKey(); universe@130: final var method = mapping.getValue(); universe@11: try { universe@14: LOG.trace("invoke {}#{}", method.getDeclaringClass().getName(), method.getName()); universe@42: final var paramTypes = method.getParameterTypes(); universe@42: final var paramValues = new Object[paramTypes.length]; universe@42: for (int i = 0; i < paramTypes.length; i++) { universe@42: if (paramTypes[i].isAssignableFrom(HttpServletRequest.class)) { universe@42: paramValues[i] = req; universe@42: } else if (paramTypes[i].isAssignableFrom(HttpServletResponse.class)) { universe@42: paramValues[i] = resp; universe@42: } universe@167: if (paramTypes[i].isAssignableFrom(DataAccessObject.class)) { universe@42: paramValues[i] = dao; universe@42: } universe@130: if (paramTypes[i].isAssignableFrom(PathParameters.class)) { universe@130: paramValues[i] = pathPattern.obtainPathParameters(sanitizeRequestPath(req)); universe@130: } universe@42: } universe@157: method.invoke(this, paramValues); universe@73: } catch (InvocationTargetException ex) { universe@73: LOG.error("invocation of method {}::{} failed: {}", universe@73: method.getDeclaringClass().getName(), method.getName(), ex.getTargetException().getMessage()); universe@73: LOG.debug("Details: ", ex.getTargetException()); universe@73: resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getTargetException().getMessage()); universe@12: } catch (ReflectiveOperationException | ClassCastException ex) { universe@73: LOG.error("invocation of method {}::{} failed: {}", universe@73: method.getDeclaringClass().getName(), method.getName(), ex.getMessage()); universe@38: LOG.debug("Details: ", ex); universe@73: resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getMessage()); universe@11: } universe@11: } universe@11: universe@11: @Override universe@11: public void init() throws ServletException { universe@78: scanForRequestMappings(); universe@33: universe@12: LOG.trace("{} initialized", getServletName()); universe@12: } universe@12: universe@12: private void scanForRequestMappings() { universe@12: try { universe@11: Method[] methods = getClass().getDeclaredMethods(); universe@11: for (Method method : methods) { universe@11: Optional mapping = Optional.ofNullable(method.getAnnotation(RequestMapping.class)); universe@11: if (mapping.isPresent()) { universe@130: if (mapping.get().requestPath().isBlank()) { universe@130: LOG.warn("{} is annotated with {} but request path is empty", universe@130: method.getName(), RequestMapping.class.getSimpleName() universe@130: ); universe@130: continue; universe@130: } universe@130: universe@11: if (!Modifier.isPublic(method.getModifiers())) { universe@11: LOG.warn("{} is annotated with {} but is not public", universe@11: method.getName(), RequestMapping.class.getSimpleName() universe@11: ); universe@11: continue; universe@11: } universe@11: if (Modifier.isAbstract(method.getModifiers())) { universe@11: LOG.warn("{} is annotated with {} but is abstract", universe@11: method.getName(), RequestMapping.class.getSimpleName() universe@11: ); universe@11: continue; universe@11: } universe@12: universe@42: boolean paramsInjectible = true; universe@42: for (var param : method.getParameterTypes()) { universe@42: paramsInjectible &= HttpServletRequest.class.isAssignableFrom(param) universe@167: || HttpServletResponse.class.isAssignableFrom(param) universe@167: || PathParameters.class.isAssignableFrom(param) universe@167: || DataAccessObject.class.isAssignableFrom(param); universe@42: } universe@42: if (paramsInjectible) { universe@130: try { universe@130: PathPattern pathPattern = new PathPattern(mapping.get().requestPath()); universe@12: universe@131: final var methodMappings = mappings.computeIfAbsent(mapping.get().method(), k -> new HashMap<>()); universe@131: final var currentMapping = methodMappings.putIfAbsent(pathPattern, method); universe@131: if (currentMapping != null) { universe@131: LOG.warn("Cannot map {} {} to {} in class {} - this would override the mapping to {}", universe@130: mapping.get().method(), universe@131: mapping.get().requestPath(), universe@131: method.getName(), universe@131: getClass().getSimpleName(), universe@131: currentMapping.getName() universe@130: ); universe@130: } universe@130: universe@130: LOG.debug("{} {} maps to {}::{}", universe@11: mapping.get().method(), universe@130: mapping.get().requestPath(), universe@130: getClass().getSimpleName(), universe@130: method.getName() universe@130: ); universe@130: } catch (IllegalArgumentException ex) { universe@130: LOG.warn("Request mapping for {} failed: path pattern '{}' is syntactically invalid", universe@130: method.getName(), mapping.get().requestPath() universe@11: ); universe@11: } universe@11: } else { universe@130: LOG.warn("{} is annotated with {} but has the wrong parameters - only HttpServletRequest, HttpServletResponse, PathParameters, and DataAccessObjects are allowed", universe@11: method.getName(), RequestMapping.class.getSimpleName() universe@11: ); universe@11: } universe@11: } universe@11: } universe@12: } catch (SecurityException ex) { universe@12: LOG.error("Scan for request mappings on declared methods failed.", ex); universe@11: } universe@11: } universe@11: universe@11: @Override universe@11: public void destroy() { universe@11: mappings.clear(); universe@11: LOG.trace("{} destroyed", getServletName()); universe@11: } universe@34: universe@13: /** universe@74: * Sets the name of the content page. universe@34: *

universe@13: * It is sufficient to specify the name without any extension. The extension universe@13: * is added automatically if not specified. universe@34: * universe@74: * @param req the servlet request object universe@74: * @param pageName the name of the content page universe@74: * @see Constants#REQ_ATTR_CONTENT_PAGE universe@13: */ universe@74: protected void setContentPage(HttpServletRequest req, String pageName) { universe@158: req.setAttribute(Constants.REQ_ATTR_CONTENT_PAGE, jspPath(pageName)); universe@13: } universe@34: universe@11: /** universe@96: * Sets the navigation menu. universe@71: * universe@109: * @param req the servlet request object universe@109: * @param jspName the name of the menu's jsp file universe@96: * @see Constants#REQ_ATTR_NAVIGATION universe@71: */ universe@109: protected void setNavigationMenu(HttpServletRequest req, String jspName) { universe@158: req.setAttribute(Constants.REQ_ATTR_NAVIGATION, jspPath(jspName)); universe@71: } universe@71: universe@71: /** universe@47: * @param req the servlet request object universe@47: * @param location the location where to redirect universe@47: * @see Constants#REQ_ATTR_REDIRECT_LOCATION universe@47: */ universe@63: protected void setRedirectLocation(HttpServletRequest req, String location) { universe@47: if (location.startsWith("./")) { universe@158: location = location.replaceFirst("\\./", baseHref(req)); universe@47: } universe@47: req.setAttribute(Constants.REQ_ATTR_REDIRECT_LOCATION, location); universe@47: } universe@47: universe@47: /** universe@163: * Specifies the names of additional stylesheets used by this Servlet. universe@34: *

universe@13: * It is sufficient to specify the name without any extension. The extension universe@13: * is added automatically if not specified. universe@34: * universe@163: * @param req the servlet request object universe@163: * @param stylesheets the names of the stylesheets universe@11: */ universe@163: public void setStylesheet(HttpServletRequest req, String ... stylesheets) { universe@163: req.setAttribute(Constants.REQ_ATTR_STYLESHEET, Arrays universe@163: .stream(stylesheets) universe@163: .map(s -> enforceExt(s, ".css")) universe@163: .collect(Collectors.toUnmodifiableList())); universe@10: } universe@34: universe@47: /** universe@86: * Sets the view model object. universe@86: * The type must match the expected type in the JSP file. universe@86: * universe@86: * @param req the servlet request object universe@86: * @param viewModel the view model object universe@86: */ universe@86: public void setViewModel(HttpServletRequest req, Object viewModel) { universe@86: req.setAttribute(Constants.REQ_ATTR_VIEWMODEL, viewModel); universe@86: } universe@86: universe@131: private Optional parseParameter(String paramValue, Class clazz) { universe@131: if (paramValue == null) return Optional.empty(); universe@131: if (clazz.equals(Boolean.class)) { universe@168: if (paramValue.equalsIgnoreCase("false") || paramValue.equals("0")) { universe@131: return Optional.of((T) Boolean.FALSE); universe@131: } else { universe@131: return Optional.of((T) Boolean.TRUE); universe@131: } universe@131: } universe@131: if (clazz.equals(String.class)) return Optional.of((T) paramValue); universe@131: if (java.sql.Date.class.isAssignableFrom(clazz)) { universe@131: try { universe@131: return Optional.of((T) java.sql.Date.valueOf(paramValue)); universe@131: } catch (IllegalArgumentException ex) { universe@131: return Optional.empty(); universe@131: } universe@131: } universe@131: try { universe@131: final Constructor ctor = clazz.getConstructor(String.class); universe@131: return Optional.of(ctor.newInstance(paramValue)); universe@131: } catch (ReflectiveOperationException e) { universe@131: // does not type check and is not convertible - treat as if the parameter was never set universe@131: return Optional.empty(); universe@131: } universe@131: } universe@131: universe@86: /** universe@47: * Obtains a request parameter of the specified type. universe@47: * The specified type must have a single-argument constructor accepting a string to perform conversion. universe@47: * The constructor of the specified type may throw an exception on conversion failures. universe@47: * universe@71: * @param req the servlet request object universe@47: * @param clazz the class object of the expected type universe@71: * @param name the name of the parameter universe@71: * @param the expected type universe@47: * @return the parameter value or an empty optional, if no parameter with the specified name was found universe@47: */ universe@71: protected Optional getParameter(HttpServletRequest req, Class clazz, String name) { universe@83: if (clazz.isArray()) { universe@83: final String[] paramValues = req.getParameterValues(name); universe@83: int len = paramValues == null ? 0 : paramValues.length; universe@83: final var array = (T) Array.newInstance(clazz.getComponentType(), len); universe@86: for (int i = 0; i < len; i++) { universe@83: try { universe@83: final Constructor ctor = clazz.getComponentType().getConstructor(String.class); universe@83: Array.set(array, i, ctor.newInstance(paramValues[i])); universe@83: } catch (ReflectiveOperationException e) { universe@83: throw new RuntimeException(e); universe@83: } universe@83: } universe@83: return Optional.of(array); universe@83: } else { universe@131: return parseParameter(req.getParameter(name), clazz); universe@80: } universe@47: } universe@47: universe@63: /** universe@63: * Tries to look up an entity with a key obtained from a request parameter. universe@63: * universe@71: * @param req the servlet request object universe@63: * @param clazz the class representing the type of the request parameter universe@71: * @param name the name of the request parameter universe@71: * @param find the find function (typically a DAO function) universe@71: * @param the type of the request parameter universe@71: * @param the type of the looked up entity universe@63: * @return the retrieved entity or an empty optional if there is no such entity or the request parameter was missing universe@63: * @throws SQLException if the find function throws an exception universe@63: */ universe@167: protected Optional findByParameter(HttpServletRequest req, Class clazz, String name, Function find) { universe@63: final var param = getParameter(req, clazz, name); universe@63: if (param.isPresent()) { universe@63: return Optional.ofNullable(find.apply(param.get())); universe@63: } else { universe@63: return Optional.empty(); universe@63: } universe@63: } universe@63: universe@168: protected void setAttributeFromParameter(HttpServletRequest req, String name) { universe@168: final var parm = req.getParameter(name); universe@168: if (parm != null) { universe@168: req.setAttribute(name, parm); universe@168: } universe@168: } universe@168: universe@45: private String sanitizeRequestPath(HttpServletRequest req) { universe@45: return Optional.ofNullable(req.getPathInfo()).orElse("/"); universe@45: } universe@45: universe@130: private Optional> findMapping(HttpMethod method, HttpServletRequest req) { universe@130: return Optional.ofNullable(mappings.get(method)).flatMap(rm -> universe@130: rm.entrySet().stream().filter( universe@130: kv -> kv.getKey().matches(sanitizeRequestPath(req)) universe@130: ).findAny() universe@130: ); universe@11: } universe@34: universe@157: protected void renderSite(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { universe@157: req.getRequestDispatcher(SITE_JSP).forward(req, resp); universe@12: } universe@34: universe@158: protected Optional availableLanguages() { universe@158: return Optional.ofNullable(getServletContext().getInitParameter(Constants.CTX_ATTR_LANGUAGES)).map((x) -> x.split("\\s*,\\s*")); universe@158: } universe@158: universe@158: private static String baseHref(HttpServletRequest req) { universe@158: return String.format("%s://%s:%d%s/", universe@158: req.getScheme(), universe@158: req.getServerName(), universe@158: req.getServerPort(), universe@158: req.getContextPath()); universe@158: } universe@158: universe@158: private static String enforceExt(String filename, String ext) { universe@158: return filename.endsWith(ext) ? filename : filename + ext; universe@158: } universe@158: universe@158: private static String jspPath(String filename) { universe@158: return enforceExt(Constants.JSP_PATH_PREFIX + filename, ".jsp"); universe@158: } universe@158: universe@38: private void doProcess(HttpMethod method, HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { universe@160: // the very first thing to do is to force UTF-8 universe@160: req.setCharacterEncoding("UTF-8"); universe@27: universe@13: // choose the requested language as session language (if available) or fall back to english, otherwise universe@20: HttpSession session = req.getSession(); universe@13: if (session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) == null) { universe@158: Optional> availableLanguages = availableLanguages().map(Arrays::asList); universe@13: Optional reqLocale = Optional.of(req.getLocale()); universe@13: Locale sessionLocale = reqLocale.filter((rl) -> availableLanguages.map((al) -> al.contains(rl.getLanguage())).orElse(false)).orElse(Locale.ENGLISH); universe@13: session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, sessionLocale); universe@34: LOG.debug("Setting language for new session {}: {}", session.getId(), sessionLocale.getDisplayLanguage()); universe@14: } else { universe@15: Locale sessionLocale = (Locale) session.getAttribute(Constants.SESSION_ATTR_LANGUAGE); universe@15: resp.setLocale(sessionLocale); universe@15: LOG.trace("Continuing session {} with language {}", session.getId(), sessionLocale); universe@13: } universe@34: universe@21: // set some internal request attributes universe@158: final String fullPath = req.getServletPath() + Optional.ofNullable(req.getPathInfo()).orElse(""); universe@158: req.setAttribute(Constants.REQ_ATTR_BASE_HREF, baseHref(req)); universe@53: req.setAttribute(Constants.REQ_ATTR_PATH, fullPath); universe@34: universe@53: // if this is an error path, bypass the normal flow universe@53: if (fullPath.startsWith("/error/")) { universe@53: final var mapping = findMapping(method, req); universe@53: if (mapping.isPresent()) { universe@157: invokeMapping(mapping.get(), req, resp, null); universe@53: } universe@53: return; universe@53: } universe@53: universe@38: // obtain a connection and create the data access objects universe@151: final var db = (DataSourceProvider) req.getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME()); universe@53: final var ds = db.getDataSource(); universe@53: if (ds == null) { universe@53: resp.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "JNDI DataSource lookup failed. See log for details."); universe@53: return; universe@53: } universe@53: try (final var connection = ds.getConnection()) { universe@38: final var dao = createDataAccessObjects(connection); universe@39: try { universe@39: connection.setAutoCommit(false); universe@39: // call the handler, if available, or send an HTTP 404 error universe@39: final var mapping = findMapping(method, req); universe@39: if (mapping.isPresent()) { universe@157: invokeMapping(mapping.get(), req, resp, dao); universe@39: } else { universe@39: resp.sendError(HttpServletResponse.SC_NOT_FOUND); universe@39: } universe@39: connection.commit(); universe@39: } catch (SQLException ex) { universe@39: LOG.warn("Database transaction failed (Code {}): {}", ex.getErrorCode(), ex.getMessage()); universe@39: LOG.debug("Details: ", ex); universe@54: resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unhandled Transaction Error - Code: " + ex.getErrorCode()); universe@39: connection.rollback(); universe@38: } universe@38: } catch (SQLException ex) { universe@39: LOG.error("Severe Database Exception (Code {}): {}", ex.getErrorCode(), ex.getMessage()); universe@38: LOG.debug("Details: ", ex); universe@54: resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Database Error - Code: " + ex.getErrorCode()); universe@12: } universe@12: } universe@34: universe@7: @Override universe@7: protected final void doGet(HttpServletRequest req, HttpServletResponse resp) universe@7: throws ServletException, IOException { universe@12: doProcess(HttpMethod.GET, req, resp); universe@7: } universe@7: universe@7: @Override universe@7: protected final void doPost(HttpServletRequest req, HttpServletResponse resp) universe@7: throws ServletException, IOException { universe@12: doProcess(HttpMethod.POST, req, resp); universe@7: } universe@7: }