diff options
Diffstat (limited to 'Duplicati/Library/Backend/IDrive/IDriveApiClient.cs')
-rw-r--r-- | Duplicati/Library/Backend/IDrive/IDriveApiClient.cs | 393 |
1 files changed, 393 insertions, 0 deletions
diff --git a/Duplicati/Library/Backend/IDrive/IDriveApiClient.cs b/Duplicati/Library/Backend/IDrive/IDriveApiClient.cs new file mode 100644 index 000000000..b37af6677 --- /dev/null +++ b/Duplicati/Library/Backend/IDrive/IDriveApiClient.cs @@ -0,0 +1,393 @@ +// Copyright (C) 2015, The Duplicati Team +// http://www.duplicati.com, info@duplicati.com +// +// This library is free software; you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation; either version 2.1 of the +// License, or (at your option) any later version. +// +// This library is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with this library; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Security.Authentication; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; + +// Full IDrive Sync API documentation can be found here: https://www.idrivesync.com/evs/web-developers-guide.htm +namespace Duplicati.Library.Backend.IDrive +{ + /// <summary> + /// Provides access to an IDrive Sync. + /// </summary> + public class IDriveApiClient + { + private const string IDRIVE_AUTH_CGI_URL = "https://www1.idrive.com/cgi-bin/v1/user-details.cgi"; + private const string IDRIVE_SYNC_GET_SERVER_ADDRESS_URL = "https://evs.idrivesync.com/evs/getServerAddress"; + private const string SUCCESS = "SUCCESS"; + private const string MESSAGE_ATTRIBUTE = "message"; + private const string XML_RESPONSE_TAG = "tree"; + + private string _idriveUsername; + private string _idrivePassword; + + private string _syncUsername; + private string _syncPassword; + private string _syncHostname; + + private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); + private static SemaphoreSlim UploadSemaphore = new SemaphoreSlim(10); + + public string UserAgent { get; set; } = "Duplicati-IDrive-API-Client/" + Assembly.GetExecutingAssembly().GetName().Version; + + public IDriveApiClient() + { + } + + public async Task LoginAsync(string username, string password) + { + _idriveUsername = username; + _idrivePassword = password; + + await IDriveAuthAsync(_cancellationTokenSource.Token); + await UpdateSyncHostnameAsync(_cancellationTokenSource.Token); + } + + private async Task IDriveAuthAsync(CancellationToken cancellationToken) + { + // IDrive auth logic was reverse engineered from code found in the IDriveForLinux PERL scripts provided by IDrive. Download from: https://www.idrive.com/linux-backup-scripts + // The auth response payload contains the login credentials for the associated IDrive Sync account. + const string methodName = IDRIVE_AUTH_CGI_URL; + using (var httpClient = GetHttpClient()) + { + var parameters = new List<KeyValuePair<string, string>>() { + new KeyValuePair<string, string> ( "username", _idriveUsername), + new KeyValuePair<string, string> ( "password", _idrivePassword) + }; + var content = new FormUrlEncodedContent(parameters); + using (var response = await httpClient.PostAsync(IDRIVE_AUTH_CGI_URL, content)) + { + if (response.StatusCode != System.Net.HttpStatusCode.OK) + throw new AuthenticationException($"Failed IDrive authentication request. Server response: {response}"); + + // Sample response (masked): "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<root>\n <login remote_manage_ip=\"173.255.13.30\" quota=\"5000000000000\" datacenter=\"evsvirginia.idrive.com\" enctype=\"DEFAULT\" pns_sync=\"notify2.idrive.com\" remote_manage_server_https=\"wsn16s.idrive.com\" jspsrvr=\"www.idrive.com\" plan_type=\"Regular\" dedup=\"on\" username_sync=\"abc12345678901234def\" password_sync=\"fed09887654321098cba\" dedup_enabled=\"no\" desc=\"Success\" evssrvrip=\"148.51.142.138\" plan=\"Personal\" acctype=\"IBSYNC\" cnfgstat=\"SET\" accstat=\"Y\" evswebsrvr=\"evsweb5114.idrive.com\" remote_manage_websock_server=\"yes\" evssrvr=\"evs5114.idrive.com\" message=\"SUCCESS\" remote_manage_ip_https=\"173.255.13.31\" cnfgstatus=\"Y\" remote_manage_server=\"wsn16.idrive.com\" evswebsrvrip=\"148.51.142.139\" quota_used=\"100000000000\"></login>\n</root>\n" + string responseString = await response.Content.ReadAsStringAsync(); + var responseXml = new XmlDocument(); + responseXml.LoadXml(responseString); + var nodes = responseXml.GetElementsByTagName("login"); + if (nodes.Count == 0) + throw new AuthenticationException($"Failed '{methodName}' request. Unexpected authentication response data (no login element). Server response: {response}"); + + var responseNode = nodes[0]; + + if (responseNode.Attributes[MESSAGE_ATTRIBUTE]?.Value != SUCCESS) + throw new AuthenticationException($"Failed IDrive authentication request. Non-{SUCCESS}. Description: {responseNode.Attributes["desc"]?.Value}"); + + _syncUsername = responseNode.Attributes["username_sync"]?.Value; + _syncPassword = responseNode.Attributes["password_sync"]?.Value; + + if (string.IsNullOrEmpty(_syncUsername) || string.IsNullOrEmpty(_syncPassword)) + throw new AuthenticationException($"Failed '{methodName}' request. IDrive Sync username and/or password were not provided. Server response: {response}"); + } + } + } + + private async Task UpdateSyncHostnameAsync(CancellationToken cancellationToken) + { + // The API docs state that the sync web API server may change over time and must be retrieved on each login. + // The server may be different for different accounts, depending where the data is stored. + var responseNode = await GetSimpleTreeResponseAsync(IDRIVE_SYNC_GET_SERVER_ADDRESS_URL, "getServerAddress", cancellationToken); + + _syncHostname = responseNode.Attributes["webApiServer"]?.Value; + + if (string.IsNullOrEmpty(_syncHostname)) + throw new AuthenticationException($"Failed 'getServerAddress' request. Empty hostname. Tree XML: {responseNode.OuterXml}"); + } + + public async Task<List<FileEntry>> GetFileEntryListAsync(string directoryPath, string searchCriteria = null) + { + string methodName = string.IsNullOrEmpty(searchCriteria) ? "browseFolder" : "searchFiles"; + // NOTE: The IDrive "searchFiles" API method has a bug that returns the name of the directory being listed as one of the items when searching for "*" + string url = GetSyncServiceUrl(methodName); + var list = new List<FileEntry>(); + + using (var httpClient = GetHttpClient()) + using (var content = GetSyncPostContent(new NameValueCollection { { "p", directoryPath }, { "searchkey", searchCriteria } })) + using (var response = await httpClient.PostAsync(url, content)) + { + if (response.StatusCode != System.Net.HttpStatusCode.OK) + throw new ApplicationException($"Failed '{methodName}' request. Server response: {response}"); + + using (var responseStream = await response.Content.ReadAsStreamAsync()) + using (var xmlReader = XmlReader.Create(responseStream, new XmlReaderSettings() { Async = true })) + { + bool success = false; + while (await xmlReader.ReadAsync()) + { + if (xmlReader.Name != XML_RESPONSE_TAG) + continue; + + success = xmlReader.GetAttribute(MESSAGE_ATTRIBUTE) == SUCCESS; + break; + } + + if (!success) + throw new ApplicationException($"Failed '{methodName}' request. Non-{SUCCESS}. Description: {xmlReader.GetAttribute("desc")}"); + + while (await xmlReader.ReadAsync()) + { + // Item XML example: <item restype="1" resname="Myoffice.txt" size="9583" lmd="2010/05/26 01:58:57" ver="1" thumb="N"/> + if (xmlReader.Name != "item") + continue; + + string resname = xmlReader.GetAttribute("resname"); + if (string.IsNullOrEmpty(resname)) + continue; + + string restype = xmlReader.GetAttribute("restype"); + string size = xmlReader.GetAttribute("size"); + string lmd = xmlReader.GetAttribute("lmd"); + + long.TryParse(size, out long parsedSize); + DateTime.TryParse(lmd, out DateTime parsedModificationDate); + + var fileEntry = new FileEntry(resname) + { + IsFolder = restype != "1", + Name = resname, + Size = parsedSize, + LastModification = parsedModificationDate + }; + + list.Add(fileEntry); + } + } + } + + return list; + } + + public async Task CreateDirectoryAsync(string directoryName, string baseDirectoryPath, CancellationToken cancellationToken) + { + const string methodName = "createFolder"; + string url = GetSyncServiceUrl(methodName); + try + { + await GetSimpleTreeResponseAsync(url, methodName, cancellationToken, new NameValueCollection { { "p", baseDirectoryPath }, { "foldername", directoryName } }); + } + catch (Exception ex) + { + if (ex.Message.Contains(@"FOLDER ALREADY EXISTS WITH THIS NAME.")) + return; + + throw; + } + } + + public async Task DeleteAsync(string filePath, CancellationToken cancellationToken, bool moveToTrash = true) + { + const string methodName = "deleteFile"; + string url = GetSyncServiceUrl(methodName); + await GetSimpleTreeResponseAsync(url, methodName, cancellationToken, new NameValueCollection { { "p", filePath }, { "trash", moveToTrash ? "yes" : "no" } }); + } + + public async Task<FileEntry> UploadAsync(Stream stream, string filename, string directoryPath, CancellationToken cancellationToken) + { + const string methodName = "uploadFile"; + string url = GetSyncServiceUrl(methodName); + + await UploadSemaphore.WaitAsync(cancellationToken); + + try + { + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(); + + long? streamLength = null; + try { streamLength = stream.Length; } + catch { } // Fail gracefully is stream does not support Length + + using (var httpClient = GetHttpClient(TimeSpan.FromHours(24))) + using (var content = GetSyncPostContent(new NameValueCollection { { "p", directoryPath } }, isMultiPart: true)) + using (var streamContent = new StreamContent(stream)) + { + ((MultipartFormDataContent)content).Add(streamContent, filename, filename); + + using (var response = await httpClient.PostAsync(url, content, cancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(); + + if (response.StatusCode != System.Net.HttpStatusCode.OK) + throw new ApplicationException($"Failed '{methodName}' request. Server response: {response}"); + + string responseString = await response.Content.ReadAsStringAsync(); + var responseXml = new XmlDocument(); + responseXml.LoadXml(responseString); + var nodes = responseXml.GetElementsByTagName(XML_RESPONSE_TAG); + if (nodes.Count == 0) + throw new ApplicationException($"Failed '{methodName}' request. Unexpected response. Server response: {response}"); + + var responseNode = nodes[0]; + if (responseNode == null) + throw new ApplicationException($"Failed '{methodName}' request. Missing {XML_RESPONSE_TAG} node. Server response: {response}"); + + if (responseNode.Attributes[MESSAGE_ATTRIBUTE]?.Value != SUCCESS) + throw new ApplicationException($"Failed '{methodName}' request. Non-{SUCCESS}. Description: {responseNode.Attributes["desc"]?.Value}"); + } + } + + // Double check the upload by searching for the file on the server and validating the response + var fileEntryList = await GetFileEntryListAsync(directoryPath, filename); + if (fileEntryList.Count != 1) + throw new FileMissingException($"Upload failed. File not found on server."); + + var fileEntry = fileEntryList[0]; + + if (streamLength != null && fileEntry.Size != streamLength.Value) + throw new FileMissingException($"Upload failed. File size on server does not match source."); + + if (fileEntry.Name != filename) + throw new FileMissingException($"Upload failed. Wrong file not found on server."); // this should never happen + + return fileEntry; + } + finally + { + UploadSemaphore.Release(); + } + } + + public async Task DownloadAsync(string filePath, Stream stream) + { + const string methodName = "downloadFile"; + string url = GetSyncServiceUrl(methodName); + + using (var httpClient = GetHttpClient()) + using (var content = GetSyncPostContent(new NameValueCollection { { "p", filePath } })) + using (var response = await httpClient.PostAsync(url, content)) + { + if (response.StatusCode != System.Net.HttpStatusCode.OK) + throw new FileMissingException($"Failed '{methodName}' request. Server response: {response}"); + + response.Headers.TryGetValues("RESTORE_STATUS", out var restoreStatus); // The download API uses RESTORE_STATUS to indicate success instead of body XML + + using (var responseStream = await response.Content.ReadAsStreamAsync()) + { + if (restoreStatus.FirstOrDefault() == SUCCESS) + { + Library.Utility.Utility.CopyStream(responseStream, stream); + return; + } + + using (var xmlReader = XmlReader.Create(responseStream, new XmlReaderSettings() { Async = true })) + { + bool success = false; + while (await xmlReader.ReadAsync()) + { + if (xmlReader.Name != XML_RESPONSE_TAG) + continue; + + success = xmlReader.GetAttribute(MESSAGE_ATTRIBUTE) == SUCCESS; + break; + } + + if (!success) + throw new FileMissingException($"Failed '{methodName}' request. Non-{SUCCESS}. Description: {xmlReader.GetAttribute("desc")}"); + + throw new FileMissingException($"Failed '{methodName}' request. Invalid RESTORE_STATUS result with invalid {SUCCESS} message."); // this should never happen + } + } + } + } + + private HttpClient GetHttpClient(TimeSpan? timeout = null) + { + var httpClient = new HttpClient() + { + Timeout = timeout ?? TimeSpan.FromMinutes(5) // more generous default timeout + }; + + if (!string.IsNullOrEmpty(UserAgent)) + httpClient.DefaultRequestHeaders.Add("User-Agent", UserAgent); + + return httpClient; + } + + private string GetSyncServiceUrl(string serviceName) + { + return $"https://{_syncHostname}/evs/{serviceName}"; + } + + private async Task<XmlNode> GetSimpleTreeResponseAsync(string url, string methodName, CancellationToken cancellationToken, NameValueCollection parameters = null) + { + using (var httpClient = GetHttpClient()) + using (var content = GetSyncPostContent(parameters)) + using (var response = await httpClient.PostAsync(url, content, cancellationToken)) + { + if (response.StatusCode != System.Net.HttpStatusCode.OK) + throw new ApplicationException($"Failed '{methodName}' request. Server response: {response}"); + + // Sample response: "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<tree message=\"SUCCESS\" cmdUtilityServer=\"evs19.idrivesync.com\"\n cmdUtilityServerIP=\"4.71.135.136\"\n webApiServer=\"evsweb19.idrivesync.com\" webApiServerIP=\"4.71.135.137\"\n faceWebApiServer=\"\" faceWebApiServerIP=\"\" dedup=\"off\"/>\n" + string responseString = await response.Content.ReadAsStringAsync(); + var responseXml = new XmlDocument(); + responseXml.LoadXml(responseString); + var nodes = responseXml.GetElementsByTagName(XML_RESPONSE_TAG); + if (nodes.Count == 0) + throw new ApplicationException($"Failed '{methodName}' request. Unexpected response. Server response: {response}"); + + var responseNode = nodes[0]; + if (responseNode == null) + throw new ApplicationException($"Failed '{methodName}' request. Missing {XML_RESPONSE_TAG} node. Server response: {response}"); + + if (responseNode.Attributes[MESSAGE_ATTRIBUTE]?.Value != SUCCESS) + throw new ApplicationException($"Failed '{methodName}' request. Non-{SUCCESS}. Description: {responseNode.Attributes["desc"]?.Value}"); + + return responseNode; + } + } + + private HttpContent GetSyncPostContent(NameValueCollection parameters = null, bool isMultiPart = false) + { + var allParameters = new List<KeyValuePair<string, string>>() + { + new KeyValuePair<string, string> ( "uid", _syncUsername), + new KeyValuePair<string, string> ( "pwd", _syncPassword) + }; + + if (parameters != null) + { + foreach (string key in parameters.Keys) + { + allParameters.Add(new KeyValuePair<string, string>(key, parameters[key])); + } + } + + if (!isMultiPart) + return new FormUrlEncodedContent(allParameters); + + var content = new MultipartFormDataContent(Guid.NewGuid().ToString()); + foreach (var parameter in allParameters) + { + content.Add(new StringContent(parameter.Value, Encoding.UTF8), parameter.Key); + } + + return content; + } + } +} |