From 0eb2d695593f5b4785556d300228782a10b4e9df Mon Sep 17 00:00:00 2001 From: gibson9583 Date: Thu, 2 Jul 2026 12:48:35 -0400 Subject: [PATCH 1/2] feat: Add Endpoints Required to Support a Web Administrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add engine REST support so the standalone web administrator can run fully against the engine — serving plugin UIs, exact serialization, and script tooling — with no local JVM sidecar or shared filesystem. Endpoints (all session-authed, no extra permission, auditable=false): - GET /api/webplugins — enabled extensions that ship a webadmin/ UI - GET /api/webplugins/{extensionPath}/{file} — serve an extension's webadmin/ assets - POST /api/javascript/_validate — validate JS (Rhino compiler) - POST /api/javascript/_prettyPrint — format JS (Rhino-AST formatter) - POST /api/datatypes/_serialize — serialize a message via a data type (message trees) Supporting changes: - RequestedWithFilter: exempt read-only GET /api/webplugins/* from the X-Requested-With CSRF requirement (browser module/script loads can't set headers). - DataTypeServerPlugin: add getVocabulary(version, type); override in HL7v2, EDI, NCPDP, and DICOM so /api/datatypes/_serialize returns element descriptions. New servlets auto-register via the existing package scan; no wiring changes. Signed-off-by: gibson9583 --- .../servlets/DataTypeServletInterface.java | 54 ++++ .../servlets/JavaScriptServletInterface.java | 54 ++++ .../servlets/WebPluginServletInterface.java | 60 ++++ .../connect/plugins/DataTypeServerPlugin.java | 11 + .../dicom/DICOMDataTypeServerPlugin.java | 6 + .../edi/EDIDataTypeServerPlugin.java | 6 + .../hl7v2/HL7v2DataTypeServerPlugin.java | 6 + .../ncpdp/NCPDPDataTypeServerPlugin.java | 6 + .../api/providers/RequestedWithFilter.java | 25 +- .../server/api/servlets/DataTypeServlet.java | 269 ++++++++++++++++++ .../api/servlets/JavaScriptServlet.java | 73 +++++ .../server/api/servlets/WebPluginServlet.java | 152 ++++++++++ 12 files changed, 719 insertions(+), 3 deletions(-) create mode 100644 server/src/com/mirth/connect/client/core/api/servlets/DataTypeServletInterface.java create mode 100644 server/src/com/mirth/connect/client/core/api/servlets/JavaScriptServletInterface.java create mode 100644 server/src/com/mirth/connect/client/core/api/servlets/WebPluginServletInterface.java create mode 100644 server/src/com/mirth/connect/server/api/servlets/DataTypeServlet.java create mode 100644 server/src/com/mirth/connect/server/api/servlets/JavaScriptServlet.java create mode 100644 server/src/com/mirth/connect/server/api/servlets/WebPluginServlet.java diff --git a/server/src/com/mirth/connect/client/core/api/servlets/DataTypeServletInterface.java b/server/src/com/mirth/connect/client/core/api/servlets/DataTypeServletInterface.java new file mode 100644 index 0000000000..c8227bd4b4 --- /dev/null +++ b/server/src/com/mirth/connect/client/core/api/servlets/DataTypeServletInterface.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.client.core.api.servlets; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import com.mirth.connect.client.core.ClientException; +import com.mirth.connect.client.core.api.BaseServletInterface; +import com.mirth.connect.client.core.api.MirthOperation; +import com.mirth.connect.client.core.api.Param; + +/** + * Serializes a message through a data type's own serializer — the engine's exact toXML()/toJSON() + * output — so a browser client can build message trees that match the runtime {@code msg}/{@code tmp} + * without shipping the engine's datatype libraries. Uses the installed datatype plugins, so every + * data type (and strict/non-strict via serialization-property overrides) is covered. Session-authed, + * not audited. + */ +@Path("/datatypes") +@Tag(name = "Data Types") +@Consumes({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON }) +public interface DataTypeServletInterface extends BaseServletInterface { + + @POST + @Path("/_serialize") + @Consumes(MediaType.TEXT_PLAIN) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Serializes a message via the given data type's serializer. Returns { format, data, meta: { root, descriptions } }.") + @MirthOperation(name = "serializeMessage", display = "Serialize message for data type", auditable = false) + public Response serializeMessage(// @formatter:off + // dataType is a query param (not a path segment) so values containing a slash — e.g. "EDI/X12" — pass cleanly. + @Param("dataType") @Parameter(description = "The data type name (HL7V2, XML, JSON, EDI/X12, NCPDP, DELIMITED, RAW, DICOM, HL7V3).", required = true) @QueryParam("dataType") String dataType, + @Param("props") @Parameter(description = "Optional serialization-property overrides as newline-separated key=value pairs (e.g. useStrictParser=true).", required = false) @QueryParam("props") String props, + @Param("message") String message) throws ClientException; + // @formatter:on +} diff --git a/server/src/com/mirth/connect/client/core/api/servlets/JavaScriptServletInterface.java b/server/src/com/mirth/connect/client/core/api/servlets/JavaScriptServletInterface.java new file mode 100644 index 0000000000..08332d0b40 --- /dev/null +++ b/server/src/com/mirth/connect/client/core/api/servlets/JavaScriptServletInterface.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.client.core.api.servlets; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import com.mirth.connect.client.core.ClientException; +import com.mirth.connect.client.core.api.BaseServletInterface; +import com.mirth.connect.client.core.api.MirthOperation; +import com.mirth.connect.client.core.api.Param; + +/** + * JavaScript utilities the script editors need — validation and formatting — exposed over REST + * so a browser client can use the engine's own Rhino compiler/formatter (the same ones the Swing + * client uses in-process) instead of shipping its own. Both take a raw script body, require only a + * valid session, and are not audited (editor-support calls, hit frequently). + */ +@Path("/javascript") +@Tag(name = "JavaScript") +@Consumes({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON }) +public interface JavaScriptServletInterface extends BaseServletInterface { + + @POST + @Path("/_validate") + @Consumes(MediaType.TEXT_PLAIN) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Validates a JavaScript script with the engine's Rhino compiler. Returns { \"error\": }.") + @MirthOperation(name = "validateScript", display = "Validate JavaScript", auditable = false) + public Response validateScript(@Param("script") String script) throws ClientException; + + @POST + @Path("/_prettyPrint") + @Consumes(MediaType.TEXT_PLAIN) + @Produces(MediaType.TEXT_PLAIN) + @Operation(summary = "Formats a JavaScript script with the engine's Rhino-AST pretty-printer (E4X-safe). Returns the formatted script.") + @MirthOperation(name = "prettyPrintScript", display = "Format JavaScript", auditable = false) + public Response prettyPrintScript(@Param("script") String script) throws ClientException; +} diff --git a/server/src/com/mirth/connect/client/core/api/servlets/WebPluginServletInterface.java b/server/src/com/mirth/connect/client/core/api/servlets/WebPluginServletInterface.java new file mode 100644 index 0000000000..95f5b03777 --- /dev/null +++ b/server/src/com/mirth/connect/client/core/api/servlets/WebPluginServletInterface.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.client.core.api.servlets; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import com.mirth.connect.client.core.ClientException; +import com.mirth.connect.client.core.api.BaseServletInterface; +import com.mirth.connect.client.core.api.MirthOperation; +import com.mirth.connect.client.core.api.Param; + +/** + * Serves the browser (web administrator) half of installed extensions. + * + * An extension may ship a web UI alongside its engine code as a {@code webadmin/} folder + * (containing a {@code plugin.json} manifest and its compiled ES-module assets). This servlet + * lets the web administrator discover and fetch those web halves directly from the engine that + * has them installed — so a plugin's UI follows the engine, not the web-admin install. Both + * endpoints require only a valid session (any authenticated user) and are not audited: they + * serve non-sensitive static UI code and are hit once per page load. + */ +@Path("/webplugins") +@Tag(name = "Web Plugins") +@Produces({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON }) +public interface WebPluginServletInterface extends BaseServletInterface { + + @GET + @Path("/") + @Operation(summary = "Returns the install-directory paths of all enabled extensions that ship a web administrator UI (i.e. contain webadmin/plugin.json).") + @MirthOperation(name = "getWebPluginPaths", display = "Get web plugin paths", auditable = false) + public List getWebPluginPaths() throws ClientException; + + @GET + @Path("/{extensionPath}/{resourcePath:.*}") + @Produces(MediaType.WILDCARD) + @Operation(summary = "Serves a static file from an extension's webadmin/ folder (the browser half of the plugin).") + @MirthOperation(name = "getWebPluginResource", display = "Get web plugin resource", auditable = false) + public Response getWebPluginResource(// @formatter:off + @Param("extensionPath") @Parameter(description = "The extension's install-directory name.", required = true) @PathParam("extensionPath") String extensionPath, + @Param("resourcePath") @Parameter(description = "The file path within the extension's webadmin/ folder.", required = true) @PathParam("resourcePath") String resourcePath) throws ClientException; + // @formatter:on +} diff --git a/server/src/com/mirth/connect/plugins/DataTypeServerPlugin.java b/server/src/com/mirth/connect/plugins/DataTypeServerPlugin.java index 4b9af2dfa5..a064d2b273 100644 --- a/server/src/com/mirth/connect/plugins/DataTypeServerPlugin.java +++ b/server/src/com/mirth/connect/plugins/DataTypeServerPlugin.java @@ -25,6 +25,7 @@ import com.mirth.connect.model.datatype.SerializationProperties; import com.mirth.connect.model.datatype.SerializerProperties; import com.mirth.connect.model.transmission.TransmissionModeProperties; +import com.mirth.connect.model.util.MessageVocabulary; import com.mirth.connect.server.message.DefaultAutoResponder; import com.mirth.connect.server.message.DefaultResponseValidator; @@ -90,4 +91,14 @@ public AutoResponder getAutoResponder(SerializationProperties serializationPrope public ResponseValidator getResponseValidator(SerializationProperties serializationProperties, ResponseValidationProperties responseValidationProperties) { return new DefaultResponseValidator(); } + + /** + * Get the message vocabulary for this data type — element descriptions used to annotate a + * message tree (the same text the Swing client shows). {@code version} and {@code type} come + * from the serializer's message metadata (mirth_version / mirth_type). Returns null for data + * types with no vocabulary (the default); types that have one override this. + */ + public MessageVocabulary getVocabulary(String version, String type) { + return null; + } } diff --git a/server/src/com/mirth/connect/plugins/datatypes/dicom/DICOMDataTypeServerPlugin.java b/server/src/com/mirth/connect/plugins/datatypes/dicom/DICOMDataTypeServerPlugin.java index f64df92f2f..8817350637 100644 --- a/server/src/com/mirth/connect/plugins/datatypes/dicom/DICOMDataTypeServerPlugin.java +++ b/server/src/com/mirth/connect/plugins/datatypes/dicom/DICOMDataTypeServerPlugin.java @@ -10,6 +10,7 @@ package com.mirth.connect.plugins.datatypes.dicom; import com.mirth.connect.model.datatype.DataTypeDelegate; +import com.mirth.connect.model.util.MessageVocabulary; import com.mirth.connect.plugins.DataTypeServerPlugin; public class DICOMDataTypeServerPlugin extends DataTypeServerPlugin { @@ -31,4 +32,9 @@ protected DataTypeDelegate getDataTypeDelegate() { return dataTypeDelegate; } + @Override + public MessageVocabulary getVocabulary(String version, String type) { + return new DICOMVocabulary(version, type); + } + } diff --git a/server/src/com/mirth/connect/plugins/datatypes/edi/EDIDataTypeServerPlugin.java b/server/src/com/mirth/connect/plugins/datatypes/edi/EDIDataTypeServerPlugin.java index 302ee56fbf..9cf93a3af3 100644 --- a/server/src/com/mirth/connect/plugins/datatypes/edi/EDIDataTypeServerPlugin.java +++ b/server/src/com/mirth/connect/plugins/datatypes/edi/EDIDataTypeServerPlugin.java @@ -13,6 +13,7 @@ import com.mirth.connect.donkey.server.message.batch.BatchAdaptorFactory; import com.mirth.connect.model.datatype.DataTypeDelegate; import com.mirth.connect.model.datatype.SerializerProperties; +import com.mirth.connect.model.util.MessageVocabulary; import com.mirth.connect.plugins.DataTypeServerPlugin; public class EDIDataTypeServerPlugin extends DataTypeServerPlugin { @@ -38,4 +39,9 @@ public BatchAdaptorFactory getBatchAdaptorFactory(SourceConnector sourceConnecto protected DataTypeDelegate getDataTypeDelegate() { return dataTypeDelegate; } + + @Override + public MessageVocabulary getVocabulary(String version, String type) { + return new X12Vocabulary(version, type); + } } diff --git a/server/src/com/mirth/connect/plugins/datatypes/hl7v2/HL7v2DataTypeServerPlugin.java b/server/src/com/mirth/connect/plugins/datatypes/hl7v2/HL7v2DataTypeServerPlugin.java index e1af898103..14f93be7ea 100644 --- a/server/src/com/mirth/connect/plugins/datatypes/hl7v2/HL7v2DataTypeServerPlugin.java +++ b/server/src/com/mirth/connect/plugins/datatypes/hl7v2/HL7v2DataTypeServerPlugin.java @@ -23,6 +23,7 @@ import com.mirth.connect.model.datatype.SerializerProperties; import com.mirth.connect.model.transmission.TransmissionModeProperties; import com.mirth.connect.model.transmission.framemode.FrameModeProperties; +import com.mirth.connect.model.util.MessageVocabulary; import com.mirth.connect.plugins.DataTypeServerPlugin; import com.mirth.connect.util.TcpUtil; @@ -71,4 +72,9 @@ protected DataTypeDelegate getDataTypeDelegate() { return dataTypeDelegate; } + @Override + public MessageVocabulary getVocabulary(String version, String type) { + return new HL7v2Vocabulary(version, type); + } + } diff --git a/server/src/com/mirth/connect/plugins/datatypes/ncpdp/NCPDPDataTypeServerPlugin.java b/server/src/com/mirth/connect/plugins/datatypes/ncpdp/NCPDPDataTypeServerPlugin.java index 3346d3a0b1..e74491dbbf 100644 --- a/server/src/com/mirth/connect/plugins/datatypes/ncpdp/NCPDPDataTypeServerPlugin.java +++ b/server/src/com/mirth/connect/plugins/datatypes/ncpdp/NCPDPDataTypeServerPlugin.java @@ -13,6 +13,7 @@ import com.mirth.connect.donkey.server.message.batch.BatchAdaptorFactory; import com.mirth.connect.model.datatype.DataTypeDelegate; import com.mirth.connect.model.datatype.SerializerProperties; +import com.mirth.connect.model.util.MessageVocabulary; import com.mirth.connect.plugins.DataTypeServerPlugin; public class NCPDPDataTypeServerPlugin extends DataTypeServerPlugin { @@ -38,4 +39,9 @@ public BatchAdaptorFactory getBatchAdaptorFactory(SourceConnector sourceConnecto protected DataTypeDelegate getDataTypeDelegate() { return dataTypeDelegate; } + + @Override + public MessageVocabulary getVocabulary(String version, String type) { + return new NCPDPVocabulary(version, type); + } } diff --git a/server/src/com/mirth/connect/server/api/providers/RequestedWithFilter.java b/server/src/com/mirth/connect/server/api/providers/RequestedWithFilter.java index 778f17baab..9a444ecf57 100644 --- a/server/src/com/mirth/connect/server/api/providers/RequestedWithFilter.java +++ b/server/src/com/mirth/connect/server/api/providers/RequestedWithFilter.java @@ -44,15 +44,34 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha HttpServletRequest servletRequest = (HttpServletRequest)request; String requestedWithHeader = (String) servletRequest.getHeader("X-Requested-With"); - + //if header is required and not present, send an error - if(isRequestedWithHeaderRequired && StringUtils.isBlank(requestedWithHeader)) { + if(isRequestedWithHeaderRequired && StringUtils.isBlank(requestedWithHeader) && !isWebPluginAssetRequest(servletRequest)) { res.sendError(400, "All requests must have 'X-Requested-With' header"); } else { chain.doFilter(request, response); } - + + } + + /** + * A web administrator loads a plugin's browser assets from /api/webplugins/... using + * <script>/import(), which cannot set request headers. Those GETs serve only static + * UI code (no state change, nothing sensitive), so they are exempt from the CSRF header + * requirement. State-changing requests and all other endpoints still require the header. + */ + private static boolean isWebPluginAssetRequest(HttpServletRequest request) { + if (!"GET".equalsIgnoreCase(request.getMethod())) { + return false; + } + // getPathInfo() is context-relative (e.g. "/webplugins/..."); fall back to the full + // URI so a configured http.contextpath doesn't defeat the check. + String path = request.getPathInfo(); + if (StringUtils.isBlank(path)) { + path = request.getRequestURI(); + } + return path != null && (path.startsWith("/webplugins/") || path.contains("/api/webplugins/")); } public boolean isRequestedWithHeaderRequired() { diff --git a/server/src/com/mirth/connect/server/api/servlets/DataTypeServlet.java b/server/src/com/mirth/connect/server/api/servlets/DataTypeServlet.java new file mode 100644 index 0000000000..161d16a5fd --- /dev/null +++ b/server/src/com/mirth/connect/server/api/servlets/DataTypeServlet.java @@ -0,0 +1,269 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.server.api.servlets; + +import java.io.StringReader; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; +import javax.ws.rs.core.SecurityContext; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; + +import org.apache.commons.lang3.StringUtils; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +import com.mirth.connect.client.core.api.MirthApiException; +import com.mirth.connect.client.core.api.servlets.DataTypeServletInterface; +import com.mirth.connect.donkey.model.message.SerializationType; +import com.mirth.connect.model.converters.IMessageSerializer; +import com.mirth.connect.model.datatype.DataTypeProperties; +import com.mirth.connect.model.datatype.SerializationProperties; +import com.mirth.connect.model.datatype.SerializerProperties; +import com.mirth.connect.model.util.MessageVocabulary; +import com.mirth.connect.plugins.DataTypeServerPlugin; +import com.mirth.connect.server.api.MirthServlet; +import com.mirth.connect.server.controllers.ControllerFactory; +import com.mirth.connect.server.controllers.ExtensionController; + +public class DataTypeServlet extends MirthServlet implements DataTypeServletInterface { + + private static final ExtensionController extensionController = ControllerFactory.getFactory().createExtensionController(); + + public DataTypeServlet(@Context HttpServletRequest request, @Context SecurityContext sc) { + super(request, sc); + } + + @Override + public Response serializeMessage(String dataType, String props, String message) { + // Look the data type up in the installed plugins (keyed by the data type name — "HL7V2", + // "XML", "EDI/X12", …). This uses only core interfaces, so no concrete datatype import. + DataTypeServerPlugin plugin = dataType == null ? null : extensionController.getDataTypePlugins().get(dataType); + if (plugin == null) { + throw new MirthApiException(Status.BAD_REQUEST); + } + String msg = message == null ? "" : message; + + try { + DataTypeProperties dtProps = plugin.getDefaultProperties(); + applyOverrides(dtProps, props); + + SerializerProperties serializerProps = dtProps.getSerializerProperties(); + IMessageSerializer serializer = plugin.getSerializer(serializerProps); + + boolean json = SerializationType.JSON.equals(plugin.getDefaultSerializationType()); + String data = json ? serializer.toJSON(msg) : serializer.toXML(msg); + if (data == null) { + data = ""; + } + + // Message type/version from the serializer's own metadata, then the data type's + // vocabulary (element descriptions) — the same text the Swing tree shows. Only the + // XML-serialized types decorate nodes; JSON is a plain object tree. + String[] tv = typeAndVersion(serializer, msg); + MessageVocabulary vocab = safeVocab(plugin, tv[1], tv[0]); + String root = buildRoot(tv[0], tv[1], vocab); + String descriptions = (!json && vocab != null) ? buildDescriptions(data, vocab) : "{}"; + + String out = "{" + + "\"format\":" + jsonString(json ? "json" : "xml") + "," + + "\"data\":" + jsonString(data) + "," + + "\"meta\":{\"root\":" + jsonString(root) + ",\"descriptions\":" + descriptions + "}" + + "}"; + return Response.ok(out).type(MediaType.APPLICATION_JSON).build(); + } catch (MirthApiException e) { + throw e; + } catch (Exception e) { + throw new MirthApiException(e); + } + } + + // Apply newline-separated key=value overrides to the data type's SerializationProperties, + // coercing each value to the type the property currently holds (boolean/int/String). Only keys + // the property group already exposes are touched; unknown keys are ignored. Mirrors the sidecar. + private static void applyOverrides(DataTypeProperties dtProps, String props) { + if (StringUtils.isBlank(props)) { + return; + } + SerializationProperties serProp = dtProps.getSerializationProperties(); + if (serProp == null) { + return; + } + Map current = serProp.getProperties(); + if (current == null || current.isEmpty()) { + return; + } + boolean changed = false; + for (String line : props.split("\n")) { + int eq = line.indexOf('='); + if (eq <= 0) { + continue; + } + String key = line.substring(0, eq).trim(); + String val = line.substring(eq + 1); + if (!current.containsKey(key)) { + continue; + } + Object cur = current.get(key); + Object coerced; + if (cur instanceof Boolean) { + coerced = Boolean.parseBoolean(val.trim()); + } else if (cur instanceof Integer) { + try { + coerced = Integer.parseInt(val.trim()); + } catch (NumberFormatException nfe) { + continue; + } + } else { + coerced = val; + } + current.put(key, coerced); + changed = true; + } + if (changed) { + serProp.setProperties(current); + } + } + + // [type, version] from the serializer's own message metadata (mirth_type / mirth_version). + private static String[] typeAndVersion(IMessageSerializer serializer, String message) { + try { + Map md = serializer.getMetaDataFromMessage(message); + if (md != null) { + Object t = md.get("mirth_type"); + Object v = md.get("mirth_version"); + return new String[] { t == null ? "" : t.toString().trim(), v == null ? "" : v.toString().trim() }; + } + } catch (Exception e) { + // no metadata for this type + } + return new String[] { "", "" }; + } + + private static MessageVocabulary safeVocab(DataTypeServerPlugin plugin, String version, String type) { + try { + return plugin.getVocabulary(version, type); + } catch (Exception e) { + return null; + } + } + + // " ()" plus the type's own description when the vocabulary has one — the + // message-tree root label. Empty when the data type reports no type (e.g. XML/JSON). + private static String buildRoot(String type, String version, MessageVocabulary vocab) { + if (type.isEmpty()) { + return ""; + } + String root = type + " (" + (version.isEmpty() ? "Unknown version" : version) + ")"; + String desc = vocab == null ? "" : safeDesc(vocab, type.replace("-", "")); + if (!desc.isEmpty()) { + root += " (" + desc + ")"; + } + return root; + } + + private static String safeDesc(MessageVocabulary vocab, String elementId) { + try { + String r = vocab.getDescription(elementId); + return r == null ? "" : r; + } catch (Exception e) { + return ""; + } + } + + // Build a { "": "" } JSON object from the serialized XML by walking each + // distinct element and looking up its vocabulary description. Best-effort: any failure yields + // "{}" so the client falls back to bare node names. + private static String buildDescriptions(String xml, MessageVocabulary vocab) { + if (StringUtils.isBlank(xml)) { + return "{}"; + } + try { + DocumentBuilderFactory f = DocumentBuilderFactory.newInstance(); + f.setNamespaceAware(false); + // XXE hardening: this parses the engine serializer's own output, but the factory runs + // on the full engine classpath behind a network endpoint — disable DOCTYPE/entities. + f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + f.setFeature("http://xml.org/sax/features/external-general-entities", false); + f.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + f.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + f.setExpandEntityReferences(false); + Document doc = f.newDocumentBuilder().parse(new InputSource(new StringReader(xml))); + + Map map = new LinkedHashMap(); + walk(doc.getDocumentElement(), vocab, map, new HashSet()); + + StringBuilder b = new StringBuilder("{"); + boolean first = true; + for (Map.Entry e : map.entrySet()) { + if (!first) { + b.append(","); + } + b.append(jsonString(e.getKey())).append(":").append(jsonString(e.getValue())); + first = false; + } + return b.append("}").toString(); + } catch (Exception e) { + return "{}"; + } + } + + private static void walk(Element el, MessageVocabulary vocab, Map map, Set seen) { + if (el == null) { + return; + } + String name = el.getTagName(); + if (seen.add(name)) { + String d = safeDesc(vocab, name); + if (!d.isEmpty()) { + map.put(name, d); + } + } + NodeList kids = el.getChildNodes(); + for (int i = 0; i < kids.getLength(); i++) { + Node k = kids.item(i); + if (k instanceof Element) { + walk((Element) k, vocab, map, seen); + } + } + } + + private static String jsonString(String s) { + StringBuilder b = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': b.append("\\\""); break; + case '\\': b.append("\\\\"); break; + case '\n': b.append("\\n"); break; + case '\r': b.append("\\r"); break; + case '\t': b.append("\\t"); break; + default: + if (c < 0x20) { + b.append(String.format("\\u%04x", (int) c)); + } else { + b.append(c); + } + } + } + return b.append('"').toString(); + } +} diff --git a/server/src/com/mirth/connect/server/api/servlets/JavaScriptServlet.java b/server/src/com/mirth/connect/server/api/servlets/JavaScriptServlet.java new file mode 100644 index 0000000000..50268dd6fa --- /dev/null +++ b/server/src/com/mirth/connect/server/api/servlets/JavaScriptServlet.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.server.api.servlets; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; + +import com.mirth.connect.client.core.api.servlets.JavaScriptServletInterface; +import com.mirth.connect.server.api.MirthServlet; +import com.mirth.connect.util.JavaScriptSharedUtil; + +public class JavaScriptServlet extends MirthServlet implements JavaScriptServletInterface { + + public JavaScriptServlet(@Context HttpServletRequest request, @Context SecurityContext sc) { + super(request, sc); + } + + @Override + public Response validateScript(String script) { + // JavaScriptSharedUtil.validateScript returns null when the script compiles, else the + // error text ("Error on line N: ..."). Report it as { "error": }. + String error = JavaScriptSharedUtil.validateScript(script == null ? "" : script); + String json = "{\"error\":" + (error == null ? "null" : jsonString(error)) + "}"; + return Response.ok(json).type(MediaType.APPLICATION_JSON).build(); + } + + @Override + public Response prettyPrintScript(String script) { + String source = script == null ? "" : script; + String formatted; + try { + // Same Rhino-AST formatter Swing's Format Code uses (keeps E4X XML literals intact). + formatted = JavaScriptSharedUtil.prettyPrint(source); + } catch (Exception e) { + formatted = null; + } + return Response.ok(formatted == null ? source : formatted) + .type(MediaType.TEXT_PLAIN + "; charset=utf-8").build(); + } + + // Minimal JSON string escaper (the response is a single {"error":"..."} object, so we build it + // by hand rather than pull in a serializer). + private static String jsonString(String s) { + StringBuilder b = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': b.append("\\\""); break; + case '\\': b.append("\\\\"); break; + case '\n': b.append("\\n"); break; + case '\r': b.append("\\r"); break; + case '\t': b.append("\\t"); break; + default: + if (c < 0x20) { + b.append(String.format("\\u%04x", (int) c)); + } else { + b.append(c); + } + } + } + return b.append('"').toString(); + } +} diff --git a/server/src/com/mirth/connect/server/api/servlets/WebPluginServlet.java b/server/src/com/mirth/connect/server/api/servlets/WebPluginServlet.java new file mode 100644 index 0000000000..673f0a4ea6 --- /dev/null +++ b/server/src/com/mirth/connect/server/api/servlets/WebPluginServlet.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.server.api.servlets; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; +import javax.ws.rs.core.SecurityContext; + +import org.apache.commons.lang3.StringUtils; + +import com.mirth.connect.client.core.api.MirthApiException; +import com.mirth.connect.client.core.api.servlets.WebPluginServletInterface; +import com.mirth.connect.model.MetaData; +import com.mirth.connect.server.api.MirthServlet; +import com.mirth.connect.server.controllers.ControllerFactory; +import com.mirth.connect.server.controllers.ExtensionController; + +public class WebPluginServlet extends MirthServlet implements WebPluginServletInterface { + + private static final ExtensionController extensionController = ControllerFactory.getFactory().createExtensionController(); + + private static final String WEBADMIN_DIR = "webadmin"; + private static final String MANIFEST = "plugin.json"; + // An extension's install-directory name — a single, safe path segment. Guards + // both discovery and asset serving against traversal via the extension name. + private static final Pattern SAFE_SEGMENT = Pattern.compile("[A-Za-z0-9._-]+"); + + public WebPluginServlet(@Context HttpServletRequest request, @Context SecurityContext sc) { + super(request, sc); + } + + @Override + public List getWebPluginPaths() { + Set paths = new LinkedHashSet(); + File extRoot = new File(ExtensionController.getExtensionsPath()); + addWebPluginPaths(paths, extRoot, extensionController.getPluginMetaData()); + addWebPluginPaths(paths, extRoot, extensionController.getConnectorMetaData()); + return new ArrayList(paths); + } + + private void addWebPluginPaths(Set paths, File extRoot, Map metaDataMap) { + if (metaDataMap == null) { + return; + } + for (MetaData metaData : metaDataMap.values()) { + if (metaData == null) { + continue; + } + String path = metaData.getPath(); + if (StringUtils.isBlank(path) || !SAFE_SEGMENT.matcher(path).matches()) { + continue; + } + // A disabled extension's engine half is inactive, so hide its web half too. + if (!extensionController.isExtensionEnabled(metaData.getName())) { + continue; + } + File manifest = new File(new File(new File(extRoot, path), WEBADMIN_DIR), MANIFEST); + if (manifest.isFile()) { + paths.add(path); + } + } + } + + @Override + public Response getWebPluginResource(String extensionPath, String resourcePath) { + if (StringUtils.isBlank(extensionPath) || !SAFE_SEGMENT.matcher(extensionPath).matches()) { + throw new MirthApiException(Status.NOT_FOUND); + } + // A bare .../webadmin request serves the manifest (mirrors an index). + if (StringUtils.isBlank(resourcePath)) { + resourcePath = MANIFEST; + } + + try { + File webadminRoot = new File(new File(new File(ExtensionController.getExtensionsPath()), extensionPath), WEBADMIN_DIR).getCanonicalFile(); + File target = new File(webadminRoot, resourcePath).getCanonicalFile(); + + // Confine the resolved file to the extension's webadmin/ folder: canonicalizing + // first collapses any ".." and follows symlinks, so an entry that escapes the + // folder (traversal or a planted symlink) is rejected here rather than served. + String rootPath = webadminRoot.getPath(); + if (!target.getPath().equals(rootPath) && !target.getPath().startsWith(rootPath + File.separator)) { + throw new MirthApiException(Status.FORBIDDEN); + } + if (!target.isFile()) { + throw new MirthApiException(Status.NOT_FOUND); + } + + byte[] data = Files.readAllBytes(target.toPath()); + return Response.ok(data) + .type(contentType(target.getName())) + // Plugin code changes only on install/restart, but revalidate so an + // updated web half is never served stale after an extension upgrade. + .header("Cache-Control", "no-cache") + .build(); + } catch (MirthApiException e) { + throw e; + } catch (IOException e) { + throw new MirthApiException(e); + } + } + + // Minimal extension -> MIME map for the file types a web plugin ships. ES modules + // MUST be a JavaScript type or the browser refuses to execute them; the rest keep + // images/fonts/styles rendering correctly. Unknown types fall back to octet-stream. + private static String contentType(String fileName) { + String lower = StringUtils.lowerCase(fileName); + if (lower.endsWith(".js") || lower.endsWith(".mjs")) { + return "text/javascript; charset=utf-8"; + } else if (lower.endsWith(".css")) { + return "text/css; charset=utf-8"; + } else if (lower.endsWith(".json") || lower.endsWith(".map")) { + return "application/json; charset=utf-8"; + } else if (lower.endsWith(".html")) { + return "text/html; charset=utf-8"; + } else if (lower.endsWith(".svg")) { + return "image/svg+xml"; + } else if (lower.endsWith(".png")) { + return "image/png"; + } else if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) { + return "image/jpeg"; + } else if (lower.endsWith(".gif")) { + return "image/gif"; + } else if (lower.endsWith(".woff2")) { + return "font/woff2"; + } else if (lower.endsWith(".woff")) { + return "font/woff"; + } else if (lower.endsWith(".ttf")) { + return "font/ttf"; + } + return "application/octet-stream"; + } +} From f7b057dc5002fa4ec0097671f4254fd2ebef4836 Mon Sep 17 00:00:00 2001 From: gibson9583 Date: Fri, 3 Jul 2026 19:45:13 -0400 Subject: [PATCH 2/2] Remove /_prettyPrint Not need - this just uses JS Beautify Signed-off-by: gibson9583 --- .../servlets/JavaScriptServletInterface.java | 17 +++++------------ .../server/api/servlets/JavaScriptServlet.java | 14 -------------- 2 files changed, 5 insertions(+), 26 deletions(-) diff --git a/server/src/com/mirth/connect/client/core/api/servlets/JavaScriptServletInterface.java b/server/src/com/mirth/connect/client/core/api/servlets/JavaScriptServletInterface.java index 08332d0b40..8876803e71 100644 --- a/server/src/com/mirth/connect/client/core/api/servlets/JavaScriptServletInterface.java +++ b/server/src/com/mirth/connect/client/core/api/servlets/JavaScriptServletInterface.java @@ -25,10 +25,11 @@ import com.mirth.connect.client.core.api.Param; /** - * JavaScript utilities the script editors need — validation and formatting — exposed over REST - * so a browser client can use the engine's own Rhino compiler/formatter (the same ones the Swing - * client uses in-process) instead of shipping its own. Both take a raw script body, require only a - * valid session, and are not audited (editor-support calls, hit frequently). + * JavaScript utilities the script editors need — script validation — exposed over REST so a + * browser client can use the engine's own Rhino compiler (the same one the Swing client uses + * in-process) instead of shipping its own. Takes a raw script body, requires only a valid + * session, and is not audited (editor-support calls, hit frequently). (Formatting is done + * client-side by the web admin with js-beautify, so there is no pretty-print endpoint.) */ @Path("/javascript") @Tag(name = "JavaScript") @@ -43,12 +44,4 @@ public interface JavaScriptServletInterface extends BaseServletInterface { @Operation(summary = "Validates a JavaScript script with the engine's Rhino compiler. Returns { \"error\": }.") @MirthOperation(name = "validateScript", display = "Validate JavaScript", auditable = false) public Response validateScript(@Param("script") String script) throws ClientException; - - @POST - @Path("/_prettyPrint") - @Consumes(MediaType.TEXT_PLAIN) - @Produces(MediaType.TEXT_PLAIN) - @Operation(summary = "Formats a JavaScript script with the engine's Rhino-AST pretty-printer (E4X-safe). Returns the formatted script.") - @MirthOperation(name = "prettyPrintScript", display = "Format JavaScript", auditable = false) - public Response prettyPrintScript(@Param("script") String script) throws ClientException; } diff --git a/server/src/com/mirth/connect/server/api/servlets/JavaScriptServlet.java b/server/src/com/mirth/connect/server/api/servlets/JavaScriptServlet.java index 50268dd6fa..50f3dc736d 100644 --- a/server/src/com/mirth/connect/server/api/servlets/JavaScriptServlet.java +++ b/server/src/com/mirth/connect/server/api/servlets/JavaScriptServlet.java @@ -34,20 +34,6 @@ public Response validateScript(String script) { return Response.ok(json).type(MediaType.APPLICATION_JSON).build(); } - @Override - public Response prettyPrintScript(String script) { - String source = script == null ? "" : script; - String formatted; - try { - // Same Rhino-AST formatter Swing's Format Code uses (keeps E4X XML literals intact). - formatted = JavaScriptSharedUtil.prettyPrint(source); - } catch (Exception e) { - formatted = null; - } - return Response.ok(formatted == null ? source : formatted) - .type(MediaType.TEXT_PLAIN + "; charset=utf-8").build(); - } - // Minimal JSON string escaper (the response is a single {"error":"..."} object, so we build it // by hand rather than pull in a serializer). private static String jsonString(String s) {