diff options
author | Doug Krahmer <doug.git@remhark.com> | 2022-03-16 23:04:54 +0300 |
---|---|---|
committer | Doug Krahmer <doug.git@remhark.com> | 2022-03-18 21:14:43 +0300 |
commit | d1f10ba9a1082dcb5a1d9c001752f888d80b63bd (patch) | |
tree | 5ab2908a6e1406633681556f444180e5d7fdfffa /Duplicati | |
parent | 2f3b0e83a0c3e5dbdd8ccba0a08e3623d64ca231 (diff) |
Add IDrive backend and API subset
Diffstat (limited to 'Duplicati')
14 files changed, 738 insertions, 1 deletions
diff --git a/Duplicati/CommandLine/BackendTool/Duplicati.CommandLine.BackendTool.csproj b/Duplicati/CommandLine/BackendTool/Duplicati.CommandLine.BackendTool.csproj index f344da03f..54c49db41 100644 --- a/Duplicati/CommandLine/BackendTool/Duplicati.CommandLine.BackendTool.csproj +++ b/Duplicati/CommandLine/BackendTool/Duplicati.CommandLine.BackendTool.csproj @@ -64,6 +64,10 @@ <Project>{F61679A9-E5DE-468A-B5A4-05F92D0143D2}</Project>
<Name>Duplicati.Library.Backend.FTP</Name>
</ProjectReference>
+ <ProjectReference Include="..\..\Library\Backend\IDrive\Duplicati.Library.Backend.IDrive.csproj">
+ <Project>{c16639f6-dacc-4dd9-86cd-8b937516b340}</Project>
+ <Name>Duplicati.Library.Backend.IDrive</Name>
+ </ProjectReference>
<ProjectReference Include="..\..\Library\Backend\Jottacloud\Duplicati.Library.Backend.Jottacloud.csproj">
<Project>{2cd5dbc3-3da6-432d-ba97-f0b8d24501c2}</Project>
<Name>Duplicati.Library.Backend.Jottacloud</Name>
@@ -166,6 +170,8 @@ </ProjectReference>
</ItemGroup>
<ItemGroup>
- <None Include="app.config" />
+ <None Include="app.config">
+ <SubType>Designer</SubType>
+ </None>
</ItemGroup>
</Project>
\ No newline at end of file diff --git a/Duplicati/CommandLine/Duplicati.CommandLine.csproj b/Duplicati/CommandLine/Duplicati.CommandLine.csproj index e6fc646ef..efd5352ea 100644 --- a/Duplicati/CommandLine/Duplicati.CommandLine.csproj +++ b/Duplicati/CommandLine/Duplicati.CommandLine.csproj @@ -95,6 +95,10 @@ <Project>{FC9B7611-836F-4127-8B44-A7C31F506807}</Project>
<Name>Duplicati.Library.Backend.File</Name>
</ProjectReference>
+ <ProjectReference Include="..\Library\Backend\IDrive\Duplicati.Library.Backend.IDrive.csproj">
+ <Project>{c16639f6-dacc-4dd9-86cd-8b937516b340}</Project>
+ <Name>Duplicati.Library.Backend.IDrive</Name>
+ </ProjectReference>
<ProjectReference Include="..\Library\Backend\Jottacloud\Duplicati.Library.Backend.Jottacloud.csproj">
<Project>{2cd5dbc3-3da6-432d-ba97-f0b8d24501c2}</Project>
<Name>Duplicati.Library.Backend.Jottacloud</Name>
diff --git a/Duplicati/CommandLine/RecoveryTool/Duplicati.CommandLine.RecoveryTool.csproj b/Duplicati/CommandLine/RecoveryTool/Duplicati.CommandLine.RecoveryTool.csproj index a4d631b04..204c9690a 100644 --- a/Duplicati/CommandLine/RecoveryTool/Duplicati.CommandLine.RecoveryTool.csproj +++ b/Duplicati/CommandLine/RecoveryTool/Duplicati.CommandLine.RecoveryTool.csproj @@ -98,6 +98,10 @@ <Project>{D60AD540-0E7D-40CE-83AE-D26E01FFE9B8}</Project>
<Name>Duplicati.Library.Backend.HubiC</Name>
</ProjectReference>
+ <ProjectReference Include="..\..\Library\Backend\IDrive\Duplicati.Library.Backend.IDrive.csproj">
+ <Project>{c16639f6-dacc-4dd9-86cd-8b937516b340}</Project>
+ <Name>Duplicati.Library.Backend.IDrive</Name>
+ </ProjectReference>
<ProjectReference Include="..\..\Library\Backend\Jottacloud\Duplicati.Library.Backend.Jottacloud.csproj">
<Project>{2cd5dbc3-3da6-432d-ba97-f0b8d24501c2}</Project>
<Name>Duplicati.Library.Backend.Jottacloud</Name>
diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Duplicati.GUI.TrayIcon.csproj b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Duplicati.GUI.TrayIcon.csproj index df66bc6bc..542d5e9d4 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Duplicati.GUI.TrayIcon.csproj +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Duplicati.GUI.TrayIcon.csproj @@ -189,6 +189,10 @@ <Project>{F61679A9-E5DE-468A-B5A4-05F92D0143D2}</Project>
<Name>Duplicati.Library.Backend.FTP</Name>
</ProjectReference>
+ <ProjectReference Include="..\..\Library\Backend\IDrive\Duplicati.Library.Backend.IDrive.csproj">
+ <Project>{c16639f6-dacc-4dd9-86cd-8b937516b340}</Project>
+ <Name>Duplicati.Library.Backend.IDrive</Name>
+ </ProjectReference>
<ProjectReference Include="..\..\Library\Backend\Jottacloud\Duplicati.Library.Backend.Jottacloud.csproj">
<Project>{2cd5dbc3-3da6-432d-ba97-f0b8d24501c2}</Project>
<Name>Duplicati.Library.Backend.Jottacloud</Name>
diff --git a/Duplicati/Library/Backend/IDrive/Duplicati.Library.Backend.IDrive.csproj b/Duplicati/Library/Backend/IDrive/Duplicati.Library.Backend.IDrive.csproj new file mode 100644 index 000000000..c49153699 --- /dev/null +++ b/Duplicati/Library/Backend/IDrive/Duplicati.Library.Backend.IDrive.csproj @@ -0,0 +1,66 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <PropertyGroup> + <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> + <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform> + <ProjectGuid>{C16639F6-DACC-4DD9-86CD-8B937516B340}</ProjectGuid> + <OutputType>Library</OutputType> + <RootNamespace>Duplicati.Library.Backend.IDrive</RootNamespace> + <AssemblyName>Duplicati.Library.Backend.IDrive</AssemblyName> + <AssemblyOriginatorKeyFile>Duplicati.snk</AssemblyOriginatorKeyFile> + <TargetFrameworkVersion>v4.7.1</TargetFrameworkVersion> + <UseMSBuildEngine>false</UseMSBuildEngine> + <TargetFrameworkProfile /> + </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> + <DebugSymbols>true</DebugSymbols> + <DebugType>full</DebugType> + <Optimize>false</Optimize> + <OutputPath>bin\Debug</OutputPath> + <DefineConstants>DEBUG;</DefineConstants> + <ErrorReport>prompt</ErrorReport> + <WarningLevel>4</WarningLevel> + <ConsolePause>false</ConsolePause> + </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> + <DebugType>full</DebugType> + <Optimize>true</Optimize> + <OutputPath>bin\Release</OutputPath> + <ErrorReport>prompt</ErrorReport> + <WarningLevel>4</WarningLevel> + <ConsolePause>false</ConsolePause> + </PropertyGroup> + <ItemGroup> + <Reference Include="System" /> + <Reference Include="System.Net.Http" /> + <Reference Include="System.Xml" /> + </ItemGroup> + <ItemGroup> + <Compile Include="IDriveApiClient.cs" /> + <Compile Include="Properties\AssemblyInfo.cs" /> + <Compile Include="IDriveBackend.cs" /> + <Compile Include="Strings.cs" /> + </ItemGroup> + <Import Project="$(MSBuildBinPath)\Microsoft.CSharp.targets" /> + <ItemGroup> + <ProjectReference Include="..\..\Interface\Duplicati.Library.Interface.csproj"> + <Project>{C5899F45-B0FF-483C-9D38-24A9FCAAB237}</Project> + <Name>Duplicati.Library.Interface</Name> + </ProjectReference> + <ProjectReference Include="..\..\Localization\Duplicati.Library.Localization.csproj"> + <Project>{B68F2214-951F-4F78-8488-66E1ED3F50BF}</Project> + <Name>Duplicati.Library.Localization</Name> + </ProjectReference> + <ProjectReference Include="..\..\Utility\Duplicati.Library.Utility.csproj"> + <Project>{DE3E5D4C-51AB-4E5E-BEE8-E636CEBFBA65}</Project> + <Name>Duplicati.Library.Utility</Name> + </ProjectReference> + <ProjectReference Include="..\..\Common\Duplicati.Library.Common.csproj"> + <Project>{D63E53E4-A458-4C2F-914D-92F715F58ACF}</Project> + <Name>Duplicati.Library.Common</Name> + </ProjectReference> + </ItemGroup> + <ItemGroup> + <None Include="app.config" /> + </ItemGroup> +</Project>
\ No newline at end of file diff --git a/Duplicati/Library/Backend/IDrive/Duplicati.snk b/Duplicati/Library/Backend/IDrive/Duplicati.snk Binary files differnew file mode 100644 index 000000000..e0c1e2dd8 --- /dev/null +++ b/Duplicati/Library/Backend/IDrive/Duplicati.snk diff --git a/Duplicati/Library/Backend/IDrive/IDriveApiClient.cs b/Duplicati/Library/Backend/IDrive/IDriveApiClient.cs new file mode 100644 index 000000000..a94ea3f1e --- /dev/null +++ b/Duplicati/Library/Backend/IDrive/IDriveApiClient.cs @@ -0,0 +1,352 @@ +// 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 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; + + 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(); + await UpdateSyncHostnameAsync(); + } + + private async Task IDriveAuthAsync() + { + // 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() + { + // 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"); + + _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>> GetNodeListAsync(string directoryPath, string searchCriteria = "*") + { + const string methodName = "searchFiles"; + 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) + { + const string methodName = "createFolder"; + string url = GetSyncServiceUrl(methodName); + try + { + await GetSimpleTreeResponseAsync(url, methodName, 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, bool moveToTrash = true) + { + const string methodName = "deleteFile"; + string url = GetSyncServiceUrl(methodName); + await GetSimpleTreeResponseAsync(url, methodName, new NameValueCollection { { "p", filePath }, { "trash", moveToTrash ? "yes" : "no" } }); + } + + public async Task<FileEntry> UploadAsync(Stream stream, string filename, string directoryPath, CancellationToken cancelToken) + { + const string methodName = "uploadFile"; + string url = GetSyncServiceUrl(methodName); + + using (var httpClient = GetHttpClient()) + using (var content = GetSyncPostContent(new NameValueCollection { { "p", directoryPath } }, isMultiPart: true)) + { + ((MultipartFormDataContent)content).Add(new StreamContent(stream), filename, filename); + + using (var response = await httpClient.PostAsync(url, content)) + { + 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}"); + } + } + + var nodeList = await GetNodeListAsync(directoryPath, filename); + return nodeList.FirstOrDefault(); + } + + 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 AuthenticationException($"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 ApplicationException($"Failed '{methodName}' request. Non-{SUCCESS}. Description: {xmlReader.GetAttribute("desc")}"); + + throw new ApplicationException($"Failed '{methodName}' request. Invalid RESTORE_STATUS result with invalid {SUCCESS} message."); // this should never happen + } + } + } + } + private HttpClient GetHttpClient() + { + var httpClient = new HttpClient(); + + 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, NameValueCollection parameters = null) + { + using (var httpClient = GetHttpClient()) + using (var content = GetSyncPostContent(parameters)) + using (var response = await httpClient.PostAsync(url, content)) + { + 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; + } + } +} diff --git a/Duplicati/Library/Backend/IDrive/IDriveBackend.cs b/Duplicati/Library/Backend/IDrive/IDriveBackend.cs new file mode 100644 index 000000000..e3c5383da --- /dev/null +++ b/Duplicati/Library/Backend/IDrive/IDriveBackend.cs @@ -0,0 +1,215 @@ +// 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.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Duplicati.Library.Backend.IDrive +{ + // ReSharper disable once UnusedMember.Global + // This class is instantiated dynamically in the BackendLoader. + public class IDriveBackend : IBackend, IStreamingBackend + { + private readonly string _username = null; + private readonly string _password = null; + private readonly string _baseDirectoryPath = null; + public string DisplayName => Strings.IDriveBackend.DisplayName; + public string Description => Strings.IDriveBackend.Description; + public string[] DNSName => null; + public string ProtocolKey => "idrive"; + + public IList<ICommandLineArgument> SupportedCommands + { + get + { + return new List<ICommandLineArgument>(new ICommandLineArgument[] { + new CommandLineArgument("auth-password", CommandLineArgument.ArgumentType.Password, Strings.IDriveBackend.AuthPasswordDescriptionShort, Strings.IDriveBackend.AuthPasswordDescriptionLong), + new CommandLineArgument("auth-username", CommandLineArgument.ArgumentType.String, Strings.IDriveBackend.AuthUsernameDescriptionShort, Strings.IDriveBackend.AuthUsernameDescriptionLong) + }); + } + } + + private Dictionary<string, FileEntry> _fileCache; + protected Dictionary<string, FileEntry> FileCache + { + get + { + if (_fileCache == null) + ResetFileCacheAsync().Wait(); + + return _fileCache; + } + set + { + _fileCache = value; + } + } + + private IDriveApiClient _client; + protected IDriveApiClient Client + { + get + { + if (_client == null) + { + var cl = new IDriveApiClient(); + cl.LoginAsync(_username, _password).Wait(); + _client = cl; + } + + return _client; + } + } + + public IDriveBackend() + { + } + + public IDriveBackend(string url, Dictionary<string, string> options) + { + var uri = new Utility.Uri(url); // Sample url: idrive://Directory1/SubDirectory1?auth-username=MyUsername&auth-password=MyPassword + _username = uri.Username; + _password = uri.Password; + + if (string.IsNullOrEmpty(_username) && options.TryGetValue("auth-username", out string username)) + _username = username; + if (string.IsNullOrEmpty(_password) && options.TryGetValue("auth-password", out string password)) + _password = password; + + if (string.IsNullOrEmpty(_username)) + throw new UserInformationException(Strings.IDriveBackend.NoUsernameError, "IDriveNoUsername"); + if (string.IsNullOrEmpty(_password)) + throw new UserInformationException(Strings.IDriveBackend.NoPasswordError, "IDriveNoPassword"); + + _baseDirectoryPath = ("/" + (uri.HostAndPath ?? "").Trim('/') + "/").Replace("//", "/"); + } + + private async Task ResetFileCacheAsync() + { + FileCache = (await Client.GetNodeListAsync(_baseDirectoryPath)) + .Where(x => !x.IsFolder) + .ToDictionary(x => x.Name, x => x); + } + + public IEnumerable<IFileEntry> List() + { + return FileCache.Values; + } + + public void Get(string filename, string localFilePath) + { + using (var fileStream = File.Create(localFilePath)) + Get(filename, fileStream); + } + + public void Get(string filename, Stream stream) + { + Client.DownloadAsync(Path.Combine(_baseDirectoryPath, filename), stream).Wait(); + } + + public async Task PutAsync(string filename, string localFilePath, CancellationToken cancelToken) + { + using (var fileStream = File.OpenRead(localFilePath)) + await PutAsync(filename, fileStream, cancelToken); + } + + public async Task PutAsync(string filename, Stream stream, CancellationToken cancelToken) + { + try + { + if (FileCache.ContainsKey(filename)) + Delete(filename); + + var fileNode = await Client.UploadAsync(stream, filename, _baseDirectoryPath, cancelToken); + FileCache[filename] = fileNode; + } + catch + { + FileCache = null; + throw; + } + } + + public void Delete(string filename) + { + try + { + if (!FileCache.ContainsKey(filename)) + { + ResetFileCacheAsync().Wait(); + + if (!FileCache.ContainsKey(filename)) + throw new FileMissingException(); + } + + Client.DeleteAsync(_baseDirectoryPath + filename, false).Wait(); + + FileCache.Remove(filename); + } + catch + { + FileCache = null; + throw; + } + } + + public void CreateFolder() + { + var directoryParts = _baseDirectoryPath.Split('/').Where(d => !string.IsNullOrEmpty(d)); + string baseDirectory = "/"; + + foreach (string directoryPart in directoryParts) + { + Client.CreateDirectoryAsync(directoryPart, baseDirectory).Wait(); + baseDirectory += directoryPart + "/"; + } + } + + public void Test() + { + this.TestList(); + } + + #region IDisposable Support + private bool _disposedValue = false; // To detect redundant calls + + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + } + + _disposedValue = true; + } + } + + // This code added to correctly implement the disposable pattern. + public void Dispose() + { + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); + } + #endregion + } +} diff --git a/Duplicati/Library/Backend/IDrive/Properties/AssemblyInfo.cs b/Duplicati/Library/Backend/IDrive/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..80c373101 --- /dev/null +++ b/Duplicati/Library/Backend/IDrive/Properties/AssemblyInfo.cs @@ -0,0 +1,43 @@ +// Copyright (C) 2022, 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 System.Reflection; +using System.Runtime.CompilerServices; + +// Information about this assembly is defined by the following attributes. +// Change them to the values specific to your project. + +[assembly: AssemblyTitle("Duplicati.Library.Backend.IDrive")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("")] +[assembly: AssemblyCopyright("Doug Krahmer")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// The assembly version has the format "{Major}.{Minor}.{Build}.{Revision}". +// The form "{Major}.{Minor}.*" will automatically update the build and revision, +// and "{Major}.{Minor}.{Build}.*" will update just the revision. + +[assembly: AssemblyVersion("2.0.0.1")] + +// The following attributes are used to specify the signing key for the assembly, +// if desired. See the Mono documentation for more information about signing. + +//[assembly: AssemblyDelaySign(false)] +//[assembly: AssemblyKeyFile("")] + diff --git a/Duplicati/Library/Backend/IDrive/Strings.cs b/Duplicati/Library/Backend/IDrive/Strings.cs new file mode 100644 index 000000000..9bb775e29 --- /dev/null +++ b/Duplicati/Library/Backend/IDrive/Strings.cs @@ -0,0 +1,18 @@ +using Duplicati.Library.Localization.Short; + +namespace Duplicati.Library.Backend.Strings +{ + internal static class IDriveBackend + { + public static string DisplayName { get { return LC.L(@"IDrive"); } } + public static string AuthPasswordDescriptionLong { get { return LC.L(@"The password used to connect to the server. This may also be supplied as the environment variable ""AUTH_PASSWORD""."); } } + public static string AuthPasswordDescriptionShort { get { return LC.L(@"Supplies the password used to connect to the server"); } } + public static string AuthUsernameDescriptionLong { get { return LC.L(@"The username used to connect to the server. This may also be supplied as the environment variable ""AUTH_USERNAME""."); } } + public static string AuthUsernameDescriptionShort { get { return LC.L(@"Supplies the username used to connect to the server"); } } + public static string AuthTwoFactorKeyDescriptionShort { get { return LC.L(@"The shared secret used to generate two-factor TOTP codes."); } } + public static string AuthTwoFactorKeyDescriptionLong { get { return LC.L(@"For accounts with two-factor authentication enabled, this is the shared secret used to generate the two-factor TOTP codes."); } } + public static string NoPasswordError { get { return LC.L(@"No password given"); } } + public static string NoUsernameError { get { return LC.L(@"No username given"); } } + public static string Description { get { return LC.L(@"This backend can read and write data to IDrive Sync. Allowed formats are: ""idrive://folder/subfolder"""); } } + } +}
\ No newline at end of file diff --git a/Duplicati/Library/Backend/IDrive/app.config b/Duplicati/Library/Backend/IDrive/app.config new file mode 100644 index 000000000..ad53561b2 --- /dev/null +++ b/Duplicati/Library/Backend/IDrive/app.config @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<configuration> + <startup> + <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.1" /> + </startup> + <runtime> + <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> + </assemblyBinding> + </runtime> +</configuration> diff --git a/Duplicati/Server/webroot/ngax/scripts/services/EditUriBuiltins.js b/Duplicati/Server/webroot/ngax/scripts/services/EditUriBuiltins.js index 63a94b64d..8a103104e 100644 --- a/Duplicati/Server/webroot/ngax/scripts/services/EditUriBuiltins.js +++ b/Duplicati/Server/webroot/ngax/scripts/services/EditUriBuiltins.js @@ -25,6 +25,7 @@ backupApp.service('EditUriBuiltins', function (AppService, AppUtils, SystemInfo, EditUriBackendConfig.templates['gcs'] = 'templates/backends/gcs.html'; EditUriBackendConfig.templates['b2'] = 'templates/backends/b2.html'; EditUriBackendConfig.templates['mega'] = 'templates/backends/mega.html'; + EditUriBackendConfig.templates['idrive'] = 'templates/backends/idrive.html'; EditUriBackendConfig.templates['jottacloud'] = 'templates/backends/jottacloud.html'; EditUriBackendConfig.templates['box'] = 'templates/backends/oauth.html'; EditUriBackendConfig.templates['dropbox'] = 'templates/backends/oauth.html'; diff --git a/Duplicati/Server/webroot/ngax/scripts/services/SystemInfo.js b/Duplicati/Server/webroot/ngax/scripts/services/SystemInfo.js index b6b4e4e52..a05af144a 100644 --- a/Duplicati/Server/webroot/ngax/scripts/services/SystemInfo.js +++ b/Duplicati/Server/webroot/ngax/scripts/services/SystemInfo.js @@ -66,6 +66,7 @@ backupApp.service('SystemInfo', function($rootScope, $timeout, $cookies, AppServ 'hubic': null, 'b2': null, 'mega': null, + 'idrive': null, 'box': null, 'od4b': null, 'mssp': null, diff --git a/Duplicati/Server/webroot/ngax/templates/backends/idrive.html b/Duplicati/Server/webroot/ngax/templates/backends/idrive.html new file mode 100644 index 000000000..1a5973341 --- /dev/null +++ b/Duplicati/Server/webroot/ngax/templates/backends/idrive.html @@ -0,0 +1,13 @@ +<div class="input text"> + <label for="idrive_path" translate>IDrive Sync directory path</label> + <input type="text" name="idrive_path" id="idrive_path" ng-model="$parent.Path" placeholder="{{'Enter directory path' | translate}}" /> +</div> + +<div class="input text"> + <label for="idrive_username" translate>Username</label> + <input type="text" name="idrive_username" id="idrive_username" ng-model="$parent.Username" placeholder="{{'Username' | translate}}" /> +</div> +<div class="input password"> + <label for="idrive_password" translate>Password</label> + <input autocomplete="new-password" type="password" name="idrive_password" id="idrive_password" ng-model="$parent.Password" placeholder="{{'Password' | translate}}" /> +</div> |