diff --git a/Duplicati.sln b/Duplicati.sln index b63e756dc..56517318d 100644 --- a/Duplicati.sln +++ b/Duplicati.sln @@ -160,6 +160,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.CommandLine.Secre EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.CommandLine.SecretTool", "Executables\net8\Duplicati.CommandLine.SecretTool\Duplicati.CommandLine.SecretTool.csproj", "{E1D32EF7-368E-4148-9367-B328E78C871B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Backend.pCloud", "Duplicati\Library\Backend\pCloud\Duplicati.Library.Backend.pCloud.csproj", "{CA1A0EE8-DFFF-42AF-B15D-EF538357A2DE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "rapidtest", "rapidtest\rapidtest.csproj", "{04FE4498-5772-4E67-A9E4-24D9EAF81960}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -454,6 +458,14 @@ Global {E1D32EF7-368E-4148-9367-B328E78C871B}.Debug|Any CPU.Build.0 = Debug|Any CPU {E1D32EF7-368E-4148-9367-B328E78C871B}.Release|Any CPU.ActiveCfg = Release|Any CPU {E1D32EF7-368E-4148-9367-B328E78C871B}.Release|Any CPU.Build.0 = Release|Any CPU + {CA1A0EE8-DFFF-42AF-B15D-EF538357A2DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA1A0EE8-DFFF-42AF-B15D-EF538357A2DE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA1A0EE8-DFFF-42AF-B15D-EF538357A2DE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA1A0EE8-DFFF-42AF-B15D-EF538357A2DE}.Release|Any CPU.Build.0 = Release|Any CPU + {04FE4498-5772-4E67-A9E4-24D9EAF81960}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {04FE4498-5772-4E67-A9E4-24D9EAF81960}.Debug|Any CPU.Build.0 = Debug|Any CPU + {04FE4498-5772-4E67-A9E4-24D9EAF81960}.Release|Any CPU.ActiveCfg = Release|Any CPU + {04FE4498-5772-4E67-A9E4-24D9EAF81960}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -511,6 +523,7 @@ Global {A2957269-C11F-44CE-B355-CC9BA342295D} = {6B46F6B1-1898-49B8-ADA7-5CAF68EB77E3} {D6417FD3-1645-4AB4-9F42-A1EC4A262B37} = {D19A38DD-68F1-4EF5-BF5F-8966CE0D9A5B} {E1D32EF7-368E-4148-9367-B328E78C871B} = {6B46F6B1-1898-49B8-ADA7-5CAF68EB77E3} + {CA1A0EE8-DFFF-42AF-B15D-EF538357A2DE} = {E1A9B303-F281-45C5-A4F6-CADD9DE3F3C4} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8B40BAFE-D862-4397-9495-8F5EAF5CE80C} diff --git a/Duplicati/Library/Backend/pCloud/Duplicati.Library.Backend.pCloud.csproj b/Duplicati/Library/Backend/pCloud/Duplicati.Library.Backend.pCloud.csproj new file mode 100644 index 000000000..e945cc8ab --- /dev/null +++ b/Duplicati/Library/Backend/pCloud/Duplicati.Library.Backend.pCloud.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + Library + Copyright © 2024 Team Duplicati, MIT license + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/Duplicati/Library/Backend/pCloud/Model/pClouCreateFolderResponse.cs b/Duplicati/Library/Backend/pCloud/Model/pClouCreateFolderResponse.cs new file mode 100644 index 000000000..d08bd1fe3 --- /dev/null +++ b/Duplicati/Library/Backend/pCloud/Model/pClouCreateFolderResponse.cs @@ -0,0 +1,46 @@ +// Copyright (C) 2024, 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. + +#nullable enable +namespace Duplicati.Library.Backend.pCloud; + +/// +/// Response from pCloud's create folder API endpoint +/// +/// The all lower case is to match the JSON response from the pCloud API +/// +internal record pClouCreateFolderResponse +{ + /// + /// Result code from the API call. 0 indicates success + /// + public int result { get; init; } + + /// + /// Indicates if a new folder was created (false if folder already existed) + /// + public bool created { get; init; } + + /// + /// Detailed metadata about the created or existing folder + /// + public PCloudFolderMetadata? metadata { get; init; } +} \ No newline at end of file diff --git a/Duplicati/Library/Backend/pCloud/Model/pClouFolderMetadata.cs b/Duplicati/Library/Backend/pCloud/Model/pClouFolderMetadata.cs new file mode 100644 index 000000000..8853d618d --- /dev/null +++ b/Duplicati/Library/Backend/pCloud/Model/pClouFolderMetadata.cs @@ -0,0 +1,90 @@ +// Copyright (C) 2024, 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. + +namespace Duplicati.Library.Backend.pCloud; + +/// +/// Metadata information for a pCloud folder +/// +/// The all lower case is to match the JSON response from the pCloud API +/// +internal record PCloudFolderMetadata +{ + /// + /// Name of the folder + /// + public string name { get; init; } = string.Empty; + + /// + /// Creation timestamp in format "Day, DD MMM YYYY HH:MM:SS +0000" + /// + public string created { get; init; } = string.Empty; + + /// + /// Indicates if the folder belongs to the authenticated user + /// + public bool ismine { get; init; } + + /// + /// Indicates if the folder has a thumbnail + /// + public bool thumb { get; init; } + + /// + /// Last modification timestamp in format "Day, DD MMM YYYY HH:MM:SS +0000" + /// + public string modified { get; init; } = string.Empty; + + /// + /// Number of comments on the folder + /// + public int comments { get; init; } + + /// + /// Unique identifier for the folder, prefixed with 'd' + /// + public string id { get; init; } = string.Empty; + + /// + /// Indicates if the folder is shared with other users + /// + public bool isshared { get; init; } + + /// + /// Icon type for the folder + /// + public string icon { get; init; } = string.Empty; + + /// + /// Always true for folder metadata + /// + public bool isfolder { get; init; } + + /// + /// Numeric ID of the parent folder + /// + public ulong parentfolderid { get; init; } + + /// + /// Numeric ID of this folder + /// + public ulong folderid { get; init; } +} \ No newline at end of file diff --git a/Duplicati/Library/Backend/pCloud/Model/pCloudChecksum.cs b/Duplicati/Library/Backend/pCloud/Model/pCloudChecksum.cs new file mode 100644 index 000000000..1137ca270 --- /dev/null +++ b/Duplicati/Library/Backend/pCloud/Model/pCloudChecksum.cs @@ -0,0 +1,38 @@ +// Copyright (C) 2024, 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. + +namespace Duplicati.Library.Backend.pCloud; + +/// +/// Checksum information for uploaded file +/// +internal record pCloudChecksum +{ + /// + /// SHA1 hash of the file + /// + public string sha1 { get; init; } = string.Empty; + + /// + /// SHA256 hash of the file + /// + public string sha256 { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/Duplicati/Library/Backend/pCloud/Model/pCloudDeleteResponse.cs b/Duplicati/Library/Backend/pCloud/Model/pCloudDeleteResponse.cs new file mode 100644 index 000000000..d7252b7cc --- /dev/null +++ b/Duplicati/Library/Backend/pCloud/Model/pCloudDeleteResponse.cs @@ -0,0 +1,46 @@ +// Copyright (C) 2024, 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. + +#nullable enable +namespace Duplicati.Library.Backend.pCloud; + +/// +/// Response from pCloud's create folder API endpoint +/// +/// The all lower case is to match the JSON response from the pCloud API +/// +internal record pCloudDeleteResponse +{ + /// + /// Result code from the API call. 0 indicates success + /// + public int result { get; init; } + + /// + /// Presumed to the be the ID of the deleted file + /// + public string? id { get; init; } + + /// + /// Detailed metadata about the created or existing folder + /// + public pCloudFileMetadata? metadata { get; init; } +} \ No newline at end of file diff --git a/Duplicati/Library/Backend/pCloud/Model/pCloudDownloadResponse.cs b/Duplicati/Library/Backend/pCloud/Model/pCloudDownloadResponse.cs new file mode 100644 index 000000000..1d6de47bf --- /dev/null +++ b/Duplicati/Library/Backend/pCloud/Model/pCloudDownloadResponse.cs @@ -0,0 +1,48 @@ +// Copyright (C) 2024, 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.Collections.Generic; + +namespace Duplicati.Library.Backend.pCloud; + +/// +/// Response from pCloud's download file API endpoint, containing file download information +/// +/// The all lower case is to match the JSON response from the pCloud API +/// +internal record pCloudDownloadResponse +{ + /// + /// Result code from the API call. 0 indicates success + /// + public int result { get; init; } + + /// + /// List of available download hosts/servers + /// + public List hosts { get; init; } = new(); + + /// + /// Download path to be used with the host + /// + public string path { get; init; } = string.Empty; + +} \ No newline at end of file diff --git a/Duplicati/Library/Backend/pCloud/Model/pCloudErrorList.cs b/Duplicati/Library/Backend/pCloud/Model/pCloudErrorList.cs new file mode 100644 index 000000000..14d15ea1b --- /dev/null +++ b/Duplicati/Library/Backend/pCloud/Model/pCloudErrorList.cs @@ -0,0 +1,49 @@ +// Copyright (C) 2024, 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.Collections.Generic; + +namespace Duplicati.Library.Backend.pCloud; + + /// + /// Error codes/strings for pcloud. They will not be subject to translation so messages + /// can be googled to help users diagnose issues. + /// + /// + internal static class pCloudErrorList + { + internal static readonly Dictionary ErrorMessages = new() + { + { 1000, "Log in required." }, + { 1001, "No full path or name/folderid provided." }, + { 1004, "No fileid or path provided." }, + { 1005, "Unknown content-type requested." }, + { 2000, "Log in failed." }, + { 2002, "A component of parent directory does not exist." }, + { 2003, "Access denied. You do not have permissions to preform this operation." }, + { 2009, "File not found." }, + { 2010, "Invalid path." }, + { 2094, "Invalid 'access_token' provided. Please check you are using the correct server (eapi.pcloud.com for EU or api.pcloud.com for non EU)"}, + { 2011, "Requested speed limit too low, see minspeed for minimum." }, + { 4000, "Too many login tries from this IP address." }, + { 5002, "Internal error, no servers available. Try again later." } + }; + } \ No newline at end of file diff --git a/Duplicati/Library/Backend/pCloud/Model/pCloudFileEntry.cs b/Duplicati/Library/Backend/pCloud/Model/pCloudFileEntry.cs new file mode 100644 index 000000000..efde70fe5 --- /dev/null +++ b/Duplicati/Library/Backend/pCloud/Model/pCloudFileEntry.cs @@ -0,0 +1,56 @@ +// Copyright (C) 2024, 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; + +namespace Duplicati.Library.Backend.pCloud; + +/// +/// Represents a file entry or folder in pCloud +/// +internal class pCloudFileEntry : IFileEntry +{ + /// + /// Indicates it is a folder on the remote filesystem + /// + public bool IsFolder { get; set; } + + /// + /// Last access date of the file + /// + public DateTime LastAccess { get; set; } + + /// + /// Last modification of the file + /// + public DateTime LastModification { get; set; } + + /// + /// Filename or folder name + /// + public string Name { get; set; } + + /// + /// Size of the object in bytes + /// + public long Size { get; set; } +} diff --git a/Duplicati/Library/Backend/pCloud/Model/pCloudFileMetadata.cs b/Duplicati/Library/Backend/pCloud/Model/pCloudFileMetadata.cs new file mode 100644 index 000000000..a90b1a8b4 --- /dev/null +++ b/Duplicati/Library/Backend/pCloud/Model/pCloudFileMetadata.cs @@ -0,0 +1,123 @@ +// Copyright (C) 2024, 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. + +namespace Duplicati.Library.Backend.pCloud; + +/// +/// Metadata information for a pCloud file +/// +internal record pCloudFileMetadata +{ + /// + /// Always false for file metadata + /// + public bool isfolder { get; init; } + + /// + /// Icon type representing the file type + /// + public string icon { get; init; } = string.Empty; + + /// + /// Size of the file in bytes + /// + public long size { get; init; } + + /// + /// Name of the file including extension + /// + public string name { get; init; } = string.Empty; + + /// + /// Category of the file (e.g., 1 for images) + /// + public int category { get; init; } + + /// + /// MIME type of the file + /// + public string contenttype { get; init; } = string.Empty; + + /// + /// ID of the parent folder containing this file + /// + public ulong parentfolderid { get; init; } + + /// + /// Indicates if the file has been deleted + /// + public bool isdeleted { get; init; } + + /// + /// Hash value of the file content + /// + public ulong hash { get; init; } + + /// + /// Indicates if the file belongs to the authenticated user + /// + public bool ismine { get; init; } + + /// + /// Indicates if the file is shared with other users + /// + public bool isshared { get; init; } + + /// + /// Unique identifier for the file, prefixed with 'f' + /// + public string id { get; init; } = string.Empty; + + /// + /// Height of the image in pixels (only for image files) + /// + public int height { get; init; } + + /// + /// Width of the image in pixels (only for image files) + /// + public int width { get; init; } + + /// + /// Last modification timestamp in format "Day, DD MMM YYYY HH:MM:SS +0000" + /// + public string modified { get; init; } = string.Empty; + + /// + /// Indicates if the file has a thumbnail + /// + public bool thumb { get; init; } + + /// + /// Creation timestamp in format "Day, DD MMM YYYY HH:MM:SS +0000" + /// + public string created { get; init; } = string.Empty; + + /// + /// Numeric ID of the file + /// + public ulong fileid { get; init; } + + /// + /// Full path to the file + /// + public string path { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/Duplicati/Library/Backend/pCloud/Model/pCloudFolderContent.cs b/Duplicati/Library/Backend/pCloud/Model/pCloudFolderContent.cs new file mode 100644 index 000000000..24c718553 --- /dev/null +++ b/Duplicati/Library/Backend/pCloud/Model/pCloudFolderContent.cs @@ -0,0 +1,109 @@ +// Copyright (C) 2024, 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. + +#nullable enable +namespace Duplicati.Library.Backend.pCloud; + +/// +/// Content item (file or folder) within a pCloud folder +/// +internal record pCloudFolderContent +{ + /// + /// Full path to the item + /// + public string path { get; init; } = string.Empty; + + /// + /// Name of the file or folder + /// + public string name { get; init; } = string.Empty; + + /// + /// Creation timestamp in format "Day, DD MMM YYYY HH:MM:SS +0000" + /// + public string created { get; init; } = string.Empty; + + /// + /// Indicates if the item belongs to the authenticated user + /// + public bool ismine { get; init; } + + /// + /// Indicates if the item has a thumbnail + /// + public bool thumb { get; init; } + + /// + /// Last modification timestamp in format "Day, DD MMM YYYY HH:MM:SS +0000" + /// + public string modified { get; init; } = string.Empty; + + /// + /// Number of comments on the item + /// + public int comments { get; init; } + + /// + /// Unique identifier for the item, prefixed with 'd' for folders or 'f' for files + /// + public string id { get; init; } = string.Empty; + + /// + /// Indicates if the item is shared with other users + /// + public bool isshared { get; init; } + + /// + /// Icon type for the item + /// + public string icon { get; init; } = string.Empty; + + /// + /// True if the item is a folder, false if it's a file + /// + public bool isfolder { get; init; } + + /// + /// Folder ID if the item is a folder (null otherwise) + /// + public long? folderid { get; init; } + + /// + /// File ID if the item is a file (null otherwise) + /// + public ulong? fileid { get; init; } + + /// + /// Size in bytes if the item is a file (null otherwise) + /// + public long? size { get; init; } + + /// + /// Content type if the item is a file (null otherwise) + /// + public string? contenttype { get; init; } + + /// + /// ID of the parent folder containing this item + /// + public long parentfolderid { get; init; } +} \ No newline at end of file diff --git a/Duplicati/Library/Backend/pCloud/Model/pCloudListFolderResponse.cs b/Duplicati/Library/Backend/pCloud/Model/pCloudListFolderResponse.cs new file mode 100644 index 000000000..1f3facd63 --- /dev/null +++ b/Duplicati/Library/Backend/pCloud/Model/pCloudListFolderResponse.cs @@ -0,0 +1,39 @@ +// Copyright (C) 2024, 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. + +#nullable enable +namespace Duplicati.Library.Backend.pCloud; + +/// +/// Response from pCloud's list folder API endpoint +/// +internal record pCloudListFolderResponse +{ + /// + /// Result code from the API call. 0 indicates success + /// + public int result { get; init; } + + /// + /// Detailed metadata about the folder and its contents + /// + public pCloudListFoldersMetadata? metadata { get; init; } +} \ No newline at end of file diff --git a/Duplicati/Library/Backend/pCloud/Model/pCloudListFoldersMetadata.cs b/Duplicati/Library/Backend/pCloud/Model/pCloudListFoldersMetadata.cs new file mode 100644 index 000000000..9a0bd0fde --- /dev/null +++ b/Duplicati/Library/Backend/pCloud/Model/pCloudListFoldersMetadata.cs @@ -0,0 +1,95 @@ +// Copyright (C) 2024, 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.Collections.Generic; + +namespace Duplicati.Library.Backend.pCloud; + +/// +/// Metadata information for a pCloud folder listing, including its contents +/// +internal record pCloudListFoldersMetadata +{ + /// + /// Full path to the folder + /// + public string path { get; init; } = string.Empty; + + /// + /// Name of the folder + /// + public string name { get; init; } = string.Empty; + + /// + /// Creation timestamp in format "Day, DD MMM YYYY HH:MM:SS +0000" + /// + public string created { get; init; } = string.Empty; + + /// + /// Indicates if the folder belongs to the authenticated user + /// + public bool ismine { get; init; } + + /// + /// Indicates if the folder has a thumbnail + /// + public bool thumb { get; init; } + + /// + /// Last modification timestamp in format "Day, DD MMM YYYY HH:MM:SS +0000" + /// + public string modified { get; init; } = string.Empty; + + /// + /// Number of comments on the folder + /// + public int comments { get; init; } + + /// + /// Unique identifier for the folder, prefixed with 'd' + /// + public string id { get; init; } = string.Empty; + + /// + /// Indicates if the folder is shared with other users + /// + public bool isshared { get; init; } + + /// + /// Icon type for the folder + /// + public string icon { get; init; } = string.Empty; + + /// + /// Always true for folder metadata + /// + public bool isfolder { get; init; } + + /// + /// Numeric ID of this folder + /// + public long folderid { get; init; } + + /// + /// List of files and folders contained within this folder + /// + public List contents { get; init; } = new(); +} \ No newline at end of file diff --git a/Duplicati/Library/Backend/pCloud/Model/pCloudUploadResponse.cs b/Duplicati/Library/Backend/pCloud/Model/pCloudUploadResponse.cs new file mode 100644 index 000000000..1447c181f --- /dev/null +++ b/Duplicati/Library/Backend/pCloud/Model/pCloudUploadResponse.cs @@ -0,0 +1,50 @@ +// Copyright (C) 2024, 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.Collections.Generic; + +namespace Duplicati.Library.Backend.pCloud; + +/// +/// Response from pCloud's upload file API endpoint +/// +internal record pCloudUploadResponse +{ + /// + /// Result code from the API call. 0 indicates success + /// + public int result { get; init; } + + /// + /// Array of metadata for uploaded files + /// + public List metadata { get; init; } = new(); + + /// + /// Checksums for uploaded files + /// + public List checksums { get; init; } = new(); + + /// + /// List of file IDs for uploaded files + /// + public List fileids { get; init; } = new(); +} \ No newline at end of file diff --git a/Duplicati/Library/Backend/pCloud/Strings.cs b/Duplicati/Library/Backend/pCloud/Strings.cs new file mode 100644 index 000000000..1c7c0f89a --- /dev/null +++ b/Duplicati/Library/Backend/pCloud/Strings.cs @@ -0,0 +1,35 @@ +// Copyright (C) 2024, 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 Duplicati.Library.Localization.Short; + +namespace Duplicati.Library.Backend.Strings { + internal static class pCloudBackend { + + public static string Description => LC.L(@"This backend can read and write data to pCloud with native API. Allowed format is ""pcloud://api.pcloud.com""."); + public static string DisplayName => LC.L(@"pCloud (Native API)"); + public static string AuthPasswordDescriptionLong => LC.L(@"The oAuth token used to connect to the server. This may also be supplied as the environment variable ""AUTHID""."); + public static string AuthPasswordDescriptionShort => LC.L(@"Supply the oAuth token used to connect to the server"); + public static string NoServerSpecified => LC.L(@"No server specified, must be either api.pcloud.com or eapi.pcloud.com for European hosting"); + public static string InvalidServerSpecified => LC.L(@"Invalid server specified, must be either api.pcloud.com or eapi.pcloud.com for European hosting"); + public static string FailedWithUnexpectedErrorCode(string operation, int resultcode) => LC.L(@"Operation {0} failed with unexpected result code: {1}", operation, resultcode); + } +} diff --git a/Duplicati/Library/Backend/pCloud/pCloudBackend.cs b/Duplicati/Library/Backend/pCloud/pCloudBackend.cs new file mode 100644 index 000000000..fe09d45ee --- /dev/null +++ b/Duplicati/Library/Backend/pCloud/pCloudBackend.cs @@ -0,0 +1,544 @@ +// Copyright (C) 2024, 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; + +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; + + private const string AUTHENTICATION_OPTION = "authid"; + + 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"); + + _Path = uri.Path; + _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 + /// + /// When root = true, this is the directory it will be listed. + /// When root = false, it will list the _Path directory if it exists, which is + /// where the Duplicati backup files are stored. + /// + /// True to list absolute root, false to list _Path directory + /// + private IEnumerable List(bool root) + { + if (_CachedFolderID == null && string.IsNullOrWhiteSpace(_Path) == false && root == false) + CreateFolderAsync(new CancellationToken()).GetAwaiter().GetResult(); + + ulong folderId = root ? 0 : _CachedFolderID ?? 0; + + var timeoutToken = new CancellationTokenSource(); + timeoutToken.CancelAfter(TimeSpan.FromSeconds(SHORT_OPERATION_TIMEOUT_SECONDS)); + + using var requestResources = CreateRequest($"/listfolder?folderid={folderId}", HttpMethod.Get); + + using var response = requestResources.HttpClient + .SendAsync(requestResources.RequestMessage, HttpCompletionOption.ResponseContentRead, + timeoutToken.Token).ConfigureAwait(false).GetAwaiter().GetResult(); + + response.EnsureSuccessStatusCode(); + var content = response.Content.ReadAsStringAsync(timeoutToken.Token).Result; + 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)); + + if (listFolderResponse?.metadata?.contents == null) + return new List(); + + return listFolderResponse.metadata.contents + .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(); + } + + /// + /// Implementation of interface method for listing remote folder contents + /// + /// List of IFileEntry with directory listing result + public IEnumerable List() + { + return List(false); + } + + /// + /// 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); + } + + /// + /// 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) + { + // If we don't have the folder ID cached, lets fetch it with the create folder api + if (_CachedFolderID == null) + await CreateFolderAsync(cancellationToken); + + 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); + input.Position = 0; + 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); + + var content = await response.Content.ReadAsStringAsync(combinedTokens.Token); + 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 + 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 + /// + /// FileMissingException when file is not found + /// Exceptions arising from either code execution + private async Task GetFileId(string filename) + { + var path = string.IsNullOrWhiteSpace(_Path) ? "" : $"/{_Path}"; + var encodedPath = Uri.EscapeDataString($"{path}/{filename}"); + + using var requestResources = CreateRequest($"/getfilelink?path={encodedPath}", HttpMethod.Get); + + using var response = await requestResources.HttpClient.SendAsync( + requestResources.RequestMessage, + HttpCompletionOption.ResponseContentRead); + + if (!response.IsSuccessStatusCode) + { + throw new Exception($"Failed to get download link. Status: {response.StatusCode}"); + } + + var content = await response.Content.ReadAsStringAsync(); + 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("getfileid", 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 + 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 GetFileId(remotename)); + + await requestResources.HttpClient.DownloadFile(requestResources.RequestMessage, output, null, + timeoutToken.Token); + } + 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 Task DeleteAsync(string remotename, CancellationToken cancellationToken) + { + if (!String.IsNullOrWhiteSpace(_Path)) + remotename = $"/{_Path}/{remotename}"; + + 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?path={remotename}", HttpMethod.Get); + + using var response = requestResources.HttpClient.SendAsync( + requestResources.RequestMessage, + HttpCompletionOption.ResponseContentRead, combinedTokens.Token).GetAwaiter().GetResult(); + + response.EnsureSuccessStatusCode(); + + var content = response.Content.ReadAsStringAsync(combinedTokens.Token).GetAwaiter().GetResult(); + 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)); + + return Task.CompletedTask; + } + + /// + /// 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 }); + + /// + /// Performs basic backend conectivity tests, in this case, checks + /// that the configured path, if configured, exists + /// + /// CancellationToken, in this call not used. + /// + /// Thrown when configured path does not exist + public Task TestAsync(CancellationToken cancellationToken) + { + // List the scoped root directory and check if _Path folder exists + if (!List(true).Any(x => x.IsFolder && x.Name == _Path) && !string.IsNullOrWhiteSpace(_Path)) + throw new FolderMissingException(); + + return Task.CompletedTask; + } + + /// + /// Create remote folder + /// + /// CancellationToken that will be combined with internal timeout token + /// + /// + public Task CreateFolderAsync(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($"/createfolderifnotexists?folderid=0&name={_Path}", HttpMethod.Get); + + using var response = requestResources.HttpClient.SendAsync( + requestResources.RequestMessage, + HttpCompletionOption.ResponseContentRead, combinedTokens.Token).GetAwaiter().GetResult(); + + response.EnsureSuccessStatusCode(); + + var content = response.Content.ReadAsStringAsync(combinedTokens.Token).GetAwaiter().GetResult(); + var createFolderResponse = JsonSerializer.Deserialize(content); + + if (pCloudErrorList.ErrorMessages.TryGetValue(createFolderResponse.result, out var message)) + throw new Exception(message); + /* + * If no error code is matched, result was == 0 so it successfully created the folder + * We must cache the folderID, as that will be used to upload files, reason being that + * for oauth clients, pCloud creates a scoped folder Applications/ApplicationName + * and the folders created with /createfolderifnotexists api are created under that folder. + * + * However, when uploading files, the folderid is required (and surprisingly if not provided + * it will create the file outside the scoped folder, at the root of the drive) + */ + if (createFolderResponse.result == 0) _CachedFolderID = createFolderResponse.metadata?.folderid; + + return Task.CompletedTask; + } + + + /// + /// 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() + { + } +} \ No newline at end of file diff --git a/Duplicati/Library/Backends/BackendModules.cs b/Duplicati/Library/Backends/BackendModules.cs index 255bc3b07..2c3495caa 100644 --- a/Duplicati/Library/Backends/BackendModules.cs +++ b/Duplicati/Library/Backends/BackendModules.cs @@ -65,7 +65,8 @@ public static class BackendModules IsStorjSupported? new Backend.Storj.Storj() : null, new Backend.TahoeBackend(), new Backend.TencentCOS.COS(), - new Backend.WEBDAV() + new Backend.WEBDAV(), + new Backend.pCloudBackend() } .Where(x => x != null) .ToList(); diff --git a/Duplicati/Library/Backends/Duplicati.Library.Backends.csproj b/Duplicati/Library/Backends/Duplicati.Library.Backends.csproj index 956603dc6..d06c1a059 100644 --- a/Duplicati/Library/Backends/Duplicati.Library.Backends.csproj +++ b/Duplicati/Library/Backends/Duplicati.Library.Backends.csproj @@ -20,6 +20,7 @@ + diff --git a/Duplicati/Library/Utility/HttpClientExtensions.cs b/Duplicati/Library/Utility/HttpClientExtensions.cs index 5f0ff1949..dcb835df2 100644 --- a/Duplicati/Library/Utility/HttpClientExtensions.cs +++ b/Duplicati/Library/Utility/HttpClientExtensions.cs @@ -71,7 +71,6 @@ public static class HttpClientExtensions /// public static async Task DownloadFile(this HttpClient client, HttpRequestMessage request, Stream fileStream, Action progressReportingAction = null, CancellationToken cancellationToken = default) { - using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); diff --git a/Duplicati/Server/webroot/ngax/scripts/services/EditUriBuiltins.js b/Duplicati/Server/webroot/ngax/scripts/services/EditUriBuiltins.js index d3720425e..c19c5a9a4 100644 --- a/Duplicati/Server/webroot/ngax/scripts/services/EditUriBuiltins.js +++ b/Duplicati/Server/webroot/ngax/scripts/services/EditUriBuiltins.js @@ -30,11 +30,12 @@ backupApp.service('EditUriBuiltins', function (AppService, AppUtils, SystemInfo, EditUriBackendConfig.templates['dropbox'] = 'templates/backends/oauth.html'; EditUriBackendConfig.templates['sia'] = 'templates/backends/sia.html'; EditUriBackendConfig.templates['storj'] = 'templates/backends/storj.html'; - EditUriBackendConfig.templates['rclone'] = 'templates/backends/rclone.html'; + EditUriBackendConfig.templates['rclone'] = 'templates/backends/rclone.html'; EditUriBackendConfig.templates['cos'] = 'templates/backends/cos.html'; - EditUriBackendConfig.templates['aliyunoss'] = 'templates/backends/aliyunoss.html'; + EditUriBackendConfig.templates['aliyunoss'] = 'templates/backends/aliyunoss.html'; EditUriBackendConfig.templates['e2'] = 'templates/backends/e2.html'; - + EditUriBackendConfig.templates['pcloud'] = 'templates/backends/pcloud.html'; + EditUriBackendConfig.testers['s3'] = function(scope, callback) { if (scope.s3_server != 's3.amazonaws.com') @@ -276,6 +277,8 @@ backupApp.service('EditUriBuiltins', function (AppService, AppUtils, SystemInfo, EditUriBackendConfig.loaders['jottacloud'] = function() { return this['oauth-base'].apply(this, arguments); }; EditUriBackendConfig.loaders['box'] = function() { return this['oauth-base'].apply(this, arguments); }; EditUriBackendConfig.loaders['dropbox'] = function() { return this['oauth-base'].apply(this, arguments); }; + EditUriBackendConfig.loaders['pcloud'] = function() { + return this['oauth-base'].apply(this, arguments); }; EditUriBackendConfig.loaders['openstack'] = function (scope) { if (scope.openstack_providers == null) { @@ -333,8 +336,8 @@ backupApp.service('EditUriBuiltins', function (AppService, AppUtils, SystemInfo, this['oauth-base'].apply(this, arguments); }; - - // Parsers take a decomposed uri input and sets up the scope variables + + // Parsers take a decomposed uri input and sets up the scope variables EditUriBackendConfig.parsers['file'] = function (scope, module, server, port, path, options) { if (scope.Path == null && scope.Server == null) { var ix = scope.uri.indexOf('?'); @@ -419,8 +422,8 @@ backupApp.service('EditUriBuiltins', function (AppService, AppUtils, SystemInfo, scope.AuthID = options['--authid']; delete options['--authid']; - - EditUriBackendConfig.mergeServerAndPath(scope); + if (module !== 'pcloud') + EditUriBackendConfig.mergeServerAndPath(scope); }; EditUriBackendConfig.parsers['googledrive'] = function() { return this['oauth-base'].apply(this, arguments); }; @@ -430,7 +433,8 @@ backupApp.service('EditUriBuiltins', function (AppService, AppUtils, SystemInfo, EditUriBackendConfig.parsers['jottacloud'] = function() { return this['oauth-base'].apply(this, arguments); }; EditUriBackendConfig.parsers['box'] = function() { return this['oauth-base'].apply(this, arguments); }; EditUriBackendConfig.parsers['dropbox'] = function() { return this['oauth-base'].apply(this, arguments); }; - + EditUriBackendConfig.parsers['pcloud'] = function() { return this['oauth-base'].apply(this, arguments); }; + EditUriBackendConfig.parsers['openstack'] = function (scope, module, server, port, path, options) { scope.openstack_domainname = options['--openstack-domain-name']; scope.openstack_server = scope.openstack_server_custom = options['--openstack-authuri']; @@ -651,6 +655,20 @@ backupApp.service('EditUriBuiltins', function (AppService, AppUtils, SystemInfo, return url; }; + EditUriBackendConfig.builders['pcloud'] = function (scope) { + var opts = { + 'authid': scope.AuthID + } + EditUriBackendConfig.merge_in_advanced_options(scope, opts, false); + + return AppUtils.format('{0}://{1}/{2}{3}', + scope.Backend.Key, + scope.Server, + scope.Path || '', + AppUtils.encodeDictAsUrl(opts) + ); + }; + EditUriBackendConfig.builders['googledrive'] = function() { return this['oauth-base'].apply(this, arguments); }; EditUriBackendConfig.builders['onedrive'] = function() { return this['oauth-base'].apply(this, arguments); }; EditUriBackendConfig.builders['onedrivev2'] = function() { return this['oauth-base'].apply(this, arguments); }; @@ -952,7 +970,17 @@ backupApp.service('EditUriBuiltins', function (AppService, AppUtils, SystemInfo, EditUriBackendConfig.validaters['cloudfiles'] = EditUriBackendConfig.validaters['ssh']; EditUriBackendConfig.validaters['tahoe'] = EditUriBackendConfig.validaters['ssh']; + EditUriBackendConfig.validaters['pcloud'] = function (scope, continuation) { + var res = + EditUriBackendConfig.require_server(scope) && + EditUriBackendConfig.require_field(scope, 'AuthID', gettextCatalog.getString('AuthID')); + if (res) + EditUriBackendConfig.recommend_path(scope, function() { + continuation(); + }); + }; + EditUriBackendConfig.validaters['authid-base'] = function (scope, continuation) { var res = EditUriBackendConfig.require_field(scope, 'AuthID', gettextCatalog.getString('AuthID')); @@ -972,7 +1000,8 @@ backupApp.service('EditUriBuiltins', function (AppService, AppUtils, SystemInfo, EditUriBackendConfig.validaters['onedrive'] = EditUriBackendConfig.validaters['authid-base']; EditUriBackendConfig.validaters['onedrivev2'] = EditUriBackendConfig.validaters['authid-base']; EditUriBackendConfig.validaters['sharepoint'] = EditUriBackendConfig.validaters['authid-base']; - + EditUriBackendConfig.validaters['pcloud'] = EditUriBackendConfig.validaters['pcloud']; + EditUriBackendConfig.validaters['msgroup'] = function (scope, continuation) { EditUriBackendConfig.validaters['authid-base'](scope, function () { diff --git a/Duplicati/Server/webroot/ngax/scripts/services/SystemInfo.js b/Duplicati/Server/webroot/ngax/scripts/services/SystemInfo.js index 12f05143a..3a2cbcb00 100644 --- a/Duplicati/Server/webroot/ngax/scripts/services/SystemInfo.js +++ b/Duplicati/Server/webroot/ngax/scripts/services/SystemInfo.js @@ -76,7 +76,8 @@ backupApp.service('SystemInfo', function($rootScope, $timeout, $cookies, AppServ 'jottacloud': null, 'rclone': null, 'cos': null, - 'aliyunoss': null + 'aliyunoss': null, + 'pcloud': null } }; diff --git a/Duplicati/Server/webroot/ngax/templates/backends/pcloud.html b/Duplicati/Server/webroot/ngax/templates/backends/pcloud.html new file mode 100644 index 000000000..7eba3d50f --- /dev/null +++ b/Duplicati/Server/webroot/ngax/templates/backends/pcloud.html @@ -0,0 +1,25 @@ +
+ + +
+
+ + +
+ +
+ + +