From 12278c43093682dd5388099ea9144735ec83cd6f Mon Sep 17 00:00:00 2001 From: Thai Tran Date: Fri, 24 Apr 2026 19:04:11 -0500 Subject: [PATCH 01/25] feat(version-history): add HTTPS/PAT authentication support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce SshTransportConfig and HttpsTransportConfig as dedicated TransportConfigCallback implementations, replacing the inline JSch factory in GitRepositoryService. Users can now authenticate via username + PAT (inline or from a credentials file) in addition to SSH. - GitSettings: add AUTH_TYPE_SSH/HTTPS constants, fix isSSH() null check - GitRepositoryService: replace SshSessionFactory field with TransportConfigCallback; rename validateSSHConnection → validateRemoteConnection with private overload to avoid double-build - GitOperations: wire single transportConfig through all JGit commands - GitSettingsTabPanel: add auth type switcher (SSH/HTTPS), HTTPS inline and file-path credential panels, JPasswordField for PAT masking - plugin.xml: bump version to 3.0.1, add Mirth 26.3.1 compatibility --- custom-extensions/oidc-plugin/build.xml | 154 ---------- .../client/panel/GitSettingsTabPanel.java | 274 +++++++++++++++--- custom-extensions/version-history/plugin.xml | 4 +- .../server/git/GitOperations.java | 45 +-- .../server/git/HttpsTransportConfig.java | 83 ++++++ .../server/git/SshTransportConfig.java | 83 ++++++ .../server/service/GitRepositoryService.java | 214 ++------------ .../server/service/VersionHistoryService.java | 4 +- .../shared/model/GitSettings.java | 8 +- .../model/VersionHistoryProperties.java | 6 +- 10 files changed, 452 insertions(+), 423 deletions(-) delete mode 100644 custom-extensions/oidc-plugin/build.xml create mode 100644 custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/git/HttpsTransportConfig.java create mode 100644 custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/git/SshTransportConfig.java diff --git a/custom-extensions/oidc-plugin/build.xml b/custom-extensions/oidc-plugin/build.xml deleted file mode 100644 index 169d1e481..000000000 --- a/custom-extensions/oidc-plugin/build.xml +++ /dev/null @@ -1,154 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/custom-extensions/version-history/client/src/main/java/com/innovarhealthcare/channelHistory/client/panel/GitSettingsTabPanel.java b/custom-extensions/version-history/client/src/main/java/com/innovarhealthcare/channelHistory/client/panel/GitSettingsTabPanel.java index a86cfa78b..5c49230ba 100644 --- a/custom-extensions/version-history/client/src/main/java/com/innovarhealthcare/channelHistory/client/panel/GitSettingsTabPanel.java +++ b/custom-extensions/version-history/client/src/main/java/com/innovarhealthcare/channelHistory/client/panel/GitSettingsTabPanel.java @@ -15,6 +15,7 @@ import javax.swing.JDialog; import javax.swing.JLabel; import javax.swing.JPanel; +import javax.swing.JPasswordField; import javax.swing.JProgressBar; import javax.swing.JRadioButton; import javax.swing.JScrollPane; @@ -28,6 +29,7 @@ import java.util.Properties; import com.innovarhealthcare.channelHistory.client.service.VersionHistoryServiceClient; +import com.innovarhealthcare.channelHistory.shared.model.GitSettings; import com.innovarhealthcare.channelHistory.shared.model.VersionHistoryProperties; import com.mirth.connect.client.ui.PlatformUI; import com.mirth.connect.client.ui.UIConstants; @@ -48,21 +50,36 @@ public class GitSettingsTabPanel extends JPanel { private JLabel branchNameLabel; private JTextField branchNameField; - private JLabel sshPrivateKeyLabel; + // Auth type switcher + private JRadioButton sshAuthRadio; + private JRadioButton httpsAuthRadio; + private ButtonGroup authTypeButtonGroup; + // SSH section + private JPanel sshPanel; + private JLabel sshPrivateKeyLabel; private JRadioButton sshPasteKeyRadio; private JRadioButton sshFilePathRadio; private ButtonGroup sshKeyModeButtonGroup; - private JTextArea sshPrivateKeyField; private JScrollPane sshKeyScrollPane; private JButton sshLoadButton; - private JTextField sshPrivateKeyPathField; private JLabel sshPrivateKeyPathHintLabel; - private JPanel sshPastePanel; + // HTTPS section + private JPanel httpsPanel; + private JRadioButton httpsInlineRadio; + private JRadioButton httpsFilePathRadio; + private ButtonGroup httpsCredsModeButtonGroup; + private JLabel httpsUsernameLabel; + private JTextField httpsUsernameField; + private JLabel httpsPasswordLabel; + private JPasswordField httpsPasswordField; + private JTextField httpsCredentialsPathField; + private JLabel httpsCredentialsPathHintLabel; + private JButton validateGitButton; private final VersionHistoryProperties versionHistoryProperties; @@ -78,12 +95,29 @@ private void initComponents() { remoteRepositoryUrlLabel = new JLabel("Repository URL:"); remoteRepositoryUrlField = new MirthTextField(); - remoteRepositoryUrlField.setToolTipText("Enter an SSH URL for the remote Git repository (e.g., git@github.com:user/repo.git)."); + remoteRepositoryUrlField.setToolTipText("Enter the remote Git repository URL (SSH or HTTPS)."); branchNameLabel = new JLabel("Branch Name:"); branchNameField = new MirthTextField(); branchNameField.setToolTipText("Enter the branch name to use (e.g., main, develop, or feature/xyz)."); + // ── Auth type switcher ────────────────────────────────────────── + sshAuthRadio = new MirthRadioButton("SSH"); + sshAuthRadio.setFocusable(false); + sshAuthRadio.setBackground(UIConstants.BACKGROUND_COLOR); + sshAuthRadio.setSelected(true); + sshAuthRadio.addActionListener(e -> authTypeActionPerformed()); + + httpsAuthRadio = new MirthRadioButton("HTTPS"); + httpsAuthRadio.setFocusable(false); + httpsAuthRadio.setBackground(UIConstants.BACKGROUND_COLOR); + httpsAuthRadio.addActionListener(e -> authTypeActionPerformed()); + + authTypeButtonGroup = new ButtonGroup(); + authTypeButtonGroup.add(sshAuthRadio); + authTypeButtonGroup.add(httpsAuthRadio); + + // ── SSH section ───────────────────────────────────────────────── sshPrivateKeyLabel = new JLabel("SSH Private Key:"); sshPasteKeyRadio = new MirthRadioButton("Paste key"); @@ -112,7 +146,9 @@ private void initComponents() { sshLoadButton.addActionListener(e -> loadPrivateKey()); sshPrivateKeyPathField = new MirthTextField(); - sshPrivateKeyPathField.setToolTipText("Specify the relative or absolute path to the SSH private key file.
" + "The key file can be read from the Mirth server file system.
" + "Examples:
" + "appdata/id_rsa
" + "appdata/mykey.pem
" + "c:/mycerts/id_rsa"); + sshPrivateKeyPathField.setToolTipText("Specify the relative or absolute path to the SSH private key file.
" + + "The key file can be read from the Mirth server file system.
" + + "Examples:
appdata/id_rsa
appdata/mykey.pem
c:/mycerts/id_rsa"); sshPrivateKeyPathHintLabel = new JLabel("The private key remains on the server — only the file path is stored."); sshPrivateKeyPathHintLabel.setFont(new Font("Tahoma", Font.PLAIN, 10)); @@ -120,17 +156,71 @@ private void initComponents() { sshPastePanel = new JPanel(new MigLayout("hidemode 3, insets 0, novisualpadding", "[grow][]")); sshPastePanel.setBackground(UIConstants.BACKGROUND_COLOR); - - // Paste mode components sshPastePanel.add(sshKeyScrollPane, "growx"); sshPastePanel.add(sshLoadButton, "aligny top, wrap"); - - // File path mode components (hidden by default) sshPrivateKeyPathField.setVisible(false); sshPrivateKeyPathHintLabel.setVisible(false); sshPastePanel.add(sshPrivateKeyPathField, "w 450!, span 2, wrap"); sshPastePanel.add(sshPrivateKeyPathHintLabel, "growx, span 2, wrap"); + sshPanel = new JPanel(new MigLayout("hidemode 3, novisualpadding, insets 0", "[120,right][grow]")); + sshPanel.setBackground(UIConstants.BACKGROUND_COLOR); + sshPanel.add(sshPrivateKeyLabel, "right"); + sshPanel.add(sshPasteKeyRadio, "split 2"); + sshPanel.add(sshFilePathRadio, "wrap"); + sshPanel.add(new JLabel(), "right"); + sshPanel.add(sshPastePanel, "w 450!, wrap"); + + // ── HTTPS section ─────────────────────────────────────────────── + httpsInlineRadio = new MirthRadioButton("Inline"); + httpsInlineRadio.setFocusable(false); + httpsInlineRadio.setBackground(UIConstants.BACKGROUND_COLOR); + httpsInlineRadio.setSelected(true); + httpsInlineRadio.addActionListener(e -> httpsCredsModeActionPerformed()); + + httpsFilePathRadio = new MirthRadioButton("File path"); + httpsFilePathRadio.setFocusable(false); + httpsFilePathRadio.setBackground(UIConstants.BACKGROUND_COLOR); + httpsFilePathRadio.addActionListener(e -> httpsCredsModeActionPerformed()); + + httpsCredsModeButtonGroup = new ButtonGroup(); + httpsCredsModeButtonGroup.add(httpsInlineRadio); + httpsCredsModeButtonGroup.add(httpsFilePathRadio); + + httpsUsernameLabel = new JLabel("Username:"); + httpsUsernameField = new MirthTextField(); + httpsUsernameField.setToolTipText("Enter your Git username."); + + httpsPasswordLabel = new JLabel("PAT:"); + httpsPasswordField = new JPasswordField(); + httpsPasswordField.setBackground(UIConstants.BACKGROUND_COLOR); + httpsPasswordField.setToolTipText("Enter your Personal Access Token (PAT)."); + + httpsCredentialsPathField = new MirthTextField(); + httpsCredentialsPathField.setToolTipText("Path to a credentials file on the Mirth server.
" + + "Format: line 1 = username, line 2 = PAT"); + httpsCredentialsPathField.setVisible(false); + + httpsCredentialsPathHintLabel = new JLabel("Format: line 1 = username, line 2 = PAT"); + httpsCredentialsPathHintLabel.setFont(new Font("Tahoma", Font.PLAIN, 10)); + httpsCredentialsPathHintLabel.setForeground(Color.GRAY); + httpsCredentialsPathHintLabel.setVisible(false); + + httpsPanel = new JPanel(new MigLayout("hidemode 3, novisualpadding, insets 0", "[120,right][grow]")); + httpsPanel.setBackground(UIConstants.BACKGROUND_COLOR); + httpsPanel.add(new JLabel("Credentials:"), "right"); + httpsPanel.add(httpsInlineRadio, "split 2"); + httpsPanel.add(httpsFilePathRadio, "wrap"); + httpsPanel.add(httpsUsernameLabel, "right"); + httpsPanel.add(httpsUsernameField, "w 300!, wrap"); + httpsPanel.add(httpsPasswordLabel, "right"); + httpsPanel.add(httpsPasswordField, "w 300!, wrap"); + httpsPanel.add(new JLabel(), "right"); + httpsPanel.add(httpsCredentialsPathField, "w 450!, wrap"); + httpsPanel.add(new JLabel(), "right"); + httpsPanel.add(httpsCredentialsPathHintLabel, "wrap"); + httpsPanel.setVisible(false); + validateGitButton = new JButton("Validate Connection"); validateGitButton.addActionListener(e -> validateGitRemoteRepository()); } @@ -144,30 +234,41 @@ private void initLayout() { add(branchNameLabel, "right"); add(branchNameField, "w 160!, wrap"); - add(sshPrivateKeyLabel, "right"); - add(sshPasteKeyRadio, "split 2"); - add(sshFilePathRadio, "wrap"); + add(new JLabel("Auth Type:"), "right"); + add(sshAuthRadio, "split 2"); + add(httpsAuthRadio, "wrap"); - add(new JLabel(), "right"); - add(sshPastePanel, "w 450!, wrap"); + add(sshPanel, "span 2, growx, wrap"); + add(httpsPanel, "span 2, growx, wrap"); - // Validate button add(new JLabel(), "right"); add(validateGitButton, "wrap"); } + private void authTypeActionPerformed() { + boolean isSsh = sshAuthRadio.isSelected(); + sshPanel.setVisible(isSsh); + httpsPanel.setVisible(!isSsh); + } + private void sshKeyModeActionPerformed() { boolean isPaste = sshPasteKeyRadio.isSelected(); - - // Paste mode components sshKeyScrollPane.setVisible(isPaste); sshLoadButton.setVisible(isPaste); - - // File path mode components sshPrivateKeyPathField.setVisible(!isPaste); sshPrivateKeyPathHintLabel.setVisible(!isPaste); } + private void httpsCredsModeActionPerformed() { + boolean isInline = httpsInlineRadio.isSelected(); + httpsUsernameLabel.setVisible(isInline); + httpsUsernameField.setVisible(isInline); + httpsPasswordLabel.setVisible(isInline); + httpsPasswordField.setVisible(isInline); + httpsCredentialsPathField.setVisible(!isInline); + httpsCredentialsPathHintLabel.setVisible(!isInline); + } + private void loadPrivateKey() { String content = PlatformUI.MIRTH_FRAME.browseForFileString(null); if (content != null) { @@ -176,6 +277,7 @@ private void loadPrivateKey() { } private void validateGitRemoteRepository() { + resetInvalidState(); if (!validateFields()) { return; } @@ -273,17 +375,39 @@ private Properties toGitSettingsProperties() { if (!StringUtils.isEmpty(branch)) { properties.setProperty(VersionHistoryProperties.VERSION_HISTORY_REMOTE_BRANCH, branch); } - if (sshPasteKeyRadio.isSelected()) { - String sshKey = sshPrivateKeyField.getText().trim(); - if (!StringUtils.isEmpty(sshKey)) { - properties.setProperty(VersionHistoryProperties.VERSION_HISTORY_REMOTE_SSH_KEY, sshKey); + + if (httpsAuthRadio.isSelected()) { + properties.setProperty(VersionHistoryProperties.VERSION_HISTORY_REMOTE_AUTH_TYPE, GitSettings.AUTH_TYPE_HTTPS); + if (httpsInlineRadio.isSelected()) { + String username = httpsUsernameField.getText().trim(); + String password = new String(httpsPasswordField.getPassword()).trim(); + if (!StringUtils.isEmpty(username)) { + properties.setProperty(VersionHistoryProperties.VERSION_HISTORY_REMOTE_HTTPS_USERNAME, username); + } + if (!StringUtils.isEmpty(password)) { + properties.setProperty(VersionHistoryProperties.VERSION_HISTORY_REMOTE_HTTPS_PASSWORD, password); + } + } else { + String credPath = httpsCredentialsPathField.getText().trim(); + if (!StringUtils.isEmpty(credPath)) { + properties.setProperty(VersionHistoryProperties.VERSION_HISTORY_REMOTE_HTTPS_CREDENTIALS_PATH, credPath); + } } } else { - String sshPath = sshPrivateKeyPathField.getText().trim(); - if (!StringUtils.isEmpty(sshPath)) { - properties.setProperty(VersionHistoryProperties.VERSION_HISTORY_REMOTE_SSH_KEY_PATH, sshPath); + properties.setProperty(VersionHistoryProperties.VERSION_HISTORY_REMOTE_AUTH_TYPE, GitSettings.AUTH_TYPE_SSH); + if (sshPasteKeyRadio.isSelected()) { + String sshKey = sshPrivateKeyField.getText().trim(); + if (!StringUtils.isEmpty(sshKey)) { + properties.setProperty(VersionHistoryProperties.VERSION_HISTORY_REMOTE_SSH_KEY, sshKey); + } + } else { + String sshPath = sshPrivateKeyPathField.getText().trim(); + if (!StringUtils.isEmpty(sshPath)) { + properties.setProperty(VersionHistoryProperties.VERSION_HISTORY_REMOTE_SSH_KEY_PATH, sshPath); + } } } + return properties; } @@ -291,29 +415,65 @@ public void setProperties() { remoteRepositoryUrlField.setText(versionHistoryProperties.getGitSettings().getRemoteRepositoryUrl()); branchNameField.setText(versionHistoryProperties.getGitSettings().getBranchName()); - String sshKey = versionHistoryProperties.getGitSettings().getSshPrivateKey(); - String sshPath = versionHistoryProperties.getGitSettings().getSshPrivateKeyPath(); - - if (!StringUtils.isEmpty(sshPath)) { - sshFilePathRadio.setSelected(true); - sshPrivateKeyPathField.setText(sshPath); + String authType = versionHistoryProperties.getGitSettings().getAuthType(); + if (GitSettings.AUTH_TYPE_HTTPS.equalsIgnoreCase(authType)) { + httpsAuthRadio.setSelected(true); + String credPath = versionHistoryProperties.getGitSettings().getHttpsCredentialsPath(); + if (!StringUtils.isEmpty(credPath)) { + httpsFilePathRadio.setSelected(true); + httpsCredentialsPathField.setText(credPath); + } else { + httpsInlineRadio.setSelected(true); + httpsUsernameField.setText(versionHistoryProperties.getGitSettings().getHttpsUsername()); + httpsPasswordField.setText(versionHistoryProperties.getGitSettings().getHttpsPassword()); + } + httpsCredsModeActionPerformed(); } else { - sshPasteKeyRadio.setSelected(true); - sshPrivateKeyField.setText(sshKey); + sshAuthRadio.setSelected(true); + String sshKey = versionHistoryProperties.getGitSettings().getSshPrivateKey(); + String sshPath = versionHistoryProperties.getGitSettings().getSshPrivateKeyPath(); + if (!StringUtils.isEmpty(sshPath)) { + sshFilePathRadio.setSelected(true); + sshPrivateKeyPathField.setText(sshPath); + } else { + sshPasteKeyRadio.setSelected(true); + sshPrivateKeyField.setText(sshKey); + } + sshKeyModeActionPerformed(); } - sshKeyModeActionPerformed(); + + authTypeActionPerformed(); } public void getProperties() { versionHistoryProperties.getGitSettings().setRemoteRepositoryUrl(remoteRepositoryUrlField.getText().trim()); versionHistoryProperties.getGitSettings().setBranchName(branchNameField.getText().trim()); - if (sshPasteKeyRadio.isSelected()) { - versionHistoryProperties.getGitSettings().setSshPrivateKey(sshPrivateKeyField.getText().trim()); + if (httpsAuthRadio.isSelected()) { + versionHistoryProperties.getGitSettings().setAuthType(GitSettings.AUTH_TYPE_HTTPS); + versionHistoryProperties.getGitSettings().setSshPrivateKey(""); versionHistoryProperties.getGitSettings().setSshPrivateKeyPath(""); + if (httpsInlineRadio.isSelected()) { + versionHistoryProperties.getGitSettings().setHttpsUsername(httpsUsernameField.getText().trim()); + versionHistoryProperties.getGitSettings().setHttpsPassword(new String(httpsPasswordField.getPassword()).trim()); + versionHistoryProperties.getGitSettings().setHttpsCredentialsPath(""); + } else { + versionHistoryProperties.getGitSettings().setHttpsUsername(""); + versionHistoryProperties.getGitSettings().setHttpsPassword(""); + versionHistoryProperties.getGitSettings().setHttpsCredentialsPath(httpsCredentialsPathField.getText().trim()); + } } else { - versionHistoryProperties.getGitSettings().setSshPrivateKey(""); - versionHistoryProperties.getGitSettings().setSshPrivateKeyPath(sshPrivateKeyPathField.getText().trim()); + versionHistoryProperties.getGitSettings().setAuthType(GitSettings.AUTH_TYPE_SSH); + versionHistoryProperties.getGitSettings().setHttpsUsername(""); + versionHistoryProperties.getGitSettings().setHttpsPassword(""); + versionHistoryProperties.getGitSettings().setHttpsCredentialsPath(""); + if (sshPasteKeyRadio.isSelected()) { + versionHistoryProperties.getGitSettings().setSshPrivateKey(sshPrivateKeyField.getText().trim()); + versionHistoryProperties.getGitSettings().setSshPrivateKeyPath(""); + } else { + versionHistoryProperties.getGitSettings().setSshPrivateKey(""); + versionHistoryProperties.getGitSettings().setSshPrivateKeyPath(sshPrivateKeyPathField.getText().trim()); + } } } @@ -330,14 +490,33 @@ public boolean validateFields() { branchNameField.setBackground(UIConstants.INVALID_COLOR); } - if (sshPasteKeyRadio.isSelected() && StringUtils.isEmpty(sshPrivateKeyField.getText().trim())) { - valid = false; - sshPrivateKeyField.setBackground(UIConstants.INVALID_COLOR); + if (sshAuthRadio.isSelected()) { + if (sshPasteKeyRadio.isSelected() && StringUtils.isEmpty(sshPrivateKeyField.getText().trim())) { + valid = false; + sshPrivateKeyField.setBackground(UIConstants.INVALID_COLOR); + } + if (sshFilePathRadio.isSelected() && StringUtils.isEmpty(sshPrivateKeyPathField.getText().trim())) { + valid = false; + sshPrivateKeyPathField.setBackground(UIConstants.INVALID_COLOR); + } } - if (sshFilePathRadio.isSelected() && StringUtils.isEmpty(sshPrivateKeyPathField.getText().trim())) { - valid = false; - sshPrivateKeyPathField.setBackground(UIConstants.INVALID_COLOR); + if (httpsAuthRadio.isSelected()) { + if (httpsInlineRadio.isSelected()) { + if (StringUtils.isEmpty(httpsUsernameField.getText().trim())) { + valid = false; + httpsUsernameField.setBackground(UIConstants.INVALID_COLOR); + } + if (httpsPasswordField.getPassword().length == 0) { + valid = false; + httpsPasswordField.setBackground(UIConstants.INVALID_COLOR); + } + } else { + if (StringUtils.isEmpty(httpsCredentialsPathField.getText().trim())) { + valid = false; + httpsCredentialsPathField.setBackground(UIConstants.INVALID_COLOR); + } + } } return valid; @@ -348,5 +527,8 @@ public void resetInvalidState() { branchNameField.setBackground(null); sshPrivateKeyField.setBackground(null); sshPrivateKeyPathField.setBackground(null); + httpsUsernameField.setBackground(null); + httpsPasswordField.setBackground(null); + httpsCredentialsPathField.setBackground(null); } } diff --git a/custom-extensions/version-history/plugin.xml b/custom-extensions/version-history/plugin.xml index c78d5a4a8..c5a69181a 100644 --- a/custom-extensions/version-history/plugin.xml +++ b/custom-extensions/version-history/plugin.xml @@ -2,8 +2,8 @@ Version History Plugin Innovar Healthcare - 3.0.0 - 26.3.0 + 3.0.1 + 26.3.0, 26.3.1 https://www.innovarhealthcare.com Innovar Healthcare Version History Plugin diff --git a/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/git/GitOperations.java b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/git/GitOperations.java index f80e8dfa8..27606d7cf 100644 --- a/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/git/GitOperations.java +++ b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/git/GitOperations.java @@ -51,8 +51,7 @@ import org.eclipse.jgit.transport.PushResult; import org.eclipse.jgit.transport.RefSpec; import org.eclipse.jgit.transport.RemoteRefUpdate; -import org.eclipse.jgit.transport.SshSessionFactory; -import org.eclipse.jgit.transport.SshTransport; +import org.eclipse.jgit.api.TransportConfigCallback; import org.eclipse.jgit.treewalk.CanonicalTreeParser; import org.eclipse.jgit.treewalk.EmptyTreeIterator; import org.eclipse.jgit.treewalk.TreeWalk; @@ -69,29 +68,29 @@ public class GitOperations { private final Git git; private final String branch; - private final SshSessionFactory sshSessionFactory; + private final TransportConfigCallback transportConfig; /** * Creates a new GitOperations instance * - * @param git The JGit Git instance - * @param branch The branch name (e.g., "main", "master") - * @param sshSessionFactory SSH session factory for authentication + * @param git The JGit Git instance + * @param branch The branch name (e.g., "main", "master") + * @param transportConfig Transport config callback for authentication (SSH or HTTPS) */ - public GitOperations(Git git, String branch, SshSessionFactory sshSessionFactory) { + public GitOperations(Git git, String branch, TransportConfigCallback transportConfig) { if (git == null) { throw new IllegalArgumentException("Git instance cannot be null"); } if (branch == null || branch.trim().isEmpty()) { throw new IllegalArgumentException("Branch cannot be null or empty"); } - if (sshSessionFactory == null) { - throw new IllegalArgumentException("SSH session factory cannot be null"); + if (transportConfig == null) { + throw new IllegalArgumentException("Transport config cannot be null"); } this.git = git; this.branch = branch; - this.sshSessionFactory = sshSessionFactory; + this.transportConfig = transportConfig; } /** @@ -305,12 +304,8 @@ public boolean hasRemoteChanges() throws GitAPIException, IOException { git.fetch() .setRemote("origin") .setRefSpecs(new RefSpec("refs/heads/" + branch + ":refs/remotes/origin/" + branch)) - .setTransportConfigCallback(transport -> { - if (transport instanceof SshTransport) { - ((SshTransport) transport).setSshSessionFactory(sshSessionFactory); - } - }) - .call(); + .setTransportConfigCallback(transportConfig) + .call(); //@formatter:on // Get refs @@ -350,11 +345,7 @@ public String pullWithOverwrite() throws GitAPIException, IOException { FetchCommand fetchCommand = git.fetch(); fetchCommand.setRemote("origin"); fetchCommand.setRefSpecs(new RefSpec("refs/heads/" + branch + ":refs/remotes/origin/" + branch)); - fetchCommand.setTransportConfigCallback(transport -> { - if (transport instanceof SshTransport) { - ((SshTransport) transport).setSshSessionFactory(sshSessionFactory); - } - }); + fetchCommand.setTransportConfigCallback(transportConfig); FetchResult fetchResult = fetchCommand.call(); result.append("Fetch completed: ").append(fetchResult.getMessages()).append("\n"); @@ -450,11 +441,7 @@ public String push(boolean forcePush) throws GitAPIException, GitPushFailedExcep pushCommand.setRemote("origin"); pushCommand.setRefSpecs(new RefSpec("refs/heads/" + branch)); pushCommand.setForce(forcePush); - pushCommand.setTransportConfigCallback(transport -> { - if (transport instanceof SshTransport) { - ((SshTransport) transport).setSshSessionFactory(sshSessionFactory); - } - }); + pushCommand.setTransportConfigCallback(transportConfig); Iterable results = pushCommand.call(); @@ -717,11 +704,7 @@ public void commitAndPushFiles(List filePaths, String message, PersonIde */ private void fetch() throws GitAPIException { logger.debug("Fetching from origin/{}", branch); - git.fetch().setRemote("origin").setRefSpecs(new RefSpec("refs/heads/" + branch + ":refs/remotes/origin/" + branch)).setTransportConfigCallback(transport -> { - if (transport instanceof SshTransport) { - ((SshTransport) transport).setSshSessionFactory(sshSessionFactory); - } - }).call(); + git.fetch().setRemote("origin").setRefSpecs(new RefSpec("refs/heads/" + branch + ":refs/remotes/origin/" + branch)).setTransportConfigCallback(transportConfig).call(); logger.debug("Fetch completed"); } diff --git a/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/git/HttpsTransportConfig.java b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/git/HttpsTransportConfig.java new file mode 100644 index 000000000..f755aed07 --- /dev/null +++ b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/git/HttpsTransportConfig.java @@ -0,0 +1,83 @@ +/* + * + * Copyright (c) Innovar Healthcare. All rights reserved. + * + * https://www.innovarhealthcare.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.innovarhealthcare.channelHistory.server.git; + +import com.innovarhealthcare.channelHistory.shared.model.GitSettings; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.api.TransportConfigCallback; +import org.eclipse.jgit.transport.Transport; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; + +public class HttpsTransportConfig implements TransportConfigCallback { + + private static final Logger logger = LogManager.getLogger(HttpsTransportConfig.class); + + private final CredentialsProvider credentials; + + public HttpsTransportConfig(GitSettings settings) { + this.credentials = buildCredentials(settings); + } + + @Override + public void configure(Transport transport) { + transport.setCredentialsProvider(credentials); + } + + private CredentialsProvider buildCredentials(GitSettings settings) { + String username = settings.getHttpsUsername(); + String password = settings.getHttpsPassword(); + String credentialsPath = settings.getHttpsCredentialsPath(); + + boolean hasInline = username != null && !username.trim().isEmpty() + && password != null && !password.trim().isEmpty(); + boolean hasFilePath = credentialsPath != null && !credentialsPath.trim().isEmpty(); + + if (hasInline) { + logger.debug("HTTPS credentials loaded from inline configuration"); + return new UsernamePasswordCredentialsProvider(username.trim(), password.trim()); + } + + if (hasFilePath) { + return loadFromFile(credentialsPath.trim()); + } + + throw new IllegalStateException("No HTTPS credentials configured. Provide username/PAT or a credentials file path."); + } + + /** + * Reads credentials from a file. Expected format: two lines — username on line 1, PAT on line 2. + */ + private CredentialsProvider loadFromFile(String path) { + try { + List lines = Files.readAllLines(Paths.get(path)); + if (lines.size() < 2) { + throw new IllegalArgumentException("Credentials file must contain two lines: username and PAT"); + } + String username = lines.get(0).trim(); + String password = lines.get(1).trim(); + if (username.isEmpty() || password.isEmpty()) { + throw new IllegalArgumentException("Credentials file contains empty username or PAT on line 1/2"); + } + logger.debug("HTTPS credentials loaded from file: {}", path); + return new UsernamePasswordCredentialsProvider(username, password); + } catch (IOException e) { + logger.error("Failed to read HTTPS credentials file: {}", path, e); + throw new IllegalStateException("Cannot read HTTPS credentials from file: " + path, e); + } + } +} diff --git a/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/git/SshTransportConfig.java b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/git/SshTransportConfig.java new file mode 100644 index 000000000..ff9288cd6 --- /dev/null +++ b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/git/SshTransportConfig.java @@ -0,0 +1,83 @@ +/* + * + * Copyright (c) Innovar Healthcare. All rights reserved. + * + * https://www.innovarhealthcare.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.innovarhealthcare.channelHistory.server.git; + +import com.innovarhealthcare.channelHistory.shared.model.GitSettings; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.JSchException; +import com.jcraft.jsch.Session; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.eclipse.jgit.transport.JschConfigSessionFactory; +import org.eclipse.jgit.transport.OpenSshConfig; +import org.eclipse.jgit.transport.SshSessionFactory; +import org.eclipse.jgit.transport.SshTransport; +import org.eclipse.jgit.transport.Transport; +import org.eclipse.jgit.api.TransportConfigCallback; +import org.eclipse.jgit.util.FS; + +import java.nio.charset.StandardCharsets; + +public class SshTransportConfig implements TransportConfigCallback { + + private static final Logger logger = LogManager.getLogger(SshTransportConfig.class); + private static final String SSH_KEY_IDENTITY_NAME = "version-history-ssh-key"; + + private final SshSessionFactory factory; + + public SshTransportConfig(GitSettings settings) { + this.factory = buildFactory(settings); + } + + @Override + public void configure(Transport transport) { + if (transport instanceof SshTransport) { + ((SshTransport) transport).setSshSessionFactory(factory); + } + } + + private SshSessionFactory buildFactory(GitSettings settings) { + final String sshPrivateKey = settings.getSshPrivateKey(); + final String sshPrivateKeyPath = settings.getSshPrivateKeyPath(); + + boolean hasInlineKey = sshPrivateKey != null && !sshPrivateKey.trim().isEmpty(); + boolean hasKeyPath = sshPrivateKeyPath != null && !sshPrivateKeyPath.trim().isEmpty(); + + if (!hasInlineKey && !hasKeyPath) { + throw new IllegalStateException("No SSH private key configured. Provide an inline key or a key file path."); + } + + return new JschConfigSessionFactory() { + @Override + protected void configure(OpenSshConfig.Host hc, Session session) { + session.setConfig("StrictHostKeyChecking", "no"); + } + + @Override + protected JSch createDefaultJSch(FS fs) throws JSchException { + JSch jsch = super.createDefaultJSch(fs); + try { + if (hasInlineKey) { + jsch.addIdentity(SSH_KEY_IDENTITY_NAME, sshPrivateKey.getBytes(StandardCharsets.UTF_8), null, null); + logger.debug("SSH private key loaded from inline content"); + } else { + jsch.addIdentity(sshPrivateKeyPath.trim()); + logger.debug("SSH private key loaded from path: {}", sshPrivateKeyPath); + } + } catch (JSchException e) { + logger.error("Failed to add SSH private key", e); + throw e; + } + return jsch; + } + }; + } +} diff --git a/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/service/GitRepositoryService.java b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/service/GitRepositoryService.java index edfafb9b5..6ba579d1a 100644 --- a/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/service/GitRepositoryService.java +++ b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/service/GitRepositoryService.java @@ -23,6 +23,8 @@ import com.innovarhealthcare.channelHistory.server.exception.GitOperationException; import com.innovarhealthcare.channelHistory.server.exception.GitPushFailedException; import com.innovarhealthcare.channelHistory.server.file.FileOperations; +import com.innovarhealthcare.channelHistory.server.git.HttpsTransportConfig; +import com.innovarhealthcare.channelHistory.server.git.SshTransportConfig; import com.innovarhealthcare.channelHistory.server.util.GitCommitterHelper; import com.innovarhealthcare.channelHistory.server.git.GitOperations; import com.mirth.connect.model.User; @@ -38,9 +40,6 @@ import com.innovarhealthcare.channelHistory.shared.model.CommitMetaData; import com.innovarhealthcare.channelHistory.shared.model.GitSettings; import com.innovarhealthcare.channelHistory.shared.model.VersionHistoryProperties; -import com.jcraft.jsch.JSch; -import com.jcraft.jsch.JSchException; -import com.jcraft.jsch.Session; import com.mirth.connect.donkey.server.Donkey; import com.mirth.connect.model.converters.ObjectXMLSerializer; import com.mirth.connect.server.controllers.ControllerFactory; @@ -51,12 +50,7 @@ import org.eclipse.jgit.api.PullResult; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.StoredConfig; -import org.eclipse.jgit.transport.JschConfigSessionFactory; -import org.eclipse.jgit.transport.OpenSshConfig; -import org.eclipse.jgit.transport.SshSessionFactory; -import org.eclipse.jgit.transport.SshTransport; -import org.eclipse.jgit.transport.Transport; -import org.eclipse.jgit.util.FS; +import org.eclipse.jgit.api.TransportConfigCallback; /** * Git Repository Infrastructure Service @@ -88,7 +82,6 @@ public class GitRepositoryService { private static final Logger logger = LogManager.getLogger(GitRepositoryService.class); private static final String DATA_DIR = "version-history"; - private static final String SSH_KEY_IDENTITY_NAME = "version-history-ssh-key"; // ========== State ========== private boolean started; @@ -105,7 +98,7 @@ public class GitRepositoryService { // ========== Infrastructure Components ========== private Git git; - private SshSessionFactory sshSessionFactory; + private TransportConfigCallback transportConfig; private GitOperations gitOperations; private FileOperations fileOperations; @@ -144,10 +137,11 @@ public synchronized void startGit() throws Exception { // Initialize components initializeBasicComponents(); validateConfiguration(); - createSshSessionFactory(); + transportConfig = buildTransportConfig(versionHistoryProperties.getGitSettings()); // Validate remote connection before touching the local repository - String connError = validateSSHConnection(versionHistoryProperties.getGitSettings()); + GitSettings gitSettings = versionHistoryProperties.getGitSettings(); + String connError = doValidateRemoteConnection(gitSettings.getRemoteRepositoryUrl(), gitSettings.getBranchName(), transportConfig); if (connError != null) { logger.warn("Git connection validation failed: {}", connError); this.gitAvailable = false; @@ -570,14 +564,14 @@ public synchronized void restoreFiles(java.util.Map files) throw // ========== Connection Validation ========== /** - * Validates the SSH (or default) connection to the remote repository described by gitSettings. - * Clones to a temporary directory with no checkout, then deletes the temp dir. + * Validates the remote connection using the given gitSettings. + * Uses ls-remote — no objects are downloaded, no temp directory needed. * Never throws — returns null on success or an error message string on failure. * - * @param gitSettings Settings to validate (URL, branch, SSH key or key path) + * @param gitSettings Settings to validate (URL, branch, auth credentials) * @return null on success; error message on failure */ - public String validateSSHConnection(GitSettings gitSettings) { + public String validateRemoteConnection(GitSettings gitSettings) { if (gitSettings == null) { return "Git settings cannot be null"; } @@ -592,20 +586,18 @@ public String validateSSHConnection(GitSettings gitSettings) { return "Branch name is not configured"; } - try { - SshSessionFactory tempFactory = buildSshSessionFactory(gitSettings); + return doValidateRemoteConnection(remoteUrl, branch, buildTransportConfig(gitSettings)); + } + private String doValidateRemoteConnection(String remoteUrl, String branch, TransportConfigCallback config) { + try { // Use ls-remote instead of clone — only establishes the connection and lists refs, // no objects are downloaded and no temp directory is needed. Much faster. Collection refs = Git.lsRemoteRepository() .setRemote(remoteUrl) .setHeads(true) .setTags(false) - .setTransportConfigCallback(transport -> { - if (transport instanceof SshTransport) { - ((SshTransport) transport).setSshSessionFactory(tempFactory); - } - }) + .setTransportConfigCallback(config) .call(); // Verify the configured branch exists on the remote @@ -624,58 +616,10 @@ public String validateSSHConnection(GitSettings gitSettings) { } } - /** - * Converts a raw JGit/JSch exception into a concise, user-readable error message. - */ private String friendlyValidationError(Exception e) { - String msg = e.getMessage() != null ? e.getMessage() : ""; - String cause = e.getCause() != null && e.getCause().getMessage() != null ? e.getCause().getMessage() : ""; - String combined = (msg + " " + cause).toLowerCase(); - - // SSH authentication failures - if (combined.contains("auth fail") || combined.contains("not authorized") || combined.contains("authentication failed")) { - return "Authentication failed. Verify your SSH private key is correct and has access to the repository."; - } - // Unknown/rejected host key - if (combined.contains("unknownhostkey") || combined.contains("reject hostkey") || combined.contains("hostkey")) { - return "The server's SSH host key is not recognized. The host may need to be added to known_hosts."; - } - // Repository not found - if (combined.contains("repository not found") || combined.contains("does not appear to be a git repository")) { - return "Repository not found. Check the repository URL and that your account has access."; - } - // Branch not found - if (combined.contains("remote does not have") || combined.contains("couldn't find remote ref")) { - return "Branch not found on the remote. Check the branch name."; - } - // Cannot resolve host / network unreachable - if (combined.contains("unknownhostexception") || combined.contains("nodename nor servname") || combined.contains("name or service not known")) { - return "Cannot resolve host. Check the repository URL and your network connectivity."; - } - // Connection refused / timed out - if (combined.contains("connection refused") || combined.contains("timed out") || combined.contains("connection timed out")) { - return "Connection refused or timed out. Check the host, port, and firewall settings."; - } - // Invalid URL / remote - if (combined.contains("invalid remote") || combined.contains("not a valid url")) { - return "Invalid repository URL. Use an SSH URL in the form git@host:user/repo.git."; - } - // SSH key file not found or unreadable - if (combined.contains("no such file") || combined.contains("cannot read") || combined.contains("filenotfoundexception")) { - return "SSH private key file not found or unreadable. Check the file path."; - } - // Invalid / unsupported key format - if (combined.contains("invalid privatekey") || combined.contains("invalid key") || combined.contains("unsupported key")) { - return "Invalid SSH private key format. Ensure the key is a valid OpenSSH or PEM private key."; - } - // Permission denied on key file - if (combined.contains("permission denied")) { - return "Permission denied. The SSH key may be rejected by the server, or the key file is not readable."; - } - - // Fallback — return the raw message but strip verbose package prefixes - logger.warn("Unmapped validation error: {}", msg); - return msg.isEmpty() ? "Unknown error during connection validation" : msg; + String msg = e.getMessage() != null ? e.getMessage() : "Unknown error during connection validation"; + logger.warn("Remote connection validation failed: {}", msg); + return msg; } // ========== Private Initialization Methods ========== @@ -723,101 +667,16 @@ private void validateConfiguration() { } /** - * Creates the live SSH session factory from the current plugin configuration. - * Supports two key modes: - *
    - *
  • Inline key ({@code sshPrivateKey}): key content is loaded directly from memory.
  • - *
  • File path ({@code sshPrivateKeyPath}): key is read from the given path on the - * Mirth server file system (absolute or relative to appdata).
  • - *
- * Falls back to the default JSch session factory if neither is configured. - */ - private void createSshSessionFactory() { - logger.debug("Creating SSH session factory..."); - - final String sshPrivateKey = versionHistoryProperties.getGitSettings().getSshPrivateKey(); - final String sshPrivateKeyPath = versionHistoryProperties.getGitSettings().getSshPrivateKeyPath(); - - boolean hasInlineKey = sshPrivateKey != null && !sshPrivateKey.trim().isEmpty(); - boolean hasKeyPath = sshPrivateKeyPath != null && !sshPrivateKeyPath.trim().isEmpty(); - - if (!hasInlineKey && !hasKeyPath) { - logger.warn("No SSH private key configured, using default session factory"); - sshSessionFactory = SshSessionFactory.getInstance(); - return; - } - - sshSessionFactory = new JschConfigSessionFactory() { - @Override - protected void configure(OpenSshConfig.Host hc, Session session) { - session.setConfig("StrictHostKeyChecking", "no"); - } - - @Override - protected JSch createDefaultJSch(FS fs) throws JSchException { - JSch defaultJSch = super.createDefaultJSch(fs); - try { - if (hasInlineKey) { - // Load key from in-memory bytes (paste mode) - defaultJSch.addIdentity(SSH_KEY_IDENTITY_NAME, sshPrivateKey.getBytes(), null, null); - logger.debug("SSH private key loaded from inline content"); - } else { - // Load key from file path on the server file system - defaultJSch.addIdentity(sshPrivateKeyPath.trim()); - logger.debug("SSH private key loaded from path: {}", sshPrivateKeyPath); - } - } catch (JSchException e) { - logger.error("Failed to add SSH private key", e); - throw e; - } - return defaultJSch; - } - }; - - logger.debug("SSH session factory created successfully"); - } - - /** - * Builds a standalone SshSessionFactory from the given GitSettings. - * Supports both inline key content (bytes) and file-path key. - * Used by validateSSHConnection() so it can work independently of the live sshSessionFactory. + * Builds a TransportConfigCallback for the given GitSettings. + * Returns an SshTransportConfig for SSH auth or HttpsTransportConfig for HTTPS/PAT auth. */ - private SshSessionFactory buildSshSessionFactory(GitSettings gitSettings) { - final String sshPrivateKey = gitSettings.getSshPrivateKey(); - final String sshPrivateKeyPath = gitSettings.getSshPrivateKeyPath(); - - boolean hasInlineKey = sshPrivateKey != null && !sshPrivateKey.trim().isEmpty(); - boolean hasKeyPath = sshPrivateKeyPath != null && !sshPrivateKeyPath.trim().isEmpty(); - - if (!hasInlineKey && !hasKeyPath) { - logger.warn("No SSH private key configured for validation, using default session factory"); - return SshSessionFactory.getInstance(); + private TransportConfigCallback buildTransportConfig(GitSettings gitSettings) { + if (gitSettings.isHTTPS()) { + logger.debug("Building HTTPS transport config"); + return new HttpsTransportConfig(gitSettings); } - - return new JschConfigSessionFactory() { - @Override - protected void configure(OpenSshConfig.Host hc, Session session) { - session.setConfig("StrictHostKeyChecking", "no"); - } - - @Override - protected JSch createDefaultJSch(FS fs) throws JSchException { - JSch jsch = super.createDefaultJSch(fs); - try { - if (hasInlineKey) { - jsch.addIdentity(SSH_KEY_IDENTITY_NAME, sshPrivateKey.getBytes(), null, null); - logger.debug("SSH private key added from inline content"); - } else { - jsch.addIdentity(sshPrivateKeyPath.trim()); - logger.debug("SSH private key loaded from path: {}", sshPrivateKeyPath); - } - } catch (JSchException e) { - logger.error("Failed to add SSH private key", e); - throw e; - } - return jsch; - } - }; + logger.debug("Building SSH transport config"); + return new SshTransportConfig(gitSettings); } /** @@ -900,7 +759,7 @@ private void cloneFromRemote() throws Exception { logger.info("Cloning from: {}", remoteUrl); logger.info("Branch: {}", branch); - git = Git.cloneRepository().setURI(remoteUrl).setDirectory(repositoryDirectory).setBranch(branch).setTransportConfigCallback(this::configureSsh).call(); + git = Git.cloneRepository().setURI(remoteUrl).setDirectory(repositoryDirectory).setBranch(branch).setTransportConfigCallback(transportConfig).call(); logger.info("Successfully cloned repository from: {}", remoteUrl); } @@ -913,7 +772,7 @@ private void pullLatestChanges() { logger.info("Pulling latest changes from remote..."); try { - PullResult result = git.pull().setRemote("origin").setRemoteBranchName(versionHistoryProperties.getGitSettings().getBranchName()).setTransportConfigCallback(this::configureSsh).call(); + PullResult result = git.pull().setRemote("origin").setRemoteBranchName(versionHistoryProperties.getGitSettings().getBranchName()).setTransportConfigCallback(transportConfig).call(); if (result.isSuccessful()) { logger.info("Successfully pulled latest changes"); @@ -934,7 +793,7 @@ private void pullLatestChanges() { private void createOperations() { logger.debug("Creating operations..."); - gitOperations = new GitOperations(git, versionHistoryProperties.getGitSettings().getBranchName(), sshSessionFactory); + gitOperations = new GitOperations(git, versionHistoryProperties.getGitSettings().getBranchName(), transportConfig); fileOperations = new FileOperations(repositoryDirectory, serializer); @@ -952,7 +811,7 @@ private void cleanup() { gitOperations = null; fileOperations = null; - sshSessionFactory = null; + transportConfig = null; } /** @@ -977,17 +836,6 @@ private void ensureGitAvailable() { } } - /** - * Configures SSH for transport. - * - * @param transport Git transport - */ - private void configureSsh(Transport transport) { - if (transport instanceof SshTransport) { - ((SshTransport) transport).setSshSessionFactory(sshSessionFactory); - } - } - // ========== Status Classes ========== /** diff --git a/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/service/VersionHistoryService.java b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/service/VersionHistoryService.java index 0023d5e30..f1dbe80ec 100644 --- a/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/service/VersionHistoryService.java +++ b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/service/VersionHistoryService.java @@ -87,14 +87,14 @@ public VersionHistoryService(GitRepositoryService gitRepositoryService, VersionH /** * Validates the Git connection described by the given properties. * Parses properties into a VersionHistoryProperties, then delegates to - * GitRepositoryService.validateSSHConnection() which clones to a temp dir and deletes it. + * GitRepositoryService.validateRemoteConnection() which uses ls-remote to verify access. * * @param properties Plugin properties containing Git settings * @return Success message on success; error message on failure */ public String validateGitConnection(Properties properties) { VersionHistoryProperties tempProperties = new VersionHistoryProperties(properties); - String error = gitRepositoryService.validateSSHConnection(tempProperties.getGitSettings()); + String error = gitRepositoryService.validateRemoteConnection(tempProperties.getGitSettings()); if (error != null) { throw new GitNotConnectedException(error); } diff --git a/custom-extensions/version-history/shared/src/main/java/com/innovarhealthcare/channelHistory/shared/model/GitSettings.java b/custom-extensions/version-history/shared/src/main/java/com/innovarhealthcare/channelHistory/shared/model/GitSettings.java index 7930205e6..9a844b122 100644 --- a/custom-extensions/version-history/shared/src/main/java/com/innovarhealthcare/channelHistory/shared/model/GitSettings.java +++ b/custom-extensions/version-history/shared/src/main/java/com/innovarhealthcare/channelHistory/shared/model/GitSettings.java @@ -13,6 +13,10 @@ import org.apache.commons.lang3.StringUtils; public class GitSettings { + + public static final String AUTH_TYPE_SSH = "SSH"; + public static final String AUTH_TYPE_HTTPS = "HTTPS"; + private String remoteRepositoryUrl; private String branchName; private String sshPrivateKey; @@ -97,11 +101,11 @@ public void setHttpsCredentialsPath(String httpsCredentialsPath) { } public boolean isSSH() { - return "SSH".equalsIgnoreCase(authType) || authType == null || authType.isEmpty(); + return authType == null || authType.isEmpty() || AUTH_TYPE_SSH.equalsIgnoreCase(authType); } public boolean isHTTPS() { - return "HTTPS".equalsIgnoreCase(authType); + return AUTH_TYPE_HTTPS.equalsIgnoreCase(authType); } public boolean validate() { diff --git a/custom-extensions/version-history/shared/src/main/java/com/innovarhealthcare/channelHistory/shared/model/VersionHistoryProperties.java b/custom-extensions/version-history/shared/src/main/java/com/innovarhealthcare/channelHistory/shared/model/VersionHistoryProperties.java index a86bbc7f3..436001093 100644 --- a/custom-extensions/version-history/shared/src/main/java/com/innovarhealthcare/channelHistory/shared/model/VersionHistoryProperties.java +++ b/custom-extensions/version-history/shared/src/main/java/com/innovarhealthcare/channelHistory/shared/model/VersionHistoryProperties.java @@ -75,7 +75,7 @@ public Properties toProperties() { properties.setProperty(VERSION_HISTORY_REMOTE_BRANCH, gitSettings.getBranchName() != null ? gitSettings.getBranchName() : ""); properties.setProperty(VERSION_HISTORY_REMOTE_SSH_KEY, gitSettings.getSshPrivateKey() != null ? gitSettings.getSshPrivateKey() : ""); properties.setProperty(VERSION_HISTORY_REMOTE_SSH_KEY_PATH, gitSettings.getSshPrivateKeyPath() != null ? gitSettings.getSshPrivateKeyPath() : ""); - properties.setProperty(VERSION_HISTORY_REMOTE_AUTH_TYPE, gitSettings.getAuthType() != null ? gitSettings.getAuthType() : "SSH"); + properties.setProperty(VERSION_HISTORY_REMOTE_AUTH_TYPE, gitSettings.getAuthType() != null ? gitSettings.getAuthType() : GitSettings.AUTH_TYPE_SSH); properties.setProperty(VERSION_HISTORY_REMOTE_HTTPS_USERNAME, gitSettings.getHttpsUsername() != null ? gitSettings.getHttpsUsername() : ""); properties.setProperty(VERSION_HISTORY_REMOTE_HTTPS_PASSWORD, gitSettings.getHttpsPassword() != null ? gitSettings.getHttpsPassword() : ""); properties.setProperty(VERSION_HISTORY_REMOTE_HTTPS_CREDENTIALS_PATH, gitSettings.getHttpsCredentialsPath() != null ? gitSettings.getHttpsCredentialsPath() : ""); @@ -85,7 +85,7 @@ public Properties toProperties() { properties.setProperty(VERSION_HISTORY_REMOTE_BRANCH, ""); properties.setProperty(VERSION_HISTORY_REMOTE_SSH_KEY, ""); properties.setProperty(VERSION_HISTORY_REMOTE_SSH_KEY_PATH, ""); - properties.setProperty(VERSION_HISTORY_REMOTE_AUTH_TYPE, "SSH"); + properties.setProperty(VERSION_HISTORY_REMOTE_AUTH_TYPE, GitSettings.AUTH_TYPE_SSH); properties.setProperty(VERSION_HISTORY_REMOTE_HTTPS_USERNAME, ""); properties.setProperty(VERSION_HISTORY_REMOTE_HTTPS_PASSWORD, ""); properties.setProperty(VERSION_HISTORY_REMOTE_HTTPS_CREDENTIALS_PATH, ""); @@ -109,7 +109,7 @@ public void fromProperties(Properties properties) { String branchName = getStringProperty(properties, VERSION_HISTORY_REMOTE_BRANCH, ""); String sshPrivateKey = getStringProperty(properties, VERSION_HISTORY_REMOTE_SSH_KEY, ""); String sshPrivateKeyPath = getStringProperty(properties, VERSION_HISTORY_REMOTE_SSH_KEY_PATH, ""); - String authType = getStringProperty(properties, VERSION_HISTORY_REMOTE_AUTH_TYPE, "SSH"); + String authType = getStringProperty(properties, VERSION_HISTORY_REMOTE_AUTH_TYPE, GitSettings.AUTH_TYPE_SSH); String httpsUsername = getStringProperty(properties, VERSION_HISTORY_REMOTE_HTTPS_USERNAME, ""); String httpsPassword = getStringProperty(properties, VERSION_HISTORY_REMOTE_HTTPS_PASSWORD, ""); String httpsCredentialsPath = getStringProperty(properties, VERSION_HISTORY_REMOTE_HTTPS_CREDENTIALS_PATH, ""); From e57b4dcfb91d75591fbcb6910b25c652d027f558 Mon Sep 17 00:00:00 2001 From: Thai Tran Date: Fri, 24 Apr 2026 23:59:25 -0500 Subject: [PATCH 02/25] feat(version-history): add Pull/Push/Reload actions to GitStatusTabPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 3 header actions: Reload (fetch + ahead/behind count), Pull (normal merge, conflicts resolved using remote), Push (fetch + rebase + push) - Add RemoteStatus DTO and /remoteStatus, /pull, /push endpoints - Replace pullWithOverwrite with pullNormal for Pull button; auto-resolves conflicts by taking remote version, preserves local unpushed commits - Fix UP_TO_DATE treated as push failure in GitOperations.push() - Fix git repo not re-initialized when Remote URL or Branch changes in settings — now deletes local repo and re-clones to avoid unrelated histories or wrong-branch corruption - Reformat VersionHistoryServletInterface to consistent multiline style --- .../client/panel/GitStatusTabPanel.java | 252 ++++++++++++++--- .../service/VersionHistoryServiceClient.java | 58 ++++ .../server/git/GitOperations.java | 118 +++++++- .../server/service/GitRepositoryService.java | 104 ++++++- .../server/service/VersionHistoryService.java | 32 +++ .../servlet/VersionHistoryPluginServlet.java | 75 +++++ .../shared/dto/response/RemoteStatus.java | 55 ++++ .../VersionHistoryServletInterface.java | 257 ++++++++++++++++-- 8 files changed, 882 insertions(+), 69 deletions(-) create mode 100644 custom-extensions/version-history/shared/src/main/java/com/innovarhealthcare/channelHistory/shared/dto/response/RemoteStatus.java diff --git a/custom-extensions/version-history/client/src/main/java/com/innovarhealthcare/channelHistory/client/panel/GitStatusTabPanel.java b/custom-extensions/version-history/client/src/main/java/com/innovarhealthcare/channelHistory/client/panel/GitStatusTabPanel.java index 220624543..bd699ee44 100644 --- a/custom-extensions/version-history/client/src/main/java/com/innovarhealthcare/channelHistory/client/panel/GitStatusTabPanel.java +++ b/custom-extensions/version-history/client/src/main/java/com/innovarhealthcare/channelHistory/client/panel/GitStatusTabPanel.java @@ -13,6 +13,7 @@ import javax.swing.BorderFactory; import javax.swing.JButton; import javax.swing.JLabel; +import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JProgressBar; import javax.swing.JTabbedPane; @@ -20,17 +21,23 @@ import javax.swing.event.ChangeListener; import java.awt.Color; import java.awt.Cursor; +import java.awt.FlowLayout; import java.awt.Font; +import java.awt.Frame; import java.awt.event.HierarchyEvent; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import com.innovarhealthcare.channelHistory.client.dialog.ConflictResolutionDialog; +import com.innovarhealthcare.channelHistory.client.exception.GitConflictClientException; import com.innovarhealthcare.channelHistory.client.panel.gitstatus.ChangesTabPanel; import com.innovarhealthcare.channelHistory.client.panel.gitstatus.FilesTabPanel; import com.innovarhealthcare.channelHistory.client.panel.gitstatus.HistoryTabPanel; import com.innovarhealthcare.channelHistory.client.service.VersionHistoryServiceClient; import com.innovarhealthcare.channelHistory.shared.dto.response.RepoChanges; import com.innovarhealthcare.channelHistory.shared.dto.response.RepoInfo; +import com.innovarhealthcare.channelHistory.shared.dto.response.RemoteStatus; import com.innovarhealthcare.channelHistory.shared.model.VersionHistoryProperties; import com.mirth.connect.client.core.ClientException; import com.mirth.connect.client.ui.PlatformUI; @@ -39,7 +46,7 @@ /** * Shell panel for the Git Status tab in the Version History settings. - * Owns the repository info header bar, the JTabbedPane, and the LoadDataWorker. + * Owns the repository info header bar, the JTabbedPane, and the SyncLoadWorker. * All tab-specific UI and logic is delegated to the three sub-panels: * {@link FilesTabPanel}, {@link ChangesTabPanel}, and {@link HistoryTabPanel}. * @@ -66,10 +73,15 @@ public class GitStatusTabPanel extends JPanel { // ── Main tab pane ────────────────────────────────────────────────────────── private JTabbedPane leftTabbedPane; + // ── Header action buttons ────────────────────────────────────────────────── + private JButton reloadButton; + private JButton pullButton; + private JButton pushButton; + private JLabel syncStatusLabel; + // ── Status / controls ────────────────────────────────────────────────────── private JProgressBar loadingBar; private JLabel statusLabel; - private JButton refreshButton; // ── Listener ─────────────────────────────────────────────────────────────── private ChangeListener tabChangeListener; @@ -90,7 +102,7 @@ public GitStatusTabPanel(VersionHistoryProperties versionHistoryProperties) { // VersionHistorySettingPanel's ChangeListener guarantees no unsaved changes // exist before this panel becomes visible (via UnsavedChangesDialog). if (!PlatformUI.MIRTH_FRAME.isSaveEnabled()) { - loadData(); + loadDataWithSync(); } } }); @@ -120,31 +132,52 @@ private void initComponents() { statusLabel.setFont(new Font("Tahoma", Font.PLAIN, 11)); statusLabel.setVisible(false); - refreshButton = new JButton("Refresh"); - refreshButton.addActionListener(e -> loadData()); + syncStatusLabel = new JLabel("—"); + syncStatusLabel.setFont(new Font("Tahoma", Font.PLAIN, 11)); + syncStatusLabel.setForeground(new Color(100, 100, 100)); + + reloadButton = new JButton("↺"); + reloadButton.setToolTipText("Fetch from remote and update sync status"); + + pullButton = new JButton("Pull"); + pullButton.setToolTipText("Pull from remote (overwrites local uncommitted changes)"); + + pushButton = new JButton("Push"); + pushButton.setToolTipText("Push local commits to remote"); } private void initLayout() { setLayout(new MigLayout("fill, hidemode 3, novisualpadding, insets 4", "[grow]", "[][grow][][]")); // ── Header bar ──────────────────────────────────────────────────────── - JPanel headerBar = new JPanel(new MigLayout("insets 4 8 4 8, novisualpadding", "[right]4[grow,fill]20[right]4[grow,fill]20[right]4[grow,fill]20[right]4[grow,fill]")); + JPanel infoPanel = new JPanel(new MigLayout("insets 0, novisualpadding", "[right]4[grow,fill]20[right]4[grow,fill]20[right]4[grow,fill]20[right]4[grow,fill]")); + infoPanel.setBackground(UIConstants.BACKGROUND_COLOR); + infoPanel.add(new JLabel("Local Path:")); + infoPanel.add(localRepoPathValueLabel); + infoPanel.add(new JLabel("Remote:")); + infoPanel.add(remoteUrlValueLabel); + infoPanel.add(new JLabel("Branch:")); + infoPanel.add(branchValueLabel); + infoPanel.add(new JLabel("Size:")); + infoPanel.add(sizeValueLabel); + + JPanel actionPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0)); + actionPanel.setBackground(UIConstants.BACKGROUND_COLOR); + actionPanel.add(syncStatusLabel); + actionPanel.add(pullButton); + actionPanel.add(pushButton); + actionPanel.add(reloadButton); + + JPanel headerBar = new JPanel(new MigLayout("insets 4 8 4 8, novisualpadding", "[grow,fill][]")); headerBar.setBackground(UIConstants.BACKGROUND_COLOR); headerBar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, new Color(204, 204, 204))); - headerBar.add(new JLabel("Local Path:")); - headerBar.add(localRepoPathValueLabel); - headerBar.add(new JLabel("Remote:")); - headerBar.add(remoteUrlValueLabel); - headerBar.add(new JLabel("Branch:")); - headerBar.add(branchValueLabel); - headerBar.add(new JLabel("Size:")); - headerBar.add(sizeValueLabel); + headerBar.add(infoPanel, "grow"); + headerBar.add(actionPanel); add(headerBar, "growx, wrap"); add(leftTabbedPane, "grow, push, wrap"); add(loadingBar, "growx, wrap"); add(statusLabel, "growx, wrap"); - add(refreshButton, "right"); } private void initListeners() { @@ -162,6 +195,10 @@ private void initListeners() { } }; leftTabbedPane.addChangeListener(tabChangeListener); + + reloadButton.addActionListener(e -> loadDataWithSync()); + pullButton.addActionListener(e -> onPull()); + pushButton.addActionListener(e -> onPush()); } // ========== Cross-tab Navigation ========== @@ -175,48 +212,191 @@ private void onViewFullHistory(String relativePath) { // ========== Data Loading ========== - private void loadData() { + private void loadDataWithSync() { enterLoadingState(); setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR)); - new LoadDataWorker().execute(); + new SyncLoadWorker().execute(); + } + + private void onPull() { + int choice = JOptionPane.showConfirmDialog( + this, + "Pull changes from remote?\n\n" + + "• If remote has new commits: they will be merged into local.\n" + + "• If there are conflicts: remote version wins automatically.\n" + + "• Local committed work is preserved.", + "Pull from Remote", + JOptionPane.OK_CANCEL_OPTION, + JOptionPane.INFORMATION_MESSAGE); + if (choice != JOptionPane.OK_OPTION) { + return; + } + setActionButtonsEnabled(false); + loadingBar.setVisible(true); + statusLabel.setVisible(false); + setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR)); + new SwingWorker() { + @Override + protected Void doInBackground() throws Exception { + VersionHistoryServiceClient.getInstance().pull(); + return null; + } + + @Override + protected void done() { + try { + get(); + loadDataWithSync(); + } catch (Exception ex) { + Throwable cause = ex.getCause() != null ? ex.getCause() : ex; + String msg = cause.getMessage() != null ? cause.getMessage() : "Unknown error"; + setCursor(Cursor.getDefaultCursor()); + loadingBar.setVisible(false); + setActionButtonsEnabled(true); + statusLabel.setText("Pull failed: " + msg); + statusLabel.setVisible(true); + } + } + }.execute(); + } + + private void onPush() { + setActionButtonsEnabled(false); + loadingBar.setVisible(true); + statusLabel.setVisible(false); + setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR)); + new SwingWorker() { + @Override + protected Void doInBackground() throws Exception { + VersionHistoryServiceClient.getInstance().pushOnly(); + return null; + } + + @Override + protected void done() { + setCursor(Cursor.getDefaultCursor()); + loadingBar.setVisible(false); + try { + get(); + loadDataWithSync(); + } catch (java.util.concurrent.ExecutionException ex) { + Throwable cause = ex.getCause() != null ? ex.getCause() : ex; + setActionButtonsEnabled(true); + if (cause instanceof GitConflictClientException) { + Map backed = ((GitConflictClientException) cause).getBackedUpContent(); + if (backed.isEmpty()) { + // Rebase conflict during standalone push — no working-tree changes were + // backed up (the user's work is already committed locally). Pull first + // to incorporate remote changes, then retry the push. + statusLabel.setText("Push failed: Remote branch has diverged. Pull first, then retry."); + statusLabel.setVisible(true); + } else { + Frame parent = PlatformUI.MIRTH_FRAME; + new ConflictResolutionDialog(parent, VersionHistoryServiceClient.getInstance(), backed, () -> { + setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR)); + new SwingWorker() { + @Override + protected RepoChanges doInBackground() throws Exception { + return VersionHistoryServiceClient.getInstance().getRepoChanges(); + } + + @Override + protected void done() { + setCursor(Cursor.getDefaultCursor()); + try { + changesTabPanel.populate(get()); + } catch (Exception reloadEx) { + PlatformUI.MIRTH_FRAME.alertError(GitStatusTabPanel.this, "Failed to reload: " + reloadEx.getMessage()); + } + } + }.execute(); + }).setVisible(true); + } + } else { + String msg = cause.getMessage() != null ? cause.getMessage() : "Unknown error"; + statusLabel.setText("Push failed: " + msg); + statusLabel.setVisible(true); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + setActionButtonsEnabled(true); + } catch (Exception ex) { + setActionButtonsEnabled(true); + String msg = ex.getMessage() != null ? ex.getMessage() : "Unknown error"; + statusLabel.setText("Push failed: " + msg); + statusLabel.setVisible(true); + } + } + }.execute(); } private void enterLoadingState() { loadingBar.setVisible(true); statusLabel.setVisible(false); - refreshButton.setEnabled(false); + setActionButtonsEnabled(false); clearValues(); } - private void exitLoadedState(RepoInfo info, RepoChanges changes) { + private void setActionButtonsEnabled(boolean enabled) { + reloadButton.setEnabled(enabled); + pullButton.setEnabled(enabled); + pushButton.setEnabled(enabled); + } + + private void exitLoadedState(RepoInfo info, RepoChanges changes, RemoteStatus remoteStatus) { setCursor(Cursor.getDefaultCursor()); loadingBar.setVisible(false); statusLabel.setVisible(false); - refreshButton.setEnabled(true); + setActionButtonsEnabled(true); localRepoPathValueLabel.setText(info.getLocalRepoPath()); remoteUrlValueLabel.setText(info.getRemoteUrl()); branchValueLabel.setText(info.getBranch()); sizeValueLabel.setText(formatBytes(info.getTotalSizeBytes())); + updateSyncStatusLabel(remoteStatus); + filesTabPanel.populate(info); changesTabPanel.populate(changes); // If the History tab is currently active, reload it now. // The ChangeListener only fires when the selected index *changes*, so if the - // user is already on the History tab when Refresh is clicked, the listener + // user is already on the History tab when Reload is clicked, the listener // would never fire and the tab would remain empty after clear(). if (leftTabbedPane.getSelectedIndex() == TAB_HISTORY) { historyTabPanel.onTabSelected(); } } + private void updateSyncStatusLabel(RemoteStatus status) { + if (status == null) { + syncStatusLabel.setText("—"); + syncStatusLabel.setForeground(new Color(100, 100, 100)); + return; + } + int ahead = status.getAheadCount(); + int behind = status.getBehindCount(); + if (ahead == 0 && behind == 0) { + syncStatusLabel.setText("✓ Up to date"); + syncStatusLabel.setForeground(new Color(0, 128, 0)); + } else if (ahead > 0 && behind == 0) { + syncStatusLabel.setText("↑" + ahead + " to push"); + syncStatusLabel.setForeground(new Color(0, 100, 200)); + } else if (ahead == 0) { + syncStatusLabel.setText("↓" + behind + " to pull"); + syncStatusLabel.setForeground(new Color(180, 80, 0)); + } else { + syncStatusLabel.setText("↑" + ahead + " ↓" + behind); + syncStatusLabel.setForeground(new Color(150, 0, 0)); + } + } + private void exitErrorState(String message) { setCursor(Cursor.getDefaultCursor()); loadingBar.setVisible(false); statusLabel.setText(message); statusLabel.setVisible(true); - refreshButton.setEnabled(true); + setActionButtonsEnabled(true); } private void clearValues() { @@ -224,6 +404,8 @@ private void clearValues() { remoteUrlValueLabel.setText("—"); branchValueLabel.setText("—"); sizeValueLabel.setText("—"); + syncStatusLabel.setText("—"); + syncStatusLabel.setForeground(new Color(100, 100, 100)); filesTabPanel.clear(); changesTabPanel.clear(); historyTabPanel.clear(); @@ -263,22 +445,24 @@ private static JLabel newValueLabel() { // ========== Background Worker ========== - private static final class LoadResult { + private static final class SyncLoadResult { final RepoInfo info; final RepoChanges changes; + final RemoteStatus remoteStatus; - LoadResult(RepoInfo info, RepoChanges changes) { + SyncLoadResult(RepoInfo info, RepoChanges changes, RemoteStatus remoteStatus) { this.info = info; this.changes = changes; + this.remoteStatus = remoteStatus; } } - private final class LoadDataWorker extends SwingWorker { + private final class SyncLoadWorker extends SwingWorker { @Override - protected LoadResult doInBackground() throws Exception { + protected SyncLoadResult doInBackground() throws Exception { VersionHistoryServiceClient c = VersionHistoryServiceClient.getInstance(); - // Fetch repoInfo and repoChanges in parallel + // All three calls in parallel; getRemoteStatus() does git fetch internally CompletableFuture infoFuture = CompletableFuture.supplyAsync(() -> { try { return c.getRepoInfo(); @@ -293,11 +477,17 @@ protected LoadResult doInBackground() throws Exception { throw new RuntimeException(e); } }); + CompletableFuture statusFuture = CompletableFuture.supplyAsync(() -> { + try { + return c.getRemoteStatus(); + } catch (ClientException e) { + throw new RuntimeException(e); + } + }); try { - return new LoadResult(infoFuture.join(), changesFuture.join()); + return new SyncLoadResult(infoFuture.join(), changesFuture.join(), statusFuture.join()); } catch (CompletionException ex) { - // Unwrap: CompletionException → RuntimeException → ClientException Throwable cause = ex.getCause(); if (cause instanceof RuntimeException && cause.getCause() != null) { cause = cause.getCause(); @@ -312,8 +502,8 @@ protected LoadResult doInBackground() throws Exception { @Override protected void done() { try { - LoadResult result = get(); - exitLoadedState(result.info, result.changes); + SyncLoadResult result = get(); + exitLoadedState(result.info, result.changes, result.remoteStatus); } catch (Exception e) { Throwable cause = e.getCause() != null ? e.getCause() : e; String msg = cause.getMessage() != null ? cause.getMessage() : "Unknown error"; diff --git a/custom-extensions/version-history/client/src/main/java/com/innovarhealthcare/channelHistory/client/service/VersionHistoryServiceClient.java b/custom-extensions/version-history/client/src/main/java/com/innovarhealthcare/channelHistory/client/service/VersionHistoryServiceClient.java index 5a7f1f8b1..59d515677 100644 --- a/custom-extensions/version-history/client/src/main/java/com/innovarhealthcare/channelHistory/client/service/VersionHistoryServiceClient.java +++ b/custom-extensions/version-history/client/src/main/java/com/innovarhealthcare/channelHistory/client/service/VersionHistoryServiceClient.java @@ -26,6 +26,7 @@ import com.innovarhealthcare.channelHistory.shared.dto.response.RepoInfo; import com.innovarhealthcare.channelHistory.shared.dto.response.RepoItemChange; import com.innovarhealthcare.channelHistory.shared.dto.response.RepoItemMetadata; +import com.innovarhealthcare.channelHistory.shared.dto.response.RemoteStatus; import com.innovarhealthcare.channelHistory.shared.interfaces.VersionHistoryServletInterface; import com.innovarhealthcare.channelHistory.shared.model.CommitMetaData; import com.innovarhealthcare.channelHistory.shared.model.VersionHistoryErrorCodes; @@ -797,6 +798,63 @@ public void restoreFiles(Map files) throws ClientException { } } + /** + * Fetches from origin and returns the ahead/behind commit counts relative to the remote branch. + * Contacts the remote — only call when an up-to-date status is needed (e.g., on ↺ Reload). + * + * @return RemoteStatus with aheadCount and behindCount + * @throws ClientException if Git is not connected or the operation fails + */ + public RemoteStatus getRemoteStatus() throws ClientException { + try { + String json = getServlet().getRemoteStatus(); + return JsonUtils.fromJson(json, RemoteStatus.class); + } catch (ClientException e) { + throw rethrowParsedClientError(e, true); + } catch (Exception e) { + throw new ClientException("Failed to get remote status: " + e.getMessage(), e); + } + } + + /** + * Pulls from origin with a hard reset, discarding any local uncommitted changes. + * + * @throws ClientException if Git is not connected or the pull fails + */ + public void pull() throws ClientException { + try { + getServlet().pull(); + } catch (ClientException e) { + throw rethrowParsedClientError(e, true); + } catch (Exception e) { + throw new ClientException("Pull failed: " + e.getMessage(), e); + } + } + + /** + * Pushes already-committed local work to the remote (fetch + rebase + push, no new commit). + * Use this when local has unpushed commits and no working-tree changes. + * + * @throws GitConflictClientException if the rebase conflicts with remote changes + * @throws ClientException if Git is not connected, push is rejected, or an error occurs + */ + public void pushOnly() throws ClientException { + try { + getServlet().push(); + } catch (ClientException e) { + ClientException parsed = rethrowParsedClientError(e, true); + if (parsed instanceof VersionHistoryClientException) { + VersionHistoryClientException vhe = (VersionHistoryClientException) parsed; + if (VersionHistoryErrorCodes.GIT_CONFLICT.equals(vhe.getError().getCode())) { + throw new GitConflictClientException(vhe.getError(), e); + } + } + throw parsed; + } catch (Exception e) { + throw new ClientException("Push failed: " + e.getMessage(), e); + } + } + private VersionHistoryServletInterface getServlet() { Client client = PlatformUI.MIRTH_FRAME.mirthClient; return client.getServlet(VersionHistoryServletInterface.class); diff --git a/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/git/GitOperations.java b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/git/GitOperations.java index 27606d7cf..bb732e6a6 100644 --- a/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/git/GitOperations.java +++ b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/git/GitOperations.java @@ -22,13 +22,17 @@ import com.innovarhealthcare.channelHistory.server.exception.GitOperationException; import com.innovarhealthcare.channelHistory.server.exception.GitPushFailedException; import com.innovarhealthcare.channelHistory.shared.dto.response.RepoChanges; +import com.innovarhealthcare.channelHistory.shared.dto.response.RemoteStatus; import com.innovarhealthcare.channelHistory.shared.dto.response.RepoItemChange; import com.innovarhealthcare.channelHistory.shared.model.CommitMetaData; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.eclipse.jgit.api.CheckoutCommand; import org.eclipse.jgit.api.FetchCommand; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.LogCommand; +import org.eclipse.jgit.api.MergeCommand; +import org.eclipse.jgit.api.MergeResult; import org.eclipse.jgit.api.PushCommand; import org.eclipse.jgit.api.RebaseCommand; import org.eclipse.jgit.api.RebaseResult; @@ -320,6 +324,47 @@ public boolean hasRemoteChanges() throws GitAPIException, IOException { return !localRef.getObjectId().equals(remoteRef.getObjectId()); } + /** + * Fetches from origin and returns the number of commits local is ahead of and behind the + * remote tracking branch. Both counts use a RevWalk from the respective tip to the merge-base, + * so no upstream tracking configuration is required. + * + * @return RemoteStatus with aheadCount and behindCount + * @throws GitAPIException if the fetch fails + * @throws IOException if a ref or object cannot be read + */ + public RemoteStatus getAheadBehindCounts() throws GitAPIException, IOException { + fetch(); + Ref localRef = git.getRepository().findRef(branch); + Ref remoteRef = git.getRepository().findRef("refs/remotes/origin/" + branch); + if (localRef == null || remoteRef == null) { + return new RemoteStatus(0, 0); + } + ObjectId localId = localRef.getObjectId(); + ObjectId remoteId = remoteRef.getObjectId(); + if (localId.equals(remoteId)) { + return new RemoteStatus(0, 0); + } + int ahead = 0; + try (RevWalk walk = new RevWalk(git.getRepository())) { + walk.markStart(walk.parseCommit(localId)); + walk.markUninteresting(walk.parseCommit(remoteId)); + while (walk.next() != null){ + ahead++; + } + } + int behind = 0; + try (RevWalk walk = new RevWalk(git.getRepository())) { + walk.markStart(walk.parseCommit(remoteId)); + walk.markUninteresting(walk.parseCommit(localId)); + while (walk.next() != null){ + behind++; + } + } + logger.debug("Remote sync status: ahead={}, behind={}", ahead, behind); + return new RemoteStatus(ahead, behind); + } + /** * Pulls changes from remote repository with hard reset (overwrites local changes) * @@ -365,6 +410,59 @@ public String pullWithOverwrite() throws GitAPIException, IOException { return result.toString(); } + /** + * Pulls changes from remote using a normal merge strategy. + * Fast-forward and clean merges complete silently. + * Conflicts are auto-resolved by taking the remote (theirs) version of each conflicting file, + * then a merge commit is created. This preserves any local unpushed commits. + * + * @throws GitAPIException if git operation fails + * @throws IOException if I/O error occurs + */ + public void pullNormal() throws GitAPIException, IOException { + logger.info("Pulling (normal merge) from origin/{}", branch); + + fetch(); + + Ref remoteRef = git.getRepository().findRef("refs/remotes/origin/" + branch); + if (remoteRef == null) { + throw new IOException("Remote ref not found: refs/remotes/origin/" + branch); + } + + MergeResult mergeResult = git.merge() + .include(remoteRef) + .setFastForward(MergeCommand.FastForwardMode.FF) + .call(); + + MergeResult.MergeStatus status = mergeResult.getMergeStatus(); + logger.info("Merge status: {}", status); + + if (status == MergeResult.MergeStatus.CONFLICTING) { + Map conflicts = mergeResult.getConflicts(); + logger.warn("Merge conflicts in {} file(s); auto-resolving using remote version", conflicts.size()); + + CheckoutCommand checkout = git.checkout(); + for (String conflictPath : conflicts.keySet()) { + checkout.setStage(CheckoutCommand.Stage.THEIRS).addPath(conflictPath); + } + checkout.call(); + + for (String conflictPath : conflicts.keySet()) { + git.add().addFilepattern(conflictPath).call(); + } + + git.commit() + .setMessage("Merge remote-tracking branch 'origin/" + branch + "' (conflicts resolved using remote)") + .call(); + + logger.info("Pull completed: conflicts in {} file(s) resolved using remote version", conflicts.size()); + } else if (status.isSuccessful()) { + logger.info("Pull completed successfully: {}", status); + } else { + throw new IOException("Merge failed with status: " + status); + } + } + /** * Stages files for commit * @@ -454,9 +552,10 @@ public String push(boolean forcePush) throws GitAPIException, GitPushFailedExcep for (RemoteRefUpdate update : result.getRemoteUpdates()) { resultMessage.append(" Ref: ").append(update.getRemoteName()).append(", Status: ").append(update.getStatus()).append("\n"); - if (update.getStatus() == RemoteRefUpdate.Status.OK) { + if (update.getStatus() == RemoteRefUpdate.Status.OK + || update.getStatus() == RemoteRefUpdate.Status.UP_TO_DATE) { pushSuccessful = true; - logger.info("Push successful for ref: {}", update.getRemoteName()); + logger.info("Push successful for ref: {} ({})", update.getRemoteName(), update.getStatus()); } else { logger.warn("Push status for {}: {}", update.getRemoteName(), update.getStatus()); if (update.getMessage() != null) { @@ -699,6 +798,21 @@ public void commitAndPushFiles(List filePaths, String message, PersonIde } } + /** + * Fetches from origin, rebases the local branch onto the remote tracking ref, then pushes. + * No staging or commit is performed — intended for pushing already-committed local work. + * + * @throws GitConflictException if the rebase is stopped by a conflict with remote changes + * @throws GitPushFailedException if the push is rejected by the remote + * @throws GitAPIException if any git API operation fails + * @throws IOException if an I/O error occurs during ref lookup + */ + public void fetchRebaseAndPush() throws GitAPIException, IOException, GitConflictException, GitPushFailedException { + fetch(); + rebaseOntoRemote(); + push(false); + } + /** * Fetches the configured branch from origin into refs/remotes/origin/. */ diff --git a/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/service/GitRepositoryService.java b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/service/GitRepositoryService.java index 6ba579d1a..727338b31 100644 --- a/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/service/GitRepositoryService.java +++ b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/service/GitRepositoryService.java @@ -37,6 +37,7 @@ import com.innovarhealthcare.channelHistory.shared.dto.response.RepoFolder; import com.innovarhealthcare.channelHistory.shared.dto.response.RepoInfo; import com.innovarhealthcare.channelHistory.shared.dto.response.RepoItemChange; +import com.innovarhealthcare.channelHistory.shared.dto.response.RemoteStatus; import com.innovarhealthcare.channelHistory.shared.model.CommitMetaData; import com.innovarhealthcare.channelHistory.shared.model.GitSettings; import com.innovarhealthcare.channelHistory.shared.model.VersionHistoryProperties; @@ -561,6 +562,62 @@ public synchronized void restoreFiles(java.util.Map files) throw gitOperations.writeWorkingTreeFiles(files); } + // ========== Remote Sync Operations ========== + + /** + * Fetches from origin and returns the ahead/behind commit counts relative to the remote + * tracking branch. Contacts the remote — use only when an accurate, up-to-date status is needed. + * + * @return RemoteStatus with aheadCount and behindCount + * @throws GitOperationException if the fetch or count operation fails + */ + public synchronized RemoteStatus getRemoteStatus() throws GitOperationException, GitNotConnectedException { + ensureStarted(); + ensureGitAvailable(); + try { + return gitOperations.getAheadBehindCounts(); + } catch (Exception e) { + throw new GitOperationException("Failed to get remote status: " + e.getMessage(), e); + } + } + + /** + * Pulls from remote using a normal merge. Fast-forward and clean merges complete silently. + * Conflicts are auto-resolved by taking the remote version; a merge commit is created. + * Unpushed local commits are preserved in the merge. + * + * @throws GitOperationException if the pull operation fails + */ + public synchronized void pullNormal() throws GitOperationException, GitNotConnectedException { + ensureStarted(); + ensureGitAvailable(); + try { + gitOperations.pullNormal(); + } catch (Exception e) { + throw new GitOperationException("Pull failed: " + e.getMessage(), e); + } + } + + /** + * Fetches from origin, rebases local commits onto the remote tracking branch, then pushes. + * No staging or commit is performed — use to push already-committed local work. + * + * @throws GitConflictException if the rebase finds a conflict with remote changes + * @throws GitPushFailedException if the push is rejected by the remote + * @throws GitOperationException if any other git operation fails + */ + public synchronized void pushOnly() throws GitConflictException, GitPushFailedException, GitOperationException, GitNotConnectedException { + ensureStarted(); + ensureGitAvailable(); + try { + gitOperations.fetchRebaseAndPush(); + } catch (GitConflictException | GitPushFailedException e) { + throw e; + } catch (Exception e) { + throw new GitOperationException("Push failed: " + e.getMessage(), e); + } + } + // ========== Connection Validation ========== /** @@ -713,7 +770,9 @@ private void initializeGitRepository() throws Exception { } /** - * Opens existing repository, syncs remote URL/branch from current config, then pulls. + * Opens existing repository. If the configured remote URL or branch differs from what is + * stored in .git/config, the local repository is deleted and re-cloned to avoid unrelated + * history or wrong-branch errors. Otherwise, syncs the remote config and pulls latest changes. */ private void openAndUpdate() throws Exception { logger.info("Opening existing repository..."); @@ -721,27 +780,46 @@ private void openAndUpdate() throws Exception { git = Git.open(repositoryDirectory); logger.info("Repository opened at: {}", repositoryDirectory.getAbsolutePath()); - // Sync remote URL and branch from current plugin settings into .git/config - syncRemoteConfig(); + StoredConfig config = git.getRepository().getConfig(); + String configuredUrl = versionHistoryProperties.getGitSettings().getRemoteRepositoryUrl(); + String configuredBranch = versionHistoryProperties.getGitSettings().getBranchName(); + String existingUrl = config.getString("remote", "origin", "url"); + String existingBranch = git.getRepository().getBranch(); - // Try to pull latest changes (non-fatal if fails) + boolean urlChanged = existingUrl != null && !configuredUrl.equals(existingUrl); + boolean branchChanged = existingBranch != null && !configuredBranch.equals(existingBranch); + + if (urlChanged || branchChanged) { + if (urlChanged) { + logger.warn("Remote URL changed from '{}' to '{}' — deleting local repository and re-cloning", existingUrl, configuredUrl); + } + if (branchChanged) { + logger.warn("Branch changed from '{}' to '{}' — deleting local repository and re-cloning", existingBranch, configuredBranch); + } + git.close(); + git = null; + FileUtils.deleteDirectory(repositoryDirectory); + cloneFromRemote(); + return; + } + + // Same repo and branch — sync config and pull + syncRemoteConfig(); pullLatestChanges(); } /** - * Writes the current remoteRepositoryUrl and branchName from plugin settings - * into the local .git/config so that subsequent push/pull use the new values. + * Ensures the fetch refspec in .git/config is set correctly for the current remote. + * Called only when the remote URL has not changed (URL-change is handled by re-clone in openAndUpdate). */ private void syncRemoteConfig() throws IOException { - String newUrl = versionHistoryProperties.getGitSettings().getRemoteRepositoryUrl(); - StoredConfig config = git.getRepository().getConfig(); - String currentUrl = config.getString("remote", "origin", "url"); + String currentFetch = config.getString("remote", "origin", "fetch"); + String expectedFetch = "+refs/heads/*:refs/remotes/origin/*"; - if (!newUrl.equals(currentUrl)) { - logger.info("Remote URL changed from '{}' to '{}', updating .git/config", currentUrl, newUrl); - config.setString("remote", "origin", "url", newUrl); - config.setString("remote", "origin", "fetch", "+refs/heads/*:refs/remotes/origin/*"); + if (!expectedFetch.equals(currentFetch)) { + logger.info("Updating fetch refspec in .git/config"); + config.setString("remote", "origin", "fetch", expectedFetch); config.save(); } } diff --git a/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/service/VersionHistoryService.java b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/service/VersionHistoryService.java index f1dbe80ec..e3d35a986 100644 --- a/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/service/VersionHistoryService.java +++ b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/service/VersionHistoryService.java @@ -31,6 +31,7 @@ import com.innovarhealthcare.channelHistory.shared.dto.response.RepoInfo; import com.innovarhealthcare.channelHistory.shared.dto.response.RepoItemChange; import com.innovarhealthcare.channelHistory.shared.dto.response.RepoItemMetadata; +import com.innovarhealthcare.channelHistory.shared.dto.response.RemoteStatus; import com.innovarhealthcare.channelHistory.shared.model.CommitMetaData; import com.innovarhealthcare.channelHistory.shared.model.VersionHistoryProperties; import com.innovarhealthcare.channelHistory.shared.util.CommitMessageUtil; @@ -1107,4 +1108,35 @@ private void validateRevision(String revision) { throw new IllegalArgumentException("Revision cannot be null or empty"); } } + + /** + * Fetches from origin and returns ahead/behind commit counts relative to the remote branch. + */ + public RemoteStatus getRemoteStatus() throws GitNotConnectedException, GitOperationException { + if (!gitRepositoryService.isGitAvailable()) { + throw new GitNotConnectedException("Git repository is not available: " + gitRepositoryService.getGitUnavailableReason()); + } + return gitRepositoryService.getRemoteStatus(); + } + + /** + * Pulls from origin using a normal merge. Conflicts are auto-resolved by taking the remote + * version; local unpushed commits are preserved in the resulting merge commit. + */ + public void pullNormal() throws GitNotConnectedException, GitOperationException { + if (!gitRepositoryService.isGitAvailable()) { + throw new GitNotConnectedException("Git repository is not available: " + gitRepositoryService.getGitUnavailableReason()); + } + gitRepositoryService.pullNormal(); + } + + /** + * Fetches, rebases, and pushes any already-committed local work to the remote. + */ + public void pushOnly() throws GitNotConnectedException, GitConflictException, GitPushFailedException, GitOperationException { + if (!gitRepositoryService.isGitAvailable()) { + throw new GitNotConnectedException("Git repository is not available: " + gitRepositoryService.getGitUnavailableReason()); + } + gitRepositoryService.pushOnly(); + } } diff --git a/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/servlet/VersionHistoryPluginServlet.java b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/servlet/VersionHistoryPluginServlet.java index 97712afd4..ba70c9aa8 100644 --- a/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/servlet/VersionHistoryPluginServlet.java +++ b/custom-extensions/version-history/server/src/main/java/com/innovarhealthcare/channelHistory/server/servlet/VersionHistoryPluginServlet.java @@ -33,6 +33,7 @@ import com.innovarhealthcare.channelHistory.shared.dto.response.RepoChanges; import com.innovarhealthcare.channelHistory.shared.dto.response.RepoInfo; import com.innovarhealthcare.channelHistory.shared.dto.response.RepoItemMetadata; +import com.innovarhealthcare.channelHistory.shared.dto.response.RemoteStatus; import com.innovarhealthcare.channelHistory.shared.interfaces.VersionHistoryServletInterface; import com.innovarhealthcare.channelHistory.shared.model.CommitMetaData; import com.innovarhealthcare.channelHistory.shared.model.VersionHistoryErrorCodes; @@ -734,6 +735,80 @@ public String restoreFiles(String requestJson) { throw new VersionHistoryApiException(Response.Status.INTERNAL_SERVER_ERROR, VersionHistoryErrorCodes.UNKNOWN_ERROR, "Failed to restore files: " + (e.getMessage() != null ? e.getMessage() : "Unknown error")); } } + @Override + public String getRemoteStatus() { + logger.info("getRemoteStatus: fetching and computing ahead/behind counts"); + try { + RemoteStatus status = getService().getRemoteStatus(); + return JsonUtils.toJson(status); + } catch (GitNotConnectedException e) { + logger.error("Git not connected: getRemoteStatus"); + throw new VersionHistoryApiException(Response.Status.SERVICE_UNAVAILABLE, VersionHistoryErrorCodes.GIT_NOT_CONNECTED, "Git repository is not connected. Please configure git connection first."); + } catch (GitOperationException e) { + logger.error("Git operation failed: getRemoteStatus: {}", e.getMessage(), e); + throw new VersionHistoryApiException(Response.Status.INTERNAL_SERVER_ERROR, VersionHistoryErrorCodes.GIT_OPERATION_ERROR, "Failed to get remote status: " + e.getMessage()); + } catch (VersionHistoryApiException e) { + throw e; + } catch (Exception e) { + logger.error("Unexpected error getting remote status", e); + throw new VersionHistoryApiException(Response.Status.INTERNAL_SERVER_ERROR, VersionHistoryErrorCodes.UNKNOWN_ERROR, "Failed to get remote status: " + (e.getMessage() != null ? e.getMessage() : "Unknown error")); + } + } + @Override + public void pull() { + logger.info("pull: pulling from remote (normal merge, conflicts resolved using remote)"); + try { + getService().pullNormal(); + } catch (GitNotConnectedException e) { + logger.error("Git not connected: pull"); + throw new VersionHistoryApiException(Response.Status.SERVICE_UNAVAILABLE, VersionHistoryErrorCodes.GIT_NOT_CONNECTED, "Git repository is not connected. Please configure git connection first."); + } catch (GitOperationException e) { + logger.error("Pull failed: {}", e.getMessage(), e); + throw new VersionHistoryApiException(Response.Status.INTERNAL_SERVER_ERROR, VersionHistoryErrorCodes.GIT_OPERATION_ERROR, "Pull failed: " + e.getMessage()); + } catch (VersionHistoryApiException e) { + throw e; + } catch (Exception e) { + logger.error("Unexpected error during pull", e); + throw new VersionHistoryApiException(Response.Status.INTERNAL_SERVER_ERROR, VersionHistoryErrorCodes.UNKNOWN_ERROR, "Pull failed: " + (e.getMessage() != null ? e.getMessage() : "Unknown error")); + } + } + @Override + public String push() { + logger.info("push: pushing local commits to remote"); + try { + getService().pushOnly(); + return JsonUtils.toJson("OK"); + } catch (GitNotConnectedException e) { + logger.error("Git not connected: push"); + throw new VersionHistoryApiException(Response.Status.SERVICE_UNAVAILABLE, VersionHistoryErrorCodes.GIT_NOT_CONNECTED, "Git repository is not connected. Please configure git connection first."); + } catch (GitConflictException e) { + logger.error("Rebase conflict during push: {}", e.getMessage()); + ErrorResponse err = ErrorResponseFactory.build(VersionHistoryErrorCodes.GIT_CONFLICT, "Remote has conflicting changes. Your changes have been backed up."); + if (!e.getBackedUpContent().isEmpty()) { + err.setBackedUpContent(e.getBackedUpContent()); + } + String conflictJson; + try { + conflictJson = JsonUtils.toJson(err); + } catch (Exception serEx) { + logger.error("Failed to serialize GIT_CONFLICT response: {}", serEx.getMessage(), serEx); + throw new VersionHistoryApiException(Response.Status.CONFLICT, VersionHistoryErrorCodes.GIT_CONFLICT, "Remote has conflicting changes."); + } + Response conflictResponse = Response.status(Response.Status.CONFLICT).type(MediaType.APPLICATION_JSON).entity(conflictJson).build(); + throw new VersionHistoryApiException(conflictResponse); + } catch (GitPushFailedException e) { + logger.error("Push rejected: {}", e.getMessage()); + throw new VersionHistoryApiException(Response.Status.CONFLICT, VersionHistoryErrorCodes.PUSH_REJECTED, "Push rejected: " + e.getMessage()); + } catch (GitOperationException e) { + logger.error("Git operation failed: push: {}", e.getMessage(), e); + throw new VersionHistoryApiException(Response.Status.INTERNAL_SERVER_ERROR, VersionHistoryErrorCodes.GIT_OPERATION_ERROR, "Push failed: " + e.getMessage()); + } catch (VersionHistoryApiException e) { + throw e; + } catch (Exception e) { + logger.error("Unexpected error during push", e); + throw new VersionHistoryApiException(Response.Status.INTERNAL_SERVER_ERROR, VersionHistoryErrorCodes.UNKNOWN_ERROR, "Push failed: " + (e.getMessage() != null ? e.getMessage() : "Unknown error")); + } + } private VersionHistoryService getService() { return GitRepositoryController.getInstance().getVersionHistoryService(); } diff --git a/custom-extensions/version-history/shared/src/main/java/com/innovarhealthcare/channelHistory/shared/dto/response/RemoteStatus.java b/custom-extensions/version-history/shared/src/main/java/com/innovarhealthcare/channelHistory/shared/dto/response/RemoteStatus.java new file mode 100644 index 000000000..4d08cfe7a --- /dev/null +++ b/custom-extensions/version-history/shared/src/main/java/com/innovarhealthcare/channelHistory/shared/dto/response/RemoteStatus.java @@ -0,0 +1,55 @@ +/* + * + * Copyright (c) Innovar Healthcare. All rights reserved. + * + * https://www.innovarhealthcare.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.innovarhealthcare.channelHistory.shared.dto.response; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Sync status between local branch and remote tracking branch. + * Computed after a real {@code git fetch}, so values are accurate at query time. + */ +public class RemoteStatus { + + private int aheadCount; + private int behindCount; + + public RemoteStatus() { + } + + @JsonCreator + public RemoteStatus(@JsonProperty("aheadCount") int aheadCount, + @JsonProperty("behindCount") int behindCount) { + this.aheadCount = aheadCount; + this.behindCount = behindCount; + } + + public int getAheadCount() { + return aheadCount; + } + + public void setAheadCount(int aheadCount) { + this.aheadCount = aheadCount; + } + + public int getBehindCount() { + return behindCount; + } + + public void setBehindCount(int behindCount) { + this.behindCount = behindCount; + } + + @Override + public String toString() { + return "RemoteStatus{aheadCount=" + aheadCount + ", behindCount=" + behindCount + '}'; + } +} diff --git a/custom-extensions/version-history/shared/src/main/java/com/innovarhealthcare/channelHistory/shared/interfaces/VersionHistoryServletInterface.java b/custom-extensions/version-history/shared/src/main/java/com/innovarhealthcare/channelHistory/shared/interfaces/VersionHistoryServletInterface.java index 31bfc0dbe..34d92745a 100644 --- a/custom-extensions/version-history/shared/src/main/java/com/innovarhealthcare/channelHistory/shared/interfaces/VersionHistoryServletInterface.java +++ b/custom-extensions/version-history/shared/src/main/java/com/innovarhealthcare/channelHistory/shared/interfaces/VersionHistoryServletInterface.java @@ -67,6 +67,7 @@ public String getHistory( @Parameter(description = "The type of item: 'channel' or 'codetemplate'", required = true) @QueryParam("mode") String mode ) throws ClientException; + @GET @Path("/content") @ApiResponse( @@ -95,36 +96,168 @@ public String getContentAtRevision( @Parameter(description = "The entity type: 'channel', 'library', or 'codetemplate'", required = true) @QueryParam("mode") String mode ) throws ClientException; + @POST @Path("/validateSetting") - @ApiResponse(responseCode = "200", description = "validate git repo setting", content = {@Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = String.class)), @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = String.class))}) - @MirthOperation(name = "validateSetting", display = "validate git repo setting", permission = Permissions.CHANNELS_VIEW, type = Operation.ExecuteType.SYNC, auditable = false) - public String validateSetting(@Param("properties") @RequestBody(description = "description", content = {@Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = Properties.class), examples = {@ExampleObject(name = "propertiesObject", ref = "../apiexamples/properties_xml")}), @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Properties.class), examples = {@ExampleObject(name = "propertiesObject", ref = "../apiexamples/properties_json")})}) Properties properties) throws ClientException; + @ApiResponse( + responseCode = "200", + description = "Validate git repository settings", + content = { + @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = String.class)), + @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = String.class)) + } + ) + @MirthOperation( + name = "validateSetting", + display = "Validate git repository settings", + permission = Permissions.CHANNELS_VIEW, + type = Operation.ExecuteType.SYNC, + auditable = false + ) + public String validateSetting( + @Param("properties") + @RequestBody( + description = "Git repository connection properties", + content = { + @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = Properties.class), examples = {@ExampleObject(name = "propertiesObject", ref = "../apiexamples/properties_xml")}), + @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Properties.class), examples = {@ExampleObject(name = "propertiesObject", ref = "../apiexamples/properties_json")}) + } + ) + Properties properties + ) throws ClientException; + @POST @Path("/commitAndPushChannel") - @ApiResponse(responseCode = "200", description = "commit and push channel", content = {@Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = String.class)), @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = String.class))}) - @MirthOperation(name = "commitAndPushChannel", display = "commit and push channel", permission = Permissions.CHANNELS_VIEW, type = Operation.ExecuteType.SYNC, auditable = false) - public String commitAndPushChannel(@Param("channel") @RequestBody(description = "The Channel object to create.", required = true, content = {@Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = Channel.class), examples = {@ExampleObject(name = "channel", ref = "../apiexamples/channel_xml")}), @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Channel.class), examples = {@ExampleObject(name = "channel", ref = "../apiexamples/channel_json")})}) Channel channel, @Param("message") @Parameter(description = "message", required = true) @QueryParam("message") String message, @Param("userId") @Parameter(description = "user id", required = true) @QueryParam("userId") String userId, @Param("overwrite") @Parameter(description = "true = auto-commit (pullWithOverwrite), false = manual commit (rebase+conflict detection)") @QueryParam("overwrite") @DefaultValue("true") boolean overwrite) throws ClientException; + @ApiResponse( + responseCode = "200", + description = "Commit and push channel to repository", + content = { + @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = String.class)), + @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = String.class)) + } + ) + @MirthOperation( + name = "commitAndPushChannel", + display = "Commit and push channel", + permission = Permissions.CHANNELS_VIEW, + type = Operation.ExecuteType.SYNC, + auditable = false + ) + public String commitAndPushChannel( + @Param("channel") + @RequestBody( + description = "The Channel object to commit.", + required = true, + content = { + @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = Channel.class), examples = {@ExampleObject(name = "channel", ref = "../apiexamples/channel_xml")}), + @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Channel.class), examples = {@ExampleObject(name = "channel", ref = "../apiexamples/channel_json")}) + } + ) + Channel channel, + @Param("message") + @Parameter(description = "Commit message", required = true) + @QueryParam("message") String message, + @Param("userId") + @Parameter(description = "User ID", required = true) + @QueryParam("userId") String userId, + @Param("overwrite") + @Parameter(description = "true = auto-commit (pullWithOverwrite), false = manual commit (rebase+conflict detection)") + @QueryParam("overwrite") @DefaultValue("true") boolean overwrite + ) throws ClientException; + @POST @Path("/writeChannel") @ApiResponse(responseCode = "204", description = "Write channel to working tree without committing") - @MirthOperation(name = "writeChannel", display = "write channel to working tree", permission = Permissions.CHANNELS_VIEW, type = Operation.ExecuteType.SYNC, auditable = false) - public void writeChannel(@Param("channel") @RequestBody(description = "The Channel object to write.", required = true, content = {@Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = Channel.class), examples = {@ExampleObject(name = "channel", ref = "../apiexamples/channel_xml")}), @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Channel.class), examples = {@ExampleObject(name = "channel", ref = "../apiexamples/channel_json")})}) Channel channel) throws ClientException; + @MirthOperation( + name = "writeChannel", + display = "Write channel to working tree", + permission = Permissions.CHANNELS_VIEW, + type = Operation.ExecuteType.SYNC, + auditable = false + ) + public void writeChannel( + @Param("channel") + @RequestBody( + description = "The Channel object to write.", + required = true, + content = { + @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = Channel.class), examples = {@ExampleObject(name = "channel", ref = "../apiexamples/channel_xml")}), + @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Channel.class), examples = {@ExampleObject(name = "channel", ref = "../apiexamples/channel_json")}) + } + ) + Channel channel + ) throws ClientException; + @GET @Path("/channel_on_repo") - @ApiResponse(responseCode = "200", description = "Load channels on repo", content = {@Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = String.class)), @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = String.class))}) - @MirthOperation(name = "loadChannelsMetadata", display = "load the channels on repo", permission = Permissions.CHANNELS_VIEW, type = Operation.ExecuteType.SYNC, auditable = false) + @ApiResponse( + responseCode = "200", + description = "Load channels on repo", + content = { + @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = String.class)), + @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = String.class)) + } + ) + @MirthOperation( + name = "loadChannelsMetadata", + display = "Load channels on repo", + permission = Permissions.CHANNELS_VIEW, + type = Operation.ExecuteType.SYNC, + auditable = false + ) public String loadChannelsMetadata() throws ClientException; + @GET @Path("/code_template_on_repo") - @ApiResponse(responseCode = "200", description = "Load code templates on repo", content = {@Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = String.class)), @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = String.class))}) - @MirthOperation(name = "loadCodeTemplatesMetadata", display = "load the code templates on repo", permission = Permissions.CHANNELS_VIEW, type = Operation.ExecuteType.SYNC, auditable = false) + @ApiResponse( + responseCode = "200", + description = "Load code templates on repo", + content = { + @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = String.class)), + @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = String.class)) + } + ) + @MirthOperation( + name = "loadCodeTemplatesMetadata", + display = "Load code templates on repo", + permission = Permissions.CHANNELS_VIEW, + type = Operation.ExecuteType.SYNC, + auditable = false + ) public String loadCodeTemplatesMetadata() throws ClientException; + @POST @Path("/commitAndPushCodeTemplate") - @ApiResponse(responseCode = "200", description = "commit and push channel", content = {@Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = String.class)), @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = String.class))}) - @MirthOperation(name = "commitAndPushCodeTemplate", display = "commit and push code template", permission = Permissions.CHANNELS_VIEW, type = Operation.ExecuteType.SYNC, auditable = false) - public String commitAndPushCodeTemplate(@Param("codeTemplateId") @Parameter(description = "code template id", required = true) @QueryParam("codeTemplateId") String codeTemplateId, @Param("message") @Parameter(description = "message", required = true) @QueryParam("message") String message, @Param("userId") @Parameter(description = "user id", required = true) @QueryParam("userId") String userId, @Param("overwrite") @Parameter(description = "true = auto-commit (pullWithOverwrite), false = manual commit (rebase+conflict detection)") @QueryParam("overwrite") @DefaultValue("true") boolean overwrite) throws ClientException; + @ApiResponse( + responseCode = "200", + description = "Commit and push code template to repository", + content = { + @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = String.class)), + @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = String.class)) + } + ) + @MirthOperation( + name = "commitAndPushCodeTemplate", + display = "Commit and push code template", + permission = Permissions.CHANNELS_VIEW, + type = Operation.ExecuteType.SYNC, + auditable = false + ) + public String commitAndPushCodeTemplate( + @Param("codeTemplateId") + @Parameter(description = "Code template ID", required = true) + @QueryParam("codeTemplateId") String codeTemplateId, + @Param("message") + @Parameter(description = "Commit message", required = true) + @QueryParam("message") String message, + @Param("userId") + @Parameter(description = "User ID", required = true) + @QueryParam("userId") String userId, + @Param("overwrite") + @Parameter(description = "true = auto-commit (pullWithOverwrite), false = manual commit (rebase+conflict detection)") + @QueryParam("overwrite") @DefaultValue("true") boolean overwrite + ) throws ClientException; + @GET @Path("/libraries_and_templates") @ApiResponse( @@ -142,6 +275,7 @@ public String getContentAtRevision( auditable = false ) public String loadLibrariesAndTemplateMetadata() throws ClientException; + @POST @Path("/saveLibraries") @Consumes(MediaType.MULTIPART_FORM_DATA) @@ -175,6 +309,7 @@ public String saveLibraries( required = true) @QueryParam("userId") String userId ) throws ClientException; + @POST @Path("/commitAndPushGlobalScripts") @ApiResponse(responseCode = "200", description = "commit and push global scripts", content = { @@ -249,6 +384,7 @@ public String commitAndPushGlobalScripts( @QueryParam("userId") String userId ) throws ClientException; + @GET @Path("/repoInfo") @ApiResponse( @@ -267,6 +403,7 @@ public String commitAndPushGlobalScripts( auditable = false ) public String getRepoInfo() throws ClientException; + @GET @Path("/repoChanges") @ApiResponse( @@ -285,6 +422,7 @@ public String commitAndPushGlobalScripts( auditable = false ) public String getRepoChanges() throws ClientException; + @GET @Path("/fileContent") @ApiResponse( @@ -307,6 +445,7 @@ public String getFileContent( @Parameter(description = "Relative file path from repository root (e.g., 'Channels/abc-123.xml')", required = true) @QueryParam("filePath") String filePath ) throws ClientException; + @GET @Path("/fileContentAtHead") @ApiResponse( @@ -329,6 +468,7 @@ public String getFileContentAtHead( @Parameter(description = "Relative file path from repository root (e.g., 'Channels/abc-123.xml')", required = true) @QueryParam("filePath") String filePath ) throws ClientException; + @GET @Path("/fileHistory") @ApiResponse( @@ -346,11 +486,12 @@ public String getFileContentAtHead( type = Operation.ExecuteType.SYNC, auditable = false ) - String getFileHistory( + public String getFileHistory( @Param("filePath") @Parameter(description = "Relative file path from repository root (e.g., 'Channels/abc-123.xml')", required = true) @QueryParam("filePath") String filePath ) throws ClientException; + @GET @Path("/fileContentAtRevision") @ApiResponse( @@ -368,7 +509,7 @@ String getFileHistory( type = Operation.ExecuteType.SYNC, auditable = false ) - String getFileContentAtRevision( + public String getFileContentAtRevision( @Param("filePath") @Parameter(description = "Relative file path from repository root (e.g., 'Channels/abc-123.xml')", required = true) @QueryParam("filePath") String filePath, @@ -376,6 +517,7 @@ String getFileContentAtRevision( @Parameter(description = "The commit hash to read the file at", required = true) @QueryParam("commitHash") String commitHash ) throws ClientException; + @GET @Path("/repoLog") @ApiResponse( @@ -393,11 +535,12 @@ String getFileContentAtRevision( type = Operation.ExecuteType.SYNC, auditable = false ) - String getRepoLog( + public String getRepoLog( @Param("maxCount") @Parameter(description = "Maximum number of commits to return", required = false) @QueryParam("maxCount") @DefaultValue("" + VersionControlConstants.REPO_LOG_MAX_COUNT) int maxCount ) throws ClientException; + @GET @Path("/commitChanges") @ApiResponse( @@ -415,11 +558,12 @@ String getRepoLog( type = Operation.ExecuteType.SYNC, auditable = false ) - String getCommitChanges( + public String getCommitChanges( @Param("commitHash") @Parameter(description = "The commit hash to inspect", required = true) @QueryParam("commitHash") String commitHash ) throws ClientException; + @POST @Path("/commitAndPushFiles") @ApiResponse( @@ -437,7 +581,7 @@ String getCommitChanges( type = Operation.ExecuteType.SYNC, auditable = false ) - String commitAndPushFiles( + public String commitAndPushFiles( @Param("requestJson") @RequestBody( description = "JSON-serialized CommitFilesRequest containing file paths, message, and user ID", @@ -449,14 +593,81 @@ String commitAndPushFiles( ) String requestJson ) throws ClientException; + @POST @Path("/restoreFiles") - @ApiResponse(responseCode = "200", description = "Restore backed-up file content to working tree (no commit)", content = {@Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = String.class)), @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = String.class))}) - @MirthOperation(name = "restoreFiles", display = "Restore files to working tree", permission = Permissions.CHANNELS_VIEW, type = Operation.ExecuteType.SYNC, auditable = false) - String restoreFiles( + @ApiResponse( + responseCode = "200", + description = "Restore backed-up file content to working tree (no commit)", + content = { + @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = String.class)), + @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = String.class)) + } + ) + @MirthOperation( + name = "restoreFiles", + display = "Restore files to working tree", + permission = Permissions.CHANNELS_VIEW, + type = Operation.ExecuteType.SYNC, + auditable = false + ) + public String restoreFiles( @Param("requestJson") @RequestBody(description = "JSON-serialized Map of relative file paths to their content", required = true, content = {@Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = String.class))}) String requestJson ) throws ClientException; + + @GET + @Path("/remoteStatus") + @ApiResponse( + responseCode = "200", + description = "Fetch from origin and return ahead/behind commit counts", + content = { + @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = String.class)), + @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = String.class)) + } + ) + @MirthOperation( + name = "getRemoteStatus", + display = "Get remote sync status", + permission = Permissions.CHANNELS_VIEW, + type = Operation.ExecuteType.SYNC, + auditable = false + ) + public String getRemoteStatus() throws ClientException; + + @POST + @Path("/pull") + @ApiResponse( + responseCode = "200", + description = "Pull from remote and merge; conflicts resolved using remote version" + ) + @MirthOperation( + name = "pull", + display = "Pull from remote", + permission = Permissions.CHANNELS_VIEW, + type = Operation.ExecuteType.SYNC, + auditable = false + ) + public void pull() throws ClientException; + + @POST + @Path("/push") + @ApiResponse( + responseCode = "200", + description = "Push already-committed local work to remote (fetch + rebase + push)", + content = { + @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = String.class)), + @Content(mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = String.class)) + } + ) + @MirthOperation( + name = "push", + display = "Push local commits to remote", + permission = Permissions.CHANNELS_VIEW, + type = Operation.ExecuteType.SYNC, + auditable = false + ) + public String push() throws ClientException; } //@formatter:on From aa8a1a2d310701c2a8d98c0e3d9932d7f3e139fb Mon Sep 17 00:00:00 2001 From: Thai Tran Date: Tue, 28 Apr 2026 09:30:49 -0500 Subject: [PATCH 03/25] Adds an optional useStorageStats boolean parameter (default false) to the getStatistics/getStatisticsPost pipeline. When true, deployed channel statistics are read from persistent storage instead of in-memory counters. --- .../com/mirth/connect/client/core/Client.java | 24 +++++++++---------- .../ChannelStatisticsServletInterface.java | 6 +++-- .../servlets/ChannelStatisticsServlet.java | 8 +++---- .../controllers/DonkeyEngineController.java | 16 +++++++++---- .../server/controllers/EngineController.java | 2 ++ .../ChannelStatisticsServletTest.java | 23 ++++++++---------- 6 files changed, 44 insertions(+), 35 deletions(-) diff --git a/server/src/com/mirth/connect/client/core/Client.java b/server/src/com/mirth/connect/client/core/Client.java index c101af49d..a13ed18dc 100644 --- a/server/src/com/mirth/connect/client/core/Client.java +++ b/server/src/com/mirth/connect/client/core/Client.java @@ -1577,42 +1577,42 @@ public void stopConnectors(Map> connectorInfo, boolean ret */ public List getStatistics() throws ClientException { - return getServlet(ChannelStatisticsServletInterface.class).getStatistics(null, false, null, null, false); + return getServlet(ChannelStatisticsServletInterface.class).getStatistics(null, false, null, null, false, false); } /** * Returns the individual statistics for channels. Has option to include undeployed channels. - * + * * @see ChannelStatisticsServletInterface#getStatistics */ public List getStatistics(boolean includeUndeployed) throws ClientException { - return getServlet(ChannelStatisticsServletInterface.class).getStatistics(null, includeUndeployed, null, null, false); + return getServlet(ChannelStatisticsServletInterface.class).getStatistics(null, includeUndeployed, null, null, false, false); } /** * Returns the individual statistics for channels. Has option to include undeployed channels and * to aggregate stats. - * + * * @see ChannelStatisticsServletInterface#getStatistics */ public List getStatistics(boolean includeUndeployed, boolean aggregateStats) throws ClientException { - return getServlet(ChannelStatisticsServletInterface.class).getStatistics(null, includeUndeployed, null, null, aggregateStats); + return getServlet(ChannelStatisticsServletInterface.class).getStatistics(null, includeUndeployed, null, null, aggregateStats, false); } /** * Returns the individual statistics for channels supplied. Has option to include undeployed * channels, aggregate stats, and also include OR exclude connectors. - * + * * @see ChannelStatisticsServletInterface#getStatistics */ @Override - public List getStatistics(Set channelIds, boolean includeUndeployed, Set includeMetadataIds, Set excludeMetadataIds, boolean aggregateStats) throws ClientException { + public List getStatistics(Set channelIds, boolean includeUndeployed, Set includeMetadataIds, Set excludeMetadataIds, boolean aggregateStats, boolean useStorageStats) throws ClientException { if (CollectionUtils.size(channelIds) > MAX_QUERY_PARAM_COLLECTION_SIZE) { - return getStatisticsPost(channelIds, includeUndeployed, includeMetadataIds, excludeMetadataIds, aggregateStats); + return getStatisticsPost(channelIds, includeUndeployed, includeMetadataIds, excludeMetadataIds, aggregateStats, useStorageStats); } - return getServlet(ChannelStatisticsServletInterface.class).getStatistics(channelIds, includeUndeployed, includeMetadataIds, excludeMetadataIds, aggregateStats); + return getServlet(ChannelStatisticsServletInterface.class).getStatistics(channelIds, includeUndeployed, includeMetadataIds, excludeMetadataIds, aggregateStats, useStorageStats); } /** @@ -1620,12 +1620,12 @@ public List getStatistics(Set channelIds, boolean inc * channels, aggregate stats, and also include OR exclude connectors. This is a POST request * alternative to GET /statistics that may be used when there are too many channel IDs to * include in the query parameters. - * + * * @see ChannelStatisticsServletInterface#getStatistics */ @Override - public List getStatisticsPost(Set channelIds, boolean includeUndeployed, Set includeMetadataIds, Set excludeMetadataIds, boolean aggregateStats) throws ClientException { - return getServlet(ChannelStatisticsServletInterface.class).getStatisticsPost(channelIds, includeUndeployed, includeMetadataIds, excludeMetadataIds, aggregateStats); + public List getStatisticsPost(Set channelIds, boolean includeUndeployed, Set includeMetadataIds, Set excludeMetadataIds, boolean aggregateStats, boolean useStorageStats) throws ClientException { + return getServlet(ChannelStatisticsServletInterface.class).getStatisticsPost(channelIds, includeUndeployed, includeMetadataIds, excludeMetadataIds, aggregateStats, useStorageStats); } /** diff --git a/server/src/com/mirth/connect/client/core/api/servlets/ChannelStatisticsServletInterface.java b/server/src/com/mirth/connect/client/core/api/servlets/ChannelStatisticsServletInterface.java index a6ba58b07..d989b27ab 100644 --- a/server/src/com/mirth/connect/client/core/api/servlets/ChannelStatisticsServletInterface.java +++ b/server/src/com/mirth/connect/client/core/api/servlets/ChannelStatisticsServletInterface.java @@ -58,7 +58,8 @@ public List getStatistics(//@formatter:off @Param("includeUndeployed") @Parameter(description = "If true, statistics for undeployed channels will also be included.") @QueryParam("includeUndeployed") boolean includeUndeployed, @Param("includeMetadataIds") @Parameter(description = "The ids of connectors to include. Cannot include and exclude connectors.") @QueryParam("includeMetadataId") Set includeMetadataIds, @Param("excludeMetadataIds") @Parameter(description = "The ids of connectors to exclude. Cannot include and exclude connectors.") @QueryParam("excludeMetadataId") Set excludeMetadataIds, - @Param("aggregateStats") @Parameter(description = "If true, statistics will be aggregated into one result") @QueryParam("aggregateStats") boolean aggregateStats) throws ClientException; + @Param("aggregateStats") @Parameter(description = "If true, statistics will be aggregated into one result") @QueryParam("aggregateStats") boolean aggregateStats, + @Param("useStorageStats") @Parameter(description = "If true, statistics for deployed channels will be read from persistent storage instead of in-memory counters.") @QueryParam("useStorageStats") boolean useStorageStats) throws ClientException; // @formatter:on @POST @@ -75,7 +76,8 @@ public List getStatisticsPost(//@formatter:off @Param("includeUndeployed") @Parameter(description = "If true, statistics for undeployed channels will also be included.") @FormDataParam("includeUndeployed") boolean includeUndeployed, @Param("includeMetadataIds") @Parameter(description = "The ids of connectors to include. Cannot include and exclude connectors.") @FormDataParam("includeMetadataIds") Set includeMetadataIds, @Param("excludeMetadataIds") @Parameter(description = "The ids of connectors to exclude. Cannot include and exclude connectors.") @FormDataParam("excludeMetadataIds") Set excludeMetadataIds, - @Param("aggregateStats") @Parameter(description = "If true, statistics will be aggregated into one result") @FormDataParam("aggregateStats") boolean aggregateStats) throws ClientException; + @Param("aggregateStats") @Parameter(description = "If true, statistics will be aggregated into one result") @FormDataParam("aggregateStats") boolean aggregateStats, + @Param("useStorageStats") @Parameter(description = "If true, statistics for deployed channels will be read from persistent storage instead of in-memory counters.") @FormDataParam("useStorageStats") boolean useStorageStats) throws ClientException; // @formatter:on @GET diff --git a/server/src/com/mirth/connect/server/api/servlets/ChannelStatisticsServlet.java b/server/src/com/mirth/connect/server/api/servlets/ChannelStatisticsServlet.java index 48467e659..b04d2eaa2 100644 --- a/server/src/com/mirth/connect/server/api/servlets/ChannelStatisticsServlet.java +++ b/server/src/com/mirth/connect/server/api/servlets/ChannelStatisticsServlet.java @@ -58,12 +58,12 @@ protected void initializeControllers() { } @Override - public List getStatistics(Set channelIds, boolean includeUndeployed, Set includeMetadataIds, Set excludeMetadataIds, boolean aggregateStats) { + public List getStatistics(Set channelIds, boolean includeUndeployed, Set includeMetadataIds, Set excludeMetadataIds, boolean aggregateStats, boolean useStorageStats) { if (CollectionUtils.isNotEmpty(includeMetadataIds) && CollectionUtils.isNotEmpty(excludeMetadataIds)) { throw new MirthApiException(Response.status(Response.Status.BAD_REQUEST).type(MediaType.TEXT_PLAIN_TYPE).entity("Cannot include and exclude connectors in one call").build()); } - List stats = engineController.getChannelStatisticsList(channelIds, includeUndeployed, includeMetadataIds, excludeMetadataIds); + List stats = engineController.getChannelStatisticsList(channelIds, includeUndeployed, includeMetadataIds, excludeMetadataIds, useStorageStats); if (aggregateStats) { ChannelStatistics totalStatistics = new ChannelStatistics(); @@ -93,8 +93,8 @@ public List getStatistics(Set channelIds, boolean inc } @Override - public List getStatisticsPost(Set channelIds, boolean includeUndeployed, Set includeMetadataIds, Set excludeMetadataIds, boolean aggregateStats) { - return getStatistics(channelIds, includeUndeployed, includeMetadataIds, excludeMetadataIds, aggregateStats); + public List getStatisticsPost(Set channelIds, boolean includeUndeployed, Set includeMetadataIds, Set excludeMetadataIds, boolean aggregateStats, boolean useStorageStats) { + return getStatistics(channelIds, includeUndeployed, includeMetadataIds, excludeMetadataIds, aggregateStats, useStorageStats); } @Override diff --git a/server/src/com/mirth/connect/server/controllers/DonkeyEngineController.java b/server/src/com/mirth/connect/server/controllers/DonkeyEngineController.java index 48095c0ac..3daef55a9 100644 --- a/server/src/com/mirth/connect/server/controllers/DonkeyEngineController.java +++ b/server/src/com/mirth/connect/server/controllers/DonkeyEngineController.java @@ -975,15 +975,24 @@ protected Long getDestinationQueueSize(DestinationConnector destinationConnector @Override public List getChannelStatisticsList(Set channelIds, boolean includeUndeployed) { - return getChannelStatisticsList(channelIds, includeUndeployed, null, null); + return getChannelStatisticsList(channelIds, includeUndeployed, null, null, false); } @Override public List getChannelStatisticsList(Set channelIds, boolean includeUndeployed, Set includeMetadataIds, Set excludeMetadataIds) { + return getChannelStatisticsList(channelIds, includeUndeployed, includeMetadataIds, excludeMetadataIds, false); + } + + @Override + public List getChannelStatisticsList(Set channelIds, boolean includeUndeployed, Set includeMetadataIds, Set excludeMetadataIds, boolean useStorageForDeployed) { List statistics = new ArrayList(); Map dashboardChannels = getDashboardChannels(channelIds); - statistics.addAll(getDashboardChannelStatistics(dashboardChannels.values(), includeMetadataIds, excludeMetadataIds)); + Statistics deployedStats = useStorageForDeployed + ? channelController.getStatisticsFromStorage(configurationController.getServerId()) + : channelController.getStatistics(); + + statistics.addAll(getDashboardChannelStatistics(dashboardChannels.values(), deployedStats, includeMetadataIds, excludeMetadataIds)); if (includeUndeployed) { Map channelModels = new HashMap(); @@ -998,9 +1007,8 @@ public List getChannelStatisticsList(Set channelIds, return statistics; } - private List getDashboardChannelStatistics(Collection channels, Set includeMetaDataIds, Set excludeMetaDataIds) { + private List getDashboardChannelStatistics(Collection channels, Statistics stats, Set includeMetaDataIds, Set excludeMetaDataIds) { List statisticsList = new ArrayList(); - Statistics stats = channelController.getStatistics(); String serverId = configurationController.getServerId(); diff --git a/server/src/com/mirth/connect/server/controllers/EngineController.java b/server/src/com/mirth/connect/server/controllers/EngineController.java index f088643e2..f5e5f6890 100644 --- a/server/src/com/mirth/connect/server/controllers/EngineController.java +++ b/server/src/com/mirth/connect/server/controllers/EngineController.java @@ -99,6 +99,8 @@ public interface EngineController { public List getChannelStatisticsList(Set channelIds, boolean includeUndeployed, Set includeMetadataIds, Set excludeMetadataIds); + public List getChannelStatisticsList(Set channelIds, boolean includeUndeployed, Set includeMetadataIds, Set excludeMetadataIds, boolean useStorageForDeployed); + /** * Returns a DashboardStatus object representing a running channel. */ diff --git a/server/test/com/mirth/connect/server/api/servlets/ChannelStatisticsServletTest.java b/server/test/com/mirth/connect/server/api/servlets/ChannelStatisticsServletTest.java index 23e62bbff..8c5021bd7 100644 --- a/server/test/com/mirth/connect/server/api/servlets/ChannelStatisticsServletTest.java +++ b/server/test/com/mirth/connect/server/api/servlets/ChannelStatisticsServletTest.java @@ -11,10 +11,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -32,7 +30,6 @@ import org.junit.Test; import com.mirth.connect.client.core.api.MirthApiException; -import com.mirth.connect.donkey.model.message.Status; import com.mirth.connect.model.ChannelStatistics; import com.mirth.connect.server.api.ServletTestBase; import com.mirth.connect.server.controllers.ChannelController; @@ -89,7 +86,7 @@ public static void setup() throws Exception { List singleStatsList = new ArrayList<>(); singleStatsList.add(statsA); - when(mockEngineController.getChannelStatisticsList(any(), anyBoolean(), any(), any())).thenReturn(statsList); + when(mockEngineController.getChannelStatisticsList(any(), anyBoolean(), any(), any(), anyBoolean())).thenReturn(statsList); when(mockEngineController.getChannelStatisticsList(any(Set.class), anyBoolean())).thenReturn(singleStatsList); doNothing().when(mockChannelController).resetStatistics(any(), any()); @@ -101,7 +98,7 @@ public static void setup() throws Exception { @Test public void testGetStatisticsBasic() { ChannelStatisticsServlet servlet = new ChannelStatisticsServlet(request, sc, controllerFactory); - List stats = servlet.getStatistics(null, false, null, null, false); + List stats = servlet.getStatistics(null, false, null, null, false, false); assertNotNull(stats); assertEquals(2, stats.size()); } @@ -110,14 +107,14 @@ public void testGetStatisticsBasic() { public void testGetStatisticsWithChannelIds() { ChannelStatisticsServlet servlet = new ChannelStatisticsServlet(request, sc, controllerFactory); Set channelIds = new HashSet<>(Arrays.asList(CHANNEL_ID_A)); - List stats = servlet.getStatistics(channelIds, false, null, null, false); + List stats = servlet.getStatistics(channelIds, false, null, null, false, false); assertNotNull(stats); } @Test public void testGetStatisticsAggregated() { ChannelStatisticsServlet servlet = new ChannelStatisticsServlet(request, sc, controllerFactory); - List stats = servlet.getStatistics(null, false, null, null, true); + List stats = servlet.getStatistics(null, false, null, null, true, false); assertNotNull(stats); assertEquals(1, stats.size()); @@ -135,14 +132,14 @@ public void testGetStatisticsIncludeAndExcludeConflict() { ChannelStatisticsServlet servlet = new ChannelStatisticsServlet(request, sc, controllerFactory); Set includeIds = new HashSet<>(Arrays.asList(0)); Set excludeIds = new HashSet<>(Arrays.asList(1)); - servlet.getStatistics(null, false, includeIds, excludeIds, false); + servlet.getStatistics(null, false, includeIds, excludeIds, false, false); } @Test public void testGetStatisticsWithIncludeMetadataIds() { ChannelStatisticsServlet servlet = new ChannelStatisticsServlet(request, sc, controllerFactory); Set includeIds = new HashSet<>(Arrays.asList(0, 1)); - List stats = servlet.getStatistics(null, false, includeIds, null, false); + List stats = servlet.getStatistics(null, false, includeIds, null, false, false); assertNotNull(stats); } @@ -150,14 +147,14 @@ public void testGetStatisticsWithIncludeMetadataIds() { public void testGetStatisticsWithExcludeMetadataIds() { ChannelStatisticsServlet servlet = new ChannelStatisticsServlet(request, sc, controllerFactory); Set excludeIds = new HashSet<>(Arrays.asList(2)); - List stats = servlet.getStatistics(null, false, null, excludeIds, false); + List stats = servlet.getStatistics(null, false, null, excludeIds, false, false); assertNotNull(stats); } @Test public void testGetStatisticsIncludeUndeployed() { ChannelStatisticsServlet servlet = new ChannelStatisticsServlet(request, sc, controllerFactory); - List stats = servlet.getStatistics(null, true, null, null, false); + List stats = servlet.getStatistics(null, true, null, null, false, false); assertNotNull(stats); } @@ -166,7 +163,7 @@ public void testGetStatisticsIncludeUndeployed() { @Test public void testGetStatisticsPostDelegatesToGet() { ChannelStatisticsServlet servlet = new ChannelStatisticsServlet(request, sc, controllerFactory); - List stats = servlet.getStatisticsPost(null, false, null, null, false); + List stats = servlet.getStatisticsPost(null, false, null, null, false, false); assertNotNull(stats); assertEquals(2, stats.size()); } @@ -174,7 +171,7 @@ public void testGetStatisticsPostDelegatesToGet() { @Test public void testGetStatisticsPostAggregated() { ChannelStatisticsServlet servlet = new ChannelStatisticsServlet(request, sc, controllerFactory); - List stats = servlet.getStatisticsPost(null, false, null, null, true); + List stats = servlet.getStatisticsPost(null, false, null, null, true, false); assertEquals(1, stats.size()); assertEquals(300, stats.get(0).getReceived()); } From 2df960d32187bf61d07b64729f696d7a493fec49 Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:42:03 -0500 Subject: [PATCH 04/25] chore(release): bump version to 26.3.1 - Add v26_3_1 enum entry to Version.java - Add Migrate26_3_1 no-op migrator and register in ServerMigrator - Update version in mirth-build.properties, mirth.properties (conf + setup) - Update mirthVersion in all connector and plugin xml files (server/src, server/setup, custom-extensions) --- .../dynamic-lookup-gateway/plugin.xml | 2 +- custom-extensions/message-trends/plugin.xml | 2 +- custom-extensions/version-history/plugin.xml | 2 +- server/conf/mirth.properties | 2 +- server/mirth-build.properties | 2 +- server/setup/conf/mirth.properties | 2 +- .../mirth/connect/client/core/Version.java | 3 ++- .../connect/connectors/dimse/destination.xml | 2 +- .../mirth/connect/connectors/dimse/source.xml | 2 +- .../connect/connectors/doc/destination.xml | 2 +- .../connect/connectors/file/destination.xml | 2 +- .../mirth/connect/connectors/file/source.xml | 2 +- .../connect/connectors/http/destination.xml | 2 +- .../mirth/connect/connectors/http/source.xml | 2 +- .../connect/connectors/jdbc/destination.xml | 2 +- .../mirth/connect/connectors/jdbc/source.xml | 2 +- .../connect/connectors/jms/destination.xml | 2 +- .../mirth/connect/connectors/jms/source.xml | 2 +- .../connect/connectors/js/destination.xml | 2 +- .../mirth/connect/connectors/js/source.xml | 2 +- .../connect/connectors/smtp/destination.xml | 2 +- .../connect/connectors/tcp/destination.xml | 2 +- .../mirth/connect/connectors/tcp/plugin.xml | 2 +- .../mirth/connect/connectors/tcp/source.xml | 2 +- .../connect/connectors/vm/destination.xml | 2 +- .../mirth/connect/connectors/vm/source.xml | 2 +- .../connect/connectors/ws/destination.xml | 2 +- .../mirth/connect/connectors/ws/source.xml | 2 +- .../plugins/dashboardstatus/plugin.xml | 2 +- .../connect/plugins/datapruner/plugin.xml | 2 +- .../plugins/datatypes/delimited/plugin.xml | 2 +- .../plugins/datatypes/dicom/plugin.xml | 2 +- .../connect/plugins/datatypes/edi/plugin.xml | 2 +- .../plugins/datatypes/hl7v2/plugin.xml | 2 +- .../plugins/datatypes/hl7v3/plugin.xml | 2 +- .../connect/plugins/datatypes/json/plugin.xml | 2 +- .../plugins/datatypes/ncpdp/plugin.xml | 2 +- .../connect/plugins/datatypes/raw/plugin.xml | 2 +- .../connect/plugins/datatypes/xml/plugin.xml | 2 +- .../plugins/destinationsetfilter/plugin.xml | 2 +- .../connect/plugins/dicomviewer/plugin.xml | 2 +- .../plugins/directoryresource/plugin.xml | 2 +- .../plugins/globalmapviewer/plugin.xml | 2 +- .../mirth/connect/plugins/httpauth/plugin.xml | 2 +- .../connect/plugins/imageviewer/plugin.xml | 2 +- .../connect/plugins/javascriptrule/plugin.xml | 2 +- .../connect/plugins/javascriptstep/plugin.xml | 2 +- .../mirth/connect/plugins/mapper/plugin.xml | 2 +- .../connect/plugins/messagebuilder/plugin.xml | 2 +- .../mirth/connect/plugins/mllpmode/plugin.xml | 2 +- .../connect/plugins/pdfviewer/plugin.xml | 2 +- .../connect/plugins/rulebuilder/plugin.xml | 2 +- .../connect/plugins/scriptfilerule/plugin.xml | 2 +- .../connect/plugins/scriptfilestep/plugin.xml | 2 +- .../connect/plugins/serverlog/plugin.xml | 2 +- .../connect/plugins/textviewer/plugin.xml | 2 +- .../mirth/connect/plugins/xsltstep/plugin.xml | 2 +- .../server/migration/Migrate26_3_1.java | 22 +++++++++++++++++++ .../server/migration/ServerMigrator.java | 1 + 59 files changed, 81 insertions(+), 57 deletions(-) create mode 100644 server/src/com/mirth/connect/server/migration/Migrate26_3_1.java diff --git a/custom-extensions/dynamic-lookup-gateway/plugin.xml b/custom-extensions/dynamic-lookup-gateway/plugin.xml index 37ceebb89..125d55ef7 100644 --- a/custom-extensions/dynamic-lookup-gateway/plugin.xml +++ b/custom-extensions/dynamic-lookup-gateway/plugin.xml @@ -3,7 +3,7 @@ Lookup Table Management System Daniel Svanstedt, Thai Tran 2.1.0 - 4.5.3, 4.5.4, 4.5.5, 4.6.0, 4.6.1, 26.3.0 + 4.5.3, 4.5.4, 4.5.5, 4.6.0, 4.6.1, 26.3.1 https://www.innovarhealthcare.com This plugin provides a centralized repository for key-value pairs used across channel diff --git a/custom-extensions/message-trends/plugin.xml b/custom-extensions/message-trends/plugin.xml index aa3dc05ec..7932fa5ff 100644 --- a/custom-extensions/message-trends/plugin.xml +++ b/custom-extensions/message-trends/plugin.xml @@ -3,7 +3,7 @@ Message Trends Management System Daniel Svanstedt, Thai Tran 1.0.0 - 4.5.4, 4.6.0, 4.6.1, 26.3.0 + 4.5.4, 4.6.0, 4.6.1, 26.3.1 https://www.innovarhealthcare.com This plugin provides time-series message statistics tracking and visualization diff --git a/custom-extensions/version-history/plugin.xml b/custom-extensions/version-history/plugin.xml index c5a69181a..555a265f0 100644 --- a/custom-extensions/version-history/plugin.xml +++ b/custom-extensions/version-history/plugin.xml @@ -3,7 +3,7 @@ Version History Plugin Innovar Healthcare 3.0.1 - 26.3.0, 26.3.1 + 26.3.1, 26.3.1 https://www.innovarhealthcare.com Innovar Healthcare Version History Plugin diff --git a/server/conf/mirth.properties b/server/conf/mirth.properties index 95b86083b..efb5d7bee 100644 --- a/server/conf/mirth.properties +++ b/server/conf/mirth.properties @@ -27,7 +27,7 @@ password.reuselimit = 0 password.allowusernameenumeration = false # Only used for migration purposes, do not modify -version = 26.3.0 +version = 26.3.1 # keystore keystore.path = ${dir.appdata}/keystore.jks diff --git a/server/mirth-build.properties b/server/mirth-build.properties index c872ff170..ddb62897b 100644 --- a/server/mirth-build.properties +++ b/server/mirth-build.properties @@ -5,4 +5,4 @@ webadmin=../webadmin manager=../manager cli=../command custom-extensions=../custom-extensions -version=26.3.0 +version=26.3.1 diff --git a/server/setup/conf/mirth.properties b/server/setup/conf/mirth.properties index 95b86083b..efb5d7bee 100644 --- a/server/setup/conf/mirth.properties +++ b/server/setup/conf/mirth.properties @@ -27,7 +27,7 @@ password.reuselimit = 0 password.allowusernameenumeration = false # Only used for migration purposes, do not modify -version = 26.3.0 +version = 26.3.1 # keystore keystore.path = ${dir.appdata}/keystore.jks diff --git a/server/src/com/mirth/connect/client/core/Version.java b/server/src/com/mirth/connect/client/core/Version.java index 604a9112d..094e3e672 100644 --- a/server/src/com/mirth/connect/client/core/Version.java +++ b/server/src/com/mirth/connect/client/core/Version.java @@ -87,7 +87,8 @@ public enum Version { v4_5_4("4.5.4"), //BridgeLink v4_6_0("4.6.0"),//BridgeLink v4_6_1("4.6.1"), //BridgeLink - v26_3_0("26.3.0"); //BridgeLink — SMTP OAuth + v26_3_0("26.3.0"), //BridgeLink — SMTP OAuth + v26_3_1("26.3.1"); //BridgeLink // @formatter:on diff --git a/server/src/com/mirth/connect/connectors/dimse/destination.xml b/server/src/com/mirth/connect/connectors/dimse/destination.xml index ea7f18880..1b3d4768f 100644 --- a/server/src/com/mirth/connect/connectors/dimse/destination.xml +++ b/server/src/com/mirth/connect/connectors/dimse/destination.xml @@ -2,7 +2,7 @@ DICOM Sender NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to send messages using DICOM protocol. com.mirth.connect.connectors.dimse.DICOMSender diff --git a/server/src/com/mirth/connect/connectors/dimse/source.xml b/server/src/com/mirth/connect/connectors/dimse/source.xml index 435201b21..dabc46ef8 100644 --- a/server/src/com/mirth/connect/connectors/dimse/source.xml +++ b/server/src/com/mirth/connect/connectors/dimse/source.xml @@ -2,7 +2,7 @@ DICOM Listener NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to listen for incoming messages over a standard TCP connection. com.mirth.connect.connectors.dimse.DICOMListener diff --git a/server/src/com/mirth/connect/connectors/doc/destination.xml b/server/src/com/mirth/connect/connectors/doc/destination.xml index a732c58f7..3570b05ba 100644 --- a/server/src/com/mirth/connect/connectors/doc/destination.xml +++ b/server/src/com/mirth/connect/connectors/doc/destination.xml @@ -2,7 +2,7 @@ Document Writer NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to create RTF or PDF documents based on an HTML template. PDF files can be password protected. com.mirth.connect.connectors.doc.DocumentWriter diff --git a/server/src/com/mirth/connect/connectors/file/destination.xml b/server/src/com/mirth/connect/connectors/file/destination.xml index 0cb838ae3..e155643ca 100644 --- a/server/src/com/mirth/connect/connectors/file/destination.xml +++ b/server/src/com/mirth/connect/connectors/file/destination.xml @@ -2,7 +2,7 @@ File Writer NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to write files to a local or network file system. A velocity template is available to dynamically generate files. com.mirth.connect.connectors.file.FileWriter diff --git a/server/src/com/mirth/connect/connectors/file/source.xml b/server/src/com/mirth/connect/connectors/file/source.xml index 8b4369b8a..39ebcdaa9 100644 --- a/server/src/com/mirth/connect/connectors/file/source.xml +++ b/server/src/com/mirth/connect/connectors/file/source.xml @@ -2,7 +2,7 @@ File Reader NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to poll for files from a local or network file system. com.mirth.connect.connectors.file.FileReader diff --git a/server/src/com/mirth/connect/connectors/http/destination.xml b/server/src/com/mirth/connect/connectors/http/destination.xml index 65f28557e..e47871c4d 100644 --- a/server/src/com/mirth/connect/connectors/http/destination.xml +++ b/server/src/com/mirth/connect/connectors/http/destination.xml @@ -2,7 +2,7 @@ HTTP Sender NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to send messages to an HTTP server. Custom header properties can be specified. com.mirth.connect.connectors.http.HttpSender diff --git a/server/src/com/mirth/connect/connectors/http/source.xml b/server/src/com/mirth/connect/connectors/http/source.xml index 79c383f9c..464e157c2 100644 --- a/server/src/com/mirth/connect/connectors/http/source.xml +++ b/server/src/com/mirth/connect/connectors/http/source.xml @@ -2,7 +2,7 @@ HTTP Listener NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to listen for incoming HTTP data. Messages are received as XML and include the full header contents. com.mirth.connect.connectors.http.HttpListener diff --git a/server/src/com/mirth/connect/connectors/jdbc/destination.xml b/server/src/com/mirth/connect/connectors/jdbc/destination.xml index 47583b9ba..6c61ba64e 100644 --- a/server/src/com/mirth/connect/connectors/jdbc/destination.xml +++ b/server/src/com/mirth/connect/connectors/jdbc/destination.xml @@ -2,7 +2,7 @@ Database Writer NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to write to any JDBC-compatabile database with Insert, Update or JavaScript statements. com.mirth.connect.connectors.jdbc.DatabaseWriter diff --git a/server/src/com/mirth/connect/connectors/jdbc/source.xml b/server/src/com/mirth/connect/connectors/jdbc/source.xml index b30368bbc..b6af099c2 100644 --- a/server/src/com/mirth/connect/connectors/jdbc/source.xml +++ b/server/src/com/mirth/connect/connectors/jdbc/source.xml @@ -2,7 +2,7 @@ Database Reader NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to poll any supported JDBC-compatabile database for data. Rows are returned as XML and JavaScript can be used for advanced logic. com.mirth.connect.connectors.jdbc.DatabaseReader diff --git a/server/src/com/mirth/connect/connectors/jms/destination.xml b/server/src/com/mirth/connect/connectors/jms/destination.xml index e47284ab0..0ccbd75a4 100644 --- a/server/src/com/mirth/connect/connectors/jms/destination.xml +++ b/server/src/com/mirth/connect/connectors/jms/destination.xml @@ -2,7 +2,7 @@ JMS Sender NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to write messages to a JMS queue. com.mirth.connect.connectors.jms.JmsSender diff --git a/server/src/com/mirth/connect/connectors/jms/source.xml b/server/src/com/mirth/connect/connectors/jms/source.xml index 1af77e57a..eda87e2f8 100644 --- a/server/src/com/mirth/connect/connectors/jms/source.xml +++ b/server/src/com/mirth/connect/connectors/jms/source.xml @@ -2,7 +2,7 @@ JMS Listener NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to read messages from a JMS queue. com.mirth.connect.connectors.jms.JmsListener diff --git a/server/src/com/mirth/connect/connectors/js/destination.xml b/server/src/com/mirth/connect/connectors/js/destination.xml index 4ffb643dd..b75f505ff 100644 --- a/server/src/com/mirth/connect/connectors/js/destination.xml +++ b/server/src/com/mirth/connect/connectors/js/destination.xml @@ -2,7 +2,7 @@ JavaScript Writer NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to execute JavaScript to connect to an arbitrary destination. com.mirth.connect.connectors.js.JavaScriptWriter diff --git a/server/src/com/mirth/connect/connectors/js/source.xml b/server/src/com/mirth/connect/connectors/js/source.xml index 7f789757a..898f3e892 100644 --- a/server/src/com/mirth/connect/connectors/js/source.xml +++ b/server/src/com/mirth/connect/connectors/js/source.xml @@ -2,7 +2,7 @@ JavaScript Reader NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to execute arbitrary JavaScript to pull in messages to a channel. com.mirth.connect.connectors.js.JavaScriptReader diff --git a/server/src/com/mirth/connect/connectors/smtp/destination.xml b/server/src/com/mirth/connect/connectors/smtp/destination.xml index 828a67a2d..4deb11acd 100644 --- a/server/src/com/mirth/connect/connectors/smtp/destination.xml +++ b/server/src/com/mirth/connect/connectors/smtp/destination.xml @@ -2,7 +2,7 @@ SMTP Sender NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to send email messages via SMTP. A template is available to dynamically create messages. com.mirth.connect.connectors.smtp.SmtpSender diff --git a/server/src/com/mirth/connect/connectors/tcp/destination.xml b/server/src/com/mirth/connect/connectors/tcp/destination.xml index 81f089518..000b17be5 100644 --- a/server/src/com/mirth/connect/connectors/tcp/destination.xml +++ b/server/src/com/mirth/connect/connectors/tcp/destination.xml @@ -2,7 +2,7 @@ TCP Sender NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to send messages to a TCP server. com.mirth.connect.connectors.tcp.TcpSender diff --git a/server/src/com/mirth/connect/connectors/tcp/plugin.xml b/server/src/com/mirth/connect/connectors/tcp/plugin.xml index 6bfc27d97..88d7a6309 100644 --- a/server/src/com/mirth/connect/connectors/tcp/plugin.xml +++ b/server/src/com/mirth/connect/connectors/tcp/plugin.xml @@ -2,7 +2,7 @@ TCP Connector Service Plugin NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin is required for correct use of the TCP connectors. diff --git a/server/src/com/mirth/connect/connectors/tcp/source.xml b/server/src/com/mirth/connect/connectors/tcp/source.xml index 50ae7c46a..e8093685b 100644 --- a/server/src/com/mirth/connect/connectors/tcp/source.xml +++ b/server/src/com/mirth/connect/connectors/tcp/source.xml @@ -2,7 +2,7 @@ TCP Listener NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to listen for incoming messages over a standard TCP connection. com.mirth.connect.connectors.tcp.TcpListener diff --git a/server/src/com/mirth/connect/connectors/vm/destination.xml b/server/src/com/mirth/connect/connectors/vm/destination.xml index 092d683ca..4f683e664 100644 --- a/server/src/com/mirth/connect/connectors/vm/destination.xml +++ b/server/src/com/mirth/connect/connectors/vm/destination.xml @@ -2,7 +2,7 @@ Channel Writer NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to send events to other channels in the same Mirth Connect instance. com.mirth.connect.connectors.vm.ChannelWriter diff --git a/server/src/com/mirth/connect/connectors/vm/source.xml b/server/src/com/mirth/connect/connectors/vm/source.xml index 5a25632af..22e6019eb 100644 --- a/server/src/com/mirth/connect/connectors/vm/source.xml +++ b/server/src/com/mirth/connect/connectors/vm/source.xml @@ -2,7 +2,7 @@ Channel Reader NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to listen for incoming events from other channels in the same Mirth Connect instance. com.mirth.connect.connectors.vm.ChannelReader diff --git a/server/src/com/mirth/connect/connectors/ws/destination.xml b/server/src/com/mirth/connect/connectors/ws/destination.xml index e712f226b..73ef5e275 100644 --- a/server/src/com/mirth/connect/connectors/ws/destination.xml +++ b/server/src/com/mirth/connect/connectors/ws/destination.xml @@ -2,7 +2,7 @@ Web Service Sender NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to invoke remote Web Services over HTTP. com.mirth.connect.connectors.ws.WebServiceSender diff --git a/server/src/com/mirth/connect/connectors/ws/source.xml b/server/src/com/mirth/connect/connectors/ws/source.xml index 23beffd33..0b5e640e1 100644 --- a/server/src/com/mirth/connect/connectors/ws/source.xml +++ b/server/src/com/mirth/connect/connectors/ws/source.xml @@ -2,7 +2,7 @@ Web Service Listener NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This connector allows Mirth Connect to listen on an HTTP port for incoming Web Service calls. This connector also provides a WSDL for SOAP clients to use. com.mirth.connect.connectors.ws.WebServiceListener diff --git a/server/src/com/mirth/connect/plugins/dashboardstatus/plugin.xml b/server/src/com/mirth/connect/plugins/dashboardstatus/plugin.xml index 48c818b1a..0cf1085f0 100644 --- a/server/src/com/mirth/connect/plugins/dashboardstatus/plugin.xml +++ b/server/src/com/mirth/connect/plugins/dashboardstatus/plugin.xml @@ -2,7 +2,7 @@ Dashboard Connector Status Monitor NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides a real-time connection status column on the Mirth Connect client diff --git a/server/src/com/mirth/connect/plugins/datapruner/plugin.xml b/server/src/com/mirth/connect/plugins/datapruner/plugin.xml index 02a2bf4a6..da48a6a9e 100644 --- a/server/src/com/mirth/connect/plugins/datapruner/plugin.xml +++ b/server/src/com/mirth/connect/plugins/datapruner/plugin.xml @@ -2,7 +2,7 @@ Data Pruner NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides data pruning capability for Mirth Connect diff --git a/server/src/com/mirth/connect/plugins/datatypes/delimited/plugin.xml b/server/src/com/mirth/connect/plugins/datatypes/delimited/plugin.xml index 94e15a511..d7812de78 100644 --- a/server/src/com/mirth/connect/plugins/datatypes/delimited/plugin.xml +++ b/server/src/com/mirth/connect/plugins/datatypes/delimited/plugin.xml @@ -2,7 +2,7 @@ Delimited Data Type NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides support for the delimited data type diff --git a/server/src/com/mirth/connect/plugins/datatypes/dicom/plugin.xml b/server/src/com/mirth/connect/plugins/datatypes/dicom/plugin.xml index d6ba21d9e..61288299f 100644 --- a/server/src/com/mirth/connect/plugins/datatypes/dicom/plugin.xml +++ b/server/src/com/mirth/connect/plugins/datatypes/dicom/plugin.xml @@ -2,7 +2,7 @@ DICOM Data Type NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides support for the DICOM data type diff --git a/server/src/com/mirth/connect/plugins/datatypes/edi/plugin.xml b/server/src/com/mirth/connect/plugins/datatypes/edi/plugin.xml index a922dbdd1..dafe8f712 100644 --- a/server/src/com/mirth/connect/plugins/datatypes/edi/plugin.xml +++ b/server/src/com/mirth/connect/plugins/datatypes/edi/plugin.xml @@ -2,7 +2,7 @@ EDI Data Type NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides support for the EDI data type diff --git a/server/src/com/mirth/connect/plugins/datatypes/hl7v2/plugin.xml b/server/src/com/mirth/connect/plugins/datatypes/hl7v2/plugin.xml index 52702e7c4..95c1fa1dc 100644 --- a/server/src/com/mirth/connect/plugins/datatypes/hl7v2/plugin.xml +++ b/server/src/com/mirth/connect/plugins/datatypes/hl7v2/plugin.xml @@ -2,7 +2,7 @@ HL7v2 Data Type NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides support for the HL7v2 data type diff --git a/server/src/com/mirth/connect/plugins/datatypes/hl7v3/plugin.xml b/server/src/com/mirth/connect/plugins/datatypes/hl7v3/plugin.xml index 3860afba2..7362b8add 100644 --- a/server/src/com/mirth/connect/plugins/datatypes/hl7v3/plugin.xml +++ b/server/src/com/mirth/connect/plugins/datatypes/hl7v3/plugin.xml @@ -2,7 +2,7 @@ HL7v3 Data Type NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides support for the HL7v3 data type diff --git a/server/src/com/mirth/connect/plugins/datatypes/json/plugin.xml b/server/src/com/mirth/connect/plugins/datatypes/json/plugin.xml index af0667bad..d67e3338b 100644 --- a/server/src/com/mirth/connect/plugins/datatypes/json/plugin.xml +++ b/server/src/com/mirth/connect/plugins/datatypes/json/plugin.xml @@ -2,7 +2,7 @@ JSON Data Type NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides support for the JSON data type diff --git a/server/src/com/mirth/connect/plugins/datatypes/ncpdp/plugin.xml b/server/src/com/mirth/connect/plugins/datatypes/ncpdp/plugin.xml index e8a2ac28b..8fd81d651 100644 --- a/server/src/com/mirth/connect/plugins/datatypes/ncpdp/plugin.xml +++ b/server/src/com/mirth/connect/plugins/datatypes/ncpdp/plugin.xml @@ -2,7 +2,7 @@ NCPDP Data Type NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides support for the NCPDP data type diff --git a/server/src/com/mirth/connect/plugins/datatypes/raw/plugin.xml b/server/src/com/mirth/connect/plugins/datatypes/raw/plugin.xml index c9f5a26ea..fd3a2f400 100644 --- a/server/src/com/mirth/connect/plugins/datatypes/raw/plugin.xml +++ b/server/src/com/mirth/connect/plugins/datatypes/raw/plugin.xml @@ -2,7 +2,7 @@ Raw Data Type NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides support for the raw data type diff --git a/server/src/com/mirth/connect/plugins/datatypes/xml/plugin.xml b/server/src/com/mirth/connect/plugins/datatypes/xml/plugin.xml index 148e0ac40..cff018871 100644 --- a/server/src/com/mirth/connect/plugins/datatypes/xml/plugin.xml +++ b/server/src/com/mirth/connect/plugins/datatypes/xml/plugin.xml @@ -2,7 +2,7 @@ XML Data Type NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides support for the XML data type diff --git a/server/src/com/mirth/connect/plugins/destinationsetfilter/plugin.xml b/server/src/com/mirth/connect/plugins/destinationsetfilter/plugin.xml index 75a25446c..af45d9ab5 100644 --- a/server/src/com/mirth/connect/plugins/destinationsetfilter/plugin.xml +++ b/server/src/com/mirth/connect/plugins/destinationsetfilter/plugin.xml @@ -2,7 +2,7 @@ Destination Set Filter Step NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com Provides an easy method in the source transformer to filter destinations from being executed. diff --git a/server/src/com/mirth/connect/plugins/dicomviewer/plugin.xml b/server/src/com/mirth/connect/plugins/dicomviewer/plugin.xml index c85f4a059..b4c75a5cc 100644 --- a/server/src/com/mirth/connect/plugins/dicomviewer/plugin.xml +++ b/server/src/com/mirth/connect/plugins/dicomviewer/plugin.xml @@ -2,7 +2,7 @@ DICOM Viewer NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides DICOM attachment viewing capability in message browser diff --git a/server/src/com/mirth/connect/plugins/directoryresource/plugin.xml b/server/src/com/mirth/connect/plugins/directoryresource/plugin.xml index 2b3f2c7b6..dd9b53430 100644 --- a/server/src/com/mirth/connect/plugins/directoryresource/plugin.xml +++ b/server/src/com/mirth/connect/plugins/directoryresource/plugin.xml @@ -2,7 +2,7 @@ Directory Resource Plugin NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin allows a directory to be used as a source for libraries to include in channels. diff --git a/server/src/com/mirth/connect/plugins/globalmapviewer/plugin.xml b/server/src/com/mirth/connect/plugins/globalmapviewer/plugin.xml index 73bbd3251..6ca5aa00e 100644 --- a/server/src/com/mirth/connect/plugins/globalmapviewer/plugin.xml +++ b/server/src/com/mirth/connect/plugins/globalmapviewer/plugin.xml @@ -2,7 +2,7 @@ Global Map Viewer NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin allows you to view the global map and global channel maps in the Mirth Connect Administrator. diff --git a/server/src/com/mirth/connect/plugins/httpauth/plugin.xml b/server/src/com/mirth/connect/plugins/httpauth/plugin.xml index 6352361c2..db106b556 100644 --- a/server/src/com/mirth/connect/plugins/httpauth/plugin.xml +++ b/server/src/com/mirth/connect/plugins/httpauth/plugin.xml @@ -2,7 +2,7 @@ HTTP Authentication Settings NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides advanced authentication support for HTTP-based source connectors. diff --git a/server/src/com/mirth/connect/plugins/imageviewer/plugin.xml b/server/src/com/mirth/connect/plugins/imageviewer/plugin.xml index 84345ea92..4db693ac0 100644 --- a/server/src/com/mirth/connect/plugins/imageviewer/plugin.xml +++ b/server/src/com/mirth/connect/plugins/imageviewer/plugin.xml @@ -2,7 +2,7 @@ Image Viewer NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides image attachment viewing capability in message browser. diff --git a/server/src/com/mirth/connect/plugins/javascriptrule/plugin.xml b/server/src/com/mirth/connect/plugins/javascriptrule/plugin.xml index 6b0cd7bd0..0b261eec0 100644 --- a/server/src/com/mirth/connect/plugins/javascriptrule/plugin.xml +++ b/server/src/com/mirth/connect/plugins/javascriptrule/plugin.xml @@ -2,7 +2,7 @@ JavaScript Filter Rule NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides a JavaScript rule-type for Mirth Connect filters diff --git a/server/src/com/mirth/connect/plugins/javascriptstep/plugin.xml b/server/src/com/mirth/connect/plugins/javascriptstep/plugin.xml index 43c037588..49c5e6e0c 100644 --- a/server/src/com/mirth/connect/plugins/javascriptstep/plugin.xml +++ b/server/src/com/mirth/connect/plugins/javascriptstep/plugin.xml @@ -2,7 +2,7 @@ JavaScript Transformer Step NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides a JavaScript step-type for Mirth Connect transformers diff --git a/server/src/com/mirth/connect/plugins/mapper/plugin.xml b/server/src/com/mirth/connect/plugins/mapper/plugin.xml index 436855fb7..2128a4597 100644 --- a/server/src/com/mirth/connect/plugins/mapper/plugin.xml +++ b/server/src/com/mirth/connect/plugins/mapper/plugin.xml @@ -2,7 +2,7 @@ Mapper Transformer Step NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides a Mapping step type for Mirth Connect transformers diff --git a/server/src/com/mirth/connect/plugins/messagebuilder/plugin.xml b/server/src/com/mirth/connect/plugins/messagebuilder/plugin.xml index 91ecc85a5..d3a1af13a 100644 --- a/server/src/com/mirth/connect/plugins/messagebuilder/plugin.xml +++ b/server/src/com/mirth/connect/plugins/messagebuilder/plugin.xml @@ -2,7 +2,7 @@ Message Builder Transformer Step NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides a Message Builder step-type for Mirth Connect transformers diff --git a/server/src/com/mirth/connect/plugins/mllpmode/plugin.xml b/server/src/com/mirth/connect/plugins/mllpmode/plugin.xml index bcb0bbd11..ac0c23d95 100755 --- a/server/src/com/mirth/connect/plugins/mllpmode/plugin.xml +++ b/server/src/com/mirth/connect/plugins/mllpmode/plugin.xml @@ -2,7 +2,7 @@ Transmission Mode - MLLP NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides an MLLP transmission mode for socket/serial connectors. diff --git a/server/src/com/mirth/connect/plugins/pdfviewer/plugin.xml b/server/src/com/mirth/connect/plugins/pdfviewer/plugin.xml index 0861e86bd..19962c5d7 100644 --- a/server/src/com/mirth/connect/plugins/pdfviewer/plugin.xml +++ b/server/src/com/mirth/connect/plugins/pdfviewer/plugin.xml @@ -2,7 +2,7 @@ PDF Viewer NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides PDF attachment viewing capability in message browser. diff --git a/server/src/com/mirth/connect/plugins/rulebuilder/plugin.xml b/server/src/com/mirth/connect/plugins/rulebuilder/plugin.xml index ba4e28401..9905d6973 100644 --- a/server/src/com/mirth/connect/plugins/rulebuilder/plugin.xml +++ b/server/src/com/mirth/connect/plugins/rulebuilder/plugin.xml @@ -2,7 +2,7 @@ Rule Builder Filter Rule NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides a Rule Builder type for Mirth Connect filters diff --git a/server/src/com/mirth/connect/plugins/scriptfilerule/plugin.xml b/server/src/com/mirth/connect/plugins/scriptfilerule/plugin.xml index 9655d8e23..13b212154 100644 --- a/server/src/com/mirth/connect/plugins/scriptfilerule/plugin.xml +++ b/server/src/com/mirth/connect/plugins/scriptfilerule/plugin.xml @@ -2,7 +2,7 @@ External Script Filter Step NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides an External Script step-type for Mirth Connect filters diff --git a/server/src/com/mirth/connect/plugins/scriptfilestep/plugin.xml b/server/src/com/mirth/connect/plugins/scriptfilestep/plugin.xml index af4621174..0add78a6a 100644 --- a/server/src/com/mirth/connect/plugins/scriptfilestep/plugin.xml +++ b/server/src/com/mirth/connect/plugins/scriptfilestep/plugin.xml @@ -2,7 +2,7 @@ External Script Transformer Step NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides an External Script step-type for Mirth Connect transformers diff --git a/server/src/com/mirth/connect/plugins/serverlog/plugin.xml b/server/src/com/mirth/connect/plugins/serverlog/plugin.xml index e4f029fb5..c12b45d7d 100644 --- a/server/src/com/mirth/connect/plugins/serverlog/plugin.xml +++ b/server/src/com/mirth/connect/plugins/serverlog/plugin.xml @@ -2,7 +2,7 @@ Server Log NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin allows you to view the server log on the Mirth Connect administrator. diff --git a/server/src/com/mirth/connect/plugins/textviewer/plugin.xml b/server/src/com/mirth/connect/plugins/textviewer/plugin.xml index de4ffc44b..c9a708abe 100644 --- a/server/src/com/mirth/connect/plugins/textviewer/plugin.xml +++ b/server/src/com/mirth/connect/plugins/textviewer/plugin.xml @@ -2,7 +2,7 @@ Text Viewer NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides text and RTF attachment viewing capability in message browser. diff --git a/server/src/com/mirth/connect/plugins/xsltstep/plugin.xml b/server/src/com/mirth/connect/plugins/xsltstep/plugin.xml index 7a638fb91..6a3dee8bc 100644 --- a/server/src/com/mirth/connect/plugins/xsltstep/plugin.xml +++ b/server/src/com/mirth/connect/plugins/xsltstep/plugin.xml @@ -2,7 +2,7 @@ XSLT Transformer Step NextGen Healthcare @mirthversion - 26.3.0 + 26.3.1 http://www.nextgen.com This plugin provides a XSLT support step-type for Mirth Connect transformers diff --git a/server/src/com/mirth/connect/server/migration/Migrate26_3_1.java b/server/src/com/mirth/connect/server/migration/Migrate26_3_1.java new file mode 100644 index 000000000..170258311 --- /dev/null +++ b/server/src/com/mirth/connect/server/migration/Migrate26_3_1.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Innovar Healthcare. All rights reserved + * This project is a fork of Mirth Connect by Nextgen Healthcare. + * It has been modified and maintained independently by Innovar Healthcare. + */ + +package com.mirth.connect.server.migration; + +import com.mirth.connect.model.util.MigrationException; + +public class Migrate26_3_1 extends Migrator { + + @Override + public void migrate() throws MigrationException { + // No database schema changes required for 26.3.1 + } + + @Override + public void migrateSerializedData() throws MigrationException { + // No serialized data migration required for 26.3.1 + } +} diff --git a/server/src/com/mirth/connect/server/migration/ServerMigrator.java b/server/src/com/mirth/connect/server/migration/ServerMigrator.java index 6f947cfd9..bb89ad5f6 100644 --- a/server/src/com/mirth/connect/server/migration/ServerMigrator.java +++ b/server/src/com/mirth/connect/server/migration/ServerMigrator.java @@ -247,6 +247,7 @@ private com.mirth.connect.server.migration.Migrator getMigrator(Version version) case v4_6_0: return new com.mirth.connect.server.migration.Migrate4_6_0(); case v4_6_1: return null; case v26_3_0: return new com.mirth.connect.server.migration.Migrate26_3_0(); + case v26_3_1: return null; } // @formatter:on return null; From d9b34bd2443e3d627c776dd1a6c1ed28fb58d756 Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:43:37 -0500 Subject: [PATCH 05/25] fix(security): suppress privilege-check log when running as normal user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only log when running as root/Administrator (warn). Skip the info-level message for the passing case — it adds noise without actionable signal. --- server/src/com/mirth/connect/server/Mirth.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/src/com/mirth/connect/server/Mirth.java b/server/src/com/mirth/connect/server/Mirth.java index d576ea3d9..ccd9b4058 100644 --- a/server/src/com/mirth/connect/server/Mirth.java +++ b/server/src/com/mirth/connect/server/Mirth.java @@ -203,8 +203,6 @@ private void checkRunningAsRoot() { System.exit(1); } else if (result == RootCheckResult.WARN) { logger.warn("BridgeLink is running as root/Administrator. server.allowRoot=true is set — proceeding."); - } else { - logger.info("Privilege check passed: running as user '" + userName + "' (not root/Administrator)."); } } From a2024b6f5e4ad56619c620a2df5dea012b5d7a76 Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:49:34 -0500 Subject: [PATCH 06/25] fix(custom-extensions): add 26.3.0 back to mirthVersion compatibility lists --- custom-extensions/dynamic-lookup-gateway/plugin.xml | 2 +- custom-extensions/message-trends/plugin.xml | 2 +- custom-extensions/version-history/plugin.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom-extensions/dynamic-lookup-gateway/plugin.xml b/custom-extensions/dynamic-lookup-gateway/plugin.xml index 125d55ef7..18d0c1dff 100644 --- a/custom-extensions/dynamic-lookup-gateway/plugin.xml +++ b/custom-extensions/dynamic-lookup-gateway/plugin.xml @@ -3,7 +3,7 @@ Lookup Table Management System Daniel Svanstedt, Thai Tran 2.1.0 - 4.5.3, 4.5.4, 4.5.5, 4.6.0, 4.6.1, 26.3.1 + 4.5.3, 4.5.4, 4.5.5, 4.6.0, 4.6.1, 26.3.0, 26.3.1 https://www.innovarhealthcare.com This plugin provides a centralized repository for key-value pairs used across channel diff --git a/custom-extensions/message-trends/plugin.xml b/custom-extensions/message-trends/plugin.xml index 7932fa5ff..87592afa0 100644 --- a/custom-extensions/message-trends/plugin.xml +++ b/custom-extensions/message-trends/plugin.xml @@ -3,7 +3,7 @@ Message Trends Management System Daniel Svanstedt, Thai Tran 1.0.0 - 4.5.4, 4.6.0, 4.6.1, 26.3.1 + 4.5.4, 4.6.0, 4.6.1, 26.3.0, 26.3.1 https://www.innovarhealthcare.com This plugin provides time-series message statistics tracking and visualization diff --git a/custom-extensions/version-history/plugin.xml b/custom-extensions/version-history/plugin.xml index 555a265f0..c5a69181a 100644 --- a/custom-extensions/version-history/plugin.xml +++ b/custom-extensions/version-history/plugin.xml @@ -3,7 +3,7 @@ Version History Plugin Innovar Healthcare 3.0.1 - 26.3.1, 26.3.1 + 26.3.0, 26.3.1 https://www.innovarhealthcare.com Innovar Healthcare Version History Plugin From 579b613fd32d2b11fa697cfd9e126901d9a4f45f Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:10:26 -0500 Subject: [PATCH 07/25] fix(security): suppress privilege-check log when no_new_privs is active Only warn when no_new_privs is NOT set; skip the info log when it is. --- server/src/com/mirth/connect/server/Mirth.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/src/com/mirth/connect/server/Mirth.java b/server/src/com/mirth/connect/server/Mirth.java index ccd9b4058..220b1cd6b 100644 --- a/server/src/com/mirth/connect/server/Mirth.java +++ b/server/src/com/mirth/connect/server/Mirth.java @@ -214,8 +214,6 @@ void readNoNewPrivs(Path procSelfStatus) { int value = Integer.parseInt(line.substring("NoNewPrivs:".length()).trim()); if (value == 0) { logger.warn(NO_NEW_PRIVS_WARNING); - } else { - logger.info("no_new_privs is active — kernel-level privilege escalation (sudo/SUID) is blocked."); } return; } From 3d4c9ae0398da915ec3196937c47ab1049d2212f Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Mon, 4 May 2026 11:44:39 -0500 Subject: [PATCH 08/25] fix(http): prevent static resource requests from creating channel messages (IRT-753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Jetty 9, ContextHandler.doScope() skipped dispatch if baseRequest.isHandled() was true, so RequestHandler was never called after StaticResourceHandler served a response. The Jetty 12 EE8 ContextHandler does not provide this guard — HandlerCollection calls all handlers unconditionally, causing RequestHandler to create a spurious channel message on every static resource hit. Adding the isHandled() check at the top of RequestHandler.handle() restores the original behavior. --- .../src/com/mirth/connect/connectors/http/HttpReceiver.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/src/com/mirth/connect/connectors/http/HttpReceiver.java b/server/src/com/mirth/connect/connectors/http/HttpReceiver.java index 84430abd3..a76c98365 100644 --- a/server/src/com/mirth/connect/connectors/http/HttpReceiver.java +++ b/server/src/com/mirth/connect/connectors/http/HttpReceiver.java @@ -346,6 +346,10 @@ protected String getConfigurationClass() { private class RequestHandler extends AbstractHandler { @Override public void handle(String target, Request baseRequest, HttpServletRequest servletRequest, HttpServletResponse servletResponse) throws IOException, ServletException { + // Skip if a static resource handler already served this request (Jetty 12 EE8 ContextHandler no longer guards on isHandled()) + if (baseRequest.isHandled()) { + return; + } logger.debug("received HTTP request"); eventController.dispatchEvent(new ConnectionStatusEvent(getChannelId(), getMetaDataId(), getSourceName(), ConnectionStatusEventType.CONNECTED)); DispatchResult dispatchResult = null; From 56b12645f9fe6f66c7f48817342fbfbb23d6debe Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Fri, 8 May 2026 11:40:03 -0500 Subject: [PATCH 09/25] test(IRT-577): add KeystorePasswordRegenerationTest, fix KeystoreWarningDialog message - Add KeystorePasswordRegenerationTest proving that keystore password regeneration preserves the AES data-encryption key. Both encryptData channel messages and encrypt.properties database passwords remain fully decryptable after re-keying. - Correct KeystoreWarningDialog warning text: previous message incorrectly stated that encryptData messages and encrypted DB passwords would become unreadable after regeneration. They are not affected -- only the SSL/TLS certificate changes. - Fix KeyEncryptorTest: replace SunJCE class import with string literal to avoid Java module system access error. --- .../client/ui/KeystoreWarningDialog.java | 9 +- .../encryption/test/KeyEncryptorTest.java | 9 +- .../KeystorePasswordRegenerationTest.java | 210 ++++++++++++++++++ 3 files changed, 217 insertions(+), 11 deletions(-) create mode 100644 server/test/com/mirth/connect/server/controllers/KeystorePasswordRegenerationTest.java diff --git a/client/src/com/mirth/connect/client/ui/KeystoreWarningDialog.java b/client/src/com/mirth/connect/client/ui/KeystoreWarningDialog.java index a2232b9a4..c0e1de676 100644 --- a/client/src/com/mirth/connect/client/ui/KeystoreWarningDialog.java +++ b/client/src/com/mirth/connect/client/ui/KeystoreWarningDialog.java @@ -42,12 +42,9 @@ private void initComponents() { + "default values.

" + "The keystore stores the SSL/TLS certificate for the BridgeLink API and
" + "Administrator (port 8443).

" - + "Note: If any channels have message-level encryption enabled
" - + "(encryptData=true), those stored messages will become unreadable after
" - + "regeneration. Channels will continue to process new messages normally.

" - + "Note: If encrypt.properties=true in mirth.properties, the encrypted
" - + "database.password will also become unreadable after regeneration.
" - + "This setting defaults to false in most installations.

" + + "Note: Channel message encryption (encryptData=true) and encrypted
" + + "database passwords (encrypt.properties=true) are NOT affected — they use
" + + "a separate AES key that is preserved during regeneration.

" + "Note: BridgeLink must be restarted after regenerating the keystore
" + "for the new SSL certificate to take effect."); diff --git a/server/test/com/mirth/commons/encryption/test/KeyEncryptorTest.java b/server/test/com/mirth/commons/encryption/test/KeyEncryptorTest.java index a2d0f1276..ed1eca481 100644 --- a/server/test/com/mirth/commons/encryption/test/KeyEncryptorTest.java +++ b/server/test/com/mirth/commons/encryption/test/KeyEncryptorTest.java @@ -35,7 +35,6 @@ import com.mirth.commons.encryption.Output; import com.mirth.commons.encryption.util.EncryptionUtil; import com.mirth.connect.model.EncryptionSettings; -import com.sun.crypto.provider.SunJCE; public class KeyEncryptorTest { @@ -65,7 +64,7 @@ public void testAESCBC128SunJCE() throws Exception { EncryptionSettings encryptionSettings = new EncryptionSettings(); encryptionSettings.setEncryptionAlgorithm("AES/CBC/PKCS5Padding"); encryptionSettings.setEncryptionKeyLength(128); - encryptionSettings.setSecurityProvider(SunJCE.class.getName()); + encryptionSettings.setSecurityProvider("com.sun.crypto.provider.SunJCE"); encryptionSettings.setEncryptionCharset(StandardCharsets.UTF_8.name()); testEncryptAndDecrypt(encryptionSettings); } @@ -76,7 +75,7 @@ public void testAESCBC128SunJCE() throws Exception { // EncryptionSettings encryptionSettings = new EncryptionSettings(); // encryptionSettings.setEncryptionAlgorithm("AES/CBC/PKCS5Padding"); // encryptionSettings.setEncryptionKeyLength(256); -// encryptionSettings.setSecurityProvider(SunJCE.class.getName()); +// encryptionSettings.setSecurityProvider("com.sun.crypto.provider.SunJCE"); // encryptionSettings.setEncryptionCharset(StandardCharsets.UTF_8.name()); // testEncryptAndDecrypt(encryptionSettings); // } @@ -161,7 +160,7 @@ public void testAESCBC128SunJCE_AESGCM128BC() throws Exception { EncryptionSettings oldEncryptionSettings = new EncryptionSettings(); oldEncryptionSettings.setEncryptionAlgorithm("AES/CBC/PKCS5Padding"); oldEncryptionSettings.setEncryptionKeyLength(128); - oldEncryptionSettings.setSecurityProvider(SunJCE.class.getName()); + oldEncryptionSettings.setSecurityProvider("com.sun.crypto.provider.SunJCE"); oldEncryptionSettings.setEncryptionCharset(StandardCharsets.UTF_8.name()); EncryptionSettings encryptionSettings = new EncryptionSettings(); @@ -225,7 +224,7 @@ public void testDESCBC56SunJCE() throws Exception { EncryptionSettings encryptionSettings = new EncryptionSettings(); encryptionSettings.setEncryptionAlgorithm("DES/CBC/PKCS5Padding"); encryptionSettings.setEncryptionKeyLength(56); - encryptionSettings.setSecurityProvider(SunJCE.class.getName()); + encryptionSettings.setSecurityProvider("com.sun.crypto.provider.SunJCE"); encryptionSettings.setEncryptionCharset(StandardCharsets.UTF_8.name()); testEncryptAndDecrypt(encryptionSettings); } diff --git a/server/test/com/mirth/connect/server/controllers/KeystorePasswordRegenerationTest.java b/server/test/com/mirth/connect/server/controllers/KeystorePasswordRegenerationTest.java new file mode 100644 index 000000000..3008e8ba2 --- /dev/null +++ b/server/test/com/mirth/connect/server/controllers/KeystorePasswordRegenerationTest.java @@ -0,0 +1,210 @@ +/* + * Copyright (c) Innovar Healthcare. All rights reserved. + */ + +package com.mirth.connect.server.controllers; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.util.Enumeration; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import com.mirth.commons.encryption.KeyEncryptor; +import com.mirth.commons.encryption.Output; + +/** + * Verifies that keystore password regeneration (IRT-577) does NOT affect the + * AES data-encryption key — messages encrypted with encryptData=true and + * database passwords stored via encrypt.properties=true remain fully + * decryptable after the keystore is re-keyed. + * + * The proof: after re-keying the keystore under new random passwords the same + * raw key bytes come back out, so any ciphertext produced before regeneration + * can still be decrypted. + */ +public class KeystorePasswordRegenerationTest { + + private static final String KEYSTORE_TYPE = "JCEKS"; + private static final String SECRET_KEY_ALIAS = "encryption"; + private static final String DEFAULT_STOREPASS = "81uWxplDtB"; + private static final String DEFAULT_KEYPASS = "81uWxplDtB"; + private static final String ENCRYPTION_ALGO = "AES/CBC/PKCS5Padding"; + private static final int KEY_LENGTH = 128; + + private File keystoreFile; + private SecretKey originalAesKey; + private String newStorepass; + private String newKeypass; + + @Before + public void setUp() throws Exception { + keystoreFile = File.createTempFile("test-keystore", ".jceks"); + keystoreFile.deleteOnExit(); + + BouncyCastleProvider provider = new BouncyCastleProvider(); + KeyGenerator keyGen = KeyGenerator.getInstance("AES", provider); + keyGen.init(KEY_LENGTH); + originalAesKey = keyGen.generateKey(); + + // Build a JCEKS keystore that mimics a fresh BridgeLink install: + // AES secret key stored under "encryption" alias with default passwords. + KeyStore ks = KeyStore.getInstance(KEYSTORE_TYPE); + ks.load(null, DEFAULT_STOREPASS.toCharArray()); + ks.setEntry(SECRET_KEY_ALIAS, new KeyStore.SecretKeyEntry(originalAesKey), + new KeyStore.PasswordProtection(DEFAULT_KEYPASS.toCharArray())); + try (FileOutputStream fos = new FileOutputStream(keystoreFile)) { + ks.store(fos, DEFAULT_STOREPASS.toCharArray()); + } + + // Run the same re-keying logic as DefaultConfigurationController.regenerateKeystorePassword() + newStorepass = generateNewPassword(); + newKeypass = generateNewPassword(); + reKeystore(DEFAULT_STOREPASS, DEFAULT_KEYPASS, newStorepass, newKeypass); + } + + @After + public void tearDown() { + if (keystoreFile != null) { + keystoreFile.delete(); + } + } + + /** + * Claim: encryptData messages remain readable after keystore password regeneration. + * + * The AES key bytes are unchanged — only the password wrapping them in the + * keystore changes. A message encrypted before regeneration must still decrypt + * correctly after loading the re-keyed keystore. + */ + @Test + public void encryptDataMessagesRemainsReadableAfterKeystoreRekey() throws Exception { + BouncyCastleProvider provider = new BouncyCastleProvider(); + + // Encrypt a message using the ORIGINAL key (simulates a stored encryptData message) + KeyEncryptor encryptorBefore = buildEncryptor(provider, originalAesKey); + String plaintext = "HL7 patient message - channel encryptData=true"; + String ciphertext = encryptorBefore.encrypt(plaintext); + + // After regeneration: reload keystore with NEW passwords and extract the key + SecretKey keyAfterRegen = loadKeyFromKeystore(newStorepass, newKeypass); + + // The raw key bytes must be identical + assertArrayEquals( + "AES key bytes must be unchanged after keystore re-key", + originalAesKey.getEncoded(), + keyAfterRegen.getEncoded()); + + // A KeyEncryptor built from the post-regen key must decrypt the pre-regen ciphertext + KeyEncryptor encryptorAfter = buildEncryptor(provider, keyAfterRegen); + String decrypted = encryptorAfter.decrypt(ciphertext); + + assertEquals( + "encryptData messages must remain readable after keystore password regeneration", + plaintext, decrypted); + } + + /** + * Claim: encrypt.properties database passwords remain readable after keystore password + * regeneration. + * + * Same AES key is used to encrypt mirth.properties values when encrypt.properties=true. + * After re-keying the keystore the password can still be decrypted. + */ + @Test + public void encryptPropertiesDbPasswordRemainsReadableAfterKeystoreRekey() throws Exception { + BouncyCastleProvider provider = new BouncyCastleProvider(); + + // Simulate encrypting the database password at startup (encrypt.properties=true) + KeyEncryptor encryptorBefore = buildEncryptor(provider, originalAesKey); + String dbPassword = "s3cr3tDbP@ssword!"; + String storedValue = "{enc}" + encryptorBefore.encrypt(dbPassword); + + assertTrue("Stored value must start with {enc} prefix", storedValue.startsWith("{enc}")); + + // After regeneration: reload keystore with NEW passwords + SecretKey keyAfterRegen = loadKeyFromKeystore(newStorepass, newKeypass); + + assertArrayEquals( + "AES key bytes must be unchanged after keystore re-key", + originalAesKey.getEncoded(), + keyAfterRegen.getEncoded()); + + // Strip the {enc} prefix and decrypt — same as what the server does at startup + KeyEncryptor encryptorAfter = buildEncryptor(provider, keyAfterRegen); + String encryptedPart = storedValue.substring("{enc}".length()); + String decryptedPassword = encryptorAfter.decrypt(encryptedPart); + + assertEquals( + "Encrypted database.password from encrypt.properties must remain readable after keystore password regeneration", + dbPassword, decryptedPassword); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private void reKeystore(String oldStorepass, String oldKeypass, + String newStorepass, String newKeypass) throws Exception { + KeyStore ks = KeyStore.getInstance(KEYSTORE_TYPE); + try (FileInputStream fis = new FileInputStream(keystoreFile)) { + ks.load(fis, oldStorepass.toCharArray()); + } + + Enumeration aliases = ks.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (ks.isKeyEntry(alias)) { + java.security.Key key = ks.getKey(alias, oldKeypass.toCharArray()); + java.security.cert.Certificate[] chain = ks.getCertificateChain(alias); + ks.setKeyEntry(alias, key, newKeypass.toCharArray(), chain); + } + } + + try (FileOutputStream fos = new FileOutputStream(keystoreFile)) { + ks.store(fos, newStorepass.toCharArray()); + } + } + + private SecretKey loadKeyFromKeystore(String storepass, String keypass) throws Exception { + KeyStore ks = KeyStore.getInstance(KEYSTORE_TYPE); + try (FileInputStream fis = new FileInputStream(keystoreFile)) { + ks.load(fis, storepass.toCharArray()); + } + return (SecretKey) ks.getKey(SECRET_KEY_ALIAS, keypass.toCharArray()); + } + + private KeyEncryptor buildEncryptor(BouncyCastleProvider provider, SecretKey key) throws Exception { + KeyEncryptor encryptor = new KeyEncryptor(); + encryptor.setProvider(provider); + encryptor.setKey(key); + encryptor.setAlgorithm(ENCRYPTION_ALGO); + encryptor.setFormat(Output.BASE64); + encryptor.initialize(); + return encryptor; + } + + private String generateNewPassword() { + SecureRandom random = new SecureRandom(); + byte[] bytes = new byte[16]; + random.nextBytes(bytes); + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} From 4387e01db344cb618e8054e47838913cafb7e993 Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Fri, 8 May 2026 13:52:29 -0500 Subject: [PATCH 10/25] fix(IRT-577): align checkbox with message text in KeystoreWarningDialog, restore encryption note - Indent regenerate checkbox to align with the message text column (icon width + gap) - Restore note clarifying encryptData messages and encrypt.properties passwords are NOT affected by keystore regeneration --- .../connect/client/ui/KeystoreWarningDialog.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/client/src/com/mirth/connect/client/ui/KeystoreWarningDialog.java b/client/src/com/mirth/connect/client/ui/KeystoreWarningDialog.java index c0e1de676..5d0d5804e 100644 --- a/client/src/com/mirth/connect/client/ui/KeystoreWarningDialog.java +++ b/client/src/com/mirth/connect/client/ui/KeystoreWarningDialog.java @@ -35,7 +35,9 @@ public KeystoreWarningDialog(Window owner) { private void initComponents() { setDefaultCloseOperation(javax.swing.WindowConstants.DISPOSE_ON_CLOSE); - JLabel iconLabel = new JLabel(UIManager.getIcon("OptionPane.warningIcon")); + javax.swing.Icon warningIcon = UIManager.getIcon("OptionPane.warningIcon"); + JLabel iconLabel = new JLabel(warningIcon); + int iconWidth = (warningIcon != null) ? warningIcon.getIconWidth() : 32; JLabel messageLabel = new JLabel( "The keystore passwords for this BridgeLink instance are still set to " @@ -43,8 +45,8 @@ private void initComponents() { + "The keystore stores the SSL/TLS certificate for the BridgeLink API and
" + "Administrator (port 8443).

" + "Note: Channel message encryption (encryptData=true) and encrypted
" - + "database passwords (encrypt.properties=true) are NOT affected — they use
" - + "a separate AES key that is preserved during regeneration.

" + + "database passwords (encrypt.properties=true) are NOT affected — they
" + + "use a separate AES key that is preserved during regeneration.

" + "Note: BridgeLink must be restarted after regenerating the keystore
" + "for the new SSL certificate to take effect."); @@ -84,7 +86,9 @@ public void actionPerformed(ActionEvent evt) { .addGroup(layout.createSequentialGroup() .addComponent(iconLabel) .addComponent(messageLabel)) - .addComponent(regenerateCheckBox) + .addGroup(layout.createSequentialGroup() + .addGap(iconWidth + 6) + .addComponent(regenerateCheckBox)) .addComponent(buttonPanel, javax.swing.GroupLayout.DEFAULT_SIZE, javax.swing.GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE) ); From ad37cfbd3f8b0c32d59c472fddaca617c37170f5 Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Fri, 8 May 2026 14:15:41 -0500 Subject: [PATCH 11/25] test(IRT-577): add regenerateKeystorePassword controller tests to DefaultConfigurationControllerTests Covers ALREADY_SECURE (non-default and null passwords), REGENERATED for both default password sets (Set A and Set B) including usingDefaultKeystorePassword flag check via reflection, and FileNotFoundException propagation on missing keystore file. saveMirthConfig() enabled by stubbing getConfigurationDir() in @BeforeClass. --- .../DefaultConfigurationControllerTests.java | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/server/test/com/mirth/connect/server/controllers/DefaultConfigurationControllerTests.java b/server/test/com/mirth/connect/server/controllers/DefaultConfigurationControllerTests.java index 16b976f89..4e8934c74 100644 --- a/server/test/com/mirth/connect/server/controllers/DefaultConfigurationControllerTests.java +++ b/server/test/com/mirth/connect/server/controllers/DefaultConfigurationControllerTests.java @@ -24,9 +24,13 @@ import static org.mockito.Mockito.when; import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; import java.io.StringReader; import java.io.StringWriter; import java.io.Writer; +import java.lang.reflect.Field; +import java.security.KeyStore; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -52,6 +56,7 @@ import com.google.inject.Injector; import com.mirth.connect.client.core.ControllerException; import com.mirth.connect.server.controllers.ConfigurationController; +import com.mirth.connect.util.KeystoreRegenerationResponse; import com.mirth.connect.util.ConnectionTestResponse; import com.mirth.connect.model.DriverInfo; import com.mirth.connect.util.ConfigurationProperty; @@ -68,6 +73,7 @@ public static void setup() throws Exception { ConfigurationController configController = mock(ConfigurationController.class); when(configController.getHttpsClientProtocols()).thenReturn(new String[0]); when(configController.getHttpsCipherSuites()).thenReturn(new String[0]); + when(configController.getConfigurationDir()).thenReturn(System.getProperty("java.io.tmpdir")); when(controllerFactory.createConfigurationController()).thenReturn(configController); Injector injector = Guice.createInjector(new AbstractModule() { @@ -567,6 +573,154 @@ public void testSendTestEmail_OAuthEmptyTokenUrl_ThrowsException() throws Except } } + // ----------------------------------------------------------------------- + // regenerateKeystorePassword + // ----------------------------------------------------------------------- + + private static final String DEFAULT_STOREPASS_A = "81uWxplDtB"; + private static final String DEFAULT_KEYPASS_A = "81uWxplDtB"; + private static final String DEFAULT_STOREPASS_B = "nfHbaNFacIhQ"; + private static final String DEFAULT_KEYPASS_B = "tdW6edezNbmd"; + + @Test + public void regenerateKeystorePassword_NonDefaultPasswords_ReturnsAlreadySecure() throws Exception { + String prevStorepass = DefaultConfigurationController.mirthConfig.getString("keystore.storepass"); + String prevKeypass = DefaultConfigurationController.mirthConfig.getString("keystore.keypass"); + try { + DefaultConfigurationController.mirthConfig.setProperty("keystore.storepass", "uniqueNonDefault1"); + DefaultConfigurationController.mirthConfig.setProperty("keystore.keypass", "uniqueNonDefault2"); + + KeystoreRegenerationResponse response = new DefaultConfigurationController().regenerateKeystorePassword(); + + assertEquals(KeystoreRegenerationResponse.Type.ALREADY_SECURE, response.getType()); + assertEquals("uniqueNonDefault1", DefaultConfigurationController.mirthConfig.getString("keystore.storepass")); + } finally { + restoreMirthConfigProperty("keystore.storepass", prevStorepass); + restoreMirthConfigProperty("keystore.keypass", prevKeypass); + } + } + + @Test + public void regenerateKeystorePassword_NullPasswords_ReturnsAlreadySecure() throws Exception { + String prevStorepass = DefaultConfigurationController.mirthConfig.getString("keystore.storepass"); + String prevKeypass = DefaultConfigurationController.mirthConfig.getString("keystore.keypass"); + try { + DefaultConfigurationController.mirthConfig.clearProperty("keystore.storepass"); + DefaultConfigurationController.mirthConfig.clearProperty("keystore.keypass"); + + KeystoreRegenerationResponse response = new DefaultConfigurationController().regenerateKeystorePassword(); + + assertEquals(KeystoreRegenerationResponse.Type.ALREADY_SECURE, response.getType()); + } finally { + restoreMirthConfigProperty("keystore.storepass", prevStorepass); + restoreMirthConfigProperty("keystore.keypass", prevKeypass); + } + } + + @Test + public void regenerateKeystorePassword_DefaultSetA_ReturnsRegenerated() throws Exception { + String prevStorepass = DefaultConfigurationController.mirthConfig.getString("keystore.storepass"); + String prevKeypass = DefaultConfigurationController.mirthConfig.getString("keystore.keypass"); + String prevPath = DefaultConfigurationController.mirthConfig.getString("keystore.path"); + String prevType = DefaultConfigurationController.mirthConfig.getString("keystore.type"); + File tempKs = createEmptyJceksKeystore(DEFAULT_STOREPASS_A); + try { + DefaultConfigurationController.mirthConfig.setProperty("keystore.storepass", DEFAULT_STOREPASS_A); + DefaultConfigurationController.mirthConfig.setProperty("keystore.keypass", DEFAULT_KEYPASS_A); + DefaultConfigurationController.mirthConfig.setProperty("keystore.path", tempKs.getAbsolutePath()); + DefaultConfigurationController.mirthConfig.setProperty("keystore.type", "JCEKS"); + + DefaultConfigurationController controller = new DefaultConfigurationController(); + KeystoreRegenerationResponse response = controller.regenerateKeystorePassword(); + + assertEquals(KeystoreRegenerationResponse.Type.REGENERATED, response.getType()); + assertFalse("storepass must change from Set-A default", + DEFAULT_STOREPASS_A.equals(DefaultConfigurationController.mirthConfig.getString("keystore.storepass"))); + assertFalse("keypass must change from Set-A default", + DEFAULT_KEYPASS_A.equals(DefaultConfigurationController.mirthConfig.getString("keystore.keypass"))); + Field flag = DefaultConfigurationController.class.getDeclaredField("usingDefaultKeystorePassword"); + flag.setAccessible(true); + assertFalse((Boolean) flag.get(controller)); + } finally { + restoreMirthConfigProperty("keystore.storepass", prevStorepass); + restoreMirthConfigProperty("keystore.keypass", prevKeypass); + restoreMirthConfigProperty("keystore.path", prevPath); + restoreMirthConfigProperty("keystore.type", prevType); + tempKs.delete(); + } + } + + @Test + public void regenerateKeystorePassword_DefaultSetB_ReturnsRegenerated() throws Exception { + String prevStorepass = DefaultConfigurationController.mirthConfig.getString("keystore.storepass"); + String prevKeypass = DefaultConfigurationController.mirthConfig.getString("keystore.keypass"); + String prevPath = DefaultConfigurationController.mirthConfig.getString("keystore.path"); + String prevType = DefaultConfigurationController.mirthConfig.getString("keystore.type"); + File tempKs = createEmptyJceksKeystore(DEFAULT_STOREPASS_B); + try { + DefaultConfigurationController.mirthConfig.setProperty("keystore.storepass", DEFAULT_STOREPASS_B); + DefaultConfigurationController.mirthConfig.setProperty("keystore.keypass", DEFAULT_KEYPASS_B); + DefaultConfigurationController.mirthConfig.setProperty("keystore.path", tempKs.getAbsolutePath()); + DefaultConfigurationController.mirthConfig.setProperty("keystore.type", "JCEKS"); + + KeystoreRegenerationResponse response = new DefaultConfigurationController().regenerateKeystorePassword(); + + assertEquals(KeystoreRegenerationResponse.Type.REGENERATED, response.getType()); + assertFalse("storepass must change from Set-B default", + DEFAULT_STOREPASS_B.equals(DefaultConfigurationController.mirthConfig.getString("keystore.storepass"))); + assertFalse("keypass must change from Set-B default", + DEFAULT_KEYPASS_B.equals(DefaultConfigurationController.mirthConfig.getString("keystore.keypass"))); + } finally { + restoreMirthConfigProperty("keystore.storepass", prevStorepass); + restoreMirthConfigProperty("keystore.keypass", prevKeypass); + restoreMirthConfigProperty("keystore.path", prevPath); + restoreMirthConfigProperty("keystore.type", prevType); + tempKs.delete(); + } + } + + @Test + public void regenerateKeystorePassword_MissingKeystoreFile_ThrowsException() throws Exception { + String prevStorepass = DefaultConfigurationController.mirthConfig.getString("keystore.storepass"); + String prevKeypass = DefaultConfigurationController.mirthConfig.getString("keystore.keypass"); + String prevPath = DefaultConfigurationController.mirthConfig.getString("keystore.path"); + try { + DefaultConfigurationController.mirthConfig.setProperty("keystore.storepass", DEFAULT_STOREPASS_A); + DefaultConfigurationController.mirthConfig.setProperty("keystore.keypass", DEFAULT_KEYPASS_A); + DefaultConfigurationController.mirthConfig.setProperty("keystore.path", "/no/such/file.jks"); + + try { + new DefaultConfigurationController().regenerateKeystorePassword(); + fail("Expected FileNotFoundException for missing keystore"); + } catch (FileNotFoundException e) { + // expected + } + } finally { + restoreMirthConfigProperty("keystore.storepass", prevStorepass); + restoreMirthConfigProperty("keystore.keypass", prevKeypass); + restoreMirthConfigProperty("keystore.path", prevPath); + } + } + + private static File createEmptyJceksKeystore(String storepass) throws Exception { + KeyStore ks = KeyStore.getInstance("JCEKS"); + ks.load(null, null); + File f = File.createTempFile("test-ks-", ".jks"); + f.deleteOnExit(); + try (FileOutputStream fos = new FileOutputStream(f)) { + ks.store(fos, storepass.toCharArray()); + } + return f; + } + + private static void restoreMirthConfigProperty(String key, String value) { + if (value == null) { + DefaultConfigurationController.mirthConfig.clearProperty(key); + } else { + DefaultConfigurationController.mirthConfig.setProperty(key, value); + } + } + private void assertDefaultDrivers(List drivers, boolean includeODBC) { assertEquals(includeODBC ? 7 : 6, drivers.size()); int i = 0; From e046bc49dac50bad2a9e7fa832fab200de5e536a Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Fri, 8 May 2026 14:45:59 -0500 Subject: [PATCH 12/25] fix(IRT-776): set Derby upgrade=false in mirth.properties --- server/conf/mirth.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/conf/mirth.properties b/server/conf/mirth.properties index efb5d7bee..ccc6e1263 100644 --- a/server/conf/mirth.properties +++ b/server/conf/mirth.properties @@ -89,7 +89,7 @@ database = derby # SQL Server/Sybase (jTDS) jdbc:jtds:sqlserver://localhost:1433/mirthdb # Microsoft SQL Server jdbc:sqlserver://localhost:1433;databaseName=mirthdb # If you are using the Microsoft SQL Server driver, please also specify database.driver below -database.url = jdbc:derby:${dir.appdata}/mirthdb;create=true;upgrade=true +database.url = jdbc:derby:${dir.appdata}/mirthdb;create=true;upgrade=false # If using a custom or non-default driver, specify it here. # example: From 29910fc68f07fd0605b90e1882f42f0d2c07dc56 Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Mon, 11 May 2026 10:27:47 -0500 Subject: [PATCH 13/25] chore: remove Windows installer firstLogin.password comment from mirth.properties --- server/conf/mirth.properties | 3 --- 1 file changed, 3 deletions(-) diff --git a/server/conf/mirth.properties b/server/conf/mirth.properties index ccc6e1263..999702d01 100644 --- a/server/conf/mirth.properties +++ b/server/conf/mirth.properties @@ -8,9 +8,6 @@ dir.tempdata = ${dir.appdata}/temp http.port = 8080 https.port = 8443 -# Windows installer sets this for fresh installs; leave commented for upgrades. -# default.firstLogin.password = - # password requirements password.minlength = 8 password.minupper = 1 From 0b72a86c06d6822855ae52aa82cb3be441c6081a Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Tue, 12 May 2026 15:15:48 -0500 Subject: [PATCH 14/25] fix(IRT-828): set Content-Length on static resources before writing Jetty 12 commits the response before the full body is written when no Content-Length is known, causing ERR_HTTP2_PROTOCOL_ERROR in browsers for large resources (>~30 kB). For FILE, DIRECTORY, and CUSTOM resource types, Content-Length / Content-Length-Long is now set before the first write. Skipped when GZIP is active since compressed size is unknown. Also wraps the error-path reset() call in a try/catch for IllegalStateException so a mid-stream write error that already committed the response does not mask the original exception. Adds 22 unit tests (HttpReceiverStaticResourceTest) covering all three resource types, GZIP paths, error paths, and the committed-response guard. Adds 8 integration tests (HttpReceiverStaticResourceIntegrationTest) that spin up a real Jetty 12 server on loopback and assert Content-Length is present on the wire for 100 KB CUSTOM and FILE resources, and that GZIP responses decompress correctly end-to-end. --- .../connect/connectors/http/HttpReceiver.java | 31 +- ...ReceiverStaticResourceIntegrationTest.java | 366 ++++++++++++ .../http/HttpReceiverStaticResourceTest.java | 521 ++++++++++++++++++ 3 files changed, 911 insertions(+), 7 deletions(-) create mode 100644 server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java create mode 100644 server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceTest.java diff --git a/server/src/com/mirth/connect/connectors/http/HttpReceiver.java b/server/src/com/mirth/connect/connectors/http/HttpReceiver.java index a76c98365..67a5cdd21 100644 --- a/server/src/com/mirth/connect/connectors/http/HttpReceiver.java +++ b/server/src/com/mirth/connect/connectors/http/HttpReceiver.java @@ -728,12 +728,14 @@ public void handle(String target, Request baseRequest, HttpServletRequest servle OutputStream responseOutputStream = servletResponse.getOutputStream(); // If the client accepts GZIP compression, compress the content + boolean gzipOutput = false; List acceptEncodingList = requestMessage.getCaseInsensitiveHeaders().get("Accept-Encoding"); if (CollectionUtils.isNotEmpty(acceptEncodingList)) { for (String acceptEncoding : acceptEncodingList) { if (acceptEncoding != null && acceptEncoding.contains("gzip")) { servletResponse.setHeader(HTTP.CONTENT_ENCODING, "gzip"); responseOutputStream = new GZIPOutputStream(responseOutputStream); + gzipOutput = true; break; } } @@ -743,7 +745,11 @@ public void handle(String target, Request baseRequest, HttpServletRequest servle // Just stream the file itself back to the client InputStream is = null; try { - is = new FileInputStream(value); + File f = new File(value); + if (!gzipOutput) { + servletResponse.setContentLengthLong(f.length()); + } + is = new FileInputStream(f); IOUtils.copy(is, responseOutputStream); } finally { ResourceUtil.closeResourceQuietly(is); @@ -791,6 +797,9 @@ public void handle(String target, Request baseRequest, HttpServletRequest servle // A valid file was found; stream it back to the client InputStream is = null; try { + if (!gzipOutput) { + servletResponse.setContentLengthLong(file.length()); + } is = new FileInputStream(file); IOUtils.copy(is, responseOutputStream); } finally { @@ -803,21 +812,29 @@ public void handle(String target, Request baseRequest, HttpServletRequest servle } } else { // Stream the value string back to the client - IOUtils.write(value, responseOutputStream, charset); + byte[] valueBytes = value.getBytes(charset); + if (!gzipOutput) { + servletResponse.setContentLength(valueBytes.length); + } + responseOutputStream.write(valueBytes); } // If we gzipped, we need to finish the stream now - if (responseOutputStream instanceof GZIPOutputStream) { + if (gzipOutput) { ((GZIPOutputStream) responseOutputStream).finish(); } } catch (Throwable t) { logger.error("Error handling static HTTP resource request (" + getConnectorProperties().getName() + " \"Source\" on channel " + getChannelId() + ").", t); eventController.dispatchEvent(new ErrorEvent(getChannelId(), getMetaDataId(), null, ErrorEventType.SOURCE_CONNECTOR, getSourceName(), getConnectorProperties().getName(), "Error handling static HTTP resource request", t)); - servletResponse.reset(); - servletResponse.setContentType(ContentType.TEXT_PLAIN.toString()); - servletResponse.setStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR); - servletResponse.getOutputStream().write(ExceptionUtils.getStackTrace(t).getBytes()); + try { + servletResponse.reset(); + servletResponse.setContentType(ContentType.TEXT_PLAIN.toString()); + servletResponse.setStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR); + servletResponse.getOutputStream().write(ExceptionUtils.getStackTrace(t).getBytes()); + } catch (IllegalStateException ise) { + logger.debug("Could not reset already-committed response after static resource error", ise); + } } finally { Thread.currentThread().setName(originalThreadName); } diff --git a/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java b/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java new file mode 100644 index 000000000..ef6cfc2b0 --- /dev/null +++ b/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java @@ -0,0 +1,366 @@ +package com.mirth.connect.connectors.http; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Random; +import java.util.zip.GZIPInputStream; + +import org.apache.commons.io.IOUtils; +import org.apache.http.Header; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.eclipse.jetty.ee8.nested.AbstractHandler; +import org.eclipse.jetty.ee8.nested.ContextHandler; +import org.eclipse.jetty.ee8.nested.HandlerCollection; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import com.google.inject.AbstractModule; +import com.google.inject.Guice; + +import com.mirth.connect.connectors.http.HttpStaticResource.ResourceType; +import com.mirth.connect.donkey.server.channel.Channel; +import com.mirth.connect.server.controllers.ConfigurationController; +import com.mirth.connect.server.controllers.ControllerFactory; +import com.mirth.connect.server.controllers.EventController; + +/** + * Integration tests for IRT-828: verifies that Content-Length is correctly set on the wire for + * large static resources served by {@link HttpReceiver}, using a real Jetty 12 server on loopback. + * + *

These tests close the coverage gap left by {@link HttpReceiverStaticResourceTest}: the unit + * tests verify the fix is present (Content-Length set in the mock response), but cannot + * prove the fix resolves the runtime behavior — specifically that Jetty 12 does not prematurely + * commit the response when Content-Length is set. Running against a real Jetty 12 + HTTP/1.1 + * stack proves the full write path behaves correctly for large (100 KB) resources. + * + *

HTTP/1.1 is sufficient: the {@code ERR_HTTP2_PROTOCOL_ERROR} the browser reports is a + * symptom of Jetty sending a malformed HTTP/2 stream when Content-Length is absent. Once + * Content-Length is set correctly on HTTP/1.1, the HTTP/2 path is also fixed because Jetty uses + * Content-Length to correctly signal end-of-stream in both protocols. + * + *

Verified against Jetty 12.0.x. + */ +public class HttpReceiverStaticResourceIntegrationTest { + + private static final String LOOPBACK = "127.0.0.1"; + private static final int LARGE_SIZE = 100 * 1024; // 100 KB — well above the ~30 KB failure threshold + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private HttpReceiver receiver; + private Server jettyServer; + private ServerConnector serverConnector; + + // ------------------------------------------------------------------------- + // Class-level setup — Guice / Mockito + // ------------------------------------------------------------------------- + + @BeforeClass + public static void setUpClass() { + ControllerFactory controllerFactory = mock(ControllerFactory.class); + ConfigurationController configurationController = mock(ConfigurationController.class); + when(controllerFactory.createConfigurationController()).thenReturn(configurationController); + EventController eventController = mock(EventController.class); + when(controllerFactory.createEventController()).thenReturn(eventController); + Guice.createInjector(new AbstractModule() { + @Override + protected void configure() { + requestStaticInjection(ControllerFactory.class); + bind(ControllerFactory.class).toInstance(controllerFactory); + } + }).getInstance(ControllerFactory.class); + } + + // ------------------------------------------------------------------------- + // Per-test setup / teardown + // ------------------------------------------------------------------------- + + @Before + public void setUp() throws Exception { + receiver = new HttpReceiver(); + HttpReceiverProperties props = new HttpReceiverProperties(); + props.setCharset("UTF-8"); + + Channel channel = mock(Channel.class); + doReturn("test-channel-id").when(channel).getChannelId(); + doReturn("Test Channel").when(channel).getName(); + receiver.setChannel(channel); + receiver.setConnectorProperties(props); + + Field arrayField = HttpReceiver.class.getDeclaredField("binaryMimeTypesArray"); + arrayField.setAccessible(true); + arrayField.set(receiver, new String[0]); + + Field regexField = HttpReceiver.class.getDeclaredField("binaryMimeTypesRegex"); + regexField.setAccessible(true); + regexField.set(receiver, java.util.regex.Pattern.compile("$^")); + + HttpConfiguration httpConfig = mock(HttpConfiguration.class); + when(httpConfig.getRequestInformation(any())).thenReturn(new HashMap<>()); + Field configField = HttpReceiver.class.getDeclaredField("configuration"); + configField.setAccessible(true); + configField.set(receiver, httpConfig); + } + + @After + public void tearDown() throws Exception { + if (jettyServer != null && jettyServer.isRunning()) { + jettyServer.stop(); + jettyServer = null; + } + } + + // ========================================================================= + // GROUP A — ResourceType.CUSTOM (inline string value) + // ========================================================================= + + @Test + public void testCustom_large_contentLengthPresentOnWire() throws Exception { + String content = repeat('A', LARGE_SIZE); + long expectedLen = content.getBytes(StandardCharsets.UTF_8).length; + int port = startServer(new HttpStaticResource("/resource", ResourceType.CUSTOM, content, "text/plain", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/resource"))) { + assertEquals(200, resp.getStatusLine().getStatusCode()); + Header h = resp.getFirstHeader("Content-Length"); + assertNotNull("Content-Length must be present for large CUSTOM resource", h); + assertEquals(expectedLen, Long.parseLong(h.getValue())); + } + } + + @Test + public void testCustom_large_bodyIntegrity() throws Exception { + String content = repeat('A', LARGE_SIZE); + int port = startServer(new HttpStaticResource("/resource", ResourceType.CUSTOM, content, "text/plain", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/resource"))) { + byte[] body = IOUtils.toByteArray(resp.getEntity().getContent()); + assertArrayEquals(content.getBytes(StandardCharsets.UTF_8), body); + } + } + + @Test + public void testCustom_gzip_contentEncodingHeaderPresent() throws Exception { + String content = repeat('A', LARGE_SIZE); + int port = startServer(new HttpStaticResource("/resource", ResourceType.CUSTOM, content, "text/plain", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(getGzip(port, "/resource"))) { + assertEquals(200, resp.getStatusLine().getStatusCode()); + Header h = resp.getFirstHeader("Content-Encoding"); + assertNotNull("Content-Encoding header must be present for gzip CUSTOM response", h); + assertTrue("Content-Encoding must be gzip", h.getValue().contains("gzip")); + } + } + + @Test + public void testCustom_gzip_bodyDecompressesToOriginalContent() throws Exception { + String content = repeat('A', LARGE_SIZE); + int port = startServer(new HttpStaticResource("/resource", ResourceType.CUSTOM, content, "text/plain", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(getGzip(port, "/resource"))) { + byte[] compressed = IOUtils.toByteArray(resp.getEntity().getContent()); + String decompressed; + try (GZIPInputStream gzis = new GZIPInputStream(new ByteArrayInputStream(compressed))) { + decompressed = new String(gzis.readAllBytes(), StandardCharsets.UTF_8); + } + assertEquals(content, decompressed); + } + } + + // ========================================================================= + // GROUP B — ResourceType.FILE + // ========================================================================= + + @Test + public void testFile_large_contentLengthPresentOnWire() throws Exception { + byte[] content = randomBytes(LARGE_SIZE); + File f = writeTempFile("large.bin", content); + int port = startServer(new HttpStaticResource("/resource", ResourceType.FILE, f.getAbsolutePath(), "application/octet-stream", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/resource"))) { + assertEquals(200, resp.getStatusLine().getStatusCode()); + Header h = resp.getFirstHeader("Content-Length"); + assertNotNull("Content-Length must be present for large FILE resource", h); + assertEquals(content.length, Long.parseLong(h.getValue())); + } + } + + @Test + public void testFile_large_bodyIntegrity() throws Exception { + byte[] content = randomBytes(LARGE_SIZE); + File f = writeTempFile("large-body.bin", content); + int port = startServer(new HttpStaticResource("/resource", ResourceType.FILE, f.getAbsolutePath(), "application/octet-stream", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/resource"))) { + byte[] body = IOUtils.toByteArray(resp.getEntity().getContent()); + assertArrayEquals(content, body); + } + } + + @Test + public void testFile_gzip_contentEncodingHeaderPresent() throws Exception { + byte[] content = new byte[LARGE_SIZE]; + Arrays.fill(content, (byte) 'Z'); + File f = writeTempFile("gzip-check.bin", content); + int port = startServer(new HttpStaticResource("/resource", ResourceType.FILE, f.getAbsolutePath(), "application/octet-stream", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(getGzip(port, "/resource"))) { + assertEquals(200, resp.getStatusLine().getStatusCode()); + Header h = resp.getFirstHeader("Content-Encoding"); + assertNotNull("Content-Encoding header must be present for gzip FILE response", h); + assertTrue("Content-Encoding must be gzip", h.getValue().contains("gzip")); + } + } + + @Test + public void testFile_gzip_bodyDecompressesToOriginalContent() throws Exception { + byte[] content = new byte[LARGE_SIZE]; + Arrays.fill(content, (byte) 'Z'); + File f = writeTempFile("gzip-body.bin", content); + int port = startServer(new HttpStaticResource("/resource", ResourceType.FILE, f.getAbsolutePath(), "application/octet-stream", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(getGzip(port, "/resource"))) { + byte[] compressed = IOUtils.toByteArray(resp.getEntity().getContent()); + byte[] decompressed; + try (GZIPInputStream gzis = new GZIPInputStream(new ByteArrayInputStream(compressed))) { + decompressed = gzis.readAllBytes(); + } + assertArrayEquals(content, decompressed); + } + } + + // ========================================================================= + // Helpers — Jetty server setup + // ========================================================================= + + /** + * Starts a real Jetty 12 server on a random loopback port, replicating the handler chain + * from {@link HttpReceiver#onStart()} but with {@code StaticResourceHandler} for a single + * static resource. Uses reflection to instantiate the private inner class. + * + * @return the local port the server is listening on + */ + private int startServer(HttpStaticResource resource) throws Exception { + jettyServer = new Server(); + + org.eclipse.jetty.server.HttpConfiguration httpCfg = new org.eclipse.jetty.server.HttpConfiguration(); + httpCfg.setSendServerVersion(false); + serverConnector = new ServerConnector(jettyServer, new HttpConnectionFactory(httpCfg)); + serverConnector.setHost(LOOPBACK); + serverConnector.setPort(0); // random free port + serverConnector.setIdleTimeout(30_000); + jettyServer.addConnector(serverConnector); + + // Reflectively instantiate the private StaticResourceHandler inner class + Class handlerClass = null; + for (Class c : HttpReceiver.class.getDeclaredClasses()) { + if ("StaticResourceHandler".equals(c.getSimpleName())) { + handlerClass = c; + break; + } + } + assertNotNull("StaticResourceHandler inner class not found in HttpReceiver", handlerClass); + Constructor ctor = handlerClass.getDeclaredConstructor(HttpReceiver.class, HttpStaticResource.class); + ctor.setAccessible(true); + AbstractHandler staticHandler = (AbstractHandler) ctor.newInstance(receiver, resource); + + // Replicate the EE8 handler chain from HttpReceiver.onStart() + ContextHandler resourceContext = new ContextHandler(); + resourceContext.setContextPath(resource.getContextPath()); + resourceContext.setAllowNullPathInfo(true); + resourceContext.setHandler(staticHandler); + + HandlerCollection handlers = new HandlerCollection(); + handlers.addHandler(resourceContext); + + ContextHandler rootContext = new ContextHandler(); + rootContext.setContextPath("/"); + rootContext.setHandler(handlers); + jettyServer.setHandler(rootContext.getCoreContextHandler()); + + jettyServer.start(); + return serverConnector.getLocalPort(); + } + + // ========================================================================= + // Helpers — HTTP client + // ========================================================================= + + /** + * Returns a client with content compression disabled — no automatic {@code Accept-Encoding} + * header is added and gzip responses are NOT auto-decompressed. Required so that tests can + * inspect the raw {@code Content-Length} and {@code Content-Encoding} response headers. + */ + private static CloseableHttpClient noCompressionClient() { + return HttpClients.custom().disableContentCompression().build(); + } + + private static HttpGet get(int port, String path) { + return new HttpGet("http://" + LOOPBACK + ":" + port + path); + } + + private static HttpGet getGzip(int port, String path) { + HttpGet request = new HttpGet("http://" + LOOPBACK + ":" + port + path); + request.setHeader("Accept-Encoding", "gzip"); + return request; + } + + // ========================================================================= + // Helpers — data generation and file I/O + // ========================================================================= + + private static byte[] randomBytes(int size) { + byte[] buf = new byte[size]; + new Random().nextBytes(buf); + return buf; + } + + private File writeTempFile(String name, byte[] content) throws Exception { + File f = tempFolder.newFile(name); + try (FileOutputStream fos = new FileOutputStream(f)) { + fos.write(content); + } + return f; + } + + private static String repeat(char c, int count) { + char[] buf = new char[count]; + Arrays.fill(buf, c); + return new String(buf); + } +} diff --git a/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceTest.java b/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceTest.java new file mode 100644 index 000000000..54f174466 --- /dev/null +++ b/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceTest.java @@ -0,0 +1,521 @@ +package com.mirth.connect.connectors.http; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Random; +import java.util.zip.GZIPInputStream; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.ee8.nested.Request; +import org.eclipse.jetty.http.HttpURI; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import com.google.inject.AbstractModule; +import com.google.inject.Guice; + +import com.mirth.connect.connectors.http.HttpStaticResource.ResourceType; +import com.mirth.connect.donkey.server.channel.Channel; +import com.mirth.connect.server.controllers.ConfigurationController; +import com.mirth.connect.server.controllers.ControllerFactory; +import com.mirth.connect.server.controllers.EventController; + +public class HttpReceiverStaticResourceTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private HttpReceiver receiver; + private HttpReceiverProperties props; + + @BeforeClass + public static void setupBeforeClass() { + ControllerFactory controllerFactory = mock(ControllerFactory.class); + ConfigurationController configurationController = mock(ConfigurationController.class); + when(controllerFactory.createConfigurationController()).thenReturn(configurationController); + EventController eventController = mock(EventController.class); + when(controllerFactory.createEventController()).thenReturn(eventController); + Guice.createInjector(new AbstractModule() { + @Override + protected void configure() { + requestStaticInjection(ControllerFactory.class); + bind(ControllerFactory.class).toInstance(controllerFactory); + } + }).getInstance(ControllerFactory.class); + } + + @Before + public void setUp() throws Exception { + receiver = new HttpReceiver(); + props = new HttpReceiverProperties(); + props.setCharset("UTF-8"); + + Channel channel = mock(Channel.class); + doReturn("test-channel-id").when(channel).getChannelId(); + receiver.setChannel(channel); + receiver.setConnectorProperties(props); + + // Required to avoid NPE in createRequestMessage's binary content type check + java.lang.reflect.Field arrayField = HttpReceiver.class.getDeclaredField("binaryMimeTypesArray"); + arrayField.setAccessible(true); + arrayField.set(receiver, new String[0]); + java.lang.reflect.Field regexField = HttpReceiver.class.getDeclaredField("binaryMimeTypesRegex"); + regexField.setAccessible(true); + regexField.set(receiver, java.util.regex.Pattern.compile("$^")); + + // configuration is null until onDeploy(); inject a minimal mock + HttpConfiguration httpConfig = mock(HttpConfiguration.class); + when(httpConfig.getRequestInformation(any())).thenReturn(new HashMap<>()); + java.lang.reflect.Field configField = HttpReceiver.class.getDeclaredField("configuration"); + configField.setAccessible(true); + configField.set(receiver, httpConfig); + } + + // ========================================================================= + // Infrastructure helpers + // ========================================================================= + + /** + * Reflectively invokes StaticResourceHandler.handle() without starting a real Jetty server. + * Unwraps InvocationTargetException so callers see the real exception if one escapes. + */ + private void invokeHandler(HttpStaticResource resource, Request baseRequest, + HttpServletResponse response) throws Exception { + Class cls = null; + for (Class c : HttpReceiver.class.getDeclaredClasses()) { + if ("StaticResourceHandler".equals(c.getSimpleName())) { + cls = c; + break; + } + } + assertNotNull("StaticResourceHandler inner class not found via reflection", cls); + + Constructor ctor = cls.getDeclaredConstructor(HttpReceiver.class, HttpStaticResource.class); + ctor.setAccessible(true); + Object handler = ctor.newInstance(receiver, resource); + + Method handle = cls.getMethod("handle", String.class, Request.class, + HttpServletRequest.class, HttpServletResponse.class); + try { + // target and servletRequest are unused inside the handler + handle.invoke(handler, null, baseRequest, null, response); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) throw (RuntimeException) cause; + if (cause instanceof Exception) throw (Exception) cause; + throw new RuntimeException(cause); + } + } + + /** + * Builds a mocked Jetty Request for a GET at the given context path. + * Pass acceptGzip=true to include an Accept-Encoding: gzip header. + */ + private Request createGetRequest(String contextPath, boolean acceptGzip) { + Request req = mock(Request.class); + when(req.getMethod()).thenReturn("GET"); + when(req.getRemoteAddr()).thenReturn("127.0.0.1"); + when(req.getRemotePort()).thenReturn(12345); + when(req.getLocalAddr()).thenReturn("127.0.0.1"); + when(req.getLocalPort()).thenReturn(8080); + when(req.getQueryString()).thenReturn(""); + when(req.getRequestURL()).thenReturn(new StringBuffer("http://localhost" + contextPath)); + when(req.getContentType()).thenReturn(null); + when(req.getCharacterEncoding()).thenReturn("UTF-8"); + when(req.getParameterMap()).thenReturn(new HashMap<>()); + when(req.getProtocol()).thenReturn("HTTP/1.1"); + + HttpURI httpURI = mock(HttpURI.class); + when(httpURI.getPathQuery()).thenReturn(contextPath); + when(req.getHttpURI()).thenReturn(httpURI); + + if (acceptGzip) { + when(req.getHeaderNames()).thenReturn( + Collections.enumeration(Collections.singletonList("Accept-Encoding"))); + when(req.getHeaders("Accept-Encoding")).thenReturn( + Collections.enumeration(Collections.singletonList("gzip"))); + } else { + when(req.getHeaderNames()).thenReturn( + Collections.enumeration(Collections.emptyList())); + } + + return req; + } + + // ========================================================================= + // GROUP A — ResourceType.CUSTOM (inline string value) + // ========================================================================= + + @Test + public void testCustomResource_smallString_setsContentLength() throws Exception { + String value = "Hello, static world!"; + int expectedLen = value.getBytes(StandardCharsets.UTF_8).length; + + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertEquals(expectedLen, response.capturedContentLength); + } + + @Test + public void testCustomResource_largeString_setsContentLength() throws Exception { + // 40 KB — the regression size that triggered ERR_HTTP2_PROTOCOL_ERROR + String value = "A".repeat(40 * 1024); + int expectedLen = value.getBytes(StandardCharsets.UTF_8).length; + + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertEquals(expectedLen, response.capturedContentLength); + } + + @Test + public void testCustomResource_bodyBytesMatchContentLength() throws Exception { + String value = "Body content for length verification"; + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertEquals(response.capturedContentLength, response.outputStream.size()); + } + + @Test + public void testCustomResource_bodyContentIsCorrect() throws Exception { + String value = "Expected body content"; + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertEquals(value, response.outputStream.toString("UTF-8")); + } + + @Test + public void testCustomResource_gzip_contentLengthNotSet() throws Exception { + String value = "Gzipped static content"; + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", true), response); + + assertEquals(-1, response.capturedContentLength); + } + + @Test + public void testCustomResource_gzip_bodyIsValidGzip() throws Exception { + String value = "Gzipped static content"; + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", true), response); + + byte[] compressed = response.outputStream.toByteArray(); + String decompressed; + try (GZIPInputStream gzis = new GZIPInputStream(new ByteArrayInputStream(compressed))) { + decompressed = new String(gzis.readAllBytes(), StandardCharsets.UTF_8); + } + assertEquals(value, decompressed); + } + + @Test + public void testCustomResource_nonGetMethod_noBodyWritten() throws Exception { + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, "value", "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + Request postRequest = mock(Request.class); + when(postRequest.getMethod()).thenReturn("POST"); + + invokeHandler(resource, postRequest, response); + + assertEquals(0, response.outputStream.size()); + assertEquals(-1, response.capturedContentLength); + } + + @Test + public void testCustomResource_contentTypeIsSet() throws Exception { + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, "js content", "application/javascript", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertTrue(response.contentTypeValue.contains("application/javascript")); + } + + @Test + public void testCustomResource_invalidCharset_fallsBackToTextPlain() throws Exception { + // An unrecognized charset causes UnsupportedCharsetException in ContentType.parse(), + // which the handler catches and falls back to text/plain with the connector's default charset. + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, "value", + "text/html; charset=TOTALLY-INVALID-CHARSET-XYZ", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertTrue(response.contentTypeValue.contains("text/plain")); + } + + // ========================================================================= + // GROUP B — ResourceType.FILE + // ========================================================================= + + @Test + public void testFileResource_smallFile_setsContentLength() throws Exception { + File f = tempFolder.newFile("small.txt"); + byte[] content = "small file content".getBytes(StandardCharsets.UTF_8); + try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); } + + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.FILE, f.getAbsolutePath(), "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertEquals(f.length(), response.capturedContentLength); + } + + @Test + public void testFileResource_largeFile_setsContentLength() throws Exception { + // 40 KB — the regression size + File f = tempFolder.newFile("large.bin"); + byte[] content = new byte[40 * 1024]; + new Random().nextBytes(content); + try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); } + + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.FILE, f.getAbsolutePath(), "application/octet-stream", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertEquals(f.length(), response.capturedContentLength); + } + + @Test + public void testFileResource_bodyMatchesFileContent() throws Exception { + File f = tempFolder.newFile("content.txt"); + byte[] content = "exact file bytes".getBytes(StandardCharsets.UTF_8); + try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); } + + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.FILE, f.getAbsolutePath(), "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertArrayEquals(content, response.outputStream.toByteArray()); + } + + @Test + public void testFileResource_gzip_contentLengthNotSet() throws Exception { + File f = tempFolder.newFile("gzip.txt"); + try (FileOutputStream fos = new FileOutputStream(f)) { fos.write("gzip test".getBytes()); } + + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.FILE, f.getAbsolutePath(), "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", true), response); + + assertEquals(-1, response.capturedContentLength); + } + + @Test + public void testFileResource_gzip_bodyDecompressesToFileContent() throws Exception { + File f = tempFolder.newFile("gzip_content.txt"); + byte[] content = "file content for gzip".getBytes(StandardCharsets.UTF_8); + try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); } + + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.FILE, f.getAbsolutePath(), "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", true), response); + + byte[] compressed = response.outputStream.toByteArray(); + byte[] decompressed; + try (GZIPInputStream gzis = new GZIPInputStream(new ByteArrayInputStream(compressed))) { + decompressed = gzis.readAllBytes(); + } + assertArrayEquals(content, decompressed); + } + + // ========================================================================= + // GROUP C — ResourceType.DIRECTORY + // ========================================================================= + + @Test + public void testDirectoryResource_validFile_setsContentLength() throws Exception { + File dir = tempFolder.newFolder("static"); + File f = new File(dir, "test.js"); + byte[] content = new byte[1024]; + new Random().nextBytes(content); + try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); } + + HttpStaticResource resource = new HttpStaticResource( + "/static", ResourceType.DIRECTORY, dir.getAbsolutePath(), "application/javascript", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/static/test.js", false), response); + + assertEquals(f.length(), response.capturedContentLength); + } + + @Test + public void testDirectoryResource_validFile_bodyMatchesContent() throws Exception { + File dir = tempFolder.newFolder("staticbody"); + File f = new File(dir, "data.txt"); + byte[] content = "directory file content".getBytes(StandardCharsets.UTF_8); + try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); } + + HttpStaticResource resource = new HttpStaticResource( + "/staticbody", ResourceType.DIRECTORY, dir.getAbsolutePath(), "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/staticbody/data.txt", false), response); + + assertArrayEquals(content, response.outputStream.toByteArray()); + } + + @Test + public void testDirectoryResource_subdirectoryRequest_resetsResponse() throws Exception { + File dir = tempFolder.newFolder("staticdir"); + new File(dir, "sub").mkdir(); + + HttpStaticResource resource = new HttpStaticResource( + "/staticdir", ResourceType.DIRECTORY, dir.getAbsolutePath(), "text/plain", Collections.emptyMap()); + ResettableResponse response = new ResettableResponse(); + + // childPath "sub/file.txt" contains "/" — triggers the subdirectory guard + invokeHandler(resource, createGetRequest("/staticdir/sub/file.txt", false), response); + + assertTrue(response.resetCalled); + assertEquals(0, response.outputStream.size()); + } + + @Test + public void testDirectoryResource_missingFile_resetsResponse() throws Exception { + File dir = tempFolder.newFolder("staticmissing"); + + HttpStaticResource resource = new HttpStaticResource( + "/staticmissing", ResourceType.DIRECTORY, dir.getAbsolutePath(), "text/plain", Collections.emptyMap()); + ResettableResponse response = new ResettableResponse(); + + invokeHandler(resource, createGetRequest("/staticmissing/nonexistent.js", false), response); + + assertTrue(response.resetCalled); + assertEquals(0, response.outputStream.size()); + } + + @Test + public void testDirectoryResource_directoryRequestedAsFile_resetsResponse() throws Exception { + File dir = tempFolder.newFolder("staticdirfile"); + new File(dir, "subdir").mkdir(); + + HttpStaticResource resource = new HttpStaticResource( + "/staticdirfile", ResourceType.DIRECTORY, dir.getAbsolutePath(), "text/plain", Collections.emptyMap()); + ResettableResponse response = new ResettableResponse(); + + // "subdir" exists but is itself a directory — should reset + invokeHandler(resource, createGetRequest("/staticdirfile/subdir", false), response); + + assertTrue(response.resetCalled); + assertEquals(0, response.outputStream.size()); + } + + // ========================================================================= + // GROUP D — Error path / committed response + // ========================================================================= + + @Test + public void testStaticResource_committedResponse_swallowsIllegalStateException() throws Exception { + // Non-existent file triggers FileNotFoundException in the handler catch block. + // The catch block calls reset() — on a committed response that throws ISE. + // The fix wraps this in try/catch so the ISE is swallowed rather than propagated. + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.FILE, + "/this/path/does/not/exist/anywhere.txt", "text/plain", Collections.emptyMap()); + CommittedResponse response = new CommittedResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); // must not throw + } + + @Test + public void testStaticResource_error_returns500WithStackTrace() throws Exception { + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.FILE, + "/this/path/does/not/exist/anywhere.txt", "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertEquals(500, response.statusCode); + String body = response.outputStream.toString("UTF-8"); + assertTrue("Expected stack trace to mention file not found", + body.contains("FileNotFoundException") || body.contains("NoSuchFileException")); + } + + // ========================================================================= + // Helper response implementations + // ========================================================================= + + /** Captures the value passed to setContentLength / setContentLengthLong. */ + static class ContentLengthCapturingResponse extends HttpReceiverTest.TestHttpServletResponse { + long capturedContentLength = -1; + + @Override public void setContentLength(int len) { capturedContentLength = len; } + @Override public void setContentLengthLong(long len) { capturedContentLength = len; } + } + + /** Tracks whether reset() was called; clears the output stream on reset. */ + static class ResettableResponse extends ContentLengthCapturingResponse { + boolean resetCalled = false; + + @Override + public void reset() { + resetCalled = true; + outputStream.reset(); + } + } + + /** Simulates a Jetty-committed response: reset() throws IllegalStateException. */ + static class CommittedResponse extends ContentLengthCapturingResponse { + @Override public boolean isCommitted() { return true; } + @Override public void reset() { throw new IllegalStateException("Response already committed"); } + } +} From 5309ed8f3fbf66835b3e961b928ca93a0507113f Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Tue, 12 May 2026 21:53:36 -0500 Subject: [PATCH 15/25] fix(IRT-831): enforce contextPath routing in HttpReceiver via ContextHandlerCollection Replace the broken Jetty 12 handler chain in HttpReceiver.onStart() with the correct ContextHandlerCollection + DefaultHandler + Handler.Sequence pattern, matching MirthWebServer.java. Two bugs compounded: 1. HandlerCollection has no path-based routing; inner ContextHandlers could not reject requests for paths outside their configured contextPath. 2. A root ContextHandler("/") wrapped the entire tree, accepting every URL and suppressing any 404 fallback (Jetty 12 has no implicit default 404 unlike Jetty 9). Fix: - Replace HandlerCollection + root ContextHandler with ContextHandlerCollection so Jetty enforces strict prefix matching per context. - Add explicit DefaultHandler (serveFavIcon=false, showContexts=false) so unmatched paths return 404 instead of an HTTP 200 context-listing page. - Move security handler inside each ContextHandler (per-context) instead of wrapping the whole collection, preserving per-path auth scoping. - Add setAllowNullPathInfo(true) to the main channel ContextHandler to prevent Jetty from issuing a 301 redirect when the request path equals the context path exactly. Update HttpReceiverStaticResourceIntegrationTest.startServer() to use the same ContextHandlerCollection pattern and add a regression test confirming that requests outside the resource context path return 404. --- .../connect/connectors/http/HttpReceiver.java | 35 +++++++++++------ ...ReceiverStaticResourceIntegrationTest.java | 39 ++++++++++++++----- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/server/src/com/mirth/connect/connectors/http/HttpReceiver.java b/server/src/com/mirth/connect/connectors/http/HttpReceiver.java index 67a5cdd21..178c59a6b 100644 --- a/server/src/com/mirth/connect/connectors/http/HttpReceiver.java +++ b/server/src/com/mirth/connect/connectors/http/HttpReceiver.java @@ -282,7 +282,11 @@ public void onStart() throws ConnectorTaskException { resourceContextHandler.setContextPath(staticResource.getContextPath()); // This allows resources to be requested without a relative context path (e.g. "/") resourceContextHandler.setAllowNullPathInfo(true); - resourceContextHandler.setHandler(new StaticResourceHandler(staticResource)); + org.eclipse.jetty.ee8.nested.Handler resourceInner = new StaticResourceHandler(staticResource); + if (authenticatorProvider != null) { + resourceInner = createSecurityHandler(resourceInner); + } + resourceContextHandler.setHandler(resourceInner); handlers.addHandler(resourceContextHandler); } } @@ -291,20 +295,27 @@ public void onStart() throws ConnectorTaskException { // Add the main request handler ContextHandler contextHandler = new ContextHandler(); contextHandler.setContextPath(contextPath); - contextHandler.setHandler(new RequestHandler()); + contextHandler.setAllowNullPathInfo(true); + org.eclipse.jetty.ee8.nested.Handler channelInner = new RequestHandler(); + if (authenticatorProvider != null) { + channelInner = createSecurityHandler(channelInner); + } + contextHandler.setHandler(channelInner); handlers.addHandler(contextHandler); - // Wrap the handler collection in a security handler if needed - org.eclipse.jetty.ee8.nested.Handler serverHandler = handlers; - if (authenticatorProvider != null) { - serverHandler = createSecurityHandler(handlers); + org.eclipse.jetty.server.handler.ContextHandlerCollection coreHandlers = + new org.eclipse.jetty.server.handler.ContextHandlerCollection(); + for (org.eclipse.jetty.ee8.nested.Handler h : handlers.getHandlers()) { + if (h instanceof ContextHandler ch) { + ch.setServer(server); + coreHandlers.addHandler(ch.getCoreContextHandler()); + } } - - // In Jetty 12, we need to wrap the EE8 handler in a ContextHandler and get the core handler - ContextHandler rootContextHandler = new ContextHandler(); - rootContextHandler.setContextPath("/"); - rootContextHandler.setHandler(serverHandler); - server.setHandler(rootContextHandler.getCoreContextHandler()); + org.eclipse.jetty.server.handler.DefaultHandler defaultHandler = + new org.eclipse.jetty.server.handler.DefaultHandler(); + defaultHandler.setServeFavIcon(false); + defaultHandler.setShowContexts(false); + server.setHandler(new org.eclipse.jetty.server.Handler.Sequence(coreHandlers, defaultHandler)); logger.debug("starting HTTP server with address: " + host + ":" + port); server.start(); diff --git a/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java b/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java index ef6cfc2b0..2e579b534 100644 --- a/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java +++ b/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java @@ -29,7 +29,6 @@ import org.apache.http.impl.client.HttpClients; import org.eclipse.jetty.ee8.nested.AbstractHandler; import org.eclipse.jetty.ee8.nested.ContextHandler; -import org.eclipse.jetty.ee8.nested.HandlerCollection; import org.eclipse.jetty.server.HttpConnectionFactory; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; @@ -264,14 +263,31 @@ public void testFile_gzip_bodyDecompressesToOriginalContent() throws Exception { } } + // ========================================================================= + // GROUP C — Routing: requests outside context path must return 404 (IRT-831) + // ========================================================================= + + @Test + public void testRequestOutsideResourceContextPathReturns404() throws Exception { + String content = "hello"; + int port = startServer(new HttpStaticResource("/resource", ResourceType.CUSTOM, content, + "text/plain", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/other"))) { + assertEquals(404, resp.getStatusLine().getStatusCode()); + } + } + // ========================================================================= // Helpers — Jetty server setup // ========================================================================= /** - * Starts a real Jetty 12 server on a random loopback port, replicating the handler chain - * from {@link HttpReceiver#onStart()} but with {@code StaticResourceHandler} for a single - * static resource. Uses reflection to instantiate the private inner class. + * Starts a real Jetty 12 server on a random loopback port using the IRT-831 handler chain + * ({@code ContextHandlerCollection} + {@code DefaultHandler} + {@code Handler.Sequence}) + * with {@code StaticResourceHandler} for a single static resource. + * Uses reflection to instantiate the private inner class. * * @return the local port the server is listening on */ @@ -305,13 +321,16 @@ private int startServer(HttpStaticResource resource) throws Exception { resourceContext.setAllowNullPathInfo(true); resourceContext.setHandler(staticHandler); - HandlerCollection handlers = new HandlerCollection(); - handlers.addHandler(resourceContext); + org.eclipse.jetty.server.handler.ContextHandlerCollection coreHandlers = + new org.eclipse.jetty.server.handler.ContextHandlerCollection(); + resourceContext.setServer(jettyServer); + coreHandlers.addHandler(resourceContext.getCoreContextHandler()); + + org.eclipse.jetty.server.handler.DefaultHandler defaultHandler = + new org.eclipse.jetty.server.handler.DefaultHandler(); + defaultHandler.setServeFavIcon(false); - ContextHandler rootContext = new ContextHandler(); - rootContext.setContextPath("/"); - rootContext.setHandler(handlers); - jettyServer.setHandler(rootContext.getCoreContextHandler()); + jettyServer.setHandler(new org.eclipse.jetty.server.Handler.Sequence(coreHandlers, defaultHandler)); jettyServer.start(); return serverConnector.getLocalPort(); From ae83b2a0650e91bd0c36ae73726a0344b6d44966 Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Tue, 12 May 2026 22:08:44 -0500 Subject: [PATCH 16/25] fix(IRT-832): set Content-Length in HttpReceiver sendResponse and sendErrorResponse Jetty 12 requires Content-Length to be set before writing the response body or body bytes may be silently dropped. Adds setContentLength() in the non-gzip branch of sendResponse() and in sendErrorResponse(). Adds three unit tests to HttpReceiverTest covering non-gzip Content-Length, gzip (no Content-Length), and error response Content-Length assertions. --- .../connect/connectors/http/HttpReceiver.java | 5 +- .../connectors/http/HttpReceiverTest.java | 68 ++++++++++++++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/server/src/com/mirth/connect/connectors/http/HttpReceiver.java b/server/src/com/mirth/connect/connectors/http/HttpReceiver.java index 178c59a6b..c2292b4a5 100644 --- a/server/src/com/mirth/connect/connectors/http/HttpReceiver.java +++ b/server/src/com/mirth/connect/connectors/http/HttpReceiver.java @@ -529,6 +529,7 @@ protected void sendResponse(Request baseRequest, HttpServletResponse servletResp gzipOutputStream.write(responseBytes); gzipOutputStream.finish(); } else { + servletResponse.setContentLength(responseBytes.length); responseOutputStream.write(responseBytes); } @@ -561,9 +562,11 @@ protected void sendErrorResponse(Request baseRequest, HttpServletResponse servle } } + byte[] errBytes = responseError.getBytes(); servletResponse.setContentType("text/plain"); servletResponse.setStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR); - servletResponse.getOutputStream().write(responseError.getBytes()); + servletResponse.setContentLength(errBytes.length); + servletResponse.getOutputStream().write(errBytes); } protected Object getMessage(Request request, Map sourceMap, List attachments) throws IOException, ChannelException, MessagingException, DonkeyElementException, ParserConfigurationException { diff --git a/server/test/com/mirth/connect/connectors/http/HttpReceiverTest.java b/server/test/com/mirth/connect/connectors/http/HttpReceiverTest.java index 1fa1730b7..eb8ce145b 100644 --- a/server/test/com/mirth/connect/connectors/http/HttpReceiverTest.java +++ b/server/test/com/mirth/connect/connectors/http/HttpReceiverTest.java @@ -1,5 +1,6 @@ package com.mirth.connect.connectors.http; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -987,6 +988,68 @@ public void testSendErrorResponseWithDispatchResult() throws Exception { assertTrue(dr.isAttemptedResponse()); } + // ========== IRT-832: Content-Length tests ========== + + @Test + public void testSendResponseNonGzipSetsContentLength() throws Exception { + TestHttpServletResponse servletResponse = new TestHttpServletResponse(); + Request baseRequest = mock(Request.class); + when(baseRequest.getHeaders("Accept-Encoding")).thenReturn(Collections.enumeration(Collections.emptyList())); + + props.setResponseDataTypeBinary(false); + props.setCharset("UTF-8"); + props.setResponseStatusCode("200"); + + String body = "Hello Content-Length"; + byte[] expectedBytes = body.getBytes(StandardCharsets.UTF_8); + + Response selectedResponse = mock(Response.class); + when(selectedResponse.getMessage()).thenReturn(body); + when(selectedResponse.getStatus()).thenReturn(Status.SENT); + DispatchResult dr = new TestDispatchResult(1L, message, selectedResponse, true, true); + + receiver.sendResponse(baseRequest, servletResponse, dr); + + assertEquals("Content-Length must match body byte length", expectedBytes.length, servletResponse.contentLength); + assertArrayEquals(expectedBytes, servletResponse.outputStream.toByteArray()); + } + + @Test + public void testSendResponseGzipDoesNotSetContentLength() throws Exception { + TestHttpServletResponse servletResponse = new TestHttpServletResponse(); + Request baseRequest = mock(Request.class); + Vector acceptEncodings = new Vector<>(); + acceptEncodings.add("gzip"); + when(baseRequest.getHeaders("Accept-Encoding")).thenReturn(acceptEncodings.elements()); + + props.setResponseDataTypeBinary(false); + props.setCharset("UTF-8"); + props.setResponseStatusCode("200"); + + Response selectedResponse = mock(Response.class); + when(selectedResponse.getMessage()).thenReturn("Gzip body"); + when(selectedResponse.getStatus()).thenReturn(Status.SENT); + DispatchResult dr = new TestDispatchResult(1L, message, selectedResponse, true, true); + + receiver.sendResponse(baseRequest, servletResponse, dr); + + assertEquals("Content-Length must not be set for gzip responses", -1, servletResponse.contentLength); + assertEquals("gzip", servletResponse.headers.get("Content-Encoding")); + } + + @Test + public void testSendErrorResponseSetsContentLength() throws Exception { + TestHttpServletResponse servletResponse = new TestHttpServletResponse(); + Request baseRequest = mock(Request.class); + + receiver.sendErrorResponse(baseRequest, servletResponse, null, new RuntimeException("boom")); + + byte[] errBytes = servletResponse.outputStream.toByteArray(); + assertTrue("Error body must be non-empty", errBytes.length > 0); + assertEquals("Content-Length must match error body byte length", errBytes.length, servletResponse.contentLength); + assertEquals(500, servletResponse.statusCode); + } + // ========== Helper methods ========== /** @@ -1056,6 +1119,7 @@ public void setReadListener(javax.servlet.ReadListener readListener) { static class TestHttpServletResponse implements HttpServletResponse { int statusCode; String contentTypeValue; + int contentLength = -1; ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); Map headers = new HashMap<>(); @@ -1109,8 +1173,8 @@ public void setWriteListener(WriteListener writeListener) {} @Override public String getCharacterEncoding() { return "UTF-8"; } @Override public java.io.PrintWriter getWriter() { return new java.io.PrintWriter(outputStream); } @Override public void setCharacterEncoding(String charset) {} - @Override public void setContentLength(int len) {} - @Override public void setContentLengthLong(long len) {} + @Override public void setContentLength(int len) { this.contentLength = len; } + @Override public void setContentLengthLong(long len) { this.contentLength = (int) len; } @Override public void setBufferSize(int size) {} @Override public int getBufferSize() { return 0; } @Override public void flushBuffer() {} From edaa0b8a41a4347ff3525faea3f19e9a257355e1 Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Tue, 12 May 2026 22:25:41 -0500 Subject: [PATCH 17/25] fix(IRT-833): set Content-Length on InstallerFileHandler downloads Add setContentLengthLong(file.length()) before the file copy when not gzip-encoding, matching the IRT-828 pattern from StaticResourceHandler. Move fis close to finally so it always runs. Wrap response.reset() in an inner try/catch(IllegalStateException) so a committed response does not mask the original error. Tests: non-gzip GET verifies Content-Length is set; gzip GET verifies it is not; error path verifies reset()+setStatus(500) are called and handle() completes normally. Also fix pre-existing wrong assertion in testContextPathNormalizationHandlesEmpty and replace commons-lang3 FQN calls with String.equalsIgnoreCase in MirthWebServerTest. --- .../mirth/connect/server/MirthWebServer.java | 15 +- .../connect/server/MirthWebServerTest.java | 144 +++++++++++++++++- 2 files changed, 153 insertions(+), 6 deletions(-) diff --git a/server/src/com/mirth/connect/server/MirthWebServer.java b/server/src/com/mirth/connect/server/MirthWebServer.java index fc4681715..3ba2efdcc 100644 --- a/server/src/com/mirth/connect/server/MirthWebServer.java +++ b/server/src/com/mirth/connect/server/MirthWebServer.java @@ -935,14 +935,20 @@ public void handle(String target, Request baseRequest, HttpServletRequest reques OutputStream responseOutputStream = response.getOutputStream(); // If the client accepts GZIP compression, compress the content + boolean gzip = false; for (Enumeration en = request.getHeaders("Accept-Encoding"); en.hasMoreElements();) { if (StringUtils.contains(en.nextElement(), "gzip")) { response.setHeader(HTTP.CONTENT_ENCODING, "gzip"); responseOutputStream = new GZIPOutputStream(responseOutputStream); + gzip = true; break; } } + if (!gzip) { + response.setContentLengthLong(file.length()); + } + fis = new FileInputStream(file); IOUtils.copy(fis, responseOutputStream); @@ -951,9 +957,14 @@ public void handle(String target, Request baseRequest, HttpServletRequest reques ((GZIPOutputStream) responseOutputStream).finish(); } } catch (Throwable t) { + try { + response.reset(); + response.setStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR); + } catch (IllegalStateException ise) { + logger.debug("Response already committed", ise); + } + } finally { IOUtils.closeQuietly(fis); - response.reset(); - response.setStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR); } } } else { diff --git a/server/test/com/mirth/connect/server/MirthWebServerTest.java b/server/test/com/mirth/connect/server/MirthWebServerTest.java index 45485e527..b5e17029d 100644 --- a/server/test/com/mirth/connect/server/MirthWebServerTest.java +++ b/server/test/com/mirth/connect/server/MirthWebServerTest.java @@ -2,21 +2,37 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.nio.file.Files; +import java.util.Collections; +import java.util.Enumeration; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.Set; +import java.util.Vector; import javax.servlet.FilterChain; +import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.eclipse.jetty.ee8.nested.Request; +import org.eclipse.jetty.http.MimeTypes; + import org.apache.commons.configuration2.PropertiesConfiguration; import org.junit.Test; @@ -474,7 +490,7 @@ public void testContextPathNormalizationHandlesEmpty() { if (contextPath.endsWith("/")) { contextPath = contextPath.substring(0, contextPath.length() - 1); } - assertEquals("/", contextPath); + assertEquals("", contextPath); } @Test @@ -620,7 +636,7 @@ public void testSessionCacheNoneValue() { PropertiesConfiguration mirthProperties = PropertiesConfigurationUtil.create(); mirthProperties.setProperty("server.api.sessioncache", "none"); String sessionCacheProperty = mirthProperties.getString("server.api.sessioncache", "default"); - assertTrue(org.apache.commons.lang3.StringUtils.equalsIgnoreCase(sessionCacheProperty, "none")); + assertTrue(sessionCacheProperty.equalsIgnoreCase("none")); } @Test @@ -631,7 +647,7 @@ public void testSessionCacheNoneOnlyUsedWithSessionStore() { String sessionCacheProperty = mirthProperties.getString("server.api.sessioncache", "default"); boolean sessionStore = Boolean.parseBoolean(mirthProperties.getString("server.api.sessionstore", "false")); - boolean useNullCache = org.apache.commons.lang3.StringUtils.equalsIgnoreCase(sessionCacheProperty, "none") && sessionStore; + boolean useNullCache = sessionCacheProperty.equalsIgnoreCase("none") && sessionStore; assertFalse(useNullCache); // Should not use NullSessionCache when sessionStore is false } @@ -643,7 +659,127 @@ public void testSessionCacheNoneWithSessionStoreEnabled() { String sessionCacheProperty = mirthProperties.getString("server.api.sessioncache", "default"); boolean sessionStore = Boolean.parseBoolean(mirthProperties.getString("server.api.sessionstore", "false")); - boolean useNullCache = org.apache.commons.lang3.StringUtils.equalsIgnoreCase(sessionCacheProperty, "none") && sessionStore; + boolean useNullCache = sessionCacheProperty.equalsIgnoreCase("none") && sessionStore; assertTrue(useNullCache); // Should use NullSessionCache } + + // ===== InstallerFileHandler tests (IRT-833) ===== + + @Test + public void testInstallerFileHandlerSetsContentLengthLongForNonGzip() throws Exception { + File tempFile = File.createTempFile("installer-test-", ".zip"); + tempFile.deleteOnExit(); + Files.write(tempFile.toPath(), new byte[]{1, 2, 3, 4, 5}); + + Object handler = newInstallerHandlerInstance(tempFile); + + Request baseRequest = mock(Request.class); + when(baseRequest.getMethod()).thenReturn("GET"); + + HttpServletRequest request = mock(HttpServletRequest.class); + Enumeration emptyEnum = Collections.enumeration(Collections.emptyList()); + when(request.getHeaders("Accept-Encoding")).thenReturn(emptyEnum); + + HttpServletResponse response = mock(HttpServletResponse.class); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + when(response.getOutputStream()).thenReturn(capturingStream(baos)); + + invokeInstallerHandler(handler, baseRequest, request, response); + + verify(response).setContentLengthLong(tempFile.length()); + } + + @Test + public void testInstallerFileHandlerDoesNotSetContentLengthLongForGzip() throws Exception { + File tempFile = File.createTempFile("installer-test-gzip-", ".zip"); + tempFile.deleteOnExit(); + Files.write(tempFile.toPath(), new byte[]{1, 2, 3, 4, 5}); + + Object handler = newInstallerHandlerInstance(tempFile); + + Request baseRequest = mock(Request.class); + when(baseRequest.getMethod()).thenReturn("GET"); + + HttpServletRequest request = mock(HttpServletRequest.class); + Vector encodings = new Vector<>(); + encodings.add("gzip"); + when(request.getHeaders("Accept-Encoding")).thenReturn(encodings.elements()); + + HttpServletResponse response = mock(HttpServletResponse.class); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + when(response.getOutputStream()).thenReturn(capturingStream(baos)); + + invokeInstallerHandler(handler, baseRequest, request, response); + + verify(response, never()).setContentLengthLong(anyLong()); + } + + @Test + public void testInstallerFileHandlerSwallowsIllegalStateExceptionOnReset() throws Exception { + File tempFile = File.createTempFile("installer-test-err-", ".zip"); + tempFile.deleteOnExit(); + Files.write(tempFile.toPath(), new byte[]{1, 2, 3}); + + Object handler = newInstallerHandlerInstance(tempFile); + + Request baseRequest = mock(Request.class); + when(baseRequest.getMethod()).thenReturn("GET"); + + HttpServletRequest request = mock(HttpServletRequest.class); + Enumeration emptyEnum = Collections.enumeration(Collections.emptyList()); + when(request.getHeaders("Accept-Encoding")).thenReturn(emptyEnum); + + HttpServletResponse response = mock(HttpServletResponse.class); + when(response.getOutputStream()).thenThrow(new IOException("simulated write failure")); + + // handle() should complete normally — error caught internally, 500 status set + invokeInstallerHandler(handler, baseRequest, request, response); + + verify(response).reset(); + verify(response).setStatus(org.apache.commons.httpclient.HttpStatus.SC_INTERNAL_SERVER_ERROR); + } + + private Object newInstallerHandlerInstance(File file) throws Exception { + java.lang.reflect.Field unsafeField = sun.misc.Unsafe.class.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + sun.misc.Unsafe unsafe = (sun.misc.Unsafe) unsafeField.get(null); + MirthWebServer outer = (MirthWebServer) unsafe.allocateInstance(MirthWebServer.class); + + Class handlerClass = null; + for (Class c : MirthWebServer.class.getDeclaredClasses()) { + if ("InstallerFileHandler".equals(c.getSimpleName())) { + handlerClass = c; + break; + } + } + assertNotNull("InstallerFileHandler inner class not found", handlerClass); + + Constructor ctor = handlerClass.getDeclaredConstructor(MirthWebServer.class, File.class, MimeTypes.class); + ctor.setAccessible(true); + return ctor.newInstance(outer, file, new MimeTypes()); + } + + private void invokeInstallerHandler(Object handler, Request baseRequest, + HttpServletRequest request, HttpServletResponse response) throws Exception { + Method handle = handler.getClass().getMethod("handle", String.class, Request.class, + HttpServletRequest.class, HttpServletResponse.class); + try { + handle.invoke(handler, "/test", baseRequest, request, response); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) throw (RuntimeException) cause; + if (cause instanceof Exception) throw (Exception) cause; + throw new RuntimeException(cause); + } + } + + private static ServletOutputStream capturingStream(ByteArrayOutputStream baos) { + return new ServletOutputStream() { + @Override public void write(int b) throws IOException { baos.write(b); } + @Override public void write(byte[] b) throws IOException { baos.write(b); } + @Override public void write(byte[] b, int off, int len) throws IOException { baos.write(b, off, len); } + @Override public boolean isReady() { return true; } + @Override public void setWriteListener(WriteListener wl) {} + }; + } } From 714b990b06a7063e52a19110e8af2939466264d6 Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Wed, 13 May 2026 09:41:45 -0500 Subject: [PATCH 18/25] fix(IRT-834): set Content-Length on Swagger UI static file responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the anonymous Swagger UI filter to SwaggerUiFilter (public static class) and add setContentLengthLong + try-with-resources stream close before each Files.copy call — same IRT-828 pattern applied to both the index.html branch and the generic static file branch. Without this fix Jetty 12 commits the response before the full body is written, causing ERR_HTTP2_PROTOCOL_ERROR in browsers for large assets such as lib/swagger-ui-bundle.js (~974 KB). --- .../mirth/connect/server/MirthWebServer.java | 104 ++++++++++-------- 1 file changed, 60 insertions(+), 44 deletions(-) diff --git a/server/src/com/mirth/connect/server/MirthWebServer.java b/server/src/com/mirth/connect/server/MirthWebServer.java index 3ba2efdcc..0e655b98f 100644 --- a/server/src/com/mirth/connect/server/MirthWebServer.java +++ b/server/src/com/mirth/connect/server/MirthWebServer.java @@ -528,50 +528,7 @@ private ServletContextHandler createApiServletContextHandler(String contextPath, // Swagger UI filter - must be BEFORE RequestedWithFilter so that static file requests // (index.html, css, js) don't require the X-Requested-With header final String swaggerUiBase = ControllerFactory.getFactory().createConfigurationController().getBaseDir() + File.separator + "public_api_html"; - apiServletContextHandler.addFilter(new FilterHolder(new javax.servlet.Filter() { - @Override - public void init(javax.servlet.FilterConfig filterConfig) throws javax.servlet.ServletException {} - @Override - public void destroy() {} - @Override - public void doFilter(javax.servlet.ServletRequest request, javax.servlet.ServletResponse response, javax.servlet.FilterChain chain) throws java.io.IOException, javax.servlet.ServletException { - javax.servlet.http.HttpServletRequest httpRequest = (javax.servlet.http.HttpServletRequest) request; - javax.servlet.http.HttpServletResponse httpResponse = (javax.servlet.http.HttpServletResponse) response; - String pathInfo = httpRequest.getPathInfo(); - - // Serve index.html for root /api/ request - if (pathInfo == null || pathInfo.equals("/") || pathInfo.isEmpty()) { - java.io.File indexFile = new java.io.File(swaggerUiBase, "index.html"); - if (indexFile.exists()) { - httpResponse.setContentType("text/html; charset=UTF-8"); - httpResponse.setStatus(javax.servlet.http.HttpServletResponse.SC_OK); - java.nio.file.Files.copy(indexFile.toPath(), httpResponse.getOutputStream()); - return; - } - } - - // Serve static files from public_api_html if they exist (css, js, images, etc.) - if (pathInfo != null && !pathInfo.equals("/")) { - java.io.File staticFile = new java.io.File(swaggerUiBase, pathInfo); - if (staticFile.exists() && staticFile.isFile() && staticFile.getCanonicalPath().startsWith(new java.io.File(swaggerUiBase).getCanonicalPath())) { - String fileName = staticFile.getName(); - if (fileName.endsWith(".css")) httpResponse.setContentType("text/css"); - else if (fileName.endsWith(".js")) httpResponse.setContentType("application/javascript"); - else if (fileName.endsWith(".html")) httpResponse.setContentType("text/html"); - else if (fileName.endsWith(".png")) httpResponse.setContentType("image/png"); - else if (fileName.endsWith(".json")) httpResponse.setContentType("application/json"); - else if (fileName.endsWith(".map")) httpResponse.setContentType("application/json"); - else httpResponse.setContentType("application/octet-stream"); - httpResponse.setStatus(javax.servlet.http.HttpServletResponse.SC_OK); - java.nio.file.Files.copy(staticFile.toPath(), httpResponse.getOutputStream()); - return; - } - } - - // Not a static file - pass through to remaining filters and Jersey - chain.doFilter(request, response); - } - }), "/*", EnumSet.of(DispatcherType.REQUEST)); + apiServletContextHandler.addFilter(new FilterHolder(new SwaggerUiFilter(swaggerUiBase)), "/*", EnumSet.of(DispatcherType.REQUEST)); apiServletContextHandler.addFilter(new FilterHolder(new RequestedWithFilter(mirthProperties)), "/*", EnumSet.of(DispatcherType.REQUEST)); apiServletContextHandler.addFilter(new FilterHolder(new MethodFilter()), "/*", EnumSet.of(DispatcherType.REQUEST)); @@ -974,4 +931,63 @@ public void handle(String target, Request baseRequest, HttpServletRequest reques baseRequest.setHandled(true); } } + + public static class SwaggerUiFilter implements javax.servlet.Filter { + private final String swaggerUiBase; + + public SwaggerUiFilter(String swaggerUiBase) { + this.swaggerUiBase = swaggerUiBase; + } + + @Override + public void init(javax.servlet.FilterConfig filterConfig) throws javax.servlet.ServletException {} + + @Override + public void destroy() {} + + @Override + public void doFilter(javax.servlet.ServletRequest request, javax.servlet.ServletResponse response, javax.servlet.FilterChain chain) throws java.io.IOException, javax.servlet.ServletException { + javax.servlet.http.HttpServletRequest httpRequest = (javax.servlet.http.HttpServletRequest) request; + javax.servlet.http.HttpServletResponse httpResponse = (javax.servlet.http.HttpServletResponse) response; + String pathInfo = httpRequest.getPathInfo(); + + // Serve index.html for root /api/ request + if (pathInfo == null || pathInfo.equals("/") || pathInfo.isEmpty()) { + java.io.File indexFile = new java.io.File(swaggerUiBase, "index.html"); + if (indexFile.exists()) { + httpResponse.setContentType("text/html; charset=UTF-8"); + httpResponse.setStatus(javax.servlet.http.HttpServletResponse.SC_OK); + httpResponse.setContentLengthLong(java.nio.file.Files.size(indexFile.toPath())); + try (java.io.OutputStream out = httpResponse.getOutputStream()) { + java.nio.file.Files.copy(indexFile.toPath(), out); + } + return; + } + } + + // Serve static files from public_api_html if they exist (css, js, images, etc.) + if (pathInfo != null && !pathInfo.equals("/")) { + java.io.File staticFile = new java.io.File(swaggerUiBase, pathInfo); + if (staticFile.exists() && staticFile.isFile() && staticFile.getCanonicalPath().startsWith(new java.io.File(swaggerUiBase).getCanonicalPath())) { + String fileName = staticFile.getName(); + if (fileName.endsWith(".css")) httpResponse.setContentType("text/css"); + else if (fileName.endsWith(".js")) httpResponse.setContentType("application/javascript"); + else if (fileName.endsWith(".html")) httpResponse.setContentType("text/html"); + else if (fileName.endsWith(".png")) httpResponse.setContentType("image/png"); + else if (fileName.endsWith(".json")) httpResponse.setContentType("application/json"); + else if (fileName.endsWith(".map")) httpResponse.setContentType("application/json"); + else httpResponse.setContentType("application/octet-stream"); + httpResponse.setStatus(javax.servlet.http.HttpServletResponse.SC_OK); + httpResponse.setContentLengthLong(java.nio.file.Files.size(staticFile.toPath())); + try (java.io.OutputStream out = httpResponse.getOutputStream()) { + java.nio.file.Files.copy(staticFile.toPath(), out); + } + return; + } + } + + // Not a static file - pass through to remaining filters and Jersey + chain.doFilter(request, response); + } + } } From d37db71343fcfde06ed5fa83d18ba1112320321b Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Wed, 13 May 2026 10:11:25 -0500 Subject: [PATCH 19/25] fix(IRT-835): set Content-Length on WebStart JNLP responses Buffer JNLP XML to byte[] before writing so Content-Length is known upfront, then write via getOutputStream() instead of getWriter(). Replace the unguarded null-document path in the else branch with sendError(404) + return, eliminating the NPE on non-matching URIs. Update WebStartServletTest: wire getOutputStream() to a ByteArrayOutputStream, track status and contentLength in TestHttpServletResponse, remove try-catch wrappers from three 404-path tests and assert status 404, and add testDoGetCoreSetsContentLength to verify the header is set. --- .../server/servlets/WebStartServlet.java | 22 ++++--- .../server/servlets/WebStartServletTest.java | 62 +++++++++++++------ 2 files changed, 56 insertions(+), 28 deletions(-) diff --git a/server/src/com/mirth/connect/server/servlets/WebStartServlet.java b/server/src/com/mirth/connect/server/servlets/WebStartServlet.java index 0aa6dfceb..76e52f46f 100644 --- a/server/src/com/mirth/connect/server/servlets/WebStartServlet.java +++ b/server/src/com/mirth/connect/server/servlets/WebStartServlet.java @@ -24,6 +24,8 @@ import java.io.IOException; import java.io.InputStream; import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.ArrayList; import java.util.Base64; @@ -80,28 +82,32 @@ public void doGet(HttpServletRequest request, HttpServletResponse response) thro response.setCharacterEncoding("UTF-8"); try { - response.setContentType("application/x-java-jnlp-file"); response.setHeader("Pragma", "no-cache"); response.setHeader("X-Content-Type-Options", "nosniff"); - PrintWriter out = response.getWriter(); Document jnlpDocument = null; - + PropertiesConfiguration mirthProperties = getMirthProperties(); String contextPathProp = getContextPathProp(mirthProperties); - + if ((request.getRequestURI().equals(contextPathProp + "/webstart.jnlp") || request.getRequestURI().equals(contextPathProp + "/webstart")) && isWebstartRequestValid(request)) { jnlpDocument = getAdministratorJnlp(request); response.setHeader("Content-Disposition", "attachment; filename = \"webstart.jnlp\""); } else if (request.getServletPath().equals("/webstart/extensions") && isWebstartExtensionsRequestValid(request, contextPathProp)) { String extensionPath = getExtensionPath(request); - jnlpDocument = getExtensionJnlp(getExtensionPath(request)); - response.setHeader("Content-Disposition", "attachment; filename = \"" + extensionPath + ".jnlp\""); + jnlpDocument = getExtensionJnlp(extensionPath); + response.setHeader("Content-Disposition", "attachment; filename = \"" + extensionPath + ".jnlp\""); } else { - response.setContentType(""); + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; } + response.setContentType("application/x-java-jnlp-file"); DocumentSerializer docSerializer = new DocumentSerializer(true); - docSerializer.toXML(jnlpDocument, out); + StringWriter sw = new StringWriter(); + docSerializer.toXML(jnlpDocument, new PrintWriter(sw)); + byte[] bytes = sw.toString().getBytes(StandardCharsets.UTF_8); + response.setContentLength(bytes.length); + response.getOutputStream().write(bytes); } catch (RuntimeIOException rio) { logger.debug(rio); } catch (Throwable t) { diff --git a/server/test/com/mirth/connect/server/servlets/WebStartServletTest.java b/server/test/com/mirth/connect/server/servlets/WebStartServletTest.java index 8ab82bfb6..fe7a119bf 100644 --- a/server/test/com/mirth/connect/server/servlets/WebStartServletTest.java +++ b/server/test/com/mirth/connect/server/servlets/WebStartServletTest.java @@ -25,10 +25,12 @@ import static org.mockito.Mockito.when; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -38,6 +40,7 @@ import java.util.Map; import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -132,6 +135,22 @@ public String answer(InvocationOnMock invocation) throws Throwable { assertEquals("attachment; filename = \"webstart.jnlp\"", response.getHeader("Content-Disposition")); } + @Test + public void testDoGetCoreSetsContentLength() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRequestURI()).thenReturn("/webstart"); + when(request.getServletPath()).thenReturn("/webstart"); + when(request.getParameterNames()).thenReturn(Collections.emptyEnumeration()); + + TestHttpServletResponse response = new TestHttpServletResponse(); + webStartServlet.doGet(request, response); + + String body = response.getResponseString(); + assertTrue("Content-Length should be positive", response.getContentLength() > 0); + assertEquals("Content-Length must match actual body bytes", + body.getBytes(StandardCharsets.UTF_8).length, response.getContentLength()); + } + @Test public void testDoGetCoreQueryParamsInvalidValues() throws Exception { // Test "maxHeapSize" invalid value @@ -485,12 +504,9 @@ public void testDoGetNonMatchingPath() throws Exception { TestHttpServletResponse response = new TestHttpServletResponse(); - try { - webStartServlet.doGet(request, response); - } catch (Exception e) { - // May throw ServletException wrapping NPE for null jnlpDocument - } + webStartServlet.doGet(request, response); + assertEquals(404, response.getStatus()); assertEquals("", response.getContentType()); assertNull(response.getHeader("Content-Disposition")); } @@ -504,12 +520,9 @@ public void testDoGetWebstartWithTrailingSlash() throws Exception { TestHttpServletResponse response = new TestHttpServletResponse(); - try { - webStartServlet.doGet(request, response); - } catch (Exception e) { - // URI /webstart/ doesn't match /webstart exactly - } + webStartServlet.doGet(request, response); + assertEquals(404, response.getStatus()); assertEquals("", response.getContentType()); assertNull(response.getHeader("Content-Disposition")); } @@ -598,12 +611,9 @@ public void testDoGetCoreWithContextPathMismatch() throws Exception { TestHttpServletResponse response = new TestHttpServletResponse(); - try { - contextServlet.doGet(request, response); - } catch (Exception e) { - // May throw for null jnlpDocument - } + contextServlet.doGet(request, response); + assertEquals(404, response.getStatus()); assertEquals("", response.getContentType()); assertNull(response.getHeader("Content-Disposition")); } @@ -615,6 +625,9 @@ private static class TestHttpServletResponse implements HttpServletResponse { private StringWriter stringWriter; private PrintWriter printWriter; private Map> headers; + private final ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream(); + private int status = 200; + private int contentLength = -1; public TestHttpServletResponse() { contentType = ""; @@ -625,9 +638,14 @@ public TestHttpServletResponse() { } public String getResponseString() { + if (bodyBytes.size() > 0) { + return new String(bodyBytes.toByteArray(), StandardCharsets.UTF_8); + } return stringWriter.toString(); } + public int getContentLength() { return contentLength; } + @Override public void flushBuffer() throws IOException { @@ -655,7 +673,11 @@ public Locale getLocale() { @Override public ServletOutputStream getOutputStream() throws IOException { - return null; + return new ServletOutputStream() { + @Override public void write(int b) { bodyBytes.write(b); } + @Override public boolean isReady() { return true; } + @Override public void setWriteListener(WriteListener l) {} + }; } @Override @@ -690,7 +712,7 @@ public void setCharacterEncoding(String arg0) { @Override public void setContentLength(int arg0) { - + this.contentLength = arg0; } @Override @@ -774,12 +796,12 @@ public Collection getHeaders(String key) { @Override public int getStatus() { - return 0; + return status; } @Override public void sendError(int arg0) throws IOException { - + this.status = arg0; } @Override @@ -814,7 +836,7 @@ public void setIntHeader(String arg0, int arg1) { @Override public void setStatus(int arg0) { - + this.status = arg0; } @Override From bf77a3be03b822725b54edf2c456153ff9f4a454 Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Wed, 13 May 2026 16:28:06 -0500 Subject: [PATCH 20/25] fix(IRT-831): fall through to channel handler when static resource file not found StaticResourceHandler was calling baseRequest.setHandled(true) unconditionally, even when it returned early without writing a response (e.g. requested file absent under a DIRECTORY resource). This prevented the channel RequestHandler from processing the request, causing a 404 instead of falling through. Introduced a responded flag that is only set to true when content is actually written to the response. The setHandled call is now gated on that flag. --- .../com/mirth/connect/connectors/http/HttpReceiver.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/com/mirth/connect/connectors/http/HttpReceiver.java b/server/src/com/mirth/connect/connectors/http/HttpReceiver.java index c2292b4a5..c784a2414 100644 --- a/server/src/com/mirth/connect/connectors/http/HttpReceiver.java +++ b/server/src/com/mirth/connect/connectors/http/HttpReceiver.java @@ -697,6 +697,7 @@ public void handle(String target, Request baseRequest, HttpServletRequest servle String originalThreadName = Thread.currentThread().getName(); + boolean responded = false; try { Thread.currentThread().setName("HTTP Receiver Thread on " + getChannel().getName() + " (" + getChannelId() + ") < " + originalThreadName); HttpRequestMessage requestMessage = createRequestMessage(baseRequest, true); @@ -837,6 +838,7 @@ public void handle(String target, Request baseRequest, HttpServletRequest servle if (gzipOutput) { ((GZIPOutputStream) responseOutputStream).finish(); } + responded = true; } catch (Throwable t) { logger.error("Error handling static HTTP resource request (" + getConnectorProperties().getName() + " \"Source\" on channel " + getChannelId() + ").", t); eventController.dispatchEvent(new ErrorEvent(getChannelId(), getMetaDataId(), null, ErrorEventType.SOURCE_CONNECTOR, getSourceName(), getConnectorProperties().getName(), "Error handling static HTTP resource request", t)); @@ -849,11 +851,14 @@ public void handle(String target, Request baseRequest, HttpServletRequest servle } catch (IllegalStateException ise) { logger.debug("Could not reset already-committed response after static resource error", ise); } + responded = true; } finally { Thread.currentThread().setName(originalThreadName); } - baseRequest.setHandled(true); + if (responded) { + baseRequest.setHandled(true); + } } } From 4536db6e33fb04799fa862dcfcc6652f44c73c99 Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Wed, 13 May 2026 17:26:46 -0500 Subject: [PATCH 21/25] fix(IRT-831): embed DIRECTORY handlers in channel context for fallthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoreContextHandler.handle() (the Jetty 12 EE8-to-core bridge) returns true once the request path matches the context path, even when the inner EE8 handler never sets isHandled(). Separate ContextHandlers for DIRECTORY static resources therefore block fallthrough to the channel — Handler.Sequence and ContextHandlerCollection both fail for the same reason. Fix: DIRECTORY resources are no longer given their own ContextHandler. They are collected and embedded inside the channel ContextHandler ahead of RequestHandler, in a HandlerCollection subclass that overrides handle() to stop on the first handler that sets isHandled(). HandlerCollection (not an anonymous AbstractHandler) is used so Jetty lifecycle calls (setServer, start) propagate to all inner handlers via addHandler(), preserving correct behaviour when ConstraintSecurityHandler wraps the resource handlers for auth. CUSTOM and FILE resources are unaffected — they keep their own ContextHandler since exact-path matching means fallthrough is never needed. Adds GROUP D integration tests (startServerWithDirectoryAndChannel + ChannelEchoHandler) covering: missing file falls through, existing file served, subdirectory path falls through. --- .../connect/connectors/http/HttpReceiver.java | 66 ++++++-- ...ReceiverStaticResourceIntegrationTest.java | 142 ++++++++++++++++++ 2 files changed, 194 insertions(+), 14 deletions(-) diff --git a/server/src/com/mirth/connect/connectors/http/HttpReceiver.java b/server/src/com/mirth/connect/connectors/http/HttpReceiver.java index c784a2414..8a53cb4a6 100644 --- a/server/src/com/mirth/connect/connectors/http/HttpReceiver.java +++ b/server/src/com/mirth/connect/connectors/http/HttpReceiver.java @@ -228,7 +228,13 @@ public void onStart() throws ConnectorTaskException { server = new Server(); configuration.configureReceiver(this); + // handlers: staging list for CUSTOM/FILE resources that get their own ContextHandler. + // directoryHandlers: DIRECTORY resources embedded inside the channel context instead, + // because CoreContextHandler.handle() returns true once the context path matches even + // when the inner EE8 handler never sets isHandled() — a separate ContextHandler per + // DIRECTORY resource would therefore block fallthrough to the channel handler. HandlerCollection handlers = new HandlerCollection(); + List directoryHandlers = new ArrayList<>(); // Add handlers for each static resource if (getConnectorProperties().getStaticResources() != null) { @@ -278,44 +284,76 @@ public void onStart() throws ConnectorTaskException { for (List staticResourcesList : staticResourcesMap.descendingMap().values()) { for (HttpStaticResource staticResource : staticResourcesList) { logger.debug("Adding static resource handler for context path: " + staticResource.getContextPath()); - ContextHandler resourceContextHandler = new ContextHandler(); - resourceContextHandler.setContextPath(staticResource.getContextPath()); - // This allows resources to be requested without a relative context path (e.g. "/") - resourceContextHandler.setAllowNullPathInfo(true); org.eclipse.jetty.ee8.nested.Handler resourceInner = new StaticResourceHandler(staticResource); if (authenticatorProvider != null) { resourceInner = createSecurityHandler(resourceInner); } - resourceContextHandler.setHandler(resourceInner); - handlers.addHandler(resourceContextHandler); + if (staticResource.getResourceType() == ResourceType.DIRECTORY) { + // Collected here; embedded inside the channel context below. + directoryHandlers.add(resourceInner); + } else { + ContextHandler resourceContextHandler = new ContextHandler(); + resourceContextHandler.setContextPath(staticResource.getContextPath()); + // This allows resources to be requested without a relative context path (e.g. "/") + resourceContextHandler.setAllowNullPathInfo(true); + resourceContextHandler.setHandler(resourceInner); + handlers.addHandler(resourceContextHandler); + } } } } - // Add the main request handler + // Build the channel context handler. DIRECTORY resource handlers are placed + // before RequestHandler inside a stop-on-first-handled HandlerCollection so that + // unanswered requests fall through to the channel. Using HandlerCollection (not + // an anonymous AbstractHandler) ensures Jetty's lifecycle methods (setServer, + // start) propagate to all inner handlers via addHandler(). ContextHandler contextHandler = new ContextHandler(); contextHandler.setContextPath(contextPath); contextHandler.setAllowNullPathInfo(true); - org.eclipse.jetty.ee8.nested.Handler channelInner = new RequestHandler(); + org.eclipse.jetty.ee8.nested.Handler channelBase = new RequestHandler(); if (authenticatorProvider != null) { - channelInner = createSecurityHandler(channelInner); + channelBase = createSecurityHandler(channelBase); + } + if (directoryHandlers.isEmpty()) { + contextHandler.setHandler(channelBase); + } else { + final org.eclipse.jetty.ee8.nested.Handler finalChannelBase = channelBase; + HandlerCollection dirChain = new HandlerCollection() { + @Override + public void handle(String target, Request baseRequest, + HttpServletRequest servletRequest, HttpServletResponse servletResponse) + throws IOException, ServletException { + for (org.eclipse.jetty.ee8.nested.Handler h : getHandlers()) { + h.handle(target, baseRequest, servletRequest, servletResponse); + if (baseRequest.isHandled()) { + return; + } + } + } + }; + for (org.eclipse.jetty.ee8.nested.Handler h : directoryHandlers) { + dirChain.addHandler(h); + } + dirChain.addHandler(finalChannelBase); + contextHandler.setHandler(dirChain); } - contextHandler.setHandler(channelInner); handlers.addHandler(contextHandler); - org.eclipse.jetty.server.handler.ContextHandlerCollection coreHandlers = - new org.eclipse.jetty.server.handler.ContextHandlerCollection(); + org.eclipse.jetty.server.Handler.Sequence handlerSequence = + new org.eclipse.jetty.server.Handler.Sequence(); for (org.eclipse.jetty.ee8.nested.Handler h : handlers.getHandlers()) { if (h instanceof ContextHandler ch) { ch.setServer(server); - coreHandlers.addHandler(ch.getCoreContextHandler()); + handlerSequence.addHandler(ch.getCoreContextHandler()); } } org.eclipse.jetty.server.handler.DefaultHandler defaultHandler = new org.eclipse.jetty.server.handler.DefaultHandler(); defaultHandler.setServeFavIcon(false); defaultHandler.setShowContexts(false); - server.setHandler(new org.eclipse.jetty.server.Handler.Sequence(coreHandlers, defaultHandler)); + handlerSequence.addHandler(defaultHandler); + server.setHandler(handlerSequence); logger.debug("starting HTTP server with address: " + host + ":" + port); server.start(); diff --git a/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java b/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java index 2e579b534..57616983f 100644 --- a/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java +++ b/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java @@ -12,6 +12,7 @@ import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileOutputStream; +import java.io.IOException; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; @@ -21,6 +22,10 @@ import java.util.Random; import java.util.zip.GZIPInputStream; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + import org.apache.commons.io.IOUtils; import org.apache.http.Header; import org.apache.http.client.methods.CloseableHttpResponse; @@ -29,6 +34,8 @@ import org.apache.http.impl.client.HttpClients; import org.eclipse.jetty.ee8.nested.AbstractHandler; import org.eclipse.jetty.ee8.nested.ContextHandler; +import org.eclipse.jetty.ee8.nested.HandlerCollection; +import org.eclipse.jetty.ee8.nested.Request; import org.eclipse.jetty.server.HttpConnectionFactory; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; @@ -279,6 +286,57 @@ public void testRequestOutsideResourceContextPathReturns404() throws Exception { } } + // ========================================================================= + // GROUP D — ResourceType.DIRECTORY fallthrough to channel handler (IRT-831 fix) + // Verifies that StaticResourceHandler does NOT mark the request as handled when + // a requested file is absent, allowing the channel context to process it. + // ========================================================================= + + @Test + public void testDirectory_missingFile_fallsThroughToChannelHandler() throws Exception { + File dir = tempFolder.newFolder("dir-missing"); + int port = startServerWithDirectoryAndChannel("/test/data", dir, "/test"); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/test/data/nonexistent.txt"))) { + assertEquals("Missing file under DIRECTORY resource must fall through to channel handler", + 200, resp.getStatusLine().getStatusCode()); + assertEquals("channel", + IOUtils.toString(resp.getEntity().getContent(), StandardCharsets.UTF_8)); + } + } + + @Test + public void testDirectory_existingFile_isServedByStaticHandler() throws Exception { + File dir = tempFolder.newFolder("dir-serve"); + File file = new File(dir, "hello.txt"); + try (FileOutputStream fos = new FileOutputStream(file)) { + fos.write("static-content".getBytes(StandardCharsets.UTF_8)); + } + int port = startServerWithDirectoryAndChannel("/test/data", dir, "/test"); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/test/data/hello.txt"))) { + assertEquals(200, resp.getStatusLine().getStatusCode()); + assertEquals("static-content", + IOUtils.toString(resp.getEntity().getContent(), StandardCharsets.UTF_8)); + } + } + + @Test + public void testDirectory_subdirectoryInPath_fallsThroughToChannelHandler() throws Exception { + File dir = tempFolder.newFolder("dir-subpath"); + int port = startServerWithDirectoryAndChannel("/test/data", dir, "/test"); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/test/data/sub/file.txt"))) { + assertEquals("Subdirectory path must fall through to channel handler", + 200, resp.getStatusLine().getStatusCode()); + assertEquals("channel", + IOUtils.toString(resp.getEntity().getContent(), StandardCharsets.UTF_8)); + } + } + // ========================================================================= // Helpers — Jetty server setup // ========================================================================= @@ -382,4 +440,88 @@ private static String repeat(char c, int count) { Arrays.fill(buf, c); return new String(buf); } + + /** + * Starts a server with a DIRECTORY {@link StaticResourceHandler} at {@code resourceContextPath} + * and a simple channel echo handler at {@code channelContextPath}. Used by GROUP D tests to + * verify that unanswered requests fall through from the resource context to the channel. + */ + private int startServerWithDirectoryAndChannel( + String resourceContextPath, File dir, String channelContextPath) throws Exception { + jettyServer = new Server(); + org.eclipse.jetty.server.HttpConfiguration httpCfg = + new org.eclipse.jetty.server.HttpConfiguration(); + httpCfg.setSendServerVersion(false); + serverConnector = new ServerConnector(jettyServer, new HttpConnectionFactory(httpCfg)); + serverConnector.setHost(LOOPBACK); + serverConnector.setPort(0); + serverConnector.setIdleTimeout(30_000); + jettyServer.addConnector(serverConnector); + + Class handlerClass = null; + for (Class c : HttpReceiver.class.getDeclaredClasses()) { + if ("StaticResourceHandler".equals(c.getSimpleName())) { + handlerClass = c; + break; + } + } + assertNotNull("StaticResourceHandler inner class not found in HttpReceiver", handlerClass); + Constructor ctor = handlerClass.getDeclaredConstructor( + HttpReceiver.class, HttpStaticResource.class); + ctor.setAccessible(true); + HttpStaticResource resource = new HttpStaticResource( + resourceContextPath, ResourceType.DIRECTORY, + dir.getAbsolutePath(), "application/octet-stream", Collections.emptyMap()); + AbstractHandler staticHandler = (AbstractHandler) ctor.newInstance(receiver, resource); + + // Both handlers live inside ONE channel ContextHandler with a stop-on-first-handled + // HandlerCollection. Separate contexts don't work: the EE8-to-core bridge + // (CoreContextHandler) returns true once the context path matches, even when the + // inner EE8 handler never sets isHandled(). Subclassing HandlerCollection (rather + // than using an anonymous AbstractHandler) ensures Jetty's lifecycle calls + // (setServer, start) propagate through addHandler() to both inner handlers. + HandlerCollection innerChain = new HandlerCollection() { + @Override + public void handle(String target, Request baseRequest, + HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + for (org.eclipse.jetty.ee8.nested.Handler h : getHandlers()) { + h.handle(target, baseRequest, request, response); + if (baseRequest.isHandled()) { + return; + } + } + } + }; + innerChain.addHandler(staticHandler); + innerChain.addHandler(new ChannelEchoHandler()); + + ContextHandler channelCtx = new ContextHandler(); + channelCtx.setContextPath(channelContextPath); + channelCtx.setAllowNullPathInfo(true); + channelCtx.setHandler(innerChain); + channelCtx.setServer(jettyServer); + + org.eclipse.jetty.server.handler.DefaultHandler defaultHandler = + new org.eclipse.jetty.server.handler.DefaultHandler(); + defaultHandler.setServeFavIcon(false); + + jettyServer.setHandler(new org.eclipse.jetty.server.Handler.Sequence( + channelCtx.getCoreContextHandler(), + defaultHandler)); + + jettyServer.start(); + return serverConnector.getLocalPort(); + } + + private static class ChannelEchoHandler extends AbstractHandler { + @Override + public void handle(String target, Request baseRequest, + HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + baseRequest.setHandled(true); + response.setStatus(HttpServletResponse.SC_OK); + response.getWriter().write("channel"); + } + } } From a200466b0cb639c53e1465fc3861cdb03c85abaa Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Wed, 13 May 2026 21:07:31 -0500 Subject: [PATCH 22/25] fix(IRT-831): extend resource fallthrough to CUSTOM and FILE types The previous commit only embedded DIRECTORY resources inside the channel ContextHandler. CUSTOM and FILE resources still had their own ContextHandler, which caused the same CoreContextHandler bridge problem: any sub-path that the StaticResourceHandler cannot serve (e.g. GET /test/data/wrong for a CUSTOM resource at /test/data) returned 404 instead of falling through to the channel. Remove the ResourceType.DIRECTORY condition. All static resource handlers are now collected into resourceHandlers and embedded in the channel context's stop-on-first-handled HandlerCollection, regardless of type. The handlers variable and its iteration loop are kept for structural consistency but are now only ever populated by the channel ContextHandler itself. --- .../connect/connectors/http/HttpReceiver.java | 46 ++++++++----------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/server/src/com/mirth/connect/connectors/http/HttpReceiver.java b/server/src/com/mirth/connect/connectors/http/HttpReceiver.java index 8a53cb4a6..a7427d689 100644 --- a/server/src/com/mirth/connect/connectors/http/HttpReceiver.java +++ b/server/src/com/mirth/connect/connectors/http/HttpReceiver.java @@ -228,13 +228,14 @@ public void onStart() throws ConnectorTaskException { server = new Server(); configuration.configureReceiver(this); - // handlers: staging list for CUSTOM/FILE resources that get their own ContextHandler. - // directoryHandlers: DIRECTORY resources embedded inside the channel context instead, - // because CoreContextHandler.handle() returns true once the context path matches even - // when the inner EE8 handler never sets isHandled() — a separate ContextHandler per - // DIRECTORY resource would therefore block fallthrough to the channel handler. + // All static resource handlers are embedded inside the channel ContextHandler rather + // than given their own separate ContextHandlers. CoreContextHandler.handle() returns + // true once the request path matches the context prefix — even when the inner EE8 + // handler never sets isHandled(). A separate ContextHandler per resource therefore + // blocks fallthrough to the channel for any sub-path the resource cannot serve + // (e.g. GET /test/data/wrong when the resource is the CUSTOM type at /test/data). HandlerCollection handlers = new HandlerCollection(); - List directoryHandlers = new ArrayList<>(); + List resourceHandlers = new ArrayList<>(); // Add handlers for each static resource if (getConnectorProperties().getStaticResources() != null) { @@ -288,26 +289,17 @@ public void onStart() throws ConnectorTaskException { if (authenticatorProvider != null) { resourceInner = createSecurityHandler(resourceInner); } - if (staticResource.getResourceType() == ResourceType.DIRECTORY) { - // Collected here; embedded inside the channel context below. - directoryHandlers.add(resourceInner); - } else { - ContextHandler resourceContextHandler = new ContextHandler(); - resourceContextHandler.setContextPath(staticResource.getContextPath()); - // This allows resources to be requested without a relative context path (e.g. "/") - resourceContextHandler.setAllowNullPathInfo(true); - resourceContextHandler.setHandler(resourceInner); - handlers.addHandler(resourceContextHandler); - } + // Collected here; embedded inside the channel context below. + resourceHandlers.add(resourceInner); } } } - // Build the channel context handler. DIRECTORY resource handlers are placed + // Build the channel context handler. All static resource handlers are placed // before RequestHandler inside a stop-on-first-handled HandlerCollection so that - // unanswered requests fall through to the channel. Using HandlerCollection (not - // an anonymous AbstractHandler) ensures Jetty's lifecycle methods (setServer, - // start) propagate to all inner handlers via addHandler(). + // any request the resource handler cannot serve falls through to the channel. + // Using HandlerCollection (not an anonymous AbstractHandler) ensures Jetty's + // lifecycle methods (setServer, start) propagate to all inner handlers via addHandler(). ContextHandler contextHandler = new ContextHandler(); contextHandler.setContextPath(contextPath); contextHandler.setAllowNullPathInfo(true); @@ -315,11 +307,11 @@ public void onStart() throws ConnectorTaskException { if (authenticatorProvider != null) { channelBase = createSecurityHandler(channelBase); } - if (directoryHandlers.isEmpty()) { + if (resourceHandlers.isEmpty()) { contextHandler.setHandler(channelBase); } else { final org.eclipse.jetty.ee8.nested.Handler finalChannelBase = channelBase; - HandlerCollection dirChain = new HandlerCollection() { + HandlerCollection resourceChain = new HandlerCollection() { @Override public void handle(String target, Request baseRequest, HttpServletRequest servletRequest, HttpServletResponse servletResponse) @@ -332,11 +324,11 @@ public void handle(String target, Request baseRequest, } } }; - for (org.eclipse.jetty.ee8.nested.Handler h : directoryHandlers) { - dirChain.addHandler(h); + for (org.eclipse.jetty.ee8.nested.Handler h : resourceHandlers) { + resourceChain.addHandler(h); } - dirChain.addHandler(finalChannelBase); - contextHandler.setHandler(dirChain); + resourceChain.addHandler(finalChannelBase); + contextHandler.setHandler(resourceChain); } handlers.addHandler(contextHandler); From bf386f80ccfa4c79359fec5bdab4a1bc003e7f35 Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Thu, 14 May 2026 08:56:56 -0500 Subject: [PATCH 23/25] test(IRT-831): add GROUP E fallthrough tests for CUSTOM and FILE resources Commit a200466b0 extended the channel-embedded handler architecture to all ResourceTypes, but the integration test suite only covered DIRECTORY fallthrough (GROUP D). Add GROUP E tests that prove CUSTOM and FILE sub-path misses also fall through to the channel handler, not to a 404. --- ...ReceiverStaticResourceIntegrationTest.java | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java b/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java index 57616983f..8c0d6eb17 100644 --- a/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java +++ b/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java @@ -337,6 +337,47 @@ public void testDirectory_subdirectoryInPath_fallsThroughToChannelHandler() thro } } + // ========================================================================= + // GROUP E — CUSTOM and FILE fallthrough to channel handler (IRT-831, commit a200466b0) + // Before this fix, only DIRECTORY resources were embedded inside the channel + // ContextHandler. CUSTOM and FILE still had their own ContextHandler, so + // CoreContextHandler returned true on any prefix match — blocking fallthrough + // for sub-paths the StaticResourceHandler could not serve. + // ========================================================================= + + @Test + public void testCustom_subPath_fallsThroughToChannelHandler() throws Exception { + String content = "custom-content"; + int port = startServerWithResourceAndChannel( + "/test/data", ResourceType.CUSTOM, content, "/test"); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/test/data/wrong"))) { + assertEquals( + "Sub-path miss on CUSTOM resource must fall through to channel handler", + 200, resp.getStatusLine().getStatusCode()); + assertEquals("channel", + IOUtils.toString(resp.getEntity().getContent(), StandardCharsets.UTF_8)); + } + } + + @Test + public void testFile_subPath_fallsThroughToChannelHandler() throws Exception { + byte[] content = "file-content".getBytes(StandardCharsets.UTF_8); + File f = writeTempFile("serve.bin", content); + int port = startServerWithResourceAndChannel( + "/test/data", ResourceType.FILE, f.getAbsolutePath(), "/test"); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/test/data/wrong"))) { + assertEquals( + "Sub-path miss on FILE resource must fall through to channel handler", + 200, resp.getStatusLine().getStatusCode()); + assertEquals("channel", + IOUtils.toString(resp.getEntity().getContent(), StandardCharsets.UTF_8)); + } + } + // ========================================================================= // Helpers — Jetty server setup // ========================================================================= @@ -394,6 +435,73 @@ private int startServer(HttpStaticResource resource) throws Exception { return serverConnector.getLocalPort(); } + /** + * Starts a server with a single static resource handler (any {@link ResourceType}) and a + * {@link ChannelEchoHandler} embedded inside one channel {@link ContextHandler}, replicating + * the architecture from commit a200466b0. Used by GROUP E tests. + */ + private int startServerWithResourceAndChannel( + String resourceContextPath, ResourceType resourceType, + String resourceValue, String channelContextPath) throws Exception { + jettyServer = new Server(); + org.eclipse.jetty.server.HttpConfiguration httpCfg = + new org.eclipse.jetty.server.HttpConfiguration(); + httpCfg.setSendServerVersion(false); + serverConnector = new ServerConnector(jettyServer, new HttpConnectionFactory(httpCfg)); + serverConnector.setHost(LOOPBACK); + serverConnector.setPort(0); + serverConnector.setIdleTimeout(30_000); + jettyServer.addConnector(serverConnector); + + Class handlerClass = null; + for (Class c : HttpReceiver.class.getDeclaredClasses()) { + if ("StaticResourceHandler".equals(c.getSimpleName())) { + handlerClass = c; + break; + } + } + assertNotNull("StaticResourceHandler inner class not found in HttpReceiver", handlerClass); + Constructor ctor = handlerClass.getDeclaredConstructor( + HttpReceiver.class, HttpStaticResource.class); + ctor.setAccessible(true); + HttpStaticResource resource = new HttpStaticResource( + resourceContextPath, resourceType, + resourceValue, "text/plain", Collections.emptyMap()); + AbstractHandler staticHandler = (AbstractHandler) ctor.newInstance(receiver, resource); + + HandlerCollection innerChain = new HandlerCollection() { + @Override + public void handle(String target, Request baseRequest, + HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + for (org.eclipse.jetty.ee8.nested.Handler h : getHandlers()) { + h.handle(target, baseRequest, request, response); + if (baseRequest.isHandled()) { + return; + } + } + } + }; + innerChain.addHandler(staticHandler); + innerChain.addHandler(new ChannelEchoHandler()); + + ContextHandler channelCtx = new ContextHandler(); + channelCtx.setContextPath(channelContextPath); + channelCtx.setAllowNullPathInfo(true); + channelCtx.setHandler(innerChain); + channelCtx.setServer(jettyServer); + + org.eclipse.jetty.server.handler.DefaultHandler defaultHandler = + new org.eclipse.jetty.server.handler.DefaultHandler(); + defaultHandler.setServeFavIcon(false); + + jettyServer.setHandler(new org.eclipse.jetty.server.Handler.Sequence( + channelCtx.getCoreContextHandler(), defaultHandler)); + + jettyServer.start(); + return serverConnector.getLocalPort(); + } + // ========================================================================= // Helpers — HTTP client // ========================================================================= From 4554d7f1a3d216404799bbcfeb9b70f8e99111ef Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Thu, 14 May 2026 08:59:00 -0500 Subject: [PATCH 24/25] test(IRT-834): add SwaggerUiFilter unit tests for Content-Length on static assets Verify that SwaggerUiFilter sets Content-Length and Content-Type when serving index.html and static CSS, and passes through non-static API paths unchanged. --- .../connect/server/MirthWebServerTest.java | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/server/test/com/mirth/connect/server/MirthWebServerTest.java b/server/test/com/mirth/connect/server/MirthWebServerTest.java index b5e17029d..f50d241af 100644 --- a/server/test/com/mirth/connect/server/MirthWebServerTest.java +++ b/server/test/com/mirth/connect/server/MirthWebServerTest.java @@ -782,4 +782,77 @@ private static ServletOutputStream capturingStream(ByteArrayOutputStream baos) { @Override public void setWriteListener(WriteListener wl) {} }; } + + // ===== SwaggerUiFilter tests (IRT-834) ===== + + @Test + public void testSwaggerUiFilterServesIndexHtmlWithContentLength() throws Exception { + File tmpDir = createTempDir(); + File indexFile = new File(tmpDir, "index.html"); + byte[] content = "swagger".getBytes(); + Files.write(indexFile.toPath(), content); + + MirthWebServer.SwaggerUiFilter filter = new MirthWebServer.SwaggerUiFilter(tmpDir.getAbsolutePath()); + + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getPathInfo()).thenReturn(null); + HttpServletResponse response = mock(HttpServletResponse.class); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + when(response.getOutputStream()).thenReturn(capturingStream(baos)); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(response).setContentLengthLong(content.length); + verify(response).setContentType("text/html; charset=UTF-8"); + verify(chain, never()).doFilter(request, response); + } + + @Test + public void testSwaggerUiFilterServesStaticCssWithContentLength() throws Exception { + File tmpDir = createTempDir(); + File cssFile = new File(tmpDir, "swagger-ui.css"); + byte[] content = "body { margin: 0; }".getBytes(); + Files.write(cssFile.toPath(), content); + + MirthWebServer.SwaggerUiFilter filter = new MirthWebServer.SwaggerUiFilter(tmpDir.getAbsolutePath()); + + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getPathInfo()).thenReturn("/swagger-ui.css"); + HttpServletResponse response = mock(HttpServletResponse.class); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + when(response.getOutputStream()).thenReturn(capturingStream(baos)); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(response).setContentLengthLong(content.length); + verify(response).setContentType("text/css"); + verify(chain, never()).doFilter(request, response); + } + + @Test + public void testSwaggerUiFilterPassesThroughNonStaticApiPath() throws Exception { + File tmpDir = createTempDir(); + + MirthWebServer.SwaggerUiFilter filter = new MirthWebServer.SwaggerUiFilter(tmpDir.getAbsolutePath()); + + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getPathInfo()).thenReturn("/channels"); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + verify(response, never()).setContentLengthLong(anyLong()); + } + + private File createTempDir() throws IOException { + File dir = File.createTempFile("swagger-test-", ""); + dir.delete(); + dir.mkdirs(); + dir.deleteOnExit(); + return dir; + } } From 5a9739591e68673d7a4fcae120e9160dd27f6337 Mon Sep 17 00:00:00 2001 From: Innovarzweng <116585005+Innovarzweng@users.noreply.github.com> Date: Thu, 14 May 2026 14:37:02 -0500 Subject: [PATCH 25/25] chore: remove OIE workflow file from BridgeLink repo --- .github/workflows/build.yaml | 42 ------------------------------------ 1 file changed, 42 deletions(-) delete mode 100644 .github/workflows/build.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml deleted file mode 100644 index ec879d84c..000000000 --- a/.github/workflows/build.yaml +++ /dev/null @@ -1,42 +0,0 @@ -name: Build OpenIntegrationEngine - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK - uses: actions/setup-java@v4 - with: - java-version: '17' - java-package: 'jdk+fx' - distribution: 'zulu' - - - name: Build OIE (signed) - if: github.ref == 'refs/heads/main' - working-directory: server - run: ant -f mirth-build.xml - - - name: Build OIE (unsigned) - if: github.ref != 'refs/heads/main' - working-directory: server - run: ant -f mirth-build.xml -DdisableSigning=true - - - name: Package distribution - run: tar czf openintegrationengine.tar.gz -C server/ setup --transform 's|^setup|openintegrationengine/|' - - - name: Create artifact - uses: actions/upload-artifact@v4 - with: - name: oie-build - path: openintegrationengine.tar.gz