From 6946485a959b442884f7305aaf9b7879aed7e02b Mon Sep 17 00:00:00 2001 From: jnsereko Date: Thu, 30 Mar 2023 21:32:57 +0300 Subject: [PATCH 1/4] O3-310: allow Put operations on an application-writable config.json file config.json file name shouldn't be dynamic dynamically serve 'frontend/config.json' endpoint remove unrequited change --- README.md | 11 +- .../org/openmrs/module/spa/SpaConstants.java | 1 + .../module/spa/servlet/SpaServlet.java | 126 +++++++++++++++++- .../spa/web/SpaWebComponentRegistrar.java | 48 +++++++ 4 files changed, 175 insertions(+), 11 deletions(-) create mode 100644 omod/src/main/java/org/openmrs/module/spa/web/SpaWebComponentRegistrar.java diff --git a/README.md b/README.md index ed887be..d0c4fc4 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ cd openmrs-module-spa && mvn clean install ``` ## Configurations -| Property | Description | Default Value | -| ----------- | ----------- | ------------ | -| `spa.local.directory` | The directory containing the Frontend 3.0 application's `index.html`. Can be an absolute path, or relative to the application data directory. Only used if `spa.remote.enabled` is false. | frontend | -| `spa.remote.enabled` | If enabled, serves from `spa.remote.url` instead of `spa.local.directory` | false | -| `spa.remote.url` | The URL of the Frontend 3.0 application files. Only used if `spa.remote.enabled` is true. | https://spa-modules.nyc3.digitaloceanspaces.com/@openmrs/esm-app-shell/latest/ | +| Property | Description | Default Value | +|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------| +| `spa.local.directory` | The directory containing the Frontend 3.0 application's `index.html`. Can be an absolute path, or relative to the application data directory. Only used if `spa.remote.enabled` is false. | frontend | +| `spa.remote.enabled` | If enabled, serves from `spa.remote.url` instead of `spa.local.directory` | false | +| `spa.remote.url` | The URL of the Frontend 3.0 application files. Only used if `spa.remote.enabled` is true. | https://spa-modules.nyc3.digitaloceanspaces.com/@openmrs/esm-app-shell/latest/ | + diff --git a/omod/src/main/java/org/openmrs/module/spa/SpaConstants.java b/omod/src/main/java/org/openmrs/module/spa/SpaConstants.java index 7d73bac..a254504 100644 --- a/omod/src/main/java/org/openmrs/module/spa/SpaConstants.java +++ b/omod/src/main/java/org/openmrs/module/spa/SpaConstants.java @@ -28,5 +28,6 @@ private SpaConstants() {} public static final String GP_IS_REMOTE_ENABLED = "spa.remote.enabled"; public static final String GP_REMOTE_URL = "spa.remote.url"; + } diff --git a/omod/src/main/java/org/openmrs/module/spa/servlet/SpaServlet.java b/omod/src/main/java/org/openmrs/module/spa/servlet/SpaServlet.java index b8e5b1d..aebe81a 100644 --- a/omod/src/main/java/org/openmrs/module/spa/servlet/SpaServlet.java +++ b/omod/src/main/java/org/openmrs/module/spa/servlet/SpaServlet.java @@ -11,7 +11,12 @@ import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang.StringUtils; +import org.codehaus.jackson.JsonProcessingException; +import org.codehaus.jackson.map.ObjectMapper; +import org.openmrs.User; import org.openmrs.api.context.Context; +import org.openmrs.module.ModuleException; import org.openmrs.module.spa.component.ResourceLoaderComponent; import org.openmrs.module.spa.utils.SpaModuleUtils; import org.openmrs.util.OpenmrsUtil; @@ -21,10 +26,15 @@ import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; import java.io.File; -import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Base64; @Slf4j public class SpaServlet extends HttpServlet { @@ -33,6 +43,8 @@ public class SpaServlet extends HttpServlet { private static final String BASE_URL = "/spa/spaServlet"; + private static final String JSON_CONFIG_FILE_NAME = "config.json"; + /** * Used for caching purposes * @@ -62,6 +74,84 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) t } } + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { + String requestURI = request.getRequestURI(); + if (requestURI.endsWith("/config.json")) { + if (!Context.isAuthenticated()) { + String basicAuth = request.getHeader("Authorization"); + if (basicAuth != null) { + // check that header is in format "Basic ${base64encode(username + ":" + password)}" + if (isValidAuthFormat(response, basicAuth)) return; + } + } + + User user = Context.getAuthenticatedUser(); + if (user != null && user.isSuperUser()) { + saveJsonConfigFile(request, response); + } else { + log.error("Authorisation error while creating a config.json file"); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + } + } + } + + private void saveJsonConfigFile(HttpServletRequest request, HttpServletResponse response) throws IOException { + File jsonConfigFile = getJsonConfigFile(); + try { + BufferedReader reader = request.getReader(); + StringBuilder stringBuilder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + stringBuilder.append(line); + } + String requestBody = stringBuilder.toString(); + + new ObjectMapper().readTree(requestBody); // verify that is in a valid JSON format + + InputStream inputStream = new ByteArrayInputStream(requestBody.getBytes(StandardCharsets.UTF_8)); + OutputStream outStream = Files.newOutputStream(jsonConfigFile.toPath()); + OpenmrsUtil.copyFile(inputStream, outStream); + + if (jsonConfigFile.exists()) { + log.debug("file: '{}' written successfully", jsonConfigFile.getAbsolutePath()); + response.setStatus(HttpServletResponse.SC_OK); + } + } catch (JsonProcessingException e) { + log.error("Invalid JSON format", e); + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + } + } + + private boolean isValidAuthFormat(HttpServletResponse response, String basicAuth) { + if (basicAuth.startsWith("Basic")) { + try { + // remove the leading "Basic " + basicAuth = basicAuth.substring(6); + if (StringUtils.isBlank(basicAuth)) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid credentials provided"); + return true; + } + + String decoded = new String(Base64.getDecoder().decode(basicAuth), StandardCharsets.UTF_8); + if (StringUtils.isBlank(decoded) || !decoded.contains(":")) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid credentials provided"); + return true; + } + + String[] userAndPass = decoded.split(":"); + Context.authenticate(userAndPass[0], userAndPass[1]); + log.debug("authenticated [{}]", userAndPass[0]); + } + catch (Exception ex) { + // This filter never stops execution. If the user failed to + // authenticate, that will be caught later. + log.debug("authentication exception ", ex); + } + } + return false; + } + protected void handleLocalAssets(HttpServletRequest request, HttpServletResponse response) throws IOException { File file = getFile(request); @@ -70,13 +160,16 @@ protected void handleLocalAssets(HttpServletRequest request, HttpServletResponse return; } + if (file.getAbsolutePath().endsWith("/config.json")) { + response.setContentType("application/json;charset=UTF-8"); + } response.setDateHeader("Last-Modified", file.lastModified()); addCacheControlHeader(request, response); response.setContentLength((int) file.length()); String mimeType = getServletContext().getMimeType(file.getName()); response.setContentType(mimeType); - try (InputStream is = new FileInputStream(file)) { + try (InputStream is = Files.newInputStream(file.toPath())) { OpenmrsUtil.copyFile(is, response.getOutputStream()); } } @@ -89,12 +182,15 @@ protected void handleLocalAssets(HttpServletRequest request, HttpServletResponse * @param response {@link HttpServletResponse} * @throws IOException {@link IOException} F */ - protected void handleRemoteAssets(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { + protected void handleRemoteAssets(HttpServletRequest request, HttpServletResponse response) throws IOException { Resource resource = getResource(request); if (!resource.exists()) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } + if (resource.getFilename().endsWith("/config.json")) { + response.setContentType("application/json;charset=UTF-8"); + } response.setDateHeader("Last-Modified", resource.lastModified()); addCacheControlHeader(request, response); response.setContentLength((int) resource.contentLength()); @@ -117,6 +213,10 @@ protected void handleRemoteAssets(HttpServletRequest request, HttpServletRespons */ protected Resource getResource(HttpServletRequest request) { String path = request.getPathInfo(); + if (path == null) { //dynamically defined servlets see https://wiki.openmrs.org/display/docs/Module+Servlets + String url = String.valueOf(request.getRequestURL()); + path = url.substring(url.lastIndexOf('/') + 1); + } /* * we want to extract everything after /spa/spaServlet from the path info. * This should cater for sub-directories @@ -154,8 +254,14 @@ protected File getFile(HttpServletRequest request) { // all url will have a base of /spa/spaResources/ String path = request.getPathInfo(); - // we want to extract everything after /spa/spaResources/ from the path info. This should cater for sub-directories - String extractedFile = path.substring(path.indexOf('/', BASE_URL.length() - 1) + 1); + String extractedFile = ""; + if (path == null) { //dynamically defined servlets see https://wiki.openmrs.org/display/docs/Module+Servlets + String url = String.valueOf(request.getRequestURL()); + extractedFile = url.substring(url.lastIndexOf('/') + 1); + } else { + // we want to extract everything after /spa/spaResources/ from the path info. This should cater for sub-directories + extractedFile = path.substring(path.indexOf('/', BASE_URL.length() - 1) + 1); + } File folder = SpaModuleUtils.getSpaStaticFilesDir(); //Resolve default index.html @@ -173,9 +279,17 @@ protected File getFile(HttpServletRequest request) { private void addCacheControlHeader(HttpServletRequest request, HttpServletResponse response) { String path = request.getPathInfo(); - if (path.endsWith("importmap.json") || path.endsWith("import-map.json")) { + if (path != null && (path.endsWith("importmap.json") || path.endsWith("import-map.json"))) { response.setHeader("Cache-Control", "public, must-revalidate, max-age=0;"); } } + private File getJsonConfigFile() { + File folder = SpaModuleUtils.getSpaStaticFilesDir(); + if (!folder.isDirectory()) { + throw new ModuleException("SPA frontend repository is not a directory at: " + folder.getAbsolutePath()); + } + return new File(folder.getAbsolutePath(), JSON_CONFIG_FILE_NAME); + } + } diff --git a/omod/src/main/java/org/openmrs/module/spa/web/SpaWebComponentRegistrar.java b/omod/src/main/java/org/openmrs/module/spa/web/SpaWebComponentRegistrar.java new file mode 100644 index 0000000..ca0912d --- /dev/null +++ b/omod/src/main/java/org/openmrs/module/spa/web/SpaWebComponentRegistrar.java @@ -0,0 +1,48 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.spa.web; + +import javax.servlet.DispatcherType; +import javax.servlet.FilterRegistration.Dynamic; +import javax.servlet.ServletContext; +import javax.servlet.ServletRegistration; +import java.util.EnumSet; + +import org.openmrs.module.spa.filter.SpaFilter; +import org.openmrs.module.spa.servlet.SpaServlet; +import org.springframework.stereotype.Component; +import org.springframework.web.context.ServletContextAware; + +@Component +public class SpaWebComponentRegistrar implements ServletContextAware { + + @Override + public void setServletContext(ServletContext servletContext) { + + try { + ServletRegistration openmrsServletReg = servletContext.getServletRegistration("openmrs"); + openmrsServletReg.addMapping("/frontend/*"); + + ServletRegistration servletReg = servletContext.addServlet("spaServlet", new SpaServlet()); + servletReg.addMapping("/frontend/config.json"); + + Dynamic filter = servletContext.addFilter("spaFilter", new SpaFilter()); + filter.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/frontend/config.json"); + } + catch (Exception ex) { + //TODO need a work around for: java.lang.IllegalStateException: Started + //Unable to configure mapping for servlet because this servlet context has already been initialized. + //This happens on running openmrs after InitializationFilter or UpdateFilter + //hence requiring a restart to see any page other than index.htm + //After a restart, all mappings will then happen within Listener.contextInitialized() + ex.printStackTrace(); + } + } +} From 20c5af1dd7914cd054b3627bb2edc5b1f26d784e Mon Sep 17 00:00:00 2001 From: jnsereko Date: Wed, 14 Jun 2023 12:09:57 +0300 Subject: [PATCH 2/4] push to \'/ws/frontend/*\' --- .../openmrs/module/spa/web/SpaWebComponentRegistrar.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/omod/src/main/java/org/openmrs/module/spa/web/SpaWebComponentRegistrar.java b/omod/src/main/java/org/openmrs/module/spa/web/SpaWebComponentRegistrar.java index ca0912d..02e0fdd 100644 --- a/omod/src/main/java/org/openmrs/module/spa/web/SpaWebComponentRegistrar.java +++ b/omod/src/main/java/org/openmrs/module/spa/web/SpaWebComponentRegistrar.java @@ -15,8 +15,10 @@ import javax.servlet.ServletRegistration; import java.util.EnumSet; +import org.directwebremoting.servlet.EfficientShutdownServletContextAttributeListener; import org.openmrs.module.spa.filter.SpaFilter; import org.openmrs.module.spa.servlet.SpaServlet; +import org.openmrs.web.SessionListener; import org.springframework.stereotype.Component; import org.springframework.web.context.ServletContextAware; @@ -28,13 +30,13 @@ public void setServletContext(ServletContext servletContext) { try { ServletRegistration openmrsServletReg = servletContext.getServletRegistration("openmrs"); - openmrsServletReg.addMapping("/frontend/*"); + openmrsServletReg.addMapping("/ws/frontend/*"); ServletRegistration servletReg = servletContext.addServlet("spaServlet", new SpaServlet()); - servletReg.addMapping("/frontend/config.json"); + servletReg.addMapping("/ws/frontend/config.json"); Dynamic filter = servletContext.addFilter("spaFilter", new SpaFilter()); - filter.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/frontend/config.json"); + filter.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/ws/frontend/config.json"); } catch (Exception ex) { //TODO need a work around for: java.lang.IllegalStateException: Started From 6ca7667d2db4db1ad5c552361b3490d1701cf7d6 Mon Sep 17 00:00:00 2001 From: jnsereko <58003327+jnsereko@users.noreply.github.com> Date: Mon, 26 Jun 2023 13:12:44 +0300 Subject: [PATCH 3/4] remove unnecessary mappings --- .../org/openmrs/module/spa/web/SpaWebComponentRegistrar.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/omod/src/main/java/org/openmrs/module/spa/web/SpaWebComponentRegistrar.java b/omod/src/main/java/org/openmrs/module/spa/web/SpaWebComponentRegistrar.java index 02e0fdd..3dc3520 100644 --- a/omod/src/main/java/org/openmrs/module/spa/web/SpaWebComponentRegistrar.java +++ b/omod/src/main/java/org/openmrs/module/spa/web/SpaWebComponentRegistrar.java @@ -29,9 +29,6 @@ public class SpaWebComponentRegistrar implements ServletContextAware { public void setServletContext(ServletContext servletContext) { try { - ServletRegistration openmrsServletReg = servletContext.getServletRegistration("openmrs"); - openmrsServletReg.addMapping("/ws/frontend/*"); - ServletRegistration servletReg = servletContext.addServlet("spaServlet", new SpaServlet()); servletReg.addMapping("/ws/frontend/config.json"); From dc87c4692ef1610c41599b3cdf4848f41e2c5db8 Mon Sep 17 00:00:00 2001 From: JOSHUA Date: Thu, 28 Dec 2023 14:47:56 +0300 Subject: [PATCH 4/4] O3-310: fix violated java conventions --- .../module/spa/servlet/SpaServlet.java | 29 +++++++++---------- .../spa/web/SpaWebComponentRegistrar.java | 9 ------ openmrs.log | 0 3 files changed, 13 insertions(+), 25 deletions(-) create mode 100644 openmrs.log diff --git a/omod/src/main/java/org/openmrs/module/spa/servlet/SpaServlet.java b/omod/src/main/java/org/openmrs/module/spa/servlet/SpaServlet.java index aebe81a..1e733d8 100644 --- a/omod/src/main/java/org/openmrs/module/spa/servlet/SpaServlet.java +++ b/omod/src/main/java/org/openmrs/module/spa/servlet/SpaServlet.java @@ -77,23 +77,20 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) t @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { String requestURI = request.getRequestURI(); - if (requestURI.endsWith("/config.json")) { - if (!Context.isAuthenticated()) { - String basicAuth = request.getHeader("Authorization"); - if (basicAuth != null) { - // check that header is in format "Basic ${base64encode(username + ":" + password)}" - if (isValidAuthFormat(response, basicAuth)) return; - } - } - - User user = Context.getAuthenticatedUser(); - if (user != null && user.isSuperUser()) { - saveJsonConfigFile(request, response); - } else { - log.error("Authorisation error while creating a config.json file"); - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - } + if (!requestURI.endsWith("/config.json")) { + return; + } + String basicAuth = !Context.isAuthenticated() ? request.getHeader("Authorization") : null; + if (basicAuth != null && isValidAuthFormat(response, basicAuth)) { + return; + } + User user = Context.getAuthenticatedUser(); + if (user == null || !user.isSuperUser()) { + log.error("Authorization error while creating a config.json file"); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + return; } + saveJsonConfigFile(request, response); } private void saveJsonConfigFile(HttpServletRequest request, HttpServletResponse response) throws IOException { diff --git a/omod/src/main/java/org/openmrs/module/spa/web/SpaWebComponentRegistrar.java b/omod/src/main/java/org/openmrs/module/spa/web/SpaWebComponentRegistrar.java index 3dc3520..65544d4 100644 --- a/omod/src/main/java/org/openmrs/module/spa/web/SpaWebComponentRegistrar.java +++ b/omod/src/main/java/org/openmrs/module/spa/web/SpaWebComponentRegistrar.java @@ -9,16 +9,10 @@ */ package org.openmrs.module.spa.web; -import javax.servlet.DispatcherType; -import javax.servlet.FilterRegistration.Dynamic; import javax.servlet.ServletContext; import javax.servlet.ServletRegistration; -import java.util.EnumSet; -import org.directwebremoting.servlet.EfficientShutdownServletContextAttributeListener; -import org.openmrs.module.spa.filter.SpaFilter; import org.openmrs.module.spa.servlet.SpaServlet; -import org.openmrs.web.SessionListener; import org.springframework.stereotype.Component; import org.springframework.web.context.ServletContextAware; @@ -31,9 +25,6 @@ public void setServletContext(ServletContext servletContext) { try { ServletRegistration servletReg = servletContext.addServlet("spaServlet", new SpaServlet()); servletReg.addMapping("/ws/frontend/config.json"); - - Dynamic filter = servletContext.addFilter("spaFilter", new SpaFilter()); - filter.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/ws/frontend/config.json"); } catch (Exception ex) { //TODO need a work around for: java.lang.IllegalStateException: Started diff --git a/openmrs.log b/openmrs.log new file mode 100644 index 0000000..e69de29