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 @@ -