Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/duplicati/duplicati.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKenneth Skovhede <kenneth@hexad.dk>2018-06-04 11:48:11 +0300
committerKenneth Skovhede <kenneth@hexad.dk>2018-06-04 11:48:11 +0300
commite6535e2cfa45dce449d8c00b760f5a9455807b37 (patch)
treec1804853db7eff27cc8bbc1a5f0089ba5be71a16 /Duplicati/Library/Backend
parent8da3f61346fc3ef79351c06a574e5d38c33f2a2a (diff)
parentb6a250db954dc467a6506042ba6ecd69acec031f (diff)
Merge branch 'master' of github.com:duplicati/duplicati
# Conflicts: # Duplicati/Server/WebServer/RESTMethods/UISettings.cs
Diffstat (limited to 'Duplicati/Library/Backend')
-rw-r--r--Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs1140
-rw-r--r--Duplicati/Library/Backend/SSHv2/SSHv2Backend.cs6
-rw-r--r--Duplicati/Library/Backend/Sia/Sia.cs4
3 files changed, 585 insertions, 565 deletions
diff --git a/Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs b/Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs
index 5ca73f142..ebf9a92a6 100644
--- a/Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs
+++ b/Duplicati/Library/Backend/OneDrive/MicrosoftGraphBackend.cs
@@ -1,558 +1,582 @@
-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;
-
-using Duplicati.Library.Backend.MicrosoftGraph;
-using Duplicati.Library.Interface;
-using Duplicati.Library.Utility;
-
-using Newtonsoft.Json;
-
-namespace Duplicati.Library.Backend
-{
- /// <summary>
- /// Base class for all backends based on the Microsoft Graph API:
- /// https://developer.microsoft.com/en-us/graph/
- /// </summary>
- /// <remarks>
- /// HttpClient is used instead of OAuthHelper because OAuthHelper internally converts URLs to System.Uri, which throws UriFormatException when the URL contains ':' characters.
- /// https://stackoverflow.com/questions/2143856/why-does-colon-in-uri-passed-to-uri-makerelativeuri-cause-an-exception
- /// https://social.msdn.microsoft.com/Forums/vstudio/en-US/bf11fc74-975a-4c4d-8335-8e0579d17fdf/uri-containing-colons-incorrectly-throws-uriformatexception?forum=netfxbcl
- ///
- /// Note that instead of using Task.Result to wait for the results of asynchronous operations,
- /// this class uses the Utility.Await() extension method, since it doesn't wrap exceptions in AggregateExceptions.
- /// </remarks>
- public abstract class MicrosoftGraphBackend : IBackend, IStreamingBackend, IQuotaEnabledBackend, IRenameEnabledBackend
- {
- private const string SERVICES_AGREEMENT = "https://www.microsoft.com/en-us/servicesagreement";
- private const string PRIVACY_STATEMENT = "https://privacy.microsoft.com/en-us/privacystatement";
-
- private const string BASE_ADDRESS = "https://graph.microsoft.com";
-
- private const string AUTHID_OPTION = "authid";
- private const string UPLOAD_SESSION_FRAGMENT_SIZE_OPTION = "fragment-size";
- private const string UPLOAD_SESSION_FRAGMENT_RETRY_COUNT_OPTION = "fragment-retry-count";
- private const string UPLOAD_SESSION_FRAGMENT_RETRY_DELAY_OPTION = "fragment-retry-delay";
-
- private const int UPLOAD_SESSION_FRAGMENT_DEFAULT_RETRY_COUNT = 5;
- private const int UPLOAD_SESSION_FRAGMENT_DEFAULT_RETRY_DELAY = 1000;
-
- /// <summary>
- /// Max size of file that can be uploaded in a single PUT request is 4 MB:
- /// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/driveitem_put_content
- /// </summary>
- private const int PUT_MAX_SIZE = 4 * 1000 * 1000;
-
- /// <summary>
- /// Max size of each individual upload in an upload session is 60 MiB:
- /// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_createuploadsession
- /// </summary>
- private const int UPLOAD_SESSION_FRAGMENT_MAX_SIZE = 60 * 1024 * 1024;
-
- /// <summary>
- /// Default fragment size of 10 MiB, as the documentation recommends something in the range of 5-10 MiB,
- /// and it still complies with the 320 KiB multiple requirement.
- /// </summary>
- private const int UPLOAD_SESSION_FRAGMENT_DEFAULT_SIZE = 10 * 1024 * 1024;
-
- /// <summary>
- /// Each fragment in an upload session must be a size that is multiple of this size.
- /// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_createuploadsession
- /// There is some confusion in the docs as to whether this is actually required, however...
- /// </summary>
- private const int UPLOAD_SESSION_FRAGMENT_MULTIPLE_SIZE = 320 * 1024;
-
- private static readonly HttpMethod PatchMethod = new HttpMethod("PATCH");
-
- protected delegate string DescriptionTemplateDelegate(string mssadescription, string mssalink, string msopdescription, string msoplink);
-
- private readonly JsonSerializer m_serializer = new JsonSerializer();
- private readonly OAuthHttpClient m_client;
- private readonly string m_path;
- private readonly int fragmentSize;
- private readonly int fragmentRetryCount;
- private readonly int fragmentRetryDelay; // In milliseconds
-
- private string[] dnsNames = null;
-
- protected MicrosoftGraphBackend() { } // Constructor needed for dynamic loading to find it
-
- protected MicrosoftGraphBackend(string url, Dictionary<string, string> options)
- {
- string authid;
- options.TryGetValue(AUTHID_OPTION, out authid);
- if (string.IsNullOrEmpty(authid))
- throw new UserInformationException(Strings.MicrosoftGraph.MissingAuthId(OAuthHelper.OAUTH_LOGIN_URL(this.ProtocolKey)), "MicrosoftGraphBackendMissingAuthId");
-
- string fragmentSizeStr;
- if (options.TryGetValue(UPLOAD_SESSION_FRAGMENT_SIZE_OPTION, out fragmentSizeStr) && int.TryParse(fragmentSizeStr, out this.fragmentSize))
- {
- // Make sure the fragment size is a multiple of the desired multiple size.
- // If it isn't, we round down to the nearest multiple below it.
- this.fragmentSize = (this.fragmentSize / UPLOAD_SESSION_FRAGMENT_MULTIPLE_SIZE) * UPLOAD_SESSION_FRAGMENT_MULTIPLE_SIZE;
-
- // Make sure the fragment size isn't larger than the maximum, or smaller than the minimum
- this.fragmentSize = Math.Max(Math.Min(this.fragmentSize, UPLOAD_SESSION_FRAGMENT_MAX_SIZE), UPLOAD_SESSION_FRAGMENT_MULTIPLE_SIZE);
- }
- else
- {
- this.fragmentSize = UPLOAD_SESSION_FRAGMENT_DEFAULT_SIZE;
- }
-
- string fragmentRetryCountStr;
- if (!(options.TryGetValue(UPLOAD_SESSION_FRAGMENT_RETRY_COUNT_OPTION, out fragmentRetryCountStr) && int.TryParse(fragmentRetryCountStr, out this.fragmentRetryCount)))
- {
- this.fragmentRetryCount = UPLOAD_SESSION_FRAGMENT_DEFAULT_RETRY_COUNT;
- }
-
- string fragmentRetryDelayStr;
- if (!(options.TryGetValue(UPLOAD_SESSION_FRAGMENT_RETRY_DELAY_OPTION, out fragmentRetryDelayStr) && int.TryParse(fragmentRetryDelayStr, out this.fragmentRetryDelay)))
- {
- this.fragmentRetryDelay = UPLOAD_SESSION_FRAGMENT_DEFAULT_RETRY_DELAY;
- }
-
- this.m_client = new OAuthHttpClient(authid, this.ProtocolKey);
- this.m_client.BaseAddress = new System.Uri(BASE_ADDRESS);
-
- // Extract out the path to the backup root folder from the given URI
- this.m_path = NormalizeSlashes(this.GetRootPathFromUrl(url));
- }
-
- public abstract string ProtocolKey { get; }
-
- public abstract string DisplayName { get; }
-
- public string Description
- {
- get
- {
- return this.DescriptionTemplate(
- "Microsoft Service Agreement",
- SERVICES_AGREEMENT,
- "Microsoft Online Privacy Statement",
- PRIVACY_STATEMENT);
- }
- }
-
- public IList<ICommandLineArgument> SupportedCommands
- {
- get
- {
- return new[]
- {
- new CommandLineArgument(AUTHID_OPTION, CommandLineArgument.ArgumentType.Password, Strings.MicrosoftGraph.AuthIdShort, Strings.MicrosoftGraph.AuthIdLong(OAuthHelper.OAUTH_LOGIN_URL(this.ProtocolKey))),
- new CommandLineArgument(UPLOAD_SESSION_FRAGMENT_SIZE_OPTION, CommandLineArgument.ArgumentType.Integer, Strings.MicrosoftGraph.FragmentSizeShort, Strings.MicrosoftGraph.FragmentSizeLong, Library.Utility.Utility.FormatSizeString(UPLOAD_SESSION_FRAGMENT_DEFAULT_SIZE)),
- new CommandLineArgument(UPLOAD_SESSION_FRAGMENT_RETRY_COUNT_OPTION, CommandLineArgument.ArgumentType.Integer, Strings.MicrosoftGraph.FragmentRetryCountShort, Strings.MicrosoftGraph.FragmentRetryCountLong, UPLOAD_SESSION_FRAGMENT_DEFAULT_RETRY_COUNT.ToString()),
- new CommandLineArgument(UPLOAD_SESSION_FRAGMENT_RETRY_DELAY_OPTION, CommandLineArgument.ArgumentType.Integer, Strings.MicrosoftGraph.FragmentRetryDelayShort, Strings.MicrosoftGraph.FragmentRetryDelayLong, UPLOAD_SESSION_FRAGMENT_DEFAULT_RETRY_DELAY.ToString()),
- }
- .Concat(this.AdditionalSupportedCommands).ToList();
- }
- }
-
- public string[] DNSName
- {
- get
- {
- if (this.dnsNames == null)
- {
- // The DNS names that this instance may need to access include:
- // - Core graph API endpoint
- // - Upload session endpoint (which seems to be different depending on the drive being accessed - not sure if it can vary for a single drive)
- // To get the upload session endpoint, we can start an upload session and then immediately cancel it.
- // We pick a random file name (using a guid) to make sure we don't conflict with an existing file
- string dnsTestFile = string.Format("DNSNameTest-{0}", Guid.NewGuid());
- UploadSession uploadSession = this.Post<UploadSession>(string.Format("{0}/root:{1}{2}:/createUploadSession", this.DrivePrefix, this.m_path, NormalizeSlashes(dnsTestFile)), null);
-
- // Canceling an upload session is done by sending a DELETE to the upload URL
- var request = new HttpRequestMessage(HttpMethod.Delete, uploadSession.UploadUrl);
- var response = this.m_client.SendAsync(request).Await();
- this.CheckResponse(response);
-
- this.dnsNames = new[]
- {
- new System.Uri(BASE_ADDRESS).Host,
- new System.Uri(uploadSession.UploadUrl).Host,
- }
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .ToArray();
- }
-
- return this.dnsNames;
- }
- }
-
- public IQuotaInfo Quota
- {
- get
- {
- Drive driveInfo = this.Get<Drive>(this.DrivePrefix);
- if (driveInfo.Quota != null)
- {
- // Some sources (SharePoint for example) seem to return 0 for these values even when the quota isn't exceeded..
- // As a special test, if all the returned values are 0, we pretend that no quota was reported.
- // This way we don't send spurious warnings because the quota looks like it is exceeded.
- if (driveInfo.Quota.Total != 0 || driveInfo.Quota.Remaining != 0 || driveInfo.Quota.Used != 0)
- {
- return new QuotaInfo(driveInfo.Quota.Total, driveInfo.Quota.Remaining);
- }
- }
-
- return null;
- }
- }
-
- /// <summary>
- /// Override-able fragment indicating the API version to use each query
- /// </summary>
- protected virtual string ApiVersion
- {
- get { return "/v1.0"; }
- }
-
- /// <summary>
- /// Normalized fragment (starting with a slash and ending without one) for the path to the drive to be used.
- /// For example: "/me/drive" for the default drive for a user.
- /// </summary>
- protected abstract string DrivePath { get; }
-
- protected abstract DescriptionTemplateDelegate DescriptionTemplate { get; }
-
- protected virtual IList<ICommandLineArgument> AdditionalSupportedCommands
- {
- get
- {
- return new ICommandLineArgument[0];
- }
- }
-
- private string DrivePrefix
- {
- get { return this.ApiVersion + this.DrivePath; }
- }
-
- public void CreateFolder()
- {
- string parentFolder = "root";
- string parentFolderPath = string.Empty;
- foreach (string folder in this.m_path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries))
- {
- string nextPath = parentFolderPath + "/" + folder;
- DriveItem folderItem;
- try
- {
- folderItem = this.Get<DriveItem>(string.Format("{0}/root:{1}", this.DrivePrefix, NormalizeSlashes(nextPath)));
- }
- catch (DriveItemNotFoundException)
- {
- DriveItem newFolder = new DriveItem()
- {
- Name = folder,
- Folder = new FolderFacet(),
- };
-
- folderItem = this.Post(string.Format("{0}/items/{1}/children", this.DrivePrefix, parentFolder), newFolder);
- }
-
- parentFolder = folderItem.Id;
- parentFolderPath = nextPath;
- }
- }
-
- public IEnumerable<IFileEntry> List()
- {
- try
- {
- return this.Enumerate<DriveItem>(string.Format("{0}/root:{1}:/children", this.DrivePrefix, this.m_path))
- .Where(item => item.IsFile && !item.IsDeleted) // Exclude non-files and deleted items (not sure if they show up in this listing, but make sure anyway)
- .Select(item =>
- new FileEntry(
- item.Name,
- item.Size ?? 0, // Files should always have a size, but folders don't need it
- item.FileSystemInfo?.LastAccessedDateTime?.UtcDateTime ?? new DateTime(),
- item.FileSystemInfo?.LastModifiedDateTime?.UtcDateTime ?? item.LastModifiedDateTime?.UtcDateTime ?? new DateTime()));
- }
- catch (DriveItemNotFoundException ex)
- {
- // If there's an 'item not found' exception here, it means the root folder didn't exist.
- throw new FolderMissingException(ex);
- }
- }
-
- public void Get(string remotename, string filename)
- {
- using (FileStream fileStream = File.OpenWrite(filename))
- {
- this.Get(remotename, fileStream);
- }
- }
-
- public void Get(string remotename, Stream stream)
- {
- var response = this.m_client.GetAsync(string.Format("{0}/root:{1}{2}:/content", this.DrivePrefix, this.m_path, NormalizeSlashes(remotename))).Await();
- this.CheckResponse(response);
- using (Stream responseStream = response.Content.ReadAsStreamAsync().Await())
- {
- responseStream.CopyTo(stream);
- }
- }
-
- public void Rename(string oldname, string newname)
- {
- this.Patch(string.Format("{0}/root:{1}{2}", this.DrivePrefix, this.m_path, NormalizeSlashes(oldname)), new DriveItem() { Name = newname });
- }
-
- public void Put(string remotename, string filename)
- {
- using (FileStream fileStream = File.OpenRead(filename))
- {
- this.Put(remotename, fileStream);
- }
- }
-
- public void Put(string remotename, Stream stream)
- {
- // PUT only supports up to 4 MB file uploads. There's a separate process for larger files.
- if (stream.Length < PUT_MAX_SIZE)
- {
- StreamContent streamContent = new StreamContent(stream);
- streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
- var response = this.m_client.PutAsync(string.Format("{0}/root:{1}{2}:/content", this.DrivePrefix, this.m_path, NormalizeSlashes(remotename)), streamContent).Await();
-
- // Make sure this response is a valid drive item, though we don't actually use it for anything currently.
- var result = this.ParseResponse<DriveItem>(response);
- }
- else
- {
- // This file is too large to be sent in a single request, so we need to send it in pieces in an upload session:
- // https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_createuploadsession
- // The documentation seems somewhat contradictory - it states that uploads must be done sequentially,
- // but also states that the nextExpectedRanges value returned may indicate multiple ranges...
- // For now, this plays it safe and does a sequential upload.
- HttpRequestMessage createSessionRequest = new HttpRequestMessage(HttpMethod.Post, string.Format("{0}/root:{1}{2}:/createUploadSession", this.DrivePrefix, this.m_path, NormalizeSlashes(remotename)));
-
- // Indicate that we want to replace any existing content with this new data we're uploading
- StringContent createSessionContent = this.PrepareContent(new UploadSession() { Item = new DriveItem() { ConflictBehavior = ConflictBehavior.Replace } });
-
- HttpResponseMessage createSessionResponse = this.m_client.SendAsync(createSessionRequest).Await();
- UploadSession uploadSession = this.ParseResponse<UploadSession>(createSessionResponse);
-
- // If the stream's total length is less than the chosen fragment size, then we should make the buffer only as large as the stream.
- int fragmentSize = (int)Math.Min(this.fragmentSize, stream.Length);
-
- byte[] fragmentBuffer = new byte[fragmentSize];
- int read = 0;
- for (int offset = 0; offset < stream.Length; offset += read)
- {
- read = stream.Read(fragmentBuffer, 0, fragmentSize);
-
- int retryCount = this.fragmentRetryCount;
- for (int attempt = 0; attempt < retryCount; attempt++)
- {
- ByteArrayContent fragmentContent = new ByteArrayContent(fragmentBuffer, 0, read);
- fragmentContent.Headers.ContentLength = read;
- fragmentContent.Headers.ContentRange = new ContentRangeHeaderValue(offset, offset + read - 1, stream.Length);
-
- HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Put, uploadSession.UploadUrl);
- request.Content = fragmentContent;
-
- HttpResponseMessage response = null;
- try
- {
- // The uploaded put requests will error if they are authenticated
- response = this.m_client.SendAsync(request, false).Await();
-
- // Note: On the last request, the json result includes the default properties of the item that was uploaded
- var result = this.ParseResponse<UploadSession>(response);
- }
- catch (MicrosoftGraphException ex)
- {
- // Error handling based on recommendations here:
- // https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_createuploadsession#best-practices
- if (attempt >= retryCount - 1)
- {
- // We've used up all our retry attempts
- throw new UploadSessionException(createSessionResponse, offset / fragmentSize, (int)Math.Ceiling((double)stream.Length / fragmentSize), ex);
- }
- else if ((int)ex.Response.StatusCode >= 500 && (int)ex.Response.StatusCode < 600)
- {
- // If a 5xx error code is hit, we should use an exponential backoff strategy before retrying.
- // To make things simpler, we just use the current attempt number as the exponential factor.
- Thread.Sleep((int)Math.Pow(2, attempt) * this.fragmentRetryDelay); // If this is changed to use tasks, this should be changed to Task.Await()
- continue;
- }
- else if (ex.Response.StatusCode == HttpStatusCode.NotFound)
- {
- // 404 is a special case indicating the upload session no longer exists, so the fragment shouldn't be retried.
- // Instead we'll let the caller re-attempt the whole file.
- throw new UploadSessionException(createSessionResponse, offset / fragmentSize, (int)Math.Ceiling((double)stream.Length / fragmentSize), ex);
- }
- else if ((int)ex.Response.StatusCode >= 400 && (int)ex.Response.StatusCode < 500)
- {
- // If a 4xx error code is hit, we should retry without the backoff attempt
- continue;
- }
- else
- {
- // Other errors should be rethrown
- throw new UploadSessionException(createSessionResponse, offset / fragmentSize, (int)Math.Ceiling((double)stream.Length / fragmentSize), ex);
- }
- }
-
- // If we successfully sent this piece, then we can break out of the retry loop
- break;
- }
- }
- }
- }
-
- public void Delete(string remotename)
- {
- var response = this.m_client.DeleteAsync(string.Format("{0}/root:{1}{2}", this.DrivePrefix, this.m_path, NormalizeSlashes(remotename))).Await();
- this.CheckResponse(response);
- }
-
- public void Test()
- {
- try
- {
- string rootPath = string.Format("{0}/root:{1}", this.DrivePrefix, this.m_path);
- DriveItem rootFolder = this.Get<DriveItem>(rootPath);
- }
- catch (DriveItemNotFoundException ex)
- {
- // Wrap the existing item not found error in a 'FolderMissingException'
- throw new FolderMissingException(ex);
- }
- }
-
- public void Dispose()
- {
- if (this.m_client != null)
- {
- this.m_client.Dispose();
- }
- }
-
- protected virtual string GetRootPathFromUrl(string url)
- {
- // Extract out the path to the backup root folder from the given URI
- var uri = new Utility.Uri(url);
-
- return Utility.Uri.UrlDecode(uri.HostAndPath);
- }
-
- protected T Get<T>(string url)
- {
- return this.SendRequest<T>(HttpMethod.Get, url);
- }
-
- protected T Post<T>(string url, T body)
- {
- return this.SendRequest(HttpMethod.Post, url, body);
- }
-
- protected T Patch<T>(string url, T body)
- {
- return this.SendRequest(PatchMethod, url, body);
- }
-
- private T SendRequest<T>(HttpMethod method, string url)
- {
- var request = new HttpRequestMessage(method, url);
- return this.SendRequest<T>(request);
- }
-
- private T SendRequest<T>(HttpMethod method, string url, T body)
- {
- var request = new HttpRequestMessage(method, url);
- if (body != null)
- {
- request.Content = this.PrepareContent(body);
- }
-
- return this.SendRequest<T>(request);
- }
-
- private T SendRequest<T>(HttpRequestMessage request)
- {
- var response = this.m_client.SendAsync(request).Await();
- return this.ParseResponse<T>(response);
- }
-
- private IEnumerable<T> Enumerate<T>(string url)
- {
- string nextUrl = url;
- while (!string.IsNullOrEmpty(nextUrl))
- {
- GraphCollection<T> results = this.Get<GraphCollection<T>>(nextUrl);
- foreach (T result in results.Value)
- {
- yield return result;
- }
-
- nextUrl = results.ODataNextLink;
- }
- }
-
- private void CheckResponse(HttpResponseMessage response)
- {
- if (!response.IsSuccessStatusCode)
- {
- if (response.StatusCode == HttpStatusCode.NotFound)
- {
- // It looks like this is an 'item not found' exception, so wrap it in a new exception class to make it easier to pick things out.
- throw new DriveItemNotFoundException(response);
- }
- else
- {
- // Throw a wrapper exception to make it easier for the caller to look at specific status codes, etc.
- throw new MicrosoftGraphException(response);
- }
- }
- }
-
- private T ParseResponse<T>(HttpResponseMessage response)
- {
- this.CheckResponse(response);
- using (Stream responseStream = response.Content.ReadAsStreamAsync().Await())
- using (StreamReader reader = new StreamReader(responseStream))
- using (JsonTextReader jsonReader = new JsonTextReader(reader))
- {
- return this.m_serializer.Deserialize<T>(jsonReader);
- }
- }
-
- /// <summary>
- /// Normalizes the slashes in a url fragment. For example:
- /// "" => ""
- /// "test" => "/test"
- /// "test/" => "/test"
- /// "a\b" => "/a/b"
- /// </summary>
- /// <param name="url">Url fragment to normalize</param>
- /// <returns>Normalized fragment</returns>
- private static string NormalizeSlashes(string url)
- {
- url = url.Replace('\\', '/');
-
- if (url.Length != 0 && !url.StartsWith("/", StringComparison.Ordinal))
- url = "/" + url;
-
- if (url.EndsWith("/", StringComparison.Ordinal))
- url = url.Substring(0, url.Length - 1);
-
- return url;
- }
-
- private StringContent PrepareContent<T>(T body)
- {
- return new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
- }
- }
-}
+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;
+
+using Duplicati.Library.Backend.MicrosoftGraph;
+using Duplicati.Library.Interface;
+using Duplicati.Library.Utility;
+
+using Newtonsoft.Json;
+
+namespace Duplicati.Library.Backend
+{
+ /// <summary>
+ /// Base class for all backends based on the Microsoft Graph API:
+ /// https://developer.microsoft.com/en-us/graph/
+ /// </summary>
+ /// <remarks>
+ /// HttpClient is used instead of OAuthHelper because OAuthHelper internally converts URLs to System.Uri, which throws UriFormatException when the URL contains ':' characters.
+ /// https://stackoverflow.com/questions/2143856/why-does-colon-in-uri-passed-to-uri-makerelativeuri-cause-an-exception
+ /// https://social.msdn.microsoft.com/Forums/vstudio/en-US/bf11fc74-975a-4c4d-8335-8e0579d17fdf/uri-containing-colons-incorrectly-throws-uriformatexception?forum=netfxbcl
+ ///
+ /// Note that instead of using Task.Result to wait for the results of asynchronous operations,
+ /// this class uses the Utility.Await() extension method, since it doesn't wrap exceptions in AggregateExceptions.
+ /// </remarks>
+ public abstract class MicrosoftGraphBackend : IBackend, IStreamingBackend, IQuotaEnabledBackend, IRenameEnabledBackend
+ {
+ private const string SERVICES_AGREEMENT = "https://www.microsoft.com/en-us/servicesagreement";
+ private const string PRIVACY_STATEMENT = "https://privacy.microsoft.com/en-us/privacystatement";
+
+ private const string BASE_ADDRESS = "https://graph.microsoft.com";
+
+ private const string AUTHID_OPTION = "authid";
+ private const string UPLOAD_SESSION_FRAGMENT_SIZE_OPTION = "fragment-size";
+ private const string UPLOAD_SESSION_FRAGMENT_RETRY_COUNT_OPTION = "fragment-retry-count";
+ private const string UPLOAD_SESSION_FRAGMENT_RETRY_DELAY_OPTION = "fragment-retry-delay";
+
+ private const int UPLOAD_SESSION_FRAGMENT_DEFAULT_RETRY_COUNT = 5;
+ private const int UPLOAD_SESSION_FRAGMENT_DEFAULT_RETRY_DELAY = 1000;
+
+ /// <summary>
+ /// Max size of file that can be uploaded in a single PUT request is 4 MB:
+ /// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/driveitem_put_content
+ /// </summary>
+ private const int PUT_MAX_SIZE = 4 * 1000 * 1000;
+
+ /// <summary>
+ /// Max size of each individual upload in an upload session is 60 MiB:
+ /// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_createuploadsession
+ /// </summary>
+ private const int UPLOAD_SESSION_FRAGMENT_MAX_SIZE = 60 * 1024 * 1024;
+
+ /// <summary>
+ /// Default fragment size of 10 MiB, as the documentation recommends something in the range of 5-10 MiB,
+ /// and it still complies with the 320 KiB multiple requirement.
+ /// </summary>
+ private const int UPLOAD_SESSION_FRAGMENT_DEFAULT_SIZE = 10 * 1024 * 1024;
+
+ /// <summary>
+ /// Each fragment in an upload session must be a size that is multiple of this size.
+ /// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_createuploadsession
+ /// There is some confusion in the docs as to whether this is actually required, however...
+ /// </summary>
+ private const int UPLOAD_SESSION_FRAGMENT_MULTIPLE_SIZE = 320 * 1024;
+
+ private static readonly HttpMethod PatchMethod = new HttpMethod("PATCH");
+
+ protected delegate string DescriptionTemplateDelegate(string mssadescription, string mssalink, string msopdescription, string msoplink);
+
+ private readonly JsonSerializer m_serializer = new JsonSerializer();
+ private readonly OAuthHttpClient m_client;
+ private readonly string m_path;
+ private readonly int fragmentSize;
+ private readonly int fragmentRetryCount;
+ private readonly int fragmentRetryDelay; // In milliseconds
+
+ private string[] dnsNames = null;
+
+ protected MicrosoftGraphBackend() { } // Constructor needed for dynamic loading to find it
+
+ protected MicrosoftGraphBackend(string url, Dictionary<string, string> options)
+ {
+ string authid;
+ options.TryGetValue(AUTHID_OPTION, out authid);
+ if (string.IsNullOrEmpty(authid))
+ throw new UserInformationException(Strings.MicrosoftGraph.MissingAuthId(OAuthHelper.OAUTH_LOGIN_URL(this.ProtocolKey)), "MicrosoftGraphBackendMissingAuthId");
+
+ string fragmentSizeStr;
+ if (options.TryGetValue(UPLOAD_SESSION_FRAGMENT_SIZE_OPTION, out fragmentSizeStr) && int.TryParse(fragmentSizeStr, out this.fragmentSize))
+ {
+ // Make sure the fragment size is a multiple of the desired multiple size.
+ // If it isn't, we round down to the nearest multiple below it.
+ this.fragmentSize = (this.fragmentSize / UPLOAD_SESSION_FRAGMENT_MULTIPLE_SIZE) * UPLOAD_SESSION_FRAGMENT_MULTIPLE_SIZE;
+
+ // Make sure the fragment size isn't larger than the maximum, or smaller than the minimum
+ this.fragmentSize = Math.Max(Math.Min(this.fragmentSize, UPLOAD_SESSION_FRAGMENT_MAX_SIZE), UPLOAD_SESSION_FRAGMENT_MULTIPLE_SIZE);
+ }
+ else
+ {
+ this.fragmentSize = UPLOAD_SESSION_FRAGMENT_DEFAULT_SIZE;
+ }
+
+ string fragmentRetryCountStr;
+ if (!(options.TryGetValue(UPLOAD_SESSION_FRAGMENT_RETRY_COUNT_OPTION, out fragmentRetryCountStr) && int.TryParse(fragmentRetryCountStr, out this.fragmentRetryCount)))
+ {
+ this.fragmentRetryCount = UPLOAD_SESSION_FRAGMENT_DEFAULT_RETRY_COUNT;
+ }
+
+ string fragmentRetryDelayStr;
+ if (!(options.TryGetValue(UPLOAD_SESSION_FRAGMENT_RETRY_DELAY_OPTION, out fragmentRetryDelayStr) && int.TryParse(fragmentRetryDelayStr, out this.fragmentRetryDelay)))
+ {
+ this.fragmentRetryDelay = UPLOAD_SESSION_FRAGMENT_DEFAULT_RETRY_DELAY;
+ }
+
+ this.m_client = new OAuthHttpClient(authid, this.ProtocolKey);
+ this.m_client.BaseAddress = new System.Uri(BASE_ADDRESS);
+
+ // Extract out the path to the backup root folder from the given URI
+ this.m_path = NormalizeSlashes(this.GetRootPathFromUrl(url));
+ }
+
+ public abstract string ProtocolKey { get; }
+
+ public abstract string DisplayName { get; }
+
+ public string Description
+ {
+ get
+ {
+ return this.DescriptionTemplate(
+ "Microsoft Service Agreement",
+ SERVICES_AGREEMENT,
+ "Microsoft Online Privacy Statement",
+ PRIVACY_STATEMENT);
+ }
+ }
+
+ public IList<ICommandLineArgument> SupportedCommands
+ {
+ get
+ {
+ return new[]
+ {
+ new CommandLineArgument(AUTHID_OPTION, CommandLineArgument.ArgumentType.Password, Strings.MicrosoftGraph.AuthIdShort, Strings.MicrosoftGraph.AuthIdLong(OAuthHelper.OAUTH_LOGIN_URL(this.ProtocolKey))),
+ new CommandLineArgument(UPLOAD_SESSION_FRAGMENT_SIZE_OPTION, CommandLineArgument.ArgumentType.Integer, Strings.MicrosoftGraph.FragmentSizeShort, Strings.MicrosoftGraph.FragmentSizeLong, Library.Utility.Utility.FormatSizeString(UPLOAD_SESSION_FRAGMENT_DEFAULT_SIZE)),
+ new CommandLineArgument(UPLOAD_SESSION_FRAGMENT_RETRY_COUNT_OPTION, CommandLineArgument.ArgumentType.Integer, Strings.MicrosoftGraph.FragmentRetryCountShort, Strings.MicrosoftGraph.FragmentRetryCountLong, UPLOAD_SESSION_FRAGMENT_DEFAULT_RETRY_COUNT.ToString()),
+ new CommandLineArgument(UPLOAD_SESSION_FRAGMENT_RETRY_DELAY_OPTION, CommandLineArgument.ArgumentType.Integer, Strings.MicrosoftGraph.FragmentRetryDelayShort, Strings.MicrosoftGraph.FragmentRetryDelayLong, UPLOAD_SESSION_FRAGMENT_DEFAULT_RETRY_DELAY.ToString()),
+ }
+ .Concat(this.AdditionalSupportedCommands).ToList();
+ }
+ }
+
+ public string[] DNSName
+ {
+ get
+ {
+ if (this.dnsNames == null)
+ {
+ // The DNS names that this instance may need to access include:
+ // - Core graph API endpoint
+ // - Upload session endpoint (which seems to be different depending on the drive being accessed - not sure if it can vary for a single drive)
+ // To get the upload session endpoint, we can start an upload session and then immediately cancel it.
+ // We pick a random file name (using a guid) to make sure we don't conflict with an existing file
+ string dnsTestFile = string.Format("DNSNameTest-{0}", Guid.NewGuid());
+ UploadSession uploadSession = this.Post<UploadSession>(string.Format("{0}/root:{1}{2}:/createUploadSession", this.DrivePrefix, this.m_path, NormalizeSlashes(dnsTestFile)), null);
+
+ // Canceling an upload session is done by sending a DELETE to the upload URL
+ var request = new HttpRequestMessage(HttpMethod.Delete, uploadSession.UploadUrl);
+ var response = this.m_client.SendAsync(request).Await();
+ this.CheckResponse(response);
+
+ this.dnsNames = new[]
+ {
+ new System.Uri(BASE_ADDRESS).Host,
+ new System.Uri(uploadSession.UploadUrl).Host,
+ }
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+
+ return this.dnsNames;
+ }
+ }
+
+ public IQuotaInfo Quota
+ {
+ get
+ {
+ Drive driveInfo = this.Get<Drive>(this.DrivePrefix);
+ if (driveInfo.Quota != null)
+ {
+ // Some sources (SharePoint for example) seem to return 0 for these values even when the quota isn't exceeded..
+ // As a special test, if all the returned values are 0, we pretend that no quota was reported.
+ // This way we don't send spurious warnings because the quota looks like it is exceeded.
+ if (driveInfo.Quota.Total != 0 || driveInfo.Quota.Remaining != 0 || driveInfo.Quota.Used != 0)
+ {
+ return new QuotaInfo(driveInfo.Quota.Total, driveInfo.Quota.Remaining);
+ }
+ }
+
+ return null;
+ }
+ }
+
+ /// <summary>
+ /// Override-able fragment indicating the API version to use each query
+ /// </summary>
+ protected virtual string ApiVersion
+ {
+ get { return "/v1.0"; }
+ }
+
+ /// <summary>
+ /// Normalized fragment (starting with a slash and ending without one) for the path to the drive to be used.
+ /// For example: "/me/drive" for the default drive for a user.
+ /// </summary>
+ protected abstract string DrivePath { get; }
+
+ protected abstract DescriptionTemplateDelegate DescriptionTemplate { get; }
+
+ protected virtual IList<ICommandLineArgument> AdditionalSupportedCommands
+ {
+ get
+ {
+ return new ICommandLineArgument[0];
+ }
+ }
+
+ private string DrivePrefix
+ {
+ get { return this.ApiVersion + this.DrivePath; }
+ }
+
+ public void CreateFolder()
+ {
+ string parentFolder = "root";
+ string parentFolderPath = string.Empty;
+ foreach (string folder in this.m_path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ string nextPath = parentFolderPath + "/" + folder;
+ DriveItem folderItem;
+ try
+ {
+ folderItem = this.Get<DriveItem>(string.Format("{0}/root:{1}", this.DrivePrefix, NormalizeSlashes(nextPath)));
+ }
+ catch (DriveItemNotFoundException)
+ {
+ DriveItem newFolder = new DriveItem()
+ {
+ Name = folder,
+ Folder = new FolderFacet(),
+ };
+
+ folderItem = this.Post(string.Format("{0}/items/{1}/children", this.DrivePrefix, parentFolder), newFolder);
+ }
+
+ parentFolder = folderItem.Id;
+ parentFolderPath = nextPath;
+ }
+ }
+
+ public IEnumerable<IFileEntry> List()
+ {
+ try
+ {
+ return this.Enumerate<DriveItem>(string.Format("{0}/root:{1}:/children", this.DrivePrefix, this.m_path))
+ .Where(item => item.IsFile && !item.IsDeleted) // Exclude non-files and deleted items (not sure if they show up in this listing, but make sure anyway)
+ .Select(item =>
+ new FileEntry(
+ item.Name,
+ item.Size ?? 0, // Files should always have a size, but folders don't need it
+ item.FileSystemInfo?.LastAccessedDateTime?.UtcDateTime ?? new DateTime(),
+ item.FileSystemInfo?.LastModifiedDateTime?.UtcDateTime ?? item.LastModifiedDateTime?.UtcDateTime ?? new DateTime()));
+ }
+ catch (DriveItemNotFoundException ex)
+ {
+ // If there's an 'item not found' exception here, it means the root folder didn't exist.
+ throw new FolderMissingException(ex);
+ }
+ }
+
+ public void Get(string remotename, string filename)
+ {
+ using (FileStream fileStream = File.OpenWrite(filename))
+ {
+ this.Get(remotename, fileStream);
+ }
+ }
+
+ public void Get(string remotename, Stream stream)
+ {
+ try
+ {
+ var response = this.m_client.GetAsync(string.Format("{0}/root:{1}{2}:/content", this.DrivePrefix, this.m_path, NormalizeSlashes(remotename))).Await();
+ this.CheckResponse(response);
+ using (Stream responseStream = response.Content.ReadAsStreamAsync().Await())
+ {
+ responseStream.CopyTo(stream);
+ }
+ }
+ catch (DriveItemNotFoundException ex)
+ {
+ // If the item wasn't found, wrap the exception so normal handling can occur.
+ throw new FileMissingException(ex);
+ }
+ }
+
+ public void Rename(string oldname, string newname)
+ {
+ try
+ {
+ this.Patch(string.Format("{0}/root:{1}{2}", this.DrivePrefix, this.m_path, NormalizeSlashes(oldname)), new DriveItem() { Name = newname });
+ }
+ catch (DriveItemNotFoundException ex)
+ {
+ // If the item wasn't found, wrap the exception so normal handling can occur.
+ throw new FileMissingException(ex);
+ }
+ }
+
+ public void Put(string remotename, string filename)
+ {
+ using (FileStream fileStream = File.OpenRead(filename))
+ {
+ this.Put(remotename, fileStream);
+ }
+ }
+
+ public void Put(string remotename, Stream stream)
+ {
+ // PUT only supports up to 4 MB file uploads. There's a separate process for larger files.
+ if (stream.Length < PUT_MAX_SIZE)
+ {
+ StreamContent streamContent = new StreamContent(stream);
+ streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
+ var response = this.m_client.PutAsync(string.Format("{0}/root:{1}{2}:/content", this.DrivePrefix, this.m_path, NormalizeSlashes(remotename)), streamContent).Await();
+
+ // Make sure this response is a valid drive item, though we don't actually use it for anything currently.
+ var result = this.ParseResponse<DriveItem>(response);
+ }
+ else
+ {
+ // This file is too large to be sent in a single request, so we need to send it in pieces in an upload session:
+ // https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_createuploadsession
+ // The documentation seems somewhat contradictory - it states that uploads must be done sequentially,
+ // but also states that the nextExpectedRanges value returned may indicate multiple ranges...
+ // For now, this plays it safe and does a sequential upload.
+ HttpRequestMessage createSessionRequest = new HttpRequestMessage(HttpMethod.Post, string.Format("{0}/root:{1}{2}:/createUploadSession", this.DrivePrefix, this.m_path, NormalizeSlashes(remotename)));
+
+ // Indicate that we want to replace any existing content with this new data we're uploading
+ StringContent createSessionContent = this.PrepareContent(new UploadSession() { Item = new DriveItem() { ConflictBehavior = ConflictBehavior.Replace } });
+
+ HttpResponseMessage createSessionResponse = this.m_client.SendAsync(createSessionRequest).Await();
+ UploadSession uploadSession = this.ParseResponse<UploadSession>(createSessionResponse);
+
+ // If the stream's total length is less than the chosen fragment size, then we should make the buffer only as large as the stream.
+ int fragmentSize = (int)Math.Min(this.fragmentSize, stream.Length);
+
+ byte[] fragmentBuffer = new byte[fragmentSize];
+ int read = 0;
+ for (int offset = 0; offset < stream.Length; offset += read)
+ {
+ read = stream.Read(fragmentBuffer, 0, fragmentSize);
+
+ int retryCount = this.fragmentRetryCount;
+ for (int attempt = 0; attempt < retryCount; attempt++)
+ {
+ ByteArrayContent fragmentContent = new ByteArrayContent(fragmentBuffer, 0, read);
+ fragmentContent.Headers.ContentLength = read;
+ fragmentContent.Headers.ContentRange = new ContentRangeHeaderValue(offset, offset + read - 1, stream.Length);
+
+ HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Put, uploadSession.UploadUrl);
+ request.Content = fragmentContent;
+
+ HttpResponseMessage response = null;
+ try
+ {
+ // The uploaded put requests will error if they are authenticated
+ response = this.m_client.SendAsync(request, false).Await();
+
+ // Note: On the last request, the json result includes the default properties of the item that was uploaded
+ var result = this.ParseResponse<UploadSession>(response);
+ }
+ catch (MicrosoftGraphException ex)
+ {
+ // Error handling based on recommendations here:
+ // https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_createuploadsession#best-practices
+ if (attempt >= retryCount - 1)
+ {
+ // We've used up all our retry attempts
+ throw new UploadSessionException(createSessionResponse, offset / fragmentSize, (int)Math.Ceiling((double)stream.Length / fragmentSize), ex);
+ }
+ else if ((int)ex.Response.StatusCode >= 500 && (int)ex.Response.StatusCode < 600)
+ {
+ // If a 5xx error code is hit, we should use an exponential backoff strategy before retrying.
+ // To make things simpler, we just use the current attempt number as the exponential factor.
+ Thread.Sleep((int)Math.Pow(2, attempt) * this.fragmentRetryDelay); // If this is changed to use tasks, this should be changed to Task.Await()
+ continue;
+ }
+ else if (ex.Response.StatusCode == HttpStatusCode.NotFound)
+ {
+ // 404 is a special case indicating the upload session no longer exists, so the fragment shouldn't be retried.
+ // Instead we'll let the caller re-attempt the whole file.
+ throw new UploadSessionException(createSessionResponse, offset / fragmentSize, (int)Math.Ceiling((double)stream.Length / fragmentSize), ex);
+ }
+ else if ((int)ex.Response.StatusCode >= 400 && (int)ex.Response.StatusCode < 500)
+ {
+ // If a 4xx error code is hit, we should retry without the backoff attempt
+ continue;
+ }
+ else
+ {
+ // Other errors should be rethrown
+ throw new UploadSessionException(createSessionResponse, offset / fragmentSize, (int)Math.Ceiling((double)stream.Length / fragmentSize), ex);
+ }
+ }
+
+ // If we successfully sent this piece, then we can break out of the retry loop
+ break;
+ }
+ }
+ }
+ }
+
+ public void Delete(string remotename)
+ {
+ var response = this.m_client.DeleteAsync(string.Format("{0}/root:{1}{2}", this.DrivePrefix, this.m_path, NormalizeSlashes(remotename))).Await();
+ try
+ {
+ this.CheckResponse(response);
+ }
+ catch (DriveItemNotFoundException ex)
+ {
+ // Wrap the existing item not found error in a 'FolderMissingException'
+ throw new FileMissingException(ex);
+ }
+ }
+
+ public void Test()
+ {
+ try
+ {
+ string rootPath = string.Format("{0}/root:{1}", this.DrivePrefix, this.m_path);
+ DriveItem rootFolder = this.Get<DriveItem>(rootPath);
+ }
+ catch (DriveItemNotFoundException ex)
+ {
+ // Wrap the existing item not found error in a 'FolderMissingException'
+ throw new FolderMissingException(ex);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (this.m_client != null)
+ {
+ this.m_client.Dispose();
+ }
+ }
+
+ protected virtual string GetRootPathFromUrl(string url)
+ {
+ // Extract out the path to the backup root folder from the given URI
+ var uri = new Utility.Uri(url);
+
+ return Utility.Uri.UrlDecode(uri.HostAndPath);
+ }
+
+ protected T Get<T>(string url)
+ {
+ return this.SendRequest<T>(HttpMethod.Get, url);
+ }
+
+ protected T Post<T>(string url, T body)
+ {
+ return this.SendRequest(HttpMethod.Post, url, body);
+ }
+
+ protected T Patch<T>(string url, T body)
+ {
+ return this.SendRequest(PatchMethod, url, body);
+ }
+
+ private T SendRequest<T>(HttpMethod method, string url)
+ {
+ var request = new HttpRequestMessage(method, url);
+ return this.SendRequest<T>(request);
+ }
+
+ private T SendRequest<T>(HttpMethod method, string url, T body)
+ {
+ var request = new HttpRequestMessage(method, url);
+ if (body != null)
+ {
+ request.Content = this.PrepareContent(body);
+ }
+
+ return this.SendRequest<T>(request);
+ }
+
+ private T SendRequest<T>(HttpRequestMessage request)
+ {
+ var response = this.m_client.SendAsync(request).Await();
+ return this.ParseResponse<T>(response);
+ }
+
+ private IEnumerable<T> Enumerate<T>(string url)
+ {
+ string nextUrl = url;
+ while (!string.IsNullOrEmpty(nextUrl))
+ {
+ GraphCollection<T> results = this.Get<GraphCollection<T>>(nextUrl);
+ foreach (T result in results.Value)
+ {
+ yield return result;
+ }
+
+ nextUrl = results.ODataNextLink;
+ }
+ }
+
+ private void CheckResponse(HttpResponseMessage response)
+ {
+ if (!response.IsSuccessStatusCode)
+ {
+ if (response.StatusCode == HttpStatusCode.NotFound)
+ {
+ // It looks like this is an 'item not found' exception, so wrap it in a new exception class to make it easier to pick things out.
+ throw new DriveItemNotFoundException(response);
+ }
+ else
+ {
+ // Throw a wrapper exception to make it easier for the caller to look at specific status codes, etc.
+ throw new MicrosoftGraphException(response);
+ }
+ }
+ }
+
+ private T ParseResponse<T>(HttpResponseMessage response)
+ {
+ this.CheckResponse(response);
+ using (Stream responseStream = response.Content.ReadAsStreamAsync().Await())
+ using (StreamReader reader = new StreamReader(responseStream))
+ using (JsonTextReader jsonReader = new JsonTextReader(reader))
+ {
+ return this.m_serializer.Deserialize<T>(jsonReader);
+ }
+ }
+
+ /// <summary>
+ /// Normalizes the slashes in a url fragment. For example:
+ /// "" => ""
+ /// "test" => "/test"
+ /// "test/" => "/test"
+ /// "a\b" => "/a/b"
+ /// </summary>
+ /// <param name="url">Url fragment to normalize</param>
+ /// <returns>Normalized fragment</returns>
+ private static string NormalizeSlashes(string url)
+ {
+ url = url.Replace('\\', '/');
+
+ if (url.Length != 0 && !url.StartsWith("/", StringComparison.Ordinal))
+ url = "/" + url;
+
+ if (url.EndsWith("/", StringComparison.Ordinal))
+ url = url.Substring(0, url.Length - 1);
+
+ return url;
+ }
+
+ private StringContent PrepareContent<T>(T body)
+ {
+ return new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
+ }
+ }
+}
diff --git a/Duplicati/Library/Backend/SSHv2/SSHv2Backend.cs b/Duplicati/Library/Backend/SSHv2/SSHv2Backend.cs
index 8c180f9f3..7b2de13e9 100644
--- a/Duplicati/Library/Backend/SSHv2/SSHv2Backend.cs
+++ b/Duplicati/Library/Backend/SSHv2/SSHv2Backend.cs
@@ -112,11 +112,7 @@ namespace Duplicati.Library.Backend
public void CreateFolder()
{
CreateConnection();
- //Bugfix, some SSH servers do not like a trailing slash
- string p = m_path;
- if (p.EndsWith("/", StringComparison.Ordinal))
- p.Substring(0, p.Length - 1);
- m_con.CreateDirectory(p);
+ m_con.CreateDirectory(m_path);
}
public string DisplayName
diff --git a/Duplicati/Library/Backend/Sia/Sia.cs b/Duplicati/Library/Backend/Sia/Sia.cs
index e788cb209..2576bdd0a 100644
--- a/Duplicati/Library/Backend/Sia/Sia.cs
+++ b/Duplicati/Library/Backend/Sia/Sia.cs
@@ -135,7 +135,7 @@ namespace Duplicati.Library.Backend.Sia
private SiaFileList GetFiles()
{
var fl = new SiaFileList();
- string endpoint = string.Format("/renter/files");
+ string endpoint = "/renter/files";
try
{
@@ -189,7 +189,7 @@ namespace Duplicati.Library.Backend.Sia
private SiaDownloadList GetDownloads()
{
var fl = new SiaDownloadList();
- string endpoint = string.Format("/renter/downloads");
+ string endpoint = "/renter/downloads";
try
{