Sat, 30 May 2020 18:05:06 +0200
adds version selection in issue editor
1 /*
2 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
3 *
4 * Copyright 2018 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;
31 import de.uapcore.lightpit.dao.DataAccessObjects;
32 import de.uapcore.lightpit.dao.postgres.PGDataAccessObjects;
33 import org.slf4j.Logger;
34 import org.slf4j.LoggerFactory;
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;
48 /**
49 * A special implementation of a HTTPServlet which is focused on implementing
50 * the necessary functionality for LightPIT pages.
51 */
52 public abstract class AbstractLightPITServlet extends HttpServlet {
54 private static final Logger LOG = LoggerFactory.getLogger(AbstractLightPITServlet.class);
56 private static final String SITE_JSP = Functions.jspPath("site");
59 @FunctionalInterface
60 protected interface SQLFindFunction<K, T> {
61 T apply(K key) throws SQLException;
63 default <V> SQLFindFunction<V, T> compose(Function<? super V, ? extends K> before) throws SQLException {
64 Objects.requireNonNull(before);
65 return (v) -> this.apply(before.apply(v));
66 }
68 default <V> SQLFindFunction<K, V> andThen(Function<? super T, ? extends V> after) throws SQLException {
69 Objects.requireNonNull(after);
70 return (t) -> after.apply(this.apply(t));
71 }
73 static <K> Function<K, K> identity() {
74 return (t) -> t;
75 }
76 }
78 /**
79 * Invocation mapping gathered from the {@link RequestMapping} annotations.
80 * <p>
81 * Paths in this map must always start with a leading slash, although
82 * the specification in the annotation must not start with a leading slash.
83 * <p>
84 * The reason for this is the different handling of empty paths in
85 * {@link HttpServletRequest#getPathInfo()}.
86 */
87 private final Map<HttpMethod, Map<String, Method>> mappings = new HashMap<>();
89 /**
90 * Returns the name of the resource bundle associated with this servlet.
91 * @return the resource bundle base name
92 */
93 protected abstract String getResourceBundleName();
96 /**
97 * Creates a set of data access objects for the specified connection.
98 *
99 * @param connection the SQL connection
100 * @return a set of data access objects
101 */
102 private DataAccessObjects createDataAccessObjects(Connection connection) throws SQLException {
103 final var df = (DatabaseFacade) getServletContext().getAttribute(DatabaseFacade.SC_ATTR_NAME);
104 if (df.getSQLDialect() == DatabaseFacade.Dialect.Postgres) {
105 return new PGDataAccessObjects(connection);
106 }
107 throw new AssertionError("Non-exhaustive if-else - this is a bug.");
108 }
110 private ResponseType invokeMapping(Method method, HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws IOException {
111 try {
112 LOG.trace("invoke {}#{}", method.getDeclaringClass().getName(), method.getName());
113 final var paramTypes = method.getParameterTypes();
114 final var paramValues = new Object[paramTypes.length];
115 for (int i = 0; i < paramTypes.length; i++) {
116 if (paramTypes[i].isAssignableFrom(HttpServletRequest.class)) {
117 paramValues[i] = req;
118 } else if (paramTypes[i].isAssignableFrom(HttpServletResponse.class)) {
119 paramValues[i] = resp;
120 }
121 if (paramTypes[i].isAssignableFrom(DataAccessObjects.class)) {
122 paramValues[i] = dao;
123 }
124 }
125 return (ResponseType) method.invoke(this, paramValues);
126 } catch (InvocationTargetException ex) {
127 LOG.error("invocation of method {}::{} failed: {}",
128 method.getDeclaringClass().getName(), method.getName(), ex.getTargetException().getMessage());
129 LOG.debug("Details: ", ex.getTargetException());
130 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getTargetException().getMessage());
131 return ResponseType.NONE;
132 } catch (ReflectiveOperationException | ClassCastException ex) {
133 LOG.error("invocation of method {}::{} failed: {}",
134 method.getDeclaringClass().getName(), method.getName(), ex.getMessage());
135 LOG.debug("Details: ", ex);
136 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getMessage());
137 return ResponseType.NONE;
138 }
139 }
141 @Override
142 public void init() throws ServletException {
143 scanForRequestMappings();
145 LOG.trace("{} initialized", getServletName());
146 }
148 private void scanForRequestMappings() {
149 try {
150 Method[] methods = getClass().getDeclaredMethods();
151 for (Method method : methods) {
152 Optional<RequestMapping> mapping = Optional.ofNullable(method.getAnnotation(RequestMapping.class));
153 if (mapping.isPresent()) {
154 if (!Modifier.isPublic(method.getModifiers())) {
155 LOG.warn("{} is annotated with {} but is not public",
156 method.getName(), RequestMapping.class.getSimpleName()
157 );
158 continue;
159 }
160 if (Modifier.isAbstract(method.getModifiers())) {
161 LOG.warn("{} is annotated with {} but is abstract",
162 method.getName(), RequestMapping.class.getSimpleName()
163 );
164 continue;
165 }
166 if (!ResponseType.class.isAssignableFrom(method.getReturnType())) {
167 LOG.warn("{} is annotated with {} but has the wrong return type - 'ResponseType' required",
168 method.getName(), RequestMapping.class.getSimpleName()
169 );
170 continue;
171 }
173 boolean paramsInjectible = true;
174 for (var param : method.getParameterTypes()) {
175 paramsInjectible &= HttpServletRequest.class.isAssignableFrom(param)
176 || HttpServletResponse.class.isAssignableFrom(param)
177 || DataAccessObjects.class.isAssignableFrom(param);
178 }
179 if (paramsInjectible) {
180 String requestPath = "/" + mapping.get().requestPath();
182 if (mappings
183 .computeIfAbsent(mapping.get().method(), k -> new HashMap<>())
184 .putIfAbsent(requestPath, method) != null) {
185 LOG.warn("{} {} has multiple mappings",
186 mapping.get().method(),
187 mapping.get().requestPath()
188 );
189 }
191 LOG.debug("{} {} maps to {}::{}",
192 mapping.get().method(),
193 requestPath,
194 getClass().getSimpleName(),
195 method.getName()
196 );
197 } else {
198 LOG.warn("{} is annotated with {} but has the wrong parameters - only HttpServletRequest. HttpServletResponse, and DataAccessObjects are allowed",
199 method.getName(), RequestMapping.class.getSimpleName()
200 );
201 }
202 }
203 }
204 } catch (SecurityException ex) {
205 LOG.error("Scan for request mappings on declared methods failed.", ex);
206 }
207 }
209 @Override
210 public void destroy() {
211 mappings.clear();
212 LOG.trace("{} destroyed", getServletName());
213 }
215 /**
216 * Sets the name of the content page.
217 * <p>
218 * It is sufficient to specify the name without any extension. The extension
219 * is added automatically if not specified.
220 *
221 * @param req the servlet request object
222 * @param pageName the name of the content page
223 * @see Constants#REQ_ATTR_CONTENT_PAGE
224 */
225 protected void setContentPage(HttpServletRequest req, String pageName) {
226 req.setAttribute(Constants.REQ_ATTR_CONTENT_PAGE, Functions.jspPath(pageName));
227 }
229 /**
230 * Sets the breadcrumbs menu.
231 *
232 * @param req the servlet request object
233 * @param breadcrumbs the menu entries for the breadcrumbs menu
234 * @see Constants#REQ_ATTR_BREADCRUMBS
235 */
236 protected void setBreadcrumbs(HttpServletRequest req, List<MenuEntry> breadcrumbs) {
237 req.setAttribute(Constants.REQ_ATTR_BREADCRUMBS, breadcrumbs);
238 }
240 /**
241 * @param req the servlet request object
242 * @param location the location where to redirect
243 * @see Constants#REQ_ATTR_REDIRECT_LOCATION
244 */
245 protected void setRedirectLocation(HttpServletRequest req, String location) {
246 if (location.startsWith("./")) {
247 location = location.replaceFirst("\\./", Functions.baseHref(req));
248 }
249 req.setAttribute(Constants.REQ_ATTR_REDIRECT_LOCATION, location);
250 }
252 /**
253 * Specifies the name of an additional stylesheet used by the module.
254 * <p>
255 * Setting an additional stylesheet is optional, but quite common for HTML
256 * output.
257 * <p>
258 * It is sufficient to specify the name without any extension. The extension
259 * is added automatically if not specified.
260 *
261 * @param req the servlet request object
262 * @param stylesheet the name of the stylesheet
263 */
264 public void setStylesheet(HttpServletRequest req, String stylesheet) {
265 req.setAttribute(Constants.REQ_ATTR_STYLESHEET, Functions.enforceExt(stylesheet, ".css"));
266 }
268 /**
269 * Obtains a request parameter of the specified type.
270 * The specified type must have a single-argument constructor accepting a string to perform conversion.
271 * The constructor of the specified type may throw an exception on conversion failures.
272 *
273 * @param req the servlet request object
274 * @param clazz the class object of the expected type
275 * @param name the name of the parameter
276 * @param <T> the expected type
277 * @return the parameter value or an empty optional, if no parameter with the specified name was found
278 */
279 protected <T> Optional<T> getParameter(HttpServletRequest req, Class<T> clazz, String name) {
280 if (clazz.isArray()) {
281 final String[] paramValues = req.getParameterValues(name);
282 int len = paramValues == null ? 0 : paramValues.length;
283 final var array = (T) Array.newInstance(clazz.getComponentType(), len);
284 for (int i = 0 ; i < len ; i++) {
285 try {
286 final Constructor<?> ctor = clazz.getComponentType().getConstructor(String.class);
287 Array.set(array, i, ctor.newInstance(paramValues[i]));
288 } catch (ReflectiveOperationException e) {
289 throw new RuntimeException(e);
290 }
291 }
292 return Optional.of(array);
293 } else {
294 final String paramValue = req.getParameter(name);
295 if (paramValue == null) return Optional.empty();
296 if (clazz.equals(Boolean.class)) {
297 if (paramValue.toLowerCase().equals("false") || paramValue.equals("0")) {
298 return Optional.of((T) Boolean.FALSE);
299 } else {
300 return Optional.of((T) Boolean.TRUE);
301 }
302 }
303 if (clazz.equals(String.class)) return Optional.of((T) paramValue);
304 if (java.sql.Date.class.isAssignableFrom(clazz)) {
305 try {
306 return Optional.of((T) java.sql.Date.valueOf(paramValue));
307 } catch (IllegalArgumentException ex) {
308 return Optional.empty();
309 }
310 }
311 try {
312 final Constructor<T> ctor = clazz.getConstructor(String.class);
313 return Optional.of(ctor.newInstance(paramValue));
314 } catch (ReflectiveOperationException e) {
315 throw new RuntimeException(e);
316 }
317 }
318 }
320 /**
321 * Tries to look up an entity with a key obtained from a request parameter.
322 *
323 * @param req the servlet request object
324 * @param clazz the class representing the type of the request parameter
325 * @param name the name of the request parameter
326 * @param find the find function (typically a DAO function)
327 * @param <T> the type of the request parameter
328 * @param <R> the type of the looked up entity
329 * @return the retrieved entity or an empty optional if there is no such entity or the request parameter was missing
330 * @throws SQLException if the find function throws an exception
331 */
332 protected <T, R> Optional<R> findByParameter(HttpServletRequest req, Class<T> clazz, String name, SQLFindFunction<? super T, ? extends R> find) throws SQLException {
333 final var param = getParameter(req, clazz, name);
334 if (param.isPresent()) {
335 return Optional.ofNullable(find.apply(param.get()));
336 } else {
337 return Optional.empty();
338 }
339 }
341 private void forwardToFullView(HttpServletRequest req, HttpServletResponse resp)
342 throws IOException, ServletException {
344 final String lightpitBundle = "localization.lightpit";
345 final var mainMenu = List.of(
346 new MenuEntry(new ResourceKey(lightpitBundle, "menu.projects"), "projects/"),
347 new MenuEntry(new ResourceKey(lightpitBundle, "menu.users"), "teams/"),
348 new MenuEntry(new ResourceKey(lightpitBundle, "menu.languages"), "language/")
349 );
350 for (var entry : mainMenu) {
351 if (Functions.fullPath(req).startsWith("/" + entry.getPathName())) {
352 entry.setActive(true);
353 }
354 }
355 req.setAttribute(Constants.REQ_ATTR_MENU, mainMenu);
356 req.getRequestDispatcher(SITE_JSP).forward(req, resp);
357 }
359 private String sanitizeRequestPath(HttpServletRequest req) {
360 return Optional.ofNullable(req.getPathInfo()).orElse("/");
361 }
363 private Optional<Method> findMapping(HttpMethod method, HttpServletRequest req) {
364 return Optional.ofNullable(mappings.get(method)).map(rm -> rm.get(sanitizeRequestPath(req)));
365 }
367 private void forwardAsSpecified(ResponseType type, HttpServletRequest req, HttpServletResponse resp)
368 throws ServletException, IOException {
369 switch (type) {
370 case NONE:
371 return;
372 case HTML:
373 forwardToFullView(req, resp);
374 return;
375 // TODO: implement remaining response types
376 default:
377 throw new AssertionError("ResponseType switch is not exhaustive - this is a bug!");
378 }
379 }
381 private void doProcess(HttpMethod method, HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
383 // choose the requested language as session language (if available) or fall back to english, otherwise
384 HttpSession session = req.getSession();
385 if (session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) == null) {
386 Optional<List<String>> availableLanguages = Functions.availableLanguages(getServletContext()).map(Arrays::asList);
387 Optional<Locale> reqLocale = Optional.of(req.getLocale());
388 Locale sessionLocale = reqLocale.filter((rl) -> availableLanguages.map((al) -> al.contains(rl.getLanguage())).orElse(false)).orElse(Locale.ENGLISH);
389 session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, sessionLocale);
390 LOG.debug("Setting language for new session {}: {}", session.getId(), sessionLocale.getDisplayLanguage());
391 } else {
392 Locale sessionLocale = (Locale) session.getAttribute(Constants.SESSION_ATTR_LANGUAGE);
393 resp.setLocale(sessionLocale);
394 LOG.trace("Continuing session {} with language {}", session.getId(), sessionLocale);
395 }
397 // set some internal request attributes
398 final String fullPath = Functions.fullPath(req);
399 req.setAttribute(Constants.REQ_ATTR_BASE_HREF, Functions.baseHref(req));
400 req.setAttribute(Constants.REQ_ATTR_PATH, fullPath);
401 req.setAttribute(Constants.REQ_ATTR_RESOURCE_BUNDLE, getResourceBundleName());
403 // if this is an error path, bypass the normal flow
404 if (fullPath.startsWith("/error/")) {
405 final var mapping = findMapping(method, req);
406 if (mapping.isPresent()) {
407 forwardAsSpecified(invokeMapping(mapping.get(), req, resp, null), req, resp);
408 }
409 return;
410 }
412 // obtain a connection and create the data access objects
413 final var db = (DatabaseFacade) req.getServletContext().getAttribute(DatabaseFacade.SC_ATTR_NAME);
414 final var ds = db.getDataSource();
415 if (ds == null) {
416 resp.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "JNDI DataSource lookup failed. See log for details.");
417 return;
418 }
419 try (final var connection = ds.getConnection()) {
420 final var dao = createDataAccessObjects(connection);
421 try {
422 connection.setAutoCommit(false);
423 // call the handler, if available, or send an HTTP 404 error
424 final var mapping = findMapping(method, req);
425 if (mapping.isPresent()) {
426 forwardAsSpecified(invokeMapping(mapping.get(), req, resp, dao), req, resp);
427 } else {
428 resp.sendError(HttpServletResponse.SC_NOT_FOUND);
429 }
430 connection.commit();
431 } catch (SQLException ex) {
432 LOG.warn("Database transaction failed (Code {}): {}", ex.getErrorCode(), ex.getMessage());
433 LOG.debug("Details: ", ex);
434 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unhandled Transaction Error - Code: " + ex.getErrorCode());
435 connection.rollback();
436 }
437 } catch (SQLException ex) {
438 LOG.error("Severe Database Exception (Code {}): {}", ex.getErrorCode(), ex.getMessage());
439 LOG.debug("Details: ", ex);
440 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Database Error - Code: " + ex.getErrorCode());
441 }
442 }
444 @Override
445 protected final void doGet(HttpServletRequest req, HttpServletResponse resp)
446 throws ServletException, IOException {
447 doProcess(HttpMethod.GET, req, resp);
448 }
450 @Override
451 protected final void doPost(HttpServletRequest req, HttpServletResponse resp)
452 throws ServletException, IOException {
453 doProcess(HttpMethod.POST, req, resp);
454 }
455 }