wasDAVClient – Rev 8

Subversion Repositories:
Rev:
///////////////////////////////////////////////////////////////////////////
//  Copyright (C) Wizardry and Steamworks 2016 - License: GNU GPLv3      //
//  Please see: http://www.gnu.org/licenses/gpl.html for legal details,  //
//  rights of fair usage, the disclaimer and warranty conditions.        //
///////////////////////////////////////////////////////////////////////////
// Originally based on: WebDAV .NET client by Sergey Kazantsev

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using wasDAVClient.Helpers;
using wasDAVClient.Model;

namespace wasDAVClient
{
    public class Client : IClient, IDisposable
    {
        private const int HttpStatusCode_MultiStatus = 207;

        // http://webdav.org/specs/rfc4918.html#METHOD_PROPFIND
        private const string PropFindRequestContent =
            "<?xml version=\"1.0\" encoding=\"utf-8\" ?>" +
            "<propfind xmlns=\"DAV:\">" +
            "<allprop/>" +
            //"  <propname/>" +
            //"  <prop>" +
            //"    <creationdate/>" +
            //"    <getlastmodified/>" +
            //"    <displayname/>" +
            //"    <getcontentlength/>" +
            //"    <getcontenttype/>" +
            //"    <getetag/>" +
            //"    <resourcetype/>" +
            //"  </prop> " +
            "</propfind>";

        private static readonly HttpMethod PropFind = new HttpMethod("PROPFIND");
        private static readonly HttpMethod MoveMethod = new HttpMethod("MOVE");

        private static readonly HttpMethod MkCol = new HttpMethod(WebRequestMethods.Http.MkCol);

        private static readonly string AssemblyVersion = typeof(IClient).Assembly.GetName().Version.ToString();

        private readonly HttpClient _client;
        private readonly HttpClient _uploadClient;
        private string _basePath = "/";

        private string _encodedBasePath;
        private string _server;

        public Client(ICredentials credential = null)
        {
            var handler = new HttpClientHandler();

            if (handler.SupportsProxy)
                handler.Proxy = WebRequest.DefaultWebProxy;

            if (handler.SupportsAutomaticDecompression)
                handler.AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip;

            if (credential != null)
            {
                handler.Credentials = credential;
                handler.PreAuthenticate = true;
            }

            _client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(Timeout) };
            _client.DefaultRequestHeaders.ExpectContinue = false;

            _uploadClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(Timeout) };
            _uploadClient.DefaultRequestHeaders.ExpectContinue = false;
        }

        #region WebDAV connection parameters

        /// <summary>
        ///     Specify the WebDAV hostname (required).
        /// </summary>
        public string Server
        {
            get { return _server; }
            set { _server = value.TrimEnd('/'); }
        }

        /// <summary>
        ///     Specify the path of a WebDAV directory to use as 'root' (default: /)
        /// </summary>
        public string BasePath
        {
            get { return _basePath; }
            set
            {
                value = value.Trim('/');
                if (string.IsNullOrEmpty(value))
                    _basePath = "/";
                else
                    _basePath = "/" + value + "/";
            }
        }

        /// <summary>
        ///     Specify an port (default: null = auto-detect)
        /// </summary>
        public int? Port { get; set; }

        /// <summary>
        ///     Specify the UserAgent (and UserAgent version) string to use in requests
        /// </summary>
        public string UserAgent { get; set; }

        /// <summary>
        ///     Specify the UserAgent (and UserAgent version) string to use in requests
        /// </summary>
        public string UserAgentVersion { get; set; }

        /// <summary>
        ///     The HTTP request timeout in seconds.
        /// </summary>
        public int Timeout { get; set; } = 60;

        #endregion WebDAV connection parameters

        #region WebDAV operations

        /// <summary>
        ///     List all files present on the server.
        /// </summary>
        /// <param name="path">List only files in this path</param>
        /// <param name="depth">Recursion depth</param>
        /// <returns>A list of files (entries without a trailing slash) and directories (entries with a trailing slash)</returns>
        public async Task<IEnumerable<Item>> List(string path = "/", string depth = Constants.DavDepth.MEMBERS)
        {
            var listUri = await GetServerUrl(path, true).ConfigureAwait(false);

            // Depth header: http://webdav.org/specs/rfc4918.html#rfc.section.9.1.4
            IDictionary<string, string> headers = new Dictionary<string, string>();
            headers.Add("Depth", depth);

            HttpResponseMessage response = null;

            try
            {
                response =
                    await
                        HttpRequest(listUri.Uri, PropFind, headers, Encoding.UTF8.GetBytes(PropFindRequestContent))
                            .ConfigureAwait(false);

                if (response.StatusCode != HttpStatusCode.OK &&
                    (int)response.StatusCode != HttpStatusCode_MultiStatus)
                {
                    throw new wasDAVException((int)response.StatusCode, "Failed retrieving items in folder.");
                }

                using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
                {
                    var items = ResponseParser.ParseItems(stream);

                    if (items == null)
                    {
                        throw new wasDAVException("Failed deserializing data returned from server.");
                    }

                    var listUrl = listUri.ToString();

                    return items.AsParallel().Select(async item =>
                    {
                        switch (!item.IsCollection)
                        {
                            case true:
                                return item;

                            default:
                                // If it's not the requested parent folder, add it to the result
                                if (!string.Equals((await GetServerUrl(item.Href, true).ConfigureAwait(false)).ToString(),
                                    listUrl, StringComparison.CurrentCultureIgnoreCase))
                                {
                                    return item;
                                }
                                break;
                        }

                        return null;
                    }).Select(o => o.Result).OfType<Item>();
                }
            }
            finally
            {
                response?.Dispose();
            }
        }

        /// <summary>
        ///     List all files present on the server.
        /// </summary>
        /// <returns>A list of files (entries without a trailing slash) and directories (entries with a trailing slash)</returns>
        public async Task<Item> GetFolder(string path = "/")
        {
            return await Get((await GetServerUrl(path, true).ConfigureAwait(false)).Uri).ConfigureAwait(false);
        }

        /// <summary>
        ///     List all files present on the server.
        /// </summary>
        /// <returns>A list of files (entries without a trailing slash) and directories (entries with a trailing slash)</returns>
        public async Task<Item> GetFile(string path = "/")
        {
            return await Get((await GetServerUrl(path, false).ConfigureAwait(false)).Uri).ConfigureAwait(false);
        }

        /// <summary>
        ///     List all files present on the server.
        /// </summary>
        /// <returns>A list of files (entries without a trailing slash) and directories (entries with a trailing slash)</returns>
        private async Task<Item> Get(Uri listUri)
        {
            // Depth header: http://webdav.org/specs/rfc4918.html#rfc.section.9.1.4
            IDictionary<string, string> headers = new Dictionary<string, string>();
            headers.Add("Depth", "0");

            HttpResponseMessage response = null;

            try
            {
                response =
                    await
                        HttpRequest(listUri, PropFind, headers, Encoding.UTF8.GetBytes(PropFindRequestContent))
                            .ConfigureAwait(false);

                if (response.StatusCode != HttpStatusCode.OK &&
                    (int)response.StatusCode != HttpStatusCode_MultiStatus)
                {
                    throw new wasDAVException((int)response.StatusCode,
                        $"Failed retrieving item/folder (Status Code: {response.StatusCode})");
                }

                using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
                {
                    var result = ResponseParser.ParseItem(stream);

                    if (result == null)
                    {
                        throw new wasDAVException("Failed deserializing data returned from server.");
                    }

                    return result;
                }
            }
            finally
            {
                response?.Dispose();
            }
        }

        /// <summary>
        ///     Download a file from the server
        /// </summary>
        /// <param name="remoteFilePath">Source path and filename of the file on the server</param>
        public async Task<Stream> Download(string remoteFilePath)
        {
            // Should not have a trailing slash.
            var downloadUri = await GetServerUrl(remoteFilePath, false).ConfigureAwait(false);

            var dictionary = new Dictionary<string, string> { { "translate", "f" } };
            var response = await HttpRequest(downloadUri.Uri, HttpMethod.Get, dictionary).ConfigureAwait(false);
            if (response.StatusCode != HttpStatusCode.OK)
            {
                throw new wasDAVException((int)response.StatusCode, "Failed retrieving file.");
            }
            return await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
        }

        /// <summary>
        ///     Download a file from the server
        /// </summary>
        /// <param name="remoteFilePath">Source path and filename of the file on the server</param>
        /// <param name="content"></param>
        /// <param name="name"></param>
        public async Task<bool> Upload(string remoteFilePath, Stream content, string name)
        {
            // Should not have a trailing slash.
            var uploadUri =
                await GetServerUrl(remoteFilePath.TrimEnd('/') + "/" + name.TrimStart('/'), false).ConfigureAwait(false);

            HttpResponseMessage response = null;

            try
            {
                response = await HttpUploadRequest(uploadUri.Uri, HttpMethod.Put, content).ConfigureAwait(false);

                if (response.StatusCode != HttpStatusCode.OK &&
                    response.StatusCode != HttpStatusCode.NoContent &&
                    response.StatusCode != HttpStatusCode.Created)
                {
                    throw new wasDAVException((int)response.StatusCode, "Failed uploading file.");
                }

                return response.IsSuccessStatusCode;
            }
            finally
            {
                response?.Dispose();
            }
        }

        /// <summary>
        ///     Create a directory on the server
        /// </summary>
        /// <param name="remotePath">Destination path of the directory on the server.</param>
        /// <param name="name">The name of the folder to create.</param>
        public async Task<bool> CreateDir(string remotePath, string name)
        {
            // Should not have a trailing slash.
            var dirUri =
                await GetServerUrl(remotePath.TrimEnd('/') + "/" + name.TrimStart('/'), false).ConfigureAwait(false);

            HttpResponseMessage response = null;

            try
            {
                response = await HttpRequest(dirUri.Uri, MkCol).ConfigureAwait(false);

                if (response.StatusCode == HttpStatusCode.Conflict)
                    throw new wasDAVConflictException((int)response.StatusCode, "Failed creating folder.");

                if (response.StatusCode != HttpStatusCode.OK &&
                    response.StatusCode != HttpStatusCode.NoContent &&
                    response.StatusCode != HttpStatusCode.Created)
                {
                    throw new wasDAVException((int)response.StatusCode, "Failed creating folder.");
                }

                return response.IsSuccessStatusCode;
            }
            finally
            {
                response?.Dispose();
            }
        }

        public async Task DeleteFolder(string href)
        {
            await Delete((await GetServerUrl(href, true).ConfigureAwait(false)).Uri).ConfigureAwait(false);
        }

        public async Task DeleteFile(string href)
        {
            await Delete((await GetServerUrl(href, false).ConfigureAwait(false)).Uri).ConfigureAwait(false);
        }

        private async Task Delete(Uri listUri)
        {
            var response = await HttpRequest(listUri, HttpMethod.Delete).ConfigureAwait(false);

            if (response.StatusCode != HttpStatusCode.OK &&
                response.StatusCode != HttpStatusCode.NoContent)
            {
                throw new wasDAVException((int)response.StatusCode, "Failed deleting item.");
            }
        }

        public async Task<bool> MoveFolder(string srcFolderPath, string dstFolderPath)
        {
            // Should have a trailing slash.
            return
                await
                    Move((await GetServerUrl(srcFolderPath, true).ConfigureAwait(false)).Uri,
                        (await GetServerUrl(dstFolderPath, true).ConfigureAwait(false)).Uri).ConfigureAwait(false);
        }

        public async Task<bool> MoveFile(string srcFilePath, string dstFilePath)
        {
            // Should not have a trailing slash.
            return
                await
                    Move((await GetServerUrl(srcFilePath, false).ConfigureAwait(false)).Uri,
                        (await GetServerUrl(dstFilePath, false).ConfigureAwait(false)).Uri).ConfigureAwait(false);
        }

        private async Task<bool> Move(Uri srcUri, Uri dstUri)
        {
            const string requestContent = "MOVE";

            IDictionary<string, string> headers = new Dictionary<string, string>
            {
                {"Destination", dstUri.ToString()}
            };

            var response =
                await
                    HttpRequest(srcUri, MoveMethod, headers, Encoding.UTF8.GetBytes(requestContent))
                        .ConfigureAwait(false);

            if (response.StatusCode != HttpStatusCode.OK &&
                response.StatusCode != HttpStatusCode.Created)
            {
                throw new wasDAVException((int)response.StatusCode, "Failed moving file.");
            }

            return response.IsSuccessStatusCode;
        }

        #endregion WebDAV operations

        #region Server communication

        /// <summary>
        ///     Perform the WebDAV call and fire the callback when finished.
        /// </summary>
        /// <param name="uri"></param>
        /// <param name="method"></param>
        /// <param name="headers"></param>
        /// <param name="content"></param>
        private async Task<HttpResponseMessage> HttpRequest(Uri uri, HttpMethod method,
            IDictionary<string, string> headers = null, byte[] content = null)
        {
            using (var request = new HttpRequestMessage(method, uri))
            {
                request.Headers.Connection.Add("Keep-Alive");
                request.Headers.UserAgent.Add(!string.IsNullOrWhiteSpace(UserAgent)
                    ? new ProductInfoHeaderValue(UserAgent, UserAgentVersion)
                    : new ProductInfoHeaderValue("WebDAVClient", AssemblyVersion));
                request.Headers.Add("Accept", @"*/*");
                request.Headers.Add("Accept-Encoding", "gzip,deflate");

                if (headers != null)
                {
                    foreach (var key in headers.Keys)
                    {
                        request.Headers.Add(key, headers[key]);
                    }
                }

                // Need to send along content?
                if (content != null)
                {
                    request.Content = new ByteArrayContent(content);
                    request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/xml");
                }

                return await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
            }
        }

        /// <summary>
        ///     Perform the WebDAV call and fire the callback when finished.
        /// </summary>
        /// <param name="uri"></param>
        /// <param name="headers"></param>
        /// <param name="method"></param>
        /// <param name="content"></param>
        private async Task<HttpResponseMessage> HttpUploadRequest(Uri uri, HttpMethod method, Stream content,
            IDictionary<string, string> headers = null)
        {
            using (var request = new HttpRequestMessage(method, uri))
            {
                request.Headers.Connection.Add("Keep-Alive");
                request.Headers.UserAgent.Add(!string.IsNullOrWhiteSpace(UserAgent)
                    ? new ProductInfoHeaderValue(UserAgent, UserAgentVersion)
                    : new ProductInfoHeaderValue("WebDAVClient", AssemblyVersion));

                if (headers != null)
                {
                    foreach (var key in headers.Keys)
                    {
                        request.Headers.Add(key, headers[key]);
                    }
                }

                // Need to send along content?
                if (content != null)
                {
                    request.Content = new StreamContent(content);
                }

                return await (_uploadClient ?? _client).SendAsync(request).ConfigureAwait(false);
            }
        }

        /// <summary>
        ///     Try to create an Uri with kind UriKind.Absolute
        ///     This particular implementation also works on Mono/Linux
        ///     It seems that on Mono it is expected behaviour that uris
        ///     of kind /a/b are indeed absolute uris since it referes to a file in /a/b.
        ///     https://bugzilla.xamarin.com/show_bug.cgi?id=30854
        /// </summary>
        /// <param name="uriString"></param>
        /// <param name="uriResult"></param>
        /// <returns></returns>
        private static bool TryCreateAbsolute(string uriString, out Uri uriResult)
        {
            return Uri.TryCreate(uriString, UriKind.Absolute, out uriResult) && uriResult.Scheme != Uri.UriSchemeFile;
        }

        private async Task<UriBuilder> GetServerUrl(string path, bool appendTrailingSlash)
        {
            // Resolve the base path on the server
            if (_encodedBasePath == null)
            {
                var baseUri = new UriBuilder(_server) { Path = _basePath };
                var root = await Get(baseUri.Uri).ConfigureAwait(false);

                _encodedBasePath = root.Href;
            }

            // If we've been asked for the "root" folder
            if (string.IsNullOrEmpty(path))
            {
                // If the resolved base path is an absolute URI, use it
                Uri absoluteBaseUri;
                if (TryCreateAbsolute(_encodedBasePath, out absoluteBaseUri))
                {
                    return new UriBuilder(absoluteBaseUri);
                }

                // Otherwise, use the resolved base path relatively to the server
                var baseUri = new UriBuilder(_server) { Path = _encodedBasePath };
                return baseUri;
            }

            // If the requested path is absolute, use it
            Uri absoluteUri;
            if (TryCreateAbsolute(path, out absoluteUri))
            {
                var baseUri = new UriBuilder(absoluteUri);
                return baseUri;
            }
            else
            {
                // Otherwise, create a URI relative to the server
                UriBuilder baseUri;
                if (TryCreateAbsolute(_encodedBasePath, out absoluteUri))
                {
                    baseUri = new UriBuilder(absoluteUri);

                    baseUri.Path = baseUri.Path.TrimEnd('/') + "/" + path.TrimStart('/');

                    if (appendTrailingSlash && !baseUri.Path.EndsWith("/"))
                        baseUri.Path += "/";
                }
                else
                {
                    baseUri = new UriBuilder(_server);

                    // Ensure we don't add the base path twice
                    var finalPath = path;
                    if (!finalPath.StartsWith(_encodedBasePath, StringComparison.InvariantCultureIgnoreCase))
                    {
                        finalPath = _encodedBasePath.TrimEnd('/') + "/" + path;
                    }
                    if (appendTrailingSlash)
                        finalPath = finalPath.TrimEnd('/') + "/";

                    baseUri.Path = finalPath;
                }

                return baseUri;
            }
        }

        public void Dispose()
        {
            _client?.Dispose();
            _uploadClient?.Dispose();
        }

        #endregion Server communication
    }
}