// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using Duplicati.Library.Interface; 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.Json; using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Backend.pCloud; using Duplicati.Library.Utility; using Uri = System.Uri; using System.Runtime.CompilerServices; namespace Duplicati.Library.Backend; /// /// Native pCloud Backend API implementation /// public class pCloudBackend : IStreamingBackend { /// /// Implementation of interface property for the backend key /// public string ProtocolKey => "pcloud"; /// /// Implementation of interface property for the backend display name /// public string DisplayName => Strings.pCloudBackend.DisplayName; /// /// Implementation of interface property for the backend description /// public string Description => Strings.pCloudBackend.Description; /// /// The default timeout in seconds for PUT/GET file operations /// private const int LONG_OPERATION_TIMEOUT_SECONDS = 30000; /// /// The default timeout in seconds for LIST/CreateFolder operations /// private const int SHORT_OPERATION_TIMEOUT_SECONDS = 30; /// /// The server URL to be used (pcloud uses 2 different endpoints depending if its an european or non european hosting) /// private string _ServerUrl; /// /// Bearer token to be using in the API /// private string _Token; /// /// Remote path/folder to use used in the backend /// private string _Path; /// /// Hostname only (no ports or paths) to be used on DNS resolutions. /// private string _DnsName; /// /// Variable being used to cache the folder ID, as it is required to upload files /// and the only way to obtain is with an API call. The cache is to avoid multiple /// requests /// private ulong? _CachedFolderID; /// /// Name of the authentication parameter/option /// private const string AUTHENTICATION_OPTION = "authid"; /// /// Path separators (both Windows \ and unix /) to be used in path manipulation /// private static readonly char[] PATH_SEPARATORS = ['/', '\\']; /// /// List of pcloud Servers and their respective hostnames /// private static readonly Dictionary PCLOUD_SERVERS = new(StringComparer.OrdinalIgnoreCase) { { "pCloud Global", "api.pcloud.com" }, { "pCloud (EU)", "eapi.pcloud.com" }, }; /// /// Empty constructor is required for the backend to be loaded by the backend factory /// public pCloudBackend() { } /// /// Actual constructor for the backend that accepts the url and options /// /// URL in Duplicati Uri format /// options to be used in the backend public pCloudBackend(string url, Dictionary options) { var uri = new Utility.Uri(url); uri.RequireHost(); _DnsName = uri.Host; if (options.TryGetValue(AUTHENTICATION_OPTION, out var option)) _Token = option; if (!PCLOUD_SERVERS.ContainsValue(uri.Host)) throw new UserInformationException(Strings.pCloudBackend.InvalidServerSpecified, "InvalidpCloudServerSpecified"); if (string.IsNullOrWhiteSpace(uri.Host)) throw new UserInformationException(Strings.pCloudBackend.NoServerSpecified, "NopCloudServerSpecified"); // Ensure that the path is in the correct format, without starting or tailing slashes _Path = uri.Path.TrimStart(PATH_SEPARATORS).TrimEnd(PATH_SEPARATORS).Trim(); _ServerUrl = uri.Host; } /// /// Implementation of interface property to return supported command parameters /// public IList SupportedCommands { get { return new List(new ICommandLineArgument[] { new CommandLineArgument(AUTHENTICATION_OPTION, CommandLineArgument.ArgumentType.Password, Strings.pCloudBackend.AuthPasswordDescriptionShort, Strings.pCloudBackend.AuthPasswordDescriptionLong), }); } } /// /// Implementation of interface method for listing remote folder contents. /// The root parameter is used to list the root folder, as the pCloud API /// when using oauth tokens creates an isolated folder Applications/ApplicationName /// /// /// The folder ID to consider as root /// Cancellation Token /// private async Task> List(ulong folderId, CancellationToken cancellationToken) { var result = await ListWithMetadata(folderId, cancellationToken).ConfigureAwait(false); return result.Select(item => new pCloudFileEntry { IsFolder = item.isfolder, Name = item.name, Size = item.size ?? 0, LastAccess = DateTime.Parse(item.created), LastModification = DateTime.Parse(item.modified) }) .ToList(); } /// /// Lists folders with pCloud metadata, necessary to obtain the folder IDs /// /// FolderId to be used as root. /// Cancellation Token /// /// private async Task> ListWithMetadata(ulong folderId, CancellationToken cancellationToken) { using var timeoutToken = new CancellationTokenSource(); timeoutToken.CancelAfter(TimeSpan.FromSeconds(SHORT_OPERATION_TIMEOUT_SECONDS)); using var combinedTokens = CancellationTokenSource.CreateLinkedTokenSource(timeoutToken.Token, cancellationToken); using var requestResources = CreateRequest($"/listfolder?folderid={folderId}", HttpMethod.Get); using var response = await requestResources.HttpClient .SendAsync(requestResources.RequestMessage, HttpCompletionOption.ResponseContentRead, timeoutToken.Token).ConfigureAwait(false); response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStringAsync(timeoutToken.Token).ConfigureAwait(false); var listFolderResponse = JsonSerializer.Deserialize(content); if (pCloudErrorList.ErrorMessages.TryGetValue(listFolderResponse.result, out var message)) throw new Exception(message); if (listFolderResponse.result != 0) throw new Exception(Strings.pCloudBackend.FailedWithUnexpectedErrorCode("list", listFolderResponse.result)); return listFolderResponse.metadata?.contents ?? []; } /// /// Implementation of interface method for listing remote folder contents /// /// List of IFileEntry with directory listing result public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancellationToken) { _CachedFolderID ??= await GetFolderId(cancellationToken).ConfigureAwait(false); foreach (var v in await List(_CachedFolderID.Value, cancellationToken).ConfigureAwait(false)) yield return v; } /// /// Upload files to remote location /// /// Filename at remote location /// Filename to read from /// CancellationToken that is combined with internal timeout token /// FileMissingException when file is not found /// Exceptions arising from either code execution public async Task PutAsync(string remotename, string localname, CancellationToken cancellationToken) { await using var fs = File.Open(localname, FileMode.Open, FileAccess.Read, FileShare.Read); await PutAsync(remotename, fs, cancellationToken).ConfigureAwait(false); } /// /// Upload files to remote location /// /// Filename at remote location /// Stream to read from /// CancellationToken that is combined with internal timeout token /// FileMissingException when file is not found /// Exceptions arising from either code execution public async Task PutAsync(string remotename, Stream input, CancellationToken cancellationToken) { _CachedFolderID ??= await GetFolderId(cancellationToken).ConfigureAwait(false); using var timeoutToken = new CancellationTokenSource(); timeoutToken.CancelAfter(TimeSpan.FromSeconds(LONG_OPERATION_TIMEOUT_SECONDS)); using var combinedTokens = CancellationTokenSource.CreateLinkedTokenSource(timeoutToken.Token, cancellationToken); var encodedPath = Uri.EscapeDataString(remotename); using var requestResources = CreateRequest($"/uploadfile?folderid={_CachedFolderID}&filename={encodedPath}&nopartial=1", HttpMethod.Post); requestResources.RequestMessage.Content = new StreamContent(input); requestResources.RequestMessage.Content.Headers.ContentLength = input.Length; requestResources.RequestMessage.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); using var response = await requestResources.HttpClient.SendAsync( requestResources.RequestMessage, HttpCompletionOption.ResponseContentRead, combinedTokens.Token).ConfigureAwait(false); var content = await response.Content.ReadAsStringAsync(combinedTokens.Token).ConfigureAwait(false); var uploadResponse = JsonSerializer.Deserialize(content); if (pCloudErrorList.ErrorMessages.TryGetValue(uploadResponse.result, out var message)) throw new Exception(message); if (uploadResponse.result != 0) throw new Exception(Strings.pCloudBackend.FailedWithUnexpectedErrorCode("upload", uploadResponse.result)); } /// /// Download files from remote /// /// Filename at remote location /// Destination stream to write to /// CancellationToken that is combined with internal timeout token /// FileMissingException when file is not found /// Exceptions arising from either code execution or FileMissingException public async Task GetAsync(string remotename, string localname, CancellationToken cancellationToken) { await using var fs = File.Open(localname, FileMode.Create, FileAccess.Write, FileShare.None); await GetAsync(remotename, fs, cancellationToken).ConfigureAwait(false); } /// /// Obtains the file id from the filename to be used in the download API /// /// /// Filename at remote, path is automatically concatenated if needed /// Cancellation Token /// /// FileMissingException when file is not found /// Exceptions arising from either code execution private async Task GetFileLink(string filename, CancellationToken cancellationToken) { using var timeoutToken = new CancellationTokenSource(); timeoutToken.CancelAfter(TimeSpan.FromSeconds(SHORT_OPERATION_TIMEOUT_SECONDS)); using var combinedTokens = CancellationTokenSource.CreateLinkedTokenSource(timeoutToken.Token, cancellationToken); using var requestResources = CreateRequest($"/getfilelink?fileid={await GetFileId(filename, cancellationToken).ConfigureAwait(false)}", HttpMethod.Get); using var response = await requestResources.HttpClient.SendAsync( requestResources.RequestMessage, HttpCompletionOption.ResponseContentRead, combinedTokens.Token).ConfigureAwait(false); if (!response.IsSuccessStatusCode) throw new Exception($"Failed to get download link. Status: {response.StatusCode}"); var content = await response.Content.ReadAsStringAsync(combinedTokens.Token).ConfigureAwait(false); var getFileIdResponse = JsonSerializer.Deserialize(content); if (pCloudErrorList.ErrorMessages.TryGetValue(getFileIdResponse.result, out var message)) throw new FileMissingException(message); if (getFileIdResponse.result != 0) throw new Exception( Strings.pCloudBackend.FailedWithUnexpectedErrorCode("getfilelink", getFileIdResponse.result)); return $"https://{getFileIdResponse.hosts[0]}{getFileIdResponse.path}"; } /// /// Download files from remote /// /// Filename at remote location /// Destination stream to write to /// CancellationToken that is combined with internal timeout token /// FileMissingException when file is not found /// Exceptions arising from either code execution or FileMissingException public async Task GetAsync(string remotename, Stream output, CancellationToken cancellationToken) { try { using var timeoutToken = new CancellationTokenSource(); timeoutToken.CancelAfter(TimeSpan.FromSeconds(LONG_OPERATION_TIMEOUT_SECONDS)); using var combinedTokens = CancellationTokenSource.CreateLinkedTokenSource(timeoutToken.Token, cancellationToken); using var requestResources = CreateRequest(string.Empty, HttpMethod.Get); requestResources.RequestMessage.RequestUri = new Uri(await GetFileLink(remotename, cancellationToken).ConfigureAwait(false)); await requestResources.HttpClient.DownloadFile(requestResources.RequestMessage, output, null, combinedTokens.Token).ConfigureAwait(false); } catch (HttpRequestException wex) { /* * Known Behaviour on pCloud * * If the temporary link is no longer valid, it will return a 401 * if the url is corrupted/wrong it will return a 500 */ switch (wex) { case { StatusCode: HttpStatusCode.Unauthorized }: throw new FileMissingException("Temporary link is no longer valid", wex); case { StatusCode: HttpStatusCode.InternalServerError }: throw new FileMissingException("Temporary link is corrupted", wex); default: throw; } } } /// /// Delete remote file if it exists, if now, throws FileMissingException /// /// filename to be deleted on the remote /// CancellationToken that is combined with internal timeout token /// /// FileMissingException when file is not found /// Exceptions arising from either code execution or business logic when return code from pcloud indicates an error. public async Task DeleteAsync(string remotename, CancellationToken cancellationToken) { using var timeoutToken = new CancellationTokenSource(); timeoutToken.CancelAfter(TimeSpan.FromSeconds(SHORT_OPERATION_TIMEOUT_SECONDS)); using var combinedTokens = CancellationTokenSource.CreateLinkedTokenSource(timeoutToken.Token, cancellationToken); using var requestResources = CreateRequest($"/deletefile?fileid={await GetFileId(remotename, cancellationToken).ConfigureAwait(false)}", HttpMethod.Get); using var response = await requestResources.HttpClient.SendAsync( requestResources.RequestMessage, HttpCompletionOption.ResponseContentRead, combinedTokens.Token).ConfigureAwait(false); response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStringAsync(combinedTokens.Token).ConfigureAwait(false); var deleteFileResponse = JsonSerializer.Deserialize(content); // If no error code is matched, result was == 0 so it successfully created the folder if (deleteFileResponse.result == 2009) throw new FileMissingException(); if (pCloudErrorList.ErrorMessages.TryGetValue(deleteFileResponse.result, out var message)) throw new Exception(message); if (deleteFileResponse.result != 0) throw new Exception( Strings.pCloudBackend.FailedWithUnexpectedErrorCode("delete", deleteFileResponse.result)); } /// /// Implementation of interface function to return hosnames used by the backend /// /// CancellationToken, in this call not used. /// public Task GetDNSNamesAsync(CancellationToken cancellationToken) => Task.FromResult(new[] { _DnsName }); /// /// Tests backend connectivity by verifying the configured path exists /// /// The cancellation token (not used) /// Thrown when configured path does not exist public async Task TestAsync(CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_Path) || _Path.Split(PATH_SEPARATORS, StringSplitOptions.RemoveEmptyEntries).Length == 0) return; // This method will search for the folderId recursively and throw an exception if the folder is not found await GetFolderId(cancellationToken).ConfigureAwait(false); } /// /// Create remote folder /// /// CancellationToken that will be combined with internal timeout token /// /// public async Task CreateFolderAsync(CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_Path) || _Path.Split(PATH_SEPARATORS, StringSplitOptions.RemoveEmptyEntries).Length == 0) { _CachedFolderID = 0; return; } var currentId = 0UL; foreach (var folder in _Path.Split(PATH_SEPARATORS, StringSplitOptions.RemoveEmptyEntries)) currentId = await CreateFolder(cancellationToken, currentId, folder).ConfigureAwait(false); _CachedFolderID = currentId; } /// /// Create remote folder in relation to the parent folder /// /// Cancellation Token /// Parent Folder ID /// Folder name /// The folderID of the newly created folder private async Task CreateFolder(CancellationToken cancellationToken, ulong parentFolderId, string folderName) { using var timeoutToken = new CancellationTokenSource(); timeoutToken.CancelAfter(TimeSpan.FromSeconds(SHORT_OPERATION_TIMEOUT_SECONDS)); using var combinedTokens = CancellationTokenSource.CreateLinkedTokenSource(timeoutToken.Token, cancellationToken); using var requestResources = CreateRequest($"/createfolderifnotexists?folderid={parentFolderId}&name={folderName}", HttpMethod.Get); using var response = await requestResources.HttpClient.SendAsync( requestResources.RequestMessage, HttpCompletionOption.ResponseContentRead, combinedTokens.Token).ConfigureAwait(false); response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStringAsync(combinedTokens.Token).ConfigureAwait(false); var createFolderResponse = JsonSerializer.Deserialize(content); if (pCloudErrorList.ErrorMessages.TryGetValue(createFolderResponse.result, out var message)) throw new Exception(message); return createFolderResponse is { result: 0, metadata.folderid: var id } ? id : throw new Exception(Strings.pCloudBackend.FailedWithUnexpectedErrorCode("createfolder", createFolderResponse.result)); } /// /// Returns the fileID by listing the folder and searching for the filename & metadata. /// /// For operations such as delete/getfilelink using the fileID is more reliable than using direct path/filename concatenation. /// /// The filename /// Cancellation Token /// The fileID /// private async Task GetFileId(string name, CancellationToken cancellationToken) { _CachedFolderID ??= await GetFolderId(cancellationToken).ConfigureAwait(false); var result = await ListWithMetadata((ulong)_CachedFolderID, cancellationToken).ConfigureAwait(false); return result.FirstOrDefault(x => !x.isfolder && x.name == name)?.fileid ?? throw new FileMissingException(name); } /// /// Returns the folder ID for the configured path, regardless of the depth /// /// private async Task GetFolderId(CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_Path) || _Path.Split(PATH_SEPARATORS, StringSplitOptions.RemoveEmptyEntries).Length == 0) return 0UL; var currentFolderId = 0UL; foreach (var folder in _Path.Split(PATH_SEPARATORS, StringSplitOptions.RemoveEmptyEntries)) { var folderContent = await ListWithMetadata(currentFolderId, cancellationToken).ConfigureAwait(false); var matchingFolder = folderContent.FirstOrDefault(x => x.isfolder && x.name == folder); if (matchingFolder?.folderid == null) throw new FolderMissingException(); currentFolderId = matchingFolder.folderid; } return currentFolderId; } /// /// Wrapper for the tupple of HttpClient and HttpRequestMessage used in web requests. /// /// The HTTPClient /// The HttpRequestMessage object private record RequestResources(HttpClient HttpClient, HttpRequestMessage RequestMessage) : IDisposable { public void Dispose() { try { RequestMessage?.Dispose(); } catch { // ignored } try { HttpClient?.Dispose(); } catch { // ignored } } } /// /// Helper method to create request resources with the bearer token and default headers /// /// url to be appended after host /// Http Method /// private RequestResources CreateRequest(string url, HttpMethod method = null) { HttpClient httpClient; HttpClientHandler httpHandler = new HttpClientHandler(); httpClient = HttpClientHelper.CreateClient(httpHandler); httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", _Token ); // Set the timeout to infinite, all methods are called with cancelationTokens. httpClient.Timeout = Timeout.InfiniteTimeSpan; var request = new HttpRequestMessage(HttpMethod.Get, $"https://{_ServerUrl}/{url}"); request.Headers.Add(HttpRequestHeader.UserAgent.ToString(), "Duplicati pCloud Client v" + System.Reflection.Assembly.GetExecutingAssembly().GetName().Version); if (method != null) request.Method = method; return new RequestResources(httpClient, request); } /// /// Implementation of Dispose pattern enforced by interface /// in this case, we don't need to dispose anything /// public void Dispose() { } }