diff --git a/Duplicati.sln b/Duplicati.sln
index 1fb633814..6a3adc4fe 100644
--- a/Duplicati.sln
+++ b/Duplicati.sln
@@ -1,5 +1,5 @@
-Microsoft Visual Studio Solution File, Format Version 11.00
+Microsoft Visual Studio Solution File, Format Version 11.00
# Visual Studio 2010
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Utility", "Duplicati\Library\Utility\Duplicati.Library.Utility.csproj", "{DE3E5D4C-51AB-4E5E-BEE8-E636CEBFBA65}"
EndProject
@@ -81,6 +81,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Backend.B
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.UsageReporter", "Duplicati\Library\UsageReporter\Duplicati.Library.UsageReporter.csproj", "{BB014EA5-CE2C-4444-8D30-38983A0E8553}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Backend.SharePoint", "Duplicati\Library\Backend\SharePoint\Duplicati.Library.Backend.SharePoint.csproj", "{59C8BBC5-6E42-46FB-AB3E-6C183A82459A}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -247,6 +249,10 @@ Global
{FF2BF37C-E502-4C98-BEA0-701671DDFA08}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FF2BF37C-E502-4C98-BEA0-701671DDFA08}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FF2BF37C-E502-4C98-BEA0-701671DDFA08}.Release|Any CPU.Build.0 = Release|Any CPU
+ {59C8BBC5-6E42-46FB-AB3E-6C183A82459A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {59C8BBC5-6E42-46FB-AB3E-6C183A82459A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {59C8BBC5-6E42-46FB-AB3E-6C183A82459A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {59C8BBC5-6E42-46FB-AB3E-6C183A82459A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(MonoDevelopProperties) = preSolution
Policies = $0
diff --git a/Duplicati/CommandLine/BackendTester/Duplicati.CommandLine.BackendTester.csproj b/Duplicati/CommandLine/BackendTester/Duplicati.CommandLine.BackendTester.csproj
index a3a4be85e..ff84339e3 100644
--- a/Duplicati/CommandLine/BackendTester/Duplicati.CommandLine.BackendTester.csproj
+++ b/Duplicati/CommandLine/BackendTester/Duplicati.CommandLine.BackendTester.csproj
@@ -65,6 +65,10 @@
{8E4CECFB-0413-4B00-AB93-78D1C3902BD5}
Duplicati.Library.Backend.AzureBlob
+
+ {59c8bbc5-6e42-46fb-ab3e-6c183a82459a}
+ Duplicati.Library.Backend.SharePoint
+
{DE3E5D4C-51AB-4E5E-BEE8-E636CEBFBA65}
Duplicati.Library.Utility
diff --git a/Duplicati/CommandLine/BackendTool/Duplicati.CommandLine.BackendTool.csproj b/Duplicati/CommandLine/BackendTool/Duplicati.CommandLine.BackendTool.csproj
index b62290910..135335733 100644
--- a/Duplicati/CommandLine/BackendTool/Duplicati.CommandLine.BackendTool.csproj
+++ b/Duplicati/CommandLine/BackendTool/Duplicati.CommandLine.BackendTool.csproj
@@ -71,6 +71,10 @@
{C03F6DFD-805A-4BE0-9338-64870ADDB4A2}
Duplicati.Library.Backend.S3
+
+ {59c8bbc5-6e42-46fb-ab3e-6c183a82459a}
+ Duplicati.Library.Backend.SharePoint
+
{FF2BF37C-E502-4C98-BEA0-701671DDFA08}
Duplicati.Library.Backend.SSHv2
diff --git a/Duplicati/CommandLine/Duplicati.CommandLine.csproj b/Duplicati/CommandLine/Duplicati.CommandLine.csproj
index 37c58c88d..47e6ac158 100644
--- a/Duplicati/CommandLine/Duplicati.CommandLine.csproj
+++ b/Duplicati/CommandLine/Duplicati.CommandLine.csproj
@@ -69,6 +69,10 @@
{FC9B7611-836F-4127-8B44-A7C31F506807}
Duplicati.Library.Backend.File
+
+ {59c8bbc5-6e42-46fb-ab3e-6c183a82459a}
+ Duplicati.Library.Backend.SharePoint
+
{19ECCE09-B5EB-406C-8C57-BAC66997D469}
Duplicati.Library.Compression
diff --git a/Duplicati/CommandLine/RecoveryTool/Duplicati.CommandLine.RecoveryTool.csproj b/Duplicati/CommandLine/RecoveryTool/Duplicati.CommandLine.RecoveryTool.csproj
index e49ae31d4..92c968613 100644
--- a/Duplicati/CommandLine/RecoveryTool/Duplicati.CommandLine.RecoveryTool.csproj
+++ b/Duplicati/CommandLine/RecoveryTool/Duplicati.CommandLine.RecoveryTool.csproj
@@ -1,4 +1,4 @@
-
+
Debug
@@ -91,6 +91,10 @@
{C03F6DFD-805A-4BE0-9338-64870ADDB4A2}
Duplicati.Library.Backend.S3
+
+ {59c8bbc5-6e42-46fb-ab3e-6c183a82459a}
+ Duplicati.Library.Backend.SharePoint
+
{FF2BF37C-E502-4C98-BEA0-701671DDFA08}
Duplicati.Library.Backend.SSHv2
diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Duplicati.GUI.TrayIcon.csproj b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Duplicati.GUI.TrayIcon.csproj
index f453f8801..76765718e 100644
--- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Duplicati.GUI.TrayIcon.csproj
+++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Duplicati.GUI.TrayIcon.csproj
@@ -161,6 +161,10 @@
{C03F6DFD-805A-4BE0-9338-64870ADDB4A2}
Duplicati.Library.Backend.S3
+
+ {59c8bbc5-6e42-46fb-ab3e-6c183a82459a}
+ Duplicati.Library.Backend.SharePoint
+
{FF2BF37C-E502-4C98-BEA0-701671DDFA08}
Duplicati.Library.Backend.SSHv2
diff --git a/Duplicati/Library/Backend/SharePoint/Duplicati.Library.Backend.SharePoint.csproj b/Duplicati/Library/Backend/SharePoint/Duplicati.Library.Backend.SharePoint.csproj
new file mode 100644
index 000000000..ce46170ec
--- /dev/null
+++ b/Duplicati/Library/Backend/SharePoint/Duplicati.Library.Backend.SharePoint.csproj
@@ -0,0 +1,92 @@
+
+
+
+
+ Debug
+ AnyCPU
+ {59C8BBC5-6E42-46FB-AB3E-6C183A82459A}
+ Library
+ Properties
+ Duplicati.Library.Backend
+ Duplicati.Library.Backend.SharePoint
+ v4.5
+ 512
+
+
+ true
+ full
+ false
+ bin\Debug\
+ DEBUG;TRACE
+ prompt
+ 4
+
+
+ pdbonly
+ true
+ bin\Release\
+ TRACE
+ prompt
+ 4
+
+
+ true
+
+
+ Duplicati.snk
+
+
+
+ False
+ ..\..\..\..\thirdparty\SharePoint.CSOM\Microsoft.SharePoint.Client.dll
+
+
+ False
+ ..\..\..\..\thirdparty\SharePoint.CSOM\Microsoft.SharePoint.Client.Runtime.dll
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {c5899f45-b0ff-483c-9d38-24a9fcaab237}
+ Duplicati.Library.Interface
+
+
+ {b68f2214-951f-4f78-8488-66e1ed3f50bf}
+ Duplicati.Library.Localization
+
+
+ {52826615-7964-47fe-b4b3-1b2dbdf605b9}
+ Duplicati.Library.Modules.Builtin
+
+
+ {de3e5d4c-51ab-4e5e-bee8-e636cebfba65}
+ Duplicati.Library.Utility
+
+
+
+
+
\ No newline at end of file
diff --git a/Duplicati/Library/Backend/SharePoint/Duplicati.snk b/Duplicati/Library/Backend/SharePoint/Duplicati.snk
new file mode 100644
index 000000000..e0c1e2dd8
Binary files /dev/null and b/Duplicati/Library/Backend/SharePoint/Duplicati.snk differ
diff --git a/Duplicati/Library/Backend/SharePoint/OneDriveForBusinessBackend.cs b/Duplicati/Library/Backend/SharePoint/OneDriveForBusinessBackend.cs
new file mode 100644
index 000000000..bf93ddec8
--- /dev/null
+++ b/Duplicati/Library/Backend/SharePoint/OneDriveForBusinessBackend.cs
@@ -0,0 +1,63 @@
+#region Disclaimer / License
+// Copyright (C) 2016, The Duplicati Team
+// http://www.duplicati.com, info@duplicati.com
+//
+// This library is free software; you can redistribute it and/or
+// modify it under the terms of the GNU Lesser General Public
+// License as published by the Free Software Foundation; either
+// version 2.1 of the License, or (at your option) any later version.
+//
+// This library is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public
+// License along with this library; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+//
+#endregion
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Library.Backend
+{
+ ///
+ /// Shadow class above SharePointBackend to provide an extra protocol key for OneDrive for Business.
+ /// Right now, OneDrive for Business *IS* SharePoint. But if MS turns funny sometimes in the future and
+ /// moves away from SharePoint as OD4B base, this allows to reimplement it without breaking existing
+ /// configurations...
+ ///
+ public class OneDriveForBusinessBackend : SharePointBackend
+ {
+ public override string ProtocolKey
+ {
+ get { return "od4b"; }
+ }
+
+ public override string DisplayName
+ {
+ get { return Strings.OneDriveForBusiness.DisplayName; }
+ }
+
+ public override string Description
+ {
+ get { return Strings.OneDriveForBusiness.Description; }
+ }
+
+ public OneDriveForBusinessBackend()
+ :base ()
+ { }
+
+ public OneDriveForBusinessBackend(string url, Dictionary options)
+ : base(url, options)
+ { }
+
+
+ }
+}
diff --git a/Duplicati/Library/Backend/SharePoint/Properties/AssemblyInfo.cs b/Duplicati/Library/Backend/SharePoint/Properties/AssemblyInfo.cs
new file mode 100644
index 000000000..1d9f02c30
--- /dev/null
+++ b/Duplicati/Library/Backend/SharePoint/Properties/AssemblyInfo.cs
@@ -0,0 +1,40 @@
+#region Disclaimer / License
+// Copyright (C) 2016, The Duplicati Team
+// http://www.duplicati.com, info@duplicati.com
+//
+// This library is free software; you can redistribute it and/or
+// modify it under the terms of the GNU Lesser General Public
+// License as published by the Free Software Foundation; either
+// version 2.1 of the License, or (at your option) any later version.
+//
+// This library is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public
+// License along with this library; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+//
+#endregion
+
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+[assembly: AssemblyTitle("Duplicati.Library.Backend.SharePoint")]
+[assembly: AssemblyDescription("A SharePoint Online Backend for Duplicati (includes OneDrive for Business support)")]
+[assembly: AssemblyConfiguration("")]
+[assembly: AssemblyCompany("Duplicati Team")]
+[assembly: AssemblyProduct("Duplicati.Library.Backend.SharePoint")]
+[assembly: AssemblyCopyright("Copyright © Duplicati Team 2016")]
+[assembly: AssemblyTrademark("")]
+[assembly: AssemblyCulture("")]
+
+[assembly: ComVisible(false)]
+
+// The following GUID is for the ID of the typelib if this project is exposed to COM
+[assembly: Guid("ef2ffdff-aa60-477f-82a0-06d486c6dbbf")]
+
+[assembly: AssemblyVersion("1.0.0.0")]
+[assembly: AssemblyFileVersion("1.0.0.0")]
diff --git a/Duplicati/Library/Backend/SharePoint/SharePointBackend.cs b/Duplicati/Library/Backend/SharePoint/SharePointBackend.cs
new file mode 100644
index 000000000..e2792f005
--- /dev/null
+++ b/Duplicati/Library/Backend/SharePoint/SharePointBackend.cs
@@ -0,0 +1,545 @@
+#region Disclaimer / License
+// Copyright (C) 2016, The Duplicati Team
+// http://www.duplicati.com, info@duplicati.com
+//
+// This library is free software; you can redistribute it and/or
+// modify it under the terms of the GNU Lesser General Public
+// License as published by the Free Software Foundation; either
+// version 2.1 of the License, or (at your option) any later version.
+//
+// This library is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public
+// License along with this library; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+//
+#endregion
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+using Duplicati.Library.Interface;
+
+using SP = Microsoft.SharePoint.Client;
+using Microsoft.SharePoint.Client; // Plain 'using' for extension methods
+
+namespace Duplicati.Library.Backend
+{
+
+ ///
+ /// Class implementing Duplicati's backend for SharePoint.
+ ///
+ ///
+ /// Find SharePoint Server 2013 Client Components SDK (15.xxx): https://www.microsoft.com/en-us/download/details.aspx?id=35585
+ /// Currently used is MS-package from nuget (for SharePoint Server 2016, 16.xxx): https://www.nuget.org/packages/Microsoft.SharePointOnline.CSOM
+ /// Infos for further development (pursue on demand):
+ /// Using ADAL-Tokens (Azure Active directory): https://samlman.wordpress.com/2015/02/27/using-adal-access-tokens-with-o365-rest-apis-and-csom/
+ /// Outline:
+ /// - On AzureAD --> AddNewApp and configure access to O_365 (SharePoint/OneDrive4Busi)
+ /// --> Get App's CLIENT ID and REDIRECT URIS.
+ /// - Get SAML token (WebRequest).
+ /// - Add auth code with access token in event ctx.ExecutingWebRequest
+ /// --> e.WebRequestExecutor.RequestHeaders[“Authorization”] = “Bearer ” + ar.AccessToken;
+ ///
+ public class SharePointBackend : IBackend, IStreamingBackend
+ {
+
+ #region [Variables and constants declarations]
+
+ /// Auth-stripped HTTPS-URI as passed to constructor.
+ private Utility.Uri m_orgUrl;
+ /// Server relative path to backup folder.
+ private string m_serverRelPath;
+ /// User's credentials to create client context
+ private System.Net.ICredentials m_userInfo;
+ /// Flag indicating to move files to recycler on deletion.
+ private bool m_deleteToRecycler = false;
+
+ /// URL to SharePoint web. Will be determined from m_orgUri on first use.
+ private string m_spWebUrl;
+ /// Current context to SharePoint web.
+ private SP.ClientContext m_spContext;
+
+ #endregion
+
+ #region [Public Properties]
+
+ public virtual string ProtocolKey
+ {
+ get { return "mssp"; }
+ }
+
+ public virtual string DisplayName
+ {
+ get { return Strings.SharePoint.DisplayName; }
+ }
+
+ public virtual string Description
+ {
+ get { return Strings.SharePoint.Description; }
+ }
+
+ public virtual bool SupportsStreaming
+ {
+ get { return true; }
+ }
+
+ public virtual IList SupportedCommands
+ {
+ get
+ {
+ return new List(new ICommandLineArgument[] {
+ new CommandLineArgument("auth-password", CommandLineArgument.ArgumentType.Password, Strings.SharePoint.DescriptionAuthPasswordShort, Strings.SharePoint.DescriptionAuthPasswordLong),
+ new CommandLineArgument("auth-username", CommandLineArgument.ArgumentType.String, Strings.SharePoint.DescriptionAuthUsernameShort, Strings.SharePoint.DescriptionAuthUsernameLong),
+ new CommandLineArgument("integrated-authentication", CommandLineArgument.ArgumentType.Boolean, Strings.SharePoint.DescriptionIntegratedAuthenticationShort, Strings.SharePoint.DescriptionIntegratedAuthenticationLong),
+ new CommandLineArgument("delete-to-recycler", CommandLineArgument.ArgumentType.Boolean, Strings.SharePoint.DescriptionUseRecyclerShort, Strings.SharePoint.DescriptionUseRecyclerLong),
+ });
+ }
+ }
+
+ #endregion
+
+ #region [Constructors]
+
+ public SharePointBackend()
+ { }
+
+ public SharePointBackend(string url, Dictionary options)
+ {
+ m_deleteToRecycler = Utility.Utility.ParseBoolOption(options, "delete-to-recycler");
+
+ var u = new Utility.Uri(url);
+ u.RequireHost();
+
+ // Create sanitized plain https-URI (note: still has double slashes for processing web)
+ m_orgUrl = new Utility.Uri("https", u.Host, u.Path, null, null, null, u.Port);
+
+ // Actual path to Web will be searched for on first use. Ctor should not throw.
+ m_spWebUrl = null;
+
+ m_serverRelPath = u.Path;
+ if (!m_serverRelPath.StartsWith("/"))
+ m_serverRelPath = "/" + m_serverRelPath;
+ if (!m_serverRelPath.EndsWith("/"))
+ m_serverRelPath += "/";
+ // remove marker for SP-Web
+ m_serverRelPath = m_serverRelPath.Replace("//", "/");
+
+ // Authentication settings processing:
+ // Default: try integrated auth (will normally not work for Office365, but maybe with on-prem SharePoint...).
+ // Otherwise: Use settings from URL(precedence) or from command line options.
+ bool useIntegratedAuthentication = Utility.Utility.ParseBoolOption(options, "integrated-authentication");
+
+ string useUsername = null;
+ string usePassword = null;
+
+ if (!useIntegratedAuthentication)
+ {
+ if (!string.IsNullOrEmpty(u.Username))
+ {
+ useUsername = u.Username;
+ if (!string.IsNullOrEmpty(u.Password))
+ usePassword = u.Password;
+ else if (options.ContainsKey("auth-password"))
+ usePassword = options["auth-password"];
+ }
+ else
+ {
+ if (options.ContainsKey("auth-username"))
+ {
+ useUsername = options["auth-username"];
+ if (options.ContainsKey("auth-password"))
+ usePassword = options["auth-password"];
+ }
+ }
+ }
+
+ if (useIntegratedAuthentication || (useUsername == null || usePassword == null))
+ {
+ // This might or might not work for on-premises SP. Maybe support if someone complains...
+ m_userInfo = System.Net.CredentialCache.DefaultNetworkCredentials;
+ }
+ else
+ {
+ System.Security.SecureString securePwd = new System.Security.SecureString();
+ usePassword.ToList().ForEach(c => securePwd.AppendChar(c));
+ m_userInfo = new Microsoft.SharePoint.Client.SharePointOnlineCredentials(useUsername, securePwd);
+ // Other options (also ADAL, see class remarks) might be supported on request.
+ // Maybe go in deep then and also look at:
+ // - Microsoft.SharePoint.Client.AppPrincipalCredential.CreateFromKeyGroup()
+ // - ctx.AuthenticationMode = SP.ClientAuthenticationMode.FormsAuthentication;
+ // - ctx.FormsAuthenticationLoginInfo = new SP.FormsAuthenticationLoginInfo(user, pwd);
+ }
+
+ }
+
+
+ #endregion
+
+ #region [Private helper methods]
+
+ ///
+ /// Tries a simple query to test the passed context.
+ /// Returns 0 on success, negative if completely invalid, positive if SharePoint error (wrong creds are negative).
+ ///
+ private static int testContextForWeb(SP.ClientContext ctx, bool rethrow = true)
+ {
+ try
+ {
+ ctx.Load(ctx.Web, w => w.Title);
+ ctx.ExecuteQuery(); // should fail and throw if anything wrong.
+ string webTitle = ctx.Web.Title;
+ if (webTitle == null)
+ throw new UnauthorizedAccessException(Strings.SharePoint.WebTitleReadFailedError);
+ return 0;
+ }
+ catch (Microsoft.SharePoint.Client.ServerException)
+ {
+ if (rethrow) throw;
+ else return 1;
+ }
+ catch (Exception)
+ {
+ if (rethrow) throw;
+ else return -1;
+ }
+ }
+
+ ///
+ /// Builds a client context and tries a simple query to test if there's a web.
+ /// Returns 0 on success, negative if completely invalid, positive if SharePoint error (likely wrong creds).
+ ///
+ private static int testUrlForWeb(string url, System.Net.ICredentials userInfo, bool rethrow, out SP.ClientContext retCtx)
+ {
+ int result = -1;
+ retCtx = null;
+ SP.ClientContext ctx = new ClientContext(url);
+ try
+ {
+ ctx.Credentials = userInfo;
+ result = testContextForWeb(ctx, rethrow);
+ if (result >= 0)
+ {
+ retCtx = ctx;
+ ctx = null;
+ }
+ }
+ finally { if (ctx != null) { ctx.Dispose(); } }
+
+ return result;
+ }
+
+ ///
+ /// SharePoint has nested subwebs but sometimes different webs
+ /// are hooked into a sub path.
+ /// For finding files SharePoint is picky to use the correct
+ /// path to the web, so we will trial and error here.
+ /// The user can give us a hint by supplying an URI with a double
+ /// slash to separate web.
+ /// Otherwise it's a good guess to look for "/documents", as we expect
+ /// that the default document library is used in the path.
+ /// If that won't help, we will try all possible pathes from longest
+ /// to shortest...
+ ///
+ private static string findCorrectWebPath(Utility.Uri orgUrl, System.Net.ICredentials userInfo, out SP.ClientContext retCtx)
+ {
+ retCtx = null;
+
+ string path = orgUrl.Path;
+ int webIndicatorPos = path.IndexOf("//");
+
+ // if a hint is supplied, we will of course use this first.
+ if (webIndicatorPos >= 0)
+ {
+ string testUrl = new Utility.Uri(orgUrl.Scheme, orgUrl.Host, path.Substring(0, webIndicatorPos), null, null, null, orgUrl.Port).ToString();
+ if (testUrlForWeb(testUrl, userInfo, false, out retCtx) >= 0)
+ return testUrl;
+ }
+
+ // Now go through path and see where we land a success.
+ string[] pathParts = path.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
+ // first we look for the doc library
+ int docLibrary = Array.FindIndex(pathParts, p => StringComparer.InvariantCultureIgnoreCase.Equals(p, "documents"));
+ if (docLibrary >= 0)
+ {
+ string testUrl = new Utility.Uri(orgUrl.Scheme, orgUrl.Host,
+ string.Join("/", pathParts, 0, docLibrary),
+ null, null, null, orgUrl.Port).ToString();
+ if (testUrlForWeb(testUrl, userInfo, false, out retCtx) >= 0)
+ return testUrl;
+ }
+
+ // last but not least: try one after the other.
+ for (int pi = pathParts.Length - 1; pi >= 0; pi--)
+ {
+ if (pi == docLibrary) continue; // already tested
+
+ string testUrl = new Utility.Uri(orgUrl.Scheme, orgUrl.Host,
+ string.Join("/", pathParts, 0, pi),
+ null, null, null, orgUrl.Port).ToString();
+ if (testUrlForWeb(testUrl, userInfo, false, out retCtx) >= 0)
+ return testUrl;
+ }
+
+ // nothing worked :(
+ return null;
+ }
+
+ /// Return the preconfigured SP.ClientContext to use.
+ private SP.ClientContext getSpClientContext(bool forceNewContext = false)
+ {
+ if (forceNewContext)
+ {
+ if (m_spContext != null) m_spContext.Dispose();
+ m_spContext = null;
+ }
+
+ if (m_spContext == null)
+ {
+ if (m_spWebUrl == null)
+ {
+ m_spWebUrl = findCorrectWebPath(m_orgUrl, m_userInfo, out m_spContext);
+ if (m_spWebUrl == null)
+ throw new System.Net.WebException(Strings.SharePoint.NoSharePointWebFoundError(m_orgUrl.ToString()));
+ }
+ else
+ {
+ // would query: testUrlForWeb(m_spWebUrl, userInfo, true, out m_spContext);
+ m_spContext = new ClientContext(m_spWebUrl);
+ m_spContext.Credentials = m_userInfo;
+ }
+ }
+ return m_spContext;
+ }
+
+
+ ///
+ /// Dedicated code to wrap ExecuteQuery on file ops and check for errors.
+ /// We have to check for the exceptions thrown to know about file /folder existence.
+ /// Why the funny guys at MS provided an .Exists field stays a mystery...
+ ///
+ /// the filename
+ private void wrappedExecuteQueryOnConext(SP.ClientContext ctx, string serverRelPathInfo, bool isFolder)
+ {
+ try { ctx.ExecuteQuery(); }
+ catch (ServerException ex)
+ {
+ // funny: If a folder is not found, we still get a FileNotFoundException from Server...?!?
+ // Thus, we help ourselves by just passing the info if we wanted to query a folder.
+ if (ex.ServerErrorTypeName == "System.IO.DirectoryNotFoundException"
+ || (ex.ServerErrorTypeName == "System.IO.FileNotFoundException" && isFolder))
+ throw new Interface.FolderMissingException(Strings.SharePoint.MissingElementError(serverRelPathInfo, m_spWebUrl));
+ if (ex.ServerErrorTypeName == "System.IO.FileNotFoundException")
+ throw new Interface.FileMissingException(Strings.SharePoint.MissingElementError(serverRelPathInfo, m_spWebUrl));
+ else
+ throw;
+ }
+ }
+
+ #endregion
+
+
+ #region [Public backend methods]
+
+ public void Test()
+ {
+ SP.ClientContext ctx = getSpClientContext(true);
+ testContextForWeb(ctx, true);
+ }
+
+ public List List() { return doList(false); }
+ private List doList(bool useNewContext)
+ {
+ SP.ClientContext ctx = getSpClientContext(useNewContext);
+ try
+ {
+ SP.Folder remoteFolder = ctx.Web.GetFolderByServerRelativeUrl(m_serverRelPath);
+ ctx.Load(remoteFolder, f => f.Exists);
+ ctx.Load(remoteFolder, f => f.Files, f => f.Folders);
+
+ wrappedExecuteQueryOnConext(ctx, m_serverRelPath, true);
+ if (!remoteFolder.Exists)
+ throw new Interface.FolderMissingException(Strings.SharePoint.MissingElementError(m_serverRelPath, m_spWebUrl));
+
+ List files = new List(remoteFolder.Folders.Count + remoteFolder.Files.Count);
+ foreach (var f in remoteFolder.Folders.Where(ff => ff.Exists))
+ {
+ FileEntry fe = new FileEntry(f.Name, -1, f.TimeLastModified, f.TimeLastModified); // f.TimeCreated
+ fe.IsFolder = true;
+ files.Add(fe);
+ }
+ foreach (var f in remoteFolder.Files.Where(ff => ff.Exists))
+ {
+ FileEntry fe = new FileEntry(f.Name, f.Length, f.TimeLastModified, f.TimeLastModified); // f.TimeCreated
+ fe.IsFolder = false;
+ files.Add(fe);
+ }
+ return files;
+ }
+ catch (ServerException) { throw; /* rethrow if Server answered */ }
+ catch (Interface.FileMissingException) { throw; }
+ catch (Interface.FolderMissingException) { throw; }
+ catch { if (!useNewContext) /* retry */ return doList(true); else throw; }
+ finally { }
+ }
+
+ public void Get(string remotename, string filename)
+ {
+ using (System.IO.FileStream fs = System.IO.File.Create(filename))
+ Get(remotename, fs);
+ }
+
+ public void Get(string remotename, System.IO.Stream stream) { doGet(remotename, stream, false); }
+ private void doGet(string remotename, System.IO.Stream stream, bool useNewContext)
+ {
+ string fileurl = m_serverRelPath + System.Web.HttpUtility.UrlPathEncode(remotename);
+ SP.ClientContext ctx = getSpClientContext(useNewContext);
+ try
+ {
+ SP.File remoteFile = ctx.Web.GetFileByServerRelativeUrl(fileurl);
+ ctx.Load(remoteFile, f => f.Exists);
+ wrappedExecuteQueryOnConext(ctx, fileurl, false);
+ if (!remoteFile.Exists)
+ throw new Interface.FileMissingException(Strings.SharePoint.MissingElementError(fileurl, m_spWebUrl));
+ }
+ catch (ServerException) { throw; /* rethrow if Server answered */ }
+ catch (Interface.FileMissingException) { throw; }
+ catch (Interface.FolderMissingException) { throw; }
+ catch { if (!useNewContext) /* retry */ doGet(remotename, stream, true); else throw; }
+ finally { }
+ try
+ {
+ byte[] copybuffer = new byte[Duplicati.Library.Utility.Utility.DEFAULT_BUFFER_SIZE];
+ using (var fileInfo = SP.File.OpenBinaryDirect(ctx, fileurl))
+ using (var s = fileInfo.Stream)
+ Utility.Utility.CopyStream(s, stream, true, copybuffer);
+ }
+ finally { }
+ }
+
+ public void Put(string remotename, string filename)
+ {
+ using (System.IO.FileStream fs = System.IO.File.OpenRead(filename))
+ Put(remotename, fs);
+ }
+
+ public void Put(string remotename, System.IO.Stream stream) { doPut(remotename, stream, false); }
+ private void doPut(string remotename, System.IO.Stream stream, bool useNewContext)
+ {
+ string fileurl = m_serverRelPath + System.Web.HttpUtility.UrlPathEncode(remotename);
+ SP.ClientContext ctx = getSpClientContext(useNewContext);
+ try
+ {
+ SP.Folder remoteFolder = ctx.Web.GetFolderByServerRelativeUrl(m_serverRelPath);
+ ctx.Load(remoteFolder, f => f.Exists);
+ wrappedExecuteQueryOnConext(ctx, m_serverRelPath, true);
+ if (!remoteFolder.Exists)
+ throw new Interface.FolderMissingException(Strings.SharePoint.MissingElementError(m_serverRelPath, m_spWebUrl));
+ }
+ catch (ServerException) { throw; /* rethrow if Server answered */ }
+ catch (Interface.FileMissingException) { throw; }
+ catch (Interface.FolderMissingException) { throw; }
+ catch { if (!useNewContext) /* retry */ doPut(remotename, stream, true); else throw; }
+ finally { }
+ try
+ {
+ SP.File.SaveBinaryDirect(ctx, fileurl, stream, true);
+ }
+ finally { }
+ }
+
+ public void CreateFolder() { doCreateFolder(false); }
+ private void doCreateFolder(bool useNewContext)
+ {
+ SP.ClientContext ctx = getSpClientContext(useNewContext);
+ try
+ {
+ int pathLengthToWeb = new Utility.Uri(m_spWebUrl).Path.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries).Length;
+
+ string[] folderNames = m_serverRelPath.Substring(0, m_serverRelPath.Length - 1).Split('/');
+ folderNames = Array.ConvertAll(folderNames, fold => System.Net.WebUtility.UrlDecode(fold));
+ var spfolders = new SP.Folder[folderNames.Length];
+ string folderRelPath = "";
+ int fi = 0;
+ for (; fi < folderNames.Length; fi++)
+ {
+ folderRelPath += System.Web.HttpUtility.UrlPathEncode(folderNames[fi]) + "/";
+ if (fi < pathLengthToWeb) continue;
+ var folder = ctx.Web.GetFolderByServerRelativeUrl(folderRelPath);
+ spfolders[fi] = folder;
+ ctx.Load(folder, f => f.Exists);
+ try { wrappedExecuteQueryOnConext(ctx, folderRelPath, true); }
+ catch (FolderMissingException)
+ { break; }
+ if (!folder.Exists) break;
+ }
+
+ for (; fi < folderNames.Length; fi++)
+ spfolders[fi] = spfolders[fi - 1].Folders.Add(folderNames[fi]);
+ ctx.Load(spfolders[folderNames.Length - 1], f => f.Exists);
+
+ wrappedExecuteQueryOnConext(ctx, m_serverRelPath, true);
+
+ if (!spfolders[folderNames.Length - 1].Exists)
+ throw new Interface.FolderMissingException(Strings.SharePoint.MissingElementError(m_serverRelPath, m_spWebUrl));
+ }
+ catch (ServerException) { throw; /* rethrow if Server answered */ }
+ catch (Interface.FileMissingException) { throw; }
+ catch (Interface.FolderMissingException) { throw; }
+ catch { if (!useNewContext) /* retry */ doCreateFolder(true); else throw; }
+ finally { }
+ }
+
+ public void Delete(string remotename) { doDelete(remotename, false); }
+ private void doDelete(string remotename, bool useNewContext)
+ {
+ SP.ClientContext ctx = getSpClientContext(useNewContext);
+ try
+ {
+ string fileurl = m_serverRelPath + System.Web.HttpUtility.UrlPathEncode(remotename);
+ SP.File remoteFile = ctx.Web.GetFileByServerRelativeUrl(fileurl);
+ ctx.Load(remoteFile);
+ wrappedExecuteQueryOnConext(ctx, fileurl, false);
+ if (!remoteFile.Exists)
+ throw new Interface.FileMissingException(Strings.SharePoint.MissingElementError(fileurl, m_spWebUrl));
+
+ if (m_deleteToRecycler) remoteFile.Recycle();
+ else remoteFile.DeleteObject();
+
+ ctx.ExecuteQuery();
+
+ }
+ catch (ServerException) { throw; /* rethrow if Server answered */ }
+ catch (Interface.FileMissingException) { throw; }
+ catch (Interface.FolderMissingException) { throw; }
+ catch { if (!useNewContext) /* retry */ doDelete(remotename, true); else throw; }
+ finally { }
+ }
+
+ #endregion
+
+
+ #region IDisposable Members
+
+ public void Dispose()
+ {
+ try
+ {
+ if (m_spContext != null)
+ m_spContext.Dispose();
+ }
+ catch { }
+ m_userInfo = null;
+ }
+
+ #endregion
+
+ }
+
+}
+
diff --git a/Duplicati/Library/Backend/SharePoint/Strings.cs b/Duplicati/Library/Backend/SharePoint/Strings.cs
new file mode 100644
index 000000000..ddea35e9b
--- /dev/null
+++ b/Duplicati/Library/Backend/SharePoint/Strings.cs
@@ -0,0 +1,27 @@
+using Duplicati.Library.Localization.Short;
+namespace Duplicati.Library.Backend.Strings
+{
+ internal static class SharePoint
+ {
+ public static string DisplayName { get { return LC.L(@"Microsoft SharePoint"); } }
+ public static string Description { get { return LC.L(@"Supports connections to a SharePoint server (including OneDrive for Business). Allowed formats are ""mssp://tennant.sharepoint.com/PathToWeb//BaseDocLibrary/subfolder"" or ""mssp://username:password@tennant.sharepoint.com/PathToWeb//BaseDocLibrary/subfolder"". Use a double slash '//' in the path to denote the web from the documents library."); } }
+ public static string DescriptionAuthPasswordLong { get { return LC.L(@"The password used to connect to the server. This may also be supplied as the environment variable ""AUTH_PASSWORD""."); } }
+ public static string DescriptionAuthPasswordShort { get { return LC.L(@"Supplies the password used to connect to the server"); } }
+ public static string DescriptionAuthUsernameLong { get { return LC.L(@"The username used to connect to the server. This may also be supplied as the environment variable ""AUTH_USERNAME""."); } }
+ public static string DescriptionAuthUsernameShort { get { return LC.L(@"Supplies the username used to connect to the server"); } }
+ public static string DescriptionIntegratedAuthenticationLong { get { return LC.L(@"If the server and client both supports integrated authentication, this option enables that authentication method. This is likely only available with windows servers and clients."); } }
+ public static string DescriptionIntegratedAuthenticationShort { get { return LC.L(@"Use windows integrated authentication to connect to the server"); } }
+ public static string DescriptionUseRecyclerLong { get { return LC.L(@"Use this option to have files moved to the recycle bin folder instead of removing them permanently when compacting or deleting backups."); } }
+ public static string DescriptionUseRecyclerShort { get { return LC.L(@"Move deleted files to the recycle bin"); } }
+
+ public static string MissingElementError(string serverrelpath, string hosturl) { return LC.L(@"Element with path '{0}' not found on host '{1}'.", serverrelpath, hosturl); }
+ public static string NoSharePointWebFoundError(string url) { return LC.L(@"No SharePoint web could be logged in to at path '{0}'. Maybe wrong credentials. Or try using '//' in path to separate web from folder path.", url); }
+ public static string WebTitleReadFailedError { get { return LC.L(@"Everything seemed alright, but then web title could not be read to test connection. Something's wrong."); } }
+ }
+
+ internal static class OneDriveForBusiness
+ {
+ public static string DisplayName { get { return LC.L(@"Microsoft OneDrive for Business"); } }
+ public static string Description { get { return LC.L(@"Supports connections to Microsoft OneDrive for Business. Allowed formats are ""od4b://tennant.sharepoint.com/personal/username_domain/Documents/subfolder"" or ""od4b://username:password@tennant.sharepoint.com/personal/username_domain/Documents/folder"". You can use a double slash '//' in the path to denote the base path from the documents folder."); } }
+ }
+}
diff --git a/Duplicati/Library/Backend/SharePoint/app.config b/Duplicati/Library/Backend/SharePoint/app.config
new file mode 100644
index 000000000..a80813afe
--- /dev/null
+++ b/Duplicati/Library/Backend/SharePoint/app.config
@@ -0,0 +1,3 @@
+
+
+
diff --git a/Duplicati/License/Duplicati.License.csproj b/Duplicati/License/Duplicati.License.csproj
index 432975578..ed3f01161 100644
--- a/Duplicati/License/Duplicati.License.csproj
+++ b/Duplicati/License/Duplicati.License.csproj
@@ -195,6 +195,22 @@
licenses\WindowsAzure\licensedata.json
PreserveNewest
+
+ licenses\\SharePointCSOM\download.txt
+ PreserveNewest
+
+
+ licenses\\SharePointCSOM\license.txt
+ PreserveNewest
+
+
+ licenses\\SharePointCSOM\MicrosoftSharePointClientComponentsEULA.rtf
+ PreserveNewest
+
+
+ licenses\\SharePointCSOM\licensedata.json
+ PreserveNewest
+
PreserveNewest
diff --git a/Duplicati/Server/Duplicati.Server.csproj b/Duplicati/Server/Duplicati.Server.csproj
index 1c4521278..9a69c0d11 100644
--- a/Duplicati/Server/Duplicati.Server.csproj
+++ b/Duplicati/Server/Duplicati.Server.csproj
@@ -160,6 +160,10 @@
{C03F6DFD-805A-4BE0-9338-64870ADDB4A2}
Duplicati.Library.Backend.S3
+
+ {59c8bbc5-6e42-46fb-ab3e-6c183a82459a}
+ Duplicati.Library.Backend.SharePoint
+
{FF2BF37C-E502-4C98-BEA0-701671DDFA08}
Duplicati.Library.Backend.SSHv2
@@ -275,7 +279,6 @@
-