diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 29ad797961..5a8de4b610 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -240,6 +240,7 @@ reactive-streams = { module = "org.reactivestreams:reactive-streams", version =
reflections = { module = "org.reflections:reflections", version = "0.9.10" }
rhino = { module = "org.mozilla:rhino", version = "1.7.13" }
saaj-impl = { module = "com.sun.xml.messaging.saaj:saaj-impl", version = "1.4.0" }
+sardine = { module = "com.github.lookfirst:sardine", version = "5.10" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
slf4j-log4j12 = { module = "org.slf4j:slf4j-log4j12", version.ref = "slf4j" }
snakeyaml = { module = "org.yaml:snakeyaml", version = "2.5" }
diff --git a/gradle/vendored-layout.json b/gradle/vendored-layout.json
index 446d076a41..ae46376653 100644
--- a/gradle/vendored-layout.json
+++ b/gradle/vendored-layout.json
@@ -378,6 +378,7 @@
"xpp3:xpp3": ""
},
"server-extensions": {
+ "com.github.lookfirst:sardine": "extensions/file",
"com.lowagie:itext": "extensions/doc",
"com.lowagie:itext-rtf": "extensions/doc",
"com.openhtmltopdf:openhtmltopdf-core": "extensions/doc",
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index 509312e318..ffb13271f0 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -256,6 +256,14 @@
+
+
+
+
+
+
+
+
diff --git a/server/build.gradle b/server/build.gradle
index d928023bb5..9e81757d97 100644
--- a/server/build.gradle
+++ b/server/build.gradle
@@ -9,6 +9,8 @@ import java.text.SimpleDateFormat
dependencies {
implementation libs.bundles.server.main
implementation libs.bundles.server.extensions
+ // the engine already ships httpclient and JAXB, so leave out Sardine's copies
+ implementation(libs.sardine) { transitive = false }
// classified artifact: the catalog entry carries the version, the
// classifier is selected here
implementation(variantOf(libs.netty.transport.native.epoll) { classifier('linux-x86_64') })
diff --git a/server/docs/thirdparty/THIRD-PARTY-README.txt b/server/docs/thirdparty/THIRD-PARTY-README.txt
index d0db9c2127..6b7a6b99c8 100644
--- a/server/docs/thirdparty/THIRD-PARTY-README.txt
+++ b/server/docs/thirdparty/THIRD-PARTY-README.txt
@@ -38,11 +38,11 @@ The following are distributed under the terms of the Apache License version 2.0
Netty 4.1.137
Not-Going-To-Be-Commons-SSL 0.3.18
Quartz Scheduler 2.3.2
+ Sardine 5.10 (https://github.com/lookfirst/sardine)
SQLite JDBC Driver 3.43.2.1 (https://github.com/xerial/sqlite-jdbc)
StAXON 1.3 [incl. CLI] (https://github.com/beckchr/staxon)
Swagger Core 2.0.10 [incl. CLI] (https://github.com/swagger-api/swagger-core)
Swagger UI 2.2.10 (http://swagger.io/swagger-ui/)
- webdavclient4j 0.92 (http://sourceforge.net/projects/webdavclient4j/)
Zip4j 1.3.3 (http://www.lingala.net/zip4j/)
diff --git a/server/lib/extensions/file/webdavclient4j-core-0.92.jar b/server/lib/extensions/file/webdavclient4j-core-0.92.jar
deleted file mode 100644
index 8bd3b5f73e..0000000000
Binary files a/server/lib/extensions/file/webdavclient4j-core-0.92.jar and /dev/null differ
diff --git a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/FileSystemConnectionFactory.java b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/FileSystemConnectionFactory.java
index f5ca587344..d70949b359 100644
--- a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/FileSystemConnectionFactory.java
+++ b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/FileSystemConnectionFactory.java
@@ -179,7 +179,7 @@ public PooledObject makeObject() throws Exception {
} else if (scheme.equals(FileScheme.SMB)) {
return new DefaultPooledObject(new SmbFileConnection(host, fileSystemOptions, timeout));
} else if (scheme.equals(FileScheme.WEBDAV)) {
- return new DefaultPooledObject(new WebDavConnection(host, secure, fileSystemOptions));
+ return new DefaultPooledObject(new WebDavConnection(host, port, secure, fileSystemOptions, timeout));
} else {
logger.error("makeObject doesn't handle scheme " + scheme);
throw new IOException("Unimplemented or unrecognized scheme");
diff --git a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java
index fc7209ea6a..83aafbf987 100644
--- a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java
+++ b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java
@@ -9,20 +9,41 @@
package com.mirth.connect.connectors.file.filesystems;
+import java.io.ByteArrayInputStream;
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URISyntaxException;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
-import org.apache.commons.httpclient.HttpURL;
-import org.apache.commons.httpclient.HttpsURL;
import org.apache.commons.io.filefilter.WildcardFileFilter;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
-import org.apache.webdav.lib.WebdavFile;
-import org.apache.webdav.lib.WebdavResource;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpHost;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.NTCredentials;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.CredentialsProvider;
+import org.apache.http.client.HttpResponseException;
+import org.apache.http.client.config.AuthSchemes;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.utils.URIBuilder;
+import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.InputStreamEntity;
+import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.ssl.SSLContexts;
+
+import com.github.sardine.DavResource;
+import com.github.sardine.impl.SardineImpl;
+import com.github.sardine.impl.SardineRedirectStrategy;
import com.mirth.connect.connectors.file.FileSystemConnectionOptions;
import com.mirth.connect.connectors.file.filters.RegexFilenameFilter;
@@ -30,28 +51,36 @@
public class WebDavConnection implements FileSystemConnection {
public class WebDavFileInfo implements FileInfo {
private String thePath;
- private WebdavFile theFile;
+ private String name;
+ private String url;
+ private boolean directory;
+ private long size;
+ private long lastModified;
- public WebDavFileInfo(String path, WebdavFile theFile) {
+ public WebDavFileInfo(String path, String name, String url, boolean directory, long size, long lastModified) {
this.thePath = path;
- this.theFile = theFile;
+ this.name = name;
+ this.url = url;
+ this.directory = directory;
+ this.size = size;
+ this.lastModified = lastModified;
}
public long getLastModified() {
- return theFile.lastModified();
+ return lastModified;
}
public String getName() {
- return theFile.getName();
+ return name;
}
/** Gets the absolute pathname of the file */
public String getAbsolutePath() {
- return theFile.getAbsolutePath();
+ return url;
}
public String getCanonicalPath() throws IOException {
- return this.theFile.getCanonicalPath();
+ return url;
}
/** Gets the absolute pathname of the directory holding the file */
@@ -60,51 +89,97 @@ public String getParent() {
}
public long getSize() {
- return theFile.length();
+ return size;
}
public boolean isDirectory() {
- return theFile.isDirectory();
+ return directory;
}
public boolean isFile() {
- return theFile.isFile();
+ return !directory;
}
public boolean isReadable() {
- return theFile.canRead();
+ return true;
}
@Override
public void populateSourceMap(Map sourceMap) {}
}
+ /**
+ * Streams a ByteArrayInputStream, which is what FileDispatcher passes, and resends it by
+ * resetting the stream. The PUT can then be retried after an authentication challenge without
+ * copying the payload, even when a proxy has already read the body.
+ */
+ private static class ResettableEntity extends InputStreamEntity {
+ private final ByteArrayInputStream in;
+
+ ResettableEntity(ByteArrayInputStream in, long length) {
+ super(in, length, ContentType.APPLICATION_OCTET_STREAM);
+ this.in = in;
+ in.mark(0);
+ }
+
+ @Override
+ public boolean isRepeatable() {
+ return true;
+ }
+
+ @Override
+ public void writeTo(OutputStream out) throws IOException {
+ in.reset();
+ super.writeTo(out);
+ }
+ }
+
private static transient Log logger = LogFactory.getLog(WebDavConnection.class);
/** The WebDAV client instance */
- private WebdavResource client = null;
- private boolean secure = false;
- private String username = null;
- private String password = null;
-
- public WebDavConnection(String host, boolean secure, FileSystemConnectionOptions fileSystemOptions) throws Exception {
- this.secure = secure;
- username = fileSystemOptions.getUsername();
- password = fileSystemOptions.getPassword();
+ private SardineImpl client = null;
+ private String baseUrl;
+ private InputStream readStream = null;
+ /** Whether the server answered the last PROPFIND */
+ private boolean connected = false;
+ /** Status of the last failed PROPFIND */
+ private String lastStatus = null;
+
+ public WebDavConnection(String host, int port, boolean secure, FileSystemConnectionOptions fileSystemOptions, int timeout) throws Exception {
+ this(createClient(host, port, secure, fileSystemOptions, timeout), new HttpHost(host, port, secure ? "https" : "http").toURI());
+ }
- HttpURL url = null;
+ WebDavConnection(SardineImpl client, String baseUrl) {
+ this.client = client;
+ this.baseUrl = baseUrl;
- if (secure) {
- url = new HttpsURL("https://" + host);
- } else {
- url = new HttpURL("http://" + host);
+ try {
+ stat("/");
+ } catch (IOException e) {
+ logger.debug(e);
}
+ }
+ private static SardineImpl createClient(String host, int port, boolean secure, FileSystemConnectionOptions fileSystemOptions, int timeout) {
+ String username = fileSystemOptions.getUsername();
+ String password = fileSystemOptions.getPassword();
+ CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
if (!username.equals("null")) {
- url.setUserinfo(username, password);
+ int authPort = port > 0 ? port : (secure ? 443 : 80);
+
+ // Scope credentials to this port so a redirect elsewhere doesn't get them
+ credentialsProvider.setCredentials(new AuthScope(host, authPort), new UsernamePasswordCredentials(username, password));
+
+ // NTLM needs the domain split out of DOMAIN\\user
+ int separator = username.indexOf('\\');
+ String domain = separator < 0 ? null : username.substring(0, separator);
+ credentialsProvider.setCredentials(new AuthScope(host, authPort, AuthScope.ANY_REALM, AuthSchemes.NTLM), new NTCredentials(username.substring(separator + 1), password, null, domain));
}
- client = new WebdavResource(url);
+ // The schemes httpclient 3 negotiated, in its order of preference
+ RequestConfig requestConfig = RequestConfig.custom().setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.NTLM, AuthSchemes.DIGEST, AuthSchemes.BASIC)).setConnectTimeout(timeout).setSocketTimeout(timeout).setExpectContinueEnabled(false).build();
+
+ return new SardineImpl(HttpClients.custom().setDefaultCredentialsProvider(credentialsProvider).setDefaultRequestConfig(requestConfig).setRedirectStrategy(new SardineRedirectStrategy()).setSSLSocketFactory(new SSLConnectionSocketFactory(SSLContexts.createSystemDefault())));
}
@Override
@@ -124,49 +199,40 @@ public List listFiles(String fromDir, String filenamePattern, boolean
public List listDirectories(String fromDir) throws Exception {
List directories = new ArrayList();
for (FileInfo directory : list(fromDir, false, null, false)) {
- directories.add(directory.getCanonicalPath());
+ directories.add(normalize(getFullPath(directory.getParent(), directory.getName())));
}
return directories;
}
private List list(String fromDir, boolean files, FilenameFilter filenameFilter, boolean ignoreDot) throws Exception {
- client.setPath(fromDir);
- WebdavResource[] resources = client.listWebdavResources();
-
- if (resources == null || resources.length == 0) {
- return new ArrayList();
+ String dirPath = normalize(getFullPath(fromDir, ""));
+ List resources = propfind(fromDir.isEmpty() ? "/" : fromDir, 1);
+ if (resources == null) {
+ throw new IOException("Unable to list directory: '" + fromDir + "': " + lastStatus);
}
- List fileInfoList = new ArrayList(resources.length);
- for (int i = 0; i < resources.length; i++) {
-
- WebdavFile file = null;
- String filePath = getFullPath(fromDir, resources[i].getPath());
-
- if (secure) {
-
- HttpsURL hrl = new HttpsURL("https://" + client.getHost() + filePath);
- if (!username.equals("null")) {
- hrl.setUserinfo(username, password);
- }
- file = new WebdavFile(hrl);
-
- } else {
-
- HttpURL hrl = new HttpURL("http://" + client.getHost() + filePath);
- if (!username.equals("null")) {
- hrl.setUserinfo(username, password);
- }
- file = new WebdavFile(hrl);
+ // The directory itself is part of a Depth 1 response
+ int self = selfIndex(resources, dirPath);
+ List fileInfoList = new ArrayList(resources.size());
+ for (int i = 0; i < resources.size(); i++) {
+ DavResource resource = resources.get(i);
+ // Skip the directory itself and entries without properties, e.g. 403
+ if (i == self || resource.getStatusCode() < 200 || resource.getStatusCode() >= 300) {
+ continue;
}
+ String name = resource.getName();
+ long size = Math.max(resource.getContentLength(), 0);
+ long lastModified = resource.getModified() == null ? 0 : resource.getModified().getTime();
+ WebDavFileInfo file = new WebDavFileInfo(fromDir, name, url(getFullPath(fromDir, name)), resource.isDirectory(), size, lastModified);
+
if (files) {
if (file.isFile() && filenameFilter.accept(null, file.getName()) && !(ignoreDot && file.getName().startsWith("."))) {
- fileInfoList.add(new WebDavFileInfo(fromDir, file));
+ fileInfoList.add(file);
}
} else if (file.isDirectory()) {
- fileInfoList.add(new WebDavFileInfo(fromDir, file));
+ fileInfoList.add(file);
}
}
@@ -176,8 +242,7 @@ private List list(String fromDir, boolean files, FilenameFilter filena
@Override
public boolean exists(String file, String path) {
try {
- client.setPath(getFullPath(path, file));
- return client.exists();
+ return stat(getFullPath(path, file)) != null;
} catch (IOException e) {
return false;
}
@@ -187,18 +252,27 @@ public boolean exists(String file, String path) {
public InputStream readFile(String file, String fromDir, Map sourceMap) throws Exception {
String fullPath = getFullPath(fromDir, file);
- client.setPath(fullPath);
- if (client.isCollection()) {
+ DavResource resource = stat(fullPath);
+ if (resource != null && resource.isDirectory()) {
logger.error("Invalid filepath: " + fullPath);
throw new Exception("Invalid Path");
}
- return client.getMethodData();
+ closeReadFile();
+ try {
+ readStream = client.get(url(fullPath));
+ } catch (HttpResponseException e) {
+ throw new IOException("Couldn't get file", e);
+ }
+ return readStream;
}
@Override
public void closeReadFile() throws Exception {
- // irrelevant
+ if (readStream != null) {
+ readStream.close();
+ readStream = null;
+ }
}
@Override
@@ -211,29 +285,24 @@ public void writeFile(String file, String toDir, boolean append, InputStream is,
String fullPath = getFullPath(toDir, file);
// first check if the toDir exists.
- client.setPath(toDir);
-
- if (!client.exists()) {
+ DavResource dir = stat(toDir);
+ if (dir == null) {
// create the directory.
- client.mkcolMethod(toDir);
+ createDirectory(toDir);
logger.info("Destination directory does not exist. Creating directory: '" + toDir + "'");
-
- if (!client.putMethod(fullPath, is)) {
- logger.error("Unable to write file: '" + fullPath);
- }
-
- } else {
-
+ } else if (!dir.isDirectory()) {
// make sure it's a directory, not a file.
- if (!client.isCollection()) {
- throw new Exception("The destination directory path is invalid: '" + client.getPath() + "'");
- } else {
- // valid directory. now write the file.
- if (!client.putMethod(fullPath, is)) {
- logger.error("Unable to write file: '" + fullPath);
- }
- }
+ throw new Exception("The destination directory path is invalid: '" + toDir + "'");
+ }
+
+ try {
+ // Expect: 100-continue usually gets an authentication challenge before the body. A buffering
+ // proxy may still consume the body first, so resend it when the stream allows.
+ HttpEntity entity = is instanceof ByteArrayInputStream ? new ResettableEntity((ByteArrayInputStream) is, contentLength) : new InputStreamEntity(is, contentLength, ContentType.APPLICATION_OCTET_STREAM);
+ client.put(url(fullPath), entity, null, true);
+ } catch (HttpResponseException e) {
+ logger.error("Unable to write file: '" + fullPath, e);
}
}
@@ -241,9 +310,11 @@ public void writeFile(String file, String toDir, boolean append, InputStream is,
public void delete(String file, String fromDir, boolean mayNotExist) throws Exception {
String fullPath = getFullPath(fromDir, file);
- if (!client.deleteMethod(fullPath)) {
+ try {
+ client.delete(url(fullPath));
+ } catch (HttpResponseException e) {
if (!mayNotExist) {
- logger.error("Unable to delete file: '" + fullPath + "'");
+ logger.error("Unable to delete file: '" + fullPath + "'", e);
}
}
}
@@ -254,35 +325,28 @@ public void move(String fromName, String fromDir, String toName, String toDir) t
String targetPath = getFullPath(toDir, toName);
// first check if the toDir exists.
- client.setPath(toDir);
-
- if (!client.exists()) {
+ DavResource dir = stat(toDir);
+ if (dir == null) {
// create the directory. and then move.
- client.mkcolMethod(toDir);
+ createDirectory(toDir);
logger.info("Move-To directory does not exist. Creating directory: '" + toDir + "'");
-
- if (!client.moveMethod(sourcePath, targetPath)) {
- logger.error("Unable to move file: '" + sourcePath + "' to '" + targetPath + "'");
- }
-
- } else {
-
+ } else if (!dir.isDirectory()) {
// make sure it's a directory, not a file.
- if (!client.isCollection()) {
- throw new Exception("The move-to directory path is invalid: '" + client.getPath() + "'");
- } else {
- // valid directory. now move the file.
- if (!client.moveMethod(sourcePath, targetPath)) {
- logger.error("Unable to move file: '" + sourcePath + "' to '" + targetPath + "'");
- }
- }
+ throw new Exception("The move-to directory path is invalid: '" + toDir + "'");
+ }
+
+ try {
+ // Slide never overwrote an existing target
+ client.move(url(sourcePath), url(targetPath), false);
+ } catch (HttpResponseException e) {
+ logger.error("Unable to move file: '" + sourcePath + "' to '" + targetPath + "'", e);
}
}
@Override
public boolean isConnected() {
- return client != null && client.exists();
+ return client != null && connected;
}
@Override
@@ -301,41 +365,111 @@ public void passivate() {
@Override
public void destroy() {
try {
+ closeReadFile();
if (client != null) {
- client.close();
+ client.shutdown();
}
- } catch (IOException e) {
+ } catch (Exception e) {
logger.debug(e);
}
}
@Override
public boolean isValid() {
- return client != null && client.exists();
+ return client != null && connected;
}
@Override
public boolean canRead(String readDir) {
+ return isDirectory(readDir);
+ }
+
+ @Override
+ public boolean canWrite(String writeDir) {
+ // A failed write is reported when it happens
+ return isDirectory(writeDir);
+ }
+
+ private boolean isDirectory(String path) {
try {
- client.setPath(readDir);
- return client.exists() && client.isCollection();
+ DavResource dir = stat(path);
+ return dir != null && dir.isDirectory();
} catch (IOException e) {
logger.debug(e);
return false;
}
}
- @Override
- public boolean canWrite(String writeDir) {
+ /** Returns the resource at path, or null if it can't be found */
+ private DavResource stat(String path) throws IOException {
+ List resources = propfind(path, 0);
+ return resources == null || resources.isEmpty() ? null : resources.get(0);
+ }
+
+ /** Returns the resources of a PROPFIND, or null if the request failed */
+ private List propfind(String path, int depth) throws IOException {
try {
- client.setPath(writeDir);
- return client.exists() && client.isCollection() && !client.isLocked();
+ List resources = client.list(url(path), depth, Collections.emptySet());
+ connected = true;
+ return resources;
+ } catch (HttpResponseException e) {
+ // Any answer, e.g. 404 for a missing file, means the server is reachable
+ connected = true;
+ lastStatus = e.getMessage();
+ return null;
} catch (IOException e) {
+ connected = false;
+ throw e;
+ }
+ }
+
+ /**
+ * Finds the requested directory's own entry. Behind a rewriting proxy its href carries a
+ * different prefix, so fall back to the entry every other entry lives under.
+ */
+ private static int selfIndex(List resources, String dirPath) {
+ for (int i = 0; i < resources.size(); i++) {
+ if (normalize(resources.get(i).getPath()).equalsIgnoreCase(dirPath)) {
+ return i;
+ }
+ }
+
+ for (int i = 0; i < resources.size(); i++) {
+ String path = normalize(resources.get(i).getPath());
+ String prefix = path.endsWith("/") ? path : path + "/";
+ boolean parent = true;
+ for (int j = 0; j < resources.size() && parent; j++) {
+ parent = j == i || normalize(resources.get(j).getPath()).startsWith(prefix);
+ }
+ if (parent) {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ private void createDirectory(String path) throws IOException {
+ try {
+ client.createDirectory(url(path));
+ } catch (HttpResponseException e) {
logger.debug(e);
- return false;
}
}
+ private String url(String path) throws IOException {
+ try {
+ return new URIBuilder(baseUrl).setPath(path).build().toASCIIString();
+ } catch (URISyntaxException e) {
+ throw new IOException(e);
+ }
+ }
+
+ private static String normalize(String path) {
+ path = ("/" + path).replaceAll("/+", "/");
+ return path.length() > 1 && path.endsWith("/") ? path.substring(0, path.length() - 1) : path;
+ }
+
private String getFullPath(String dir, String file) {
return ("/" + dir + "/" + file).replaceAll("//", "/");
}
diff --git a/server/src/main/resources/com/mirth/connect/connectors/file/destination.xml b/server/src/main/resources/com/mirth/connect/connectors/file/destination.xml
index f3c9326bf4..6abd9fb84a 100644
--- a/server/src/main/resources/com/mirth/connect/connectors/file/destination.xml
+++ b/server/src/main/resources/com/mirth/connect/connectors/file/destination.xml
@@ -12,7 +12,7 @@
-
+
file
diff --git a/server/src/main/resources/com/mirth/connect/connectors/file/source.xml b/server/src/main/resources/com/mirth/connect/connectors/file/source.xml
index 8a36863918..939eb50966 100644
--- a/server/src/main/resources/com/mirth/connect/connectors/file/source.xml
+++ b/server/src/main/resources/com/mirth/connect/connectors/file/source.xml
@@ -13,7 +13,7 @@
-
+
file
diff --git a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java
new file mode 100644
index 0000000000..3f028e3294
--- /dev/null
+++ b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java
@@ -0,0 +1,227 @@
+package com.mirth.connect.connectors.file.filesystems;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+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.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+
+import org.apache.http.HttpEntity;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+import com.github.sardine.DavResource;
+import com.github.sardine.impl.SardineException;
+import com.github.sardine.impl.SardineImpl;
+import com.mirth.connect.connectors.file.FileSystemConnectionOptions;
+
+public class WebDavConnectionTest {
+
+ private static final String BASE = "http://dav.example:8080";
+
+ private SardineImpl sardine;
+ private WebDavConnection connection;
+
+ @Before
+ public void setUp() throws Exception {
+ sardine = mock(SardineImpl.class);
+ // Anything not set up below doesn't exist
+ when(sardine.list(anyString(), anyInt(), any())).thenThrow(new SardineException("Unexpected response", 404, "Not Found"));
+ propfind("/", 0, dir("/"));
+ connection = new WebDavConnection(sardine, BASE);
+ }
+
+ @Test
+ public void listFiles() throws Exception {
+ DavResource forbidden = file("/in/forbidden.txt", 0);
+ when(forbidden.getStatusCode()).thenReturn(403);
+ propfind("/in", 1, dir("/in/"), file("/in/a.txt", 5), file("/in/b.hl7", 6), file("/in/.hidden.txt", 1), dir("/in/sub/"), forbidden);
+
+ List files = connection.listFiles("/in", "*.txt", false, true);
+ assertEquals(Arrays.asList("a.txt"), names(files));
+ FileInfo a = files.get(0);
+ assertEquals(5, a.getSize());
+ assertEquals(1000, a.getLastModified());
+ assertEquals("/in", a.getParent());
+ assertEquals(BASE + "/in/a.txt", a.getAbsolutePath());
+
+ assertEquals(Arrays.asList("a.txt", "b.hl7"), names(connection.listFiles("/in", "^[ab]\\..*", true, false)));
+ assertEquals(Arrays.asList("/in/sub"), connection.listDirectories("/in"));
+ }
+
+ @Test
+ public void listRoot() throws Exception {
+ propfind("/", 1, dir("/"), dir("/in/"));
+ assertEquals(Arrays.asList("/in"), connection.listDirectories("/"));
+ }
+
+ @Test
+ public void selfEntryBehindRewritingProxy() throws Exception {
+ propfind("/in", 1, dir("/dav/in/"), file("/dav/in/a.txt", 1), dir("/dav/in/sub/"));
+ assertEquals(Arrays.asList("a.txt"), names(connection.listFiles("/in", "*", false, false)));
+ assertEquals(Arrays.asList("/in/sub"), connection.listDirectories("/in"));
+ }
+
+ @Test
+ public void listingErrorIncludesStatus() throws Exception {
+ try {
+ connection.listFiles("/missing", "*", false, false);
+ fail();
+ } catch (Exception e) {
+ assertTrue(e.getMessage(), e.getMessage().contains("404"));
+ }
+ }
+
+ @Test
+ public void existsAndCanRead() throws Exception {
+ propfind("/in", 0, dir("/in/"));
+ propfind("/in/a.txt", 0, file("/in/a.txt", 1));
+ assertTrue(connection.exists("a.txt", "/in"));
+ assertFalse(connection.exists("b.txt", "/in"));
+ assertTrue(connection.canRead("/in"));
+ assertTrue(connection.canWrite("/in"));
+ assertFalse(connection.canRead("/in/a.txt"));
+ assertFalse(connection.canWrite("/missing"));
+ }
+
+ @Test
+ public void readFile() throws Exception {
+ propfind("/in/sub", 0, dir("/in/sub/"));
+ try {
+ connection.readFile("sub", "/in", new HashMap<>());
+ fail();
+ } catch (Exception e) {
+ assertEquals("Invalid Path", e.getMessage());
+ }
+ }
+
+ @Test
+ public void writeFileCreatesMissingDirectory() throws Exception {
+ connection.writeFile("a.txt", "/out", false, new ByteArrayInputStream("abc".getBytes()), 3, new HashMap<>());
+ verify(sardine).createDirectory(BASE + "/out");
+
+ // The body can be resent if a proxy consumed it before the authentication challenge
+ ArgumentCaptor entity = ArgumentCaptor.forClass(HttpEntity.class);
+ verify(sardine).put(eq(BASE + "/out/a.txt"), entity.capture(), isNull(), eq(true));
+ assertTrue(entity.getValue().isRepeatable());
+ assertEquals("abc", body(entity.getValue()));
+ assertEquals("abc", body(entity.getValue()));
+ }
+
+ @Test
+ public void otherStreamsAreNotResent() throws Exception {
+ propfind("/out", 0, dir("/out/"));
+ InputStream in = new FilterInputStream(new ByteArrayInputStream("abc".getBytes())) {};
+ connection.writeFile("a.txt", "/out", false, in, 3, new HashMap<>());
+ ArgumentCaptor entity = ArgumentCaptor.forClass(HttpEntity.class);
+ verify(sardine).put(eq(BASE + "/out/a.txt"), entity.capture(), isNull(), eq(true));
+ assertFalse(entity.getValue().isRepeatable());
+ }
+
+ @Test
+ public void writeFileIntoFileFails() throws Exception {
+ propfind("/out", 0, file("/out", 1));
+ try {
+ connection.writeFile("a.txt", "/out", false, new ByteArrayInputStream(new byte[0]), 0, new HashMap<>());
+ fail();
+ } catch (Exception e) {
+ assertEquals("The destination directory path is invalid: '/out'", e.getMessage());
+ }
+ verify(sardine, never()).put(anyString(), any(HttpEntity.class), any(), anyBoolean());
+ }
+
+ @Test
+ public void moveDoesNotOverwrite() throws Exception {
+ propfind("/done", 0, dir("/done/"));
+ connection.move("a.txt", "/in", "a.txt", "/done");
+ verify(sardine, never()).createDirectory(anyString());
+ verify(sardine).move(BASE + "/in/a.txt", BASE + "/done/a.txt", false);
+ }
+
+ @Test
+ public void pathsAreEncoded() throws Exception {
+ connection.delete("a b#?.txt", "/in", false);
+ verify(sardine).delete(BASE + "/in/a%20b%23%3F.txt");
+ }
+
+ @Test
+ public void missingFileKeepsConnectionValid() throws Exception {
+ assertTrue(connection.isValid());
+ assertFalse(connection.exists("a.txt", "/in"));
+ assertTrue(connection.isValid());
+
+ when(sardine.list(eq(BASE + "/in/b.txt"), eq(0), any())).thenThrow(new IOException("Connection reset"));
+ assertFalse(connection.exists("b.txt", "/in"));
+ assertFalse(connection.isValid());
+ }
+
+ @Test
+ public void unreachableServerIsInvalid() throws Exception {
+ WebDavConnection unreachable = new WebDavConnection("localhost", 1, false, new FileSystemConnectionOptions(false, "null", "null", null), 1000);
+ assertFalse(unreachable.isValid());
+ assertFalse(unreachable.exists("a.txt", "/in"));
+ }
+
+ private void propfind(String path, int depth, DavResource... resources) throws Exception {
+ when(sardine.list(eq(BASE + path), eq(depth), any())).thenReturn(Arrays.asList(resources));
+ }
+
+ private static DavResource dir(String path) {
+ DavResource resource = resource(path);
+ when(resource.isDirectory()).thenReturn(true);
+ when(resource.getContentLength()).thenReturn(-1L);
+ return resource;
+ }
+
+ private static DavResource file(String path, long length) {
+ DavResource resource = resource(path);
+ when(resource.getContentLength()).thenReturn(length);
+ when(resource.getModified()).thenReturn(new Date(1000));
+ return resource;
+ }
+
+ private static DavResource resource(String path) {
+ DavResource resource = mock(DavResource.class);
+ String name = path.replaceAll("/$", "");
+ when(resource.getPath()).thenReturn(path);
+ when(resource.getName()).thenReturn(name.substring(name.lastIndexOf('/') + 1));
+ when(resource.getStatusCode()).thenReturn(200);
+ return resource;
+ }
+
+ private static String body(HttpEntity entity) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ entity.writeTo(out);
+ return out.toString("UTF-8");
+ }
+
+ private static List names(List files) {
+ List names = new ArrayList<>();
+ for (FileInfo file : files) {
+ names.add(file.getName());
+ }
+ return names;
+ }
+}