// 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()
{
}
}