Files
duplicati/Duplicati/Library/Backend/SharePoint/SharePointBackend.cs
T
Kenneth Skovhede 4d569c51e4 Added read-only testing to backends
This PR adds a flag to the test method so we can choose to test for read-only access (needed for restore) or read-write (needed for backups).

Additionally, this PR fixes a logic bug in that the BackendSourceProvider would not actually test the connection, but just check the placeholder element returned (which is always returned). With this PR the backend's ListAsync command is tested fully.

Also renamed the methods on ISourceProvider to have the `...Async` prefix.

This replaces #6939
2026-06-03 17:27:34 +02:00

798 lines
38 KiB
C#

// Copyright (C) 2026, 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.Common.IO;
using Duplicati.Library.Interface;
using Duplicati.Library.Utility;
using Duplicati.Library.Utility.Options;
using Microsoft.SharePoint.Client; // Plain 'using' for extension methods
using System.Runtime.CompilerServices;
using System.Text;
using SP = Microsoft.SharePoint.Client;
namespace Duplicati.Library.Backend
{
/// <summary>
/// Class implementing Duplicati's backend for SharePoint.
/// </summary>
/// <remarks>
/// 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;
/// </remarks>
public class SharePointBackend : IBackend, IStreamingBackend, IRenameEnabledBackend
{
#region [Variables and constants declarations]
/// <summary> Auth-stripped HTTPS-URI as passed to constructor. </summary>
private readonly Utility.Uri m_orgUrl;
/// <summary> Server relative path to backup folder. </summary>
private readonly string m_serverRelPath;
/// <summary> User's credentials to create client context </summary>
private System.Net.ICredentials m_userInfo;
/// <summary> Flag indicating to move files to recycler on deletion. </summary>
private readonly bool m_deleteToRecycler = false;
/// <summary> Flag indicating to use UploadBinaryDirect. </summary>
private readonly bool m_useBinaryDirectMode = false;
/// <summary> URL to SharePoint web. Will be determined from m_orgUri on first use. </summary>
private string? m_spWebUrl;
/// <summary> Current context to SharePoint web. </summary>
private ClientContext? m_spContext;
/// <summary> The chunk size for uploading files. </summary>
private readonly int m_fileChunkSize = 4 << 20; // Default: 4MB
/// <summary> The chunk size for uploading files. </summary>
private readonly int m_useContextTimeoutMs = -1; // default: do not touch original setting
/// <summary> The timeout values to use. </summary>
private readonly TimeoutOptionsHelper.Timeouts m_timeouts;
#endregion
#region [Public Properties]
/// <inheritdoc/>
public virtual string ProtocolKey => "mssp";
/// <inheritdoc/>
public virtual string DisplayName => Strings.SharePoint.DisplayName;
/// <inheritdoc/>
public virtual string Description => Strings.SharePoint.Description;
/// <inheritdoc/>
public bool SupportsStreaming => true;
/// <inheritdoc/>
public virtual IList<ICommandLineArgument> SupportedCommands => [
.. AuthOptionsHelper.GetOptions(),
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),
new CommandLineArgument("binary-direct-mode", CommandLineArgument.ArgumentType.Boolean, Strings.SharePoint.DescriptionBinaryDirectModeShort, Strings.SharePoint.DescriptionBinaryDirectModeLong, "false"),
new CommandLineArgument("web-timeout", CommandLineArgument.ArgumentType.Timespan, Strings.SharePoint.DescriptionWebTimeoutShort, Strings.SharePoint.DescriptionWebTimeoutLong),
new CommandLineArgument("chunk-size", CommandLineArgument.ArgumentType.Size, Strings.SharePoint.DescriptionChunkSizeShort, Strings.SharePoint.DescriptionChunkSizeLong, "4mb"),
.. TimeoutOptionsHelper.GetOptions()
];
public Task<string[]> GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(new string?[] {
m_orgUrl.Host,
string.IsNullOrWhiteSpace(m_spWebUrl) ? null : new Utility.Uri(m_spWebUrl).Host
}.WhereNotNullOrWhiteSpace().ToArray());
#endregion
#region [Constructors]
public SharePointBackend()
{
m_serverRelPath = null!;
m_userInfo = null!;
m_timeouts = null!;
}
public SharePointBackend(string url, Dictionary<string, string?> options)
{
m_deleteToRecycler = Utility.Utility.ParseBoolOption(options, "delete-to-recycler");
m_useBinaryDirectMode = Utility.Utility.ParseBoolOption(options, "binary-direct-mode");
m_timeouts = TimeoutOptionsHelper.Parse(options);
try
{
if (options.TryGetValue("web-timeout", out var strSpan))
{
var ts = Timeparser.ParseTimeSpan(strSpan);
if (ts.TotalMilliseconds > 30000 && ts.TotalMilliseconds < int.MaxValue)
m_useContextTimeoutMs = (int)ts.TotalMilliseconds;
}
}
catch { }
try
{
if (options.TryGetValue("chunk-size", out var strChunkSize))
{
var pSize = Sizeparser.ParseSize(strChunkSize, "MB");
if (pSize >= (1 << 14) && pSize <= (1 << 30)) // [16kb .. 1GB]
m_fileChunkSize = (int)pSize;
}
}
catch { }
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("/", StringComparison.Ordinal))
m_serverRelPath = "/" + m_serverRelPath;
m_serverRelPath = Util.AppendDirSeparator(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)
{
var auth = AuthOptionsHelper.Parse(options, u);
// No validation here, maybe at least username should be set?
useUsername = auth.Username;
usePassword = 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
{
#pragma warning disable DE0001
var securePwd = new System.Security.SecureString();
#pragma warning restore DE0001
usePassword.ToList().ForEach(c => securePwd.AppendChar(c));
m_userInfo = new 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]
/// <summary>
/// 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).
/// </summary>
private static async Task<int> TestContextForWebAsync(ClientContext ctx, bool rethrow, TimeSpan timeout, CancellationToken cancelToken)
{
try
{
return await Utility.Utility.WithTimeout(timeout, cancelToken, async _ =>
{
ctx.Load(ctx.Web, w => w.Title);
await ctx.ExecuteQueryAsync().ConfigureAwait(false); // should fail and throw if anything wrong.
string webTitle = ctx.Web.Title;
if (webTitle == null)
throw new UnauthorizedAccessException(Strings.SharePoint.WebTitleReadFailedError);
return 0;
}).ConfigureAwait(false);
}
catch (ServerException)
{
if (rethrow) throw;
else return 1;
}
catch (Exception)
{
if (rethrow) throw;
else return -1;
}
}
/// <summary>
/// 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).
/// </summary>
private static async Task<(int status, ClientContext? retCtx)> TestUrlForWebAsync(string url, System.Net.ICredentials userInfo, bool rethrow, TimeSpan timeout, CancellationToken cancelToken)
{
int result = -1;
ClientContext? retCtx = null;
var ctx = CreateNewContext(url);
try
{
ctx.Credentials = userInfo;
result = await TestContextForWebAsync(ctx, rethrow, timeout, cancelToken).ConfigureAwait(false);
if (result >= 0)
{
retCtx = ctx;
ctx = null;
}
}
finally { if (ctx != null) { ctx.Dispose(); } }
return (result, retCtx);
}
/// <summary>
/// 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 paths from longest
/// to shortest...
/// </summary>
private static async Task<(string? testUrl, ClientContext? retCtx)> FindCorrectWebPathAsync(Utility.Uri orgUrl, System.Net.ICredentials userInfo, TimeSpan timeout, CancellationToken cancelToken)
{
ClientContext? retCtx = null;
int status;
var path = orgUrl.Path;
var webIndicatorPos = path.IndexOf("//", StringComparison.Ordinal);
// if a hint is supplied, we will of course use this first.
if (webIndicatorPos >= 0)
{
var testUrl = new Utility.Uri(orgUrl.Scheme, orgUrl.Host, path.Substring(0, webIndicatorPos), null, null, null, orgUrl.Port).ToString();
(status, retCtx) = await TestUrlForWebAsync(testUrl, userInfo, false, timeout, cancelToken).ConfigureAwait(false);
if (status >= 0)
return (testUrl, retCtx);
}
// Now go through path and see where we land a success.
var pathParts = path.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
// first we look for the doc library
var docLibrary = Array.FindIndex(pathParts, p => StringComparer.OrdinalIgnoreCase.Equals(p, "documents"));
if (docLibrary >= 0)
{
var testUrl = new Utility.Uri(orgUrl.Scheme, orgUrl.Host,
string.Join("/", pathParts, 0, docLibrary),
null, null, null, orgUrl.Port).ToString();
(status, retCtx) = await TestUrlForWebAsync(testUrl, userInfo, false, timeout, cancelToken).ConfigureAwait(false);
if (status >= 0)
return (testUrl, retCtx);
}
// last but not least: try one after the other.
for (var pi = pathParts.Length - 1; pi >= 0; pi--)
{
if (pi == docLibrary) continue; // already tested
var testUrl = new Utility.Uri(orgUrl.Scheme, orgUrl.Host,
string.Join("/", pathParts, 0, pi),
null, null, null, orgUrl.Port).ToString();
(status, retCtx) = await TestUrlForWebAsync(testUrl, userInfo, false, timeout, cancelToken).ConfigureAwait(false);
if (status >= 0)
return (testUrl, retCtx);
}
// nothing worked :(
return (null, null);
}
/// <summary> Return the preconfigured SP.ClientContext to use. </summary>
private async Task<ClientContext> GetSpClientContextAsync(bool forceNewContext, CancellationToken cancelToken)
{
if (forceNewContext)
{
if (m_spContext != null) m_spContext.Dispose();
m_spContext = null;
}
if (m_spContext == null)
{
if (m_spWebUrl == null)
{
(m_spWebUrl, m_spContext) = await FindCorrectWebPathAsync(m_orgUrl, m_userInfo, m_timeouts.ShortTimeout, cancelToken).ConfigureAwait(false);
if (m_spWebUrl == null || m_spContext == null)
throw new HttpRequestException(Strings.SharePoint.NoSharePointWebFoundError(m_orgUrl.ToString()));
}
else
{
// would query: testUrlForWeb(m_spWebUrl, userInfo, true, out m_spContext);
m_spContext = CreateNewContext(m_spWebUrl);
m_spContext.Credentials = m_userInfo;
}
if (m_useContextTimeoutMs > 0)
m_spContext.RequestTimeout = m_useContextTimeoutMs;
}
return m_spContext;
}
/// <summary>
/// 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...
/// </summary>
private async Task WrappedExecuteQueryOnContextAsync(ClientContext ctx, string serverRelPathInfo, bool isFolder, TimeSpan timeout, CancellationToken cancelToken)
{
try { await Utility.Utility.WithTimeout(timeout, cancelToken, _ => ctx.ExecuteQueryAsync()).ConfigureAwait(false); }
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 FolderMissingException(Strings.SharePoint.MissingElementError(serverRelPathInfo, m_spWebUrl));
if (ex.ServerErrorTypeName == "System.IO.FileNotFoundException")
throw new FileMissingException(Strings.SharePoint.MissingElementError(serverRelPathInfo, m_spWebUrl));
else
throw;
}
}
/// <summary>
/// Helper method to inject the custom webrequest provider that sets the UserAgent
/// </summary>
/// <returns>The new context.</returns>
/// <param name="url">The url to create the context for.</param>
private static ClientContext CreateNewContext(string url)
{
var ctx = new ClientContext(url);
ctx.WebRequestExecutorFactory = new CustomWebRequestExecutorFactory(ctx.WebRequestExecutorFactory);
return ctx;
}
/// <summary>
/// Simple factory override that creates same executor as the implementation
/// but sets the UserAgent header, to work around a problem with OD4B servers
/// </summary>
internal class CustomWebRequestExecutorFactory : WebRequestExecutorFactory
{
/// <summary>
/// The default factory
/// </summary>
private readonly WebRequestExecutorFactory m_parent;
/// <summary>
/// Initializes a new instance of the
/// <see cref="T:Duplicati.Library.Backend.SharePointBackend.CustomWebRequestExecutorFactory"/> class.
/// </summary>
/// <param name="parent">The default executor.</param>
public CustomWebRequestExecutorFactory(WebRequestExecutorFactory parent)
{
if (parent == null)
throw new ArgumentNullException(nameof(parent));
m_parent = parent;
}
/// <summary>
/// Creates the web request executor by calling the parent and setting the UserAgent.
/// </summary>
/// <returns>The web request executor.</returns>
/// <param name="context">The context to use.</param>
/// <param name="requestUrl">The request URL.</param>
public override WebRequestExecutor CreateWebRequestExecutor(ClientRuntimeContext context, string requestUrl)
{
var req = m_parent.CreateWebRequestExecutor(context, requestUrl);
if (string.IsNullOrWhiteSpace(req.WebRequest.Headers["User-Agent"]))
req.WebRequest.Headers["User-Agent"] = "Duplicati OD4B v" + System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
return req;
}
}
#endregion
#region [Public backend methods]
public async Task TestAsync(bool alsoWrite, CancellationToken cancelToken)
{
var ctx = await GetSpClientContextAsync(true, cancelToken).ConfigureAwait(false);
await TestContextForWebAsync(ctx, true, m_timeouts.ShortTimeout, cancelToken).ConfigureAwait(false);
await this.TestBackendAsync(alsoWrite, cancelToken).ConfigureAwait(false);
}
/// <inheritdoc />
public IAsyncEnumerable<IFileEntry> ListAsync(CancellationToken cancelToken)
=> DoListAsync(false, cancelToken);
private async IAsyncEnumerable<IFileEntry> DoListAsync(bool useNewContext, [EnumeratorCancellation] CancellationToken cancelToken)
{
var ctx = await GetSpClientContextAsync(useNewContext, cancelToken).ConfigureAwait(false);
Folder? remoteFolder = null;
bool retry = false;
try
{
remoteFolder = ctx.Web.GetFolderByServerRelativeUrl(m_serverRelPath);
ctx.Load(remoteFolder, f => f.Exists);
ctx.Load(remoteFolder, f => f.Files, f => f.Folders);
await WrappedExecuteQueryOnContextAsync(ctx, m_serverRelPath, true, m_timeouts.ListTimeout, cancelToken).ConfigureAwait(false);
if (!remoteFolder.Exists)
throw new FolderMissingException(Strings.SharePoint.MissingElementError(m_serverRelPath, m_spWebUrl));
}
catch (ServerException) { throw; /* rethrow if Server answered */ }
catch (FileMissingException) { throw; }
catch (FolderMissingException) { throw; }
catch
{
if (useNewContext)
throw;
retry = true;
}
if (retry)
{
// An exception was caught, and List() should be retried.
await foreach (var f in DoListAsync(true, cancelToken).ConfigureAwait(false))
yield return f;
}
else
{
if (remoteFolder == null)
throw new FolderMissingException(Strings.SharePoint.MissingElementError(m_serverRelPath, m_spWebUrl));
var list = remoteFolder.Folders.Where(ff => ff.Exists)
.Select(f => new FileEntry(f.Name, -1, f.TimeLastModified, f.TimeLastModified, true, false))
.Concat(remoteFolder.Files.Where(ff => ff.Exists)
.Select(f => new FileEntry(f.Name, f.Length, f.TimeLastModified, f.TimeLastModified, false, false)));
foreach (var f in list)
yield return f;
}
}
public async Task GetAsync(string remotename, string filename, CancellationToken cancelToken)
{
using (var fs = System.IO.File.Create(filename))
await GetAsync(remotename, fs, cancelToken);
}
public Task GetAsync(string remotename, Stream stream, CancellationToken cancelToken)
=> DoGetAsync(remotename, stream, false, cancelToken);
private async Task DoGetAsync(string remotename, Stream stream, bool useNewContext, CancellationToken cancelToken)
{
string fileurl = m_serverRelPath + System.Web.HttpUtility.UrlPathEncode(remotename);
var ctx = await GetSpClientContextAsync(useNewContext, cancelToken).ConfigureAwait(false);
try
{
var remoteFile = ctx.Web.GetFileByServerRelativeUrl(fileurl);
ctx.Load(remoteFile, f => f.Exists);
await WrappedExecuteQueryOnContextAsync(ctx, fileurl, false, m_timeouts.ShortTimeout, cancelToken).ConfigureAwait(false);
if (!remoteFile.Exists)
throw new FileMissingException(Strings.SharePoint.MissingElementError(fileurl, m_spWebUrl));
}
catch (ServerException) { throw; /* rethrow if Server answered */ }
catch (FileMissingException) { throw; }
catch (FolderMissingException) { throw; }
catch
{
if (useNewContext)
throw;
await DoGetAsync(remotename, stream, true, cancelToken);
}
using (var fileInfo = SP.File.OpenBinaryDirect(ctx, fileurl))
using (var s = fileInfo.Stream)
using (var t = s.ObserveReadTimeout(m_timeouts.ReadWriteTimeout))
await Utility.Utility.CopyStreamAsync(t, stream, true, cancelToken).ConfigureAwait(false);
}
public async Task PutAsync(string remotename, string filename, CancellationToken cancelToken)
{
using (FileStream fs = System.IO.File.OpenRead(filename))
await PutAsync(remotename, fs, cancelToken).ConfigureAwait(false);
}
public Task PutAsync(string remotename, Stream stream, CancellationToken cancelToken) { return DoPutAsync(remotename, stream, false, cancelToken); }
private async Task DoPutAsync(string remotename, Stream stream, bool useNewContext, CancellationToken cancelToken)
{
var fileurl = m_serverRelPath + System.Web.HttpUtility.UrlPathEncode(remotename);
var ctx = await GetSpClientContextAsync(useNewContext, cancelToken).ConfigureAwait(false);
try
{
var remoteFolder = ctx.Web.GetFolderByServerRelativeUrl(m_serverRelPath);
ctx.Load(remoteFolder, f => f.Exists, f => f.ServerRelativeUrl);
await WrappedExecuteQueryOnContextAsync(ctx, m_serverRelPath, true, m_timeouts.ShortTimeout, cancelToken).ConfigureAwait(false);
if (!remoteFolder.Exists)
throw new FolderMissingException(Strings.SharePoint.MissingElementError(m_serverRelPath, m_spWebUrl));
useNewContext = true; // disable retry
if (!m_useBinaryDirectMode)
await UploadFileSlicePerSlice(ctx, remoteFolder, stream, fileurl, cancelToken).ConfigureAwait(false);
}
catch (ServerException) { throw; /* rethrow if Server answered */ }
catch (FileMissingException) { throw; }
catch (FolderMissingException) { throw; }
catch
{
if (useNewContext)
throw;
await DoPutAsync(remotename, stream, true, cancelToken).ConfigureAwait(false);
}
if (m_useBinaryDirectMode)
using (var ts = stream.ObserveWriteTimeout(m_timeouts.ReadWriteTimeout, false))
SP.File.SaveBinaryDirect(ctx, fileurl, ts, true);
}
/// <summary>
/// Upload in chunks to bypass filesize limit.
/// https://msdn.microsoft.com/en-us/library/office/dn904536.aspx
/// </summary>
private async Task<SP.File> UploadFileSlicePerSlice(ClientContext ctx, Folder folder, Stream sourceFileStream, string fileName, CancellationToken cancelToken)
{
// Each sliced upload requires a unique ID.
var uploadId = Guid.NewGuid();
// Get the name of the file.
var uniqueFileName = Path.GetFileName(fileName);
// File object.
SP.File? uploadFile = null;
// Calculate block size in bytes.
var blockSize = m_fileChunkSize;
var buf = new byte[blockSize];
var needsFinalize = true;
var fileoffset = 0L;
var lastreadsize = -1;
while (lastreadsize != 0)
{
var bufCnt = 0;
// read chunk to array (necessary because chunk uploads fail if size unknown)
while (bufCnt < blockSize && (lastreadsize = await sourceFileStream.ReadAsync(buf, bufCnt, blockSize - bufCnt, cancelToken).ConfigureAwait(false)) > 0)
bufCnt += lastreadsize;
using (var contentChunk = new MemoryStream(buf, 0, bufCnt, false))
using (var timeoutStream = contentChunk.ObserveReadTimeout(m_timeouts.ReadWriteTimeout))
{
ClientResult<long>? bytesUploaded = null;
if (uploadFile == null)
{
// Add an empty / single chunk file.
var fileInfo = new FileCreationInformation();
fileInfo.Url = uniqueFileName;
fileInfo.Overwrite = true;
fileInfo.ContentStream = (bufCnt < blockSize) ? timeoutStream : new MemoryStream(0);
uploadFile = folder.Files.Add(fileInfo);
if (bufCnt < blockSize)
needsFinalize = false;
else
bytesUploaded = uploadFile.StartUpload(uploadId, timeoutStream); // new OverrideableStream(chunkStream));
}
else
{
// Get a reference to your file.
//uploadFile = ctx.Web.GetFileByServerRelativeUrl(folder.ServerRelativeUrl + System.IO.Path.AltDirectorySeparatorChar + uniqueFileName);
if (bufCnt < blockSize) // Last block: end sliced upload by calling FinishUpload.
{
uploadFile = uploadFile.FinishUpload(uploadId, fileoffset, timeoutStream);
needsFinalize = false; // signal no final call necessary.
}
else // Continue sliced upload.
{
bytesUploaded = uploadFile.ContinueUpload(uploadId, fileoffset, timeoutStream);
}
}
if (bytesUploaded == null)
ctx.Load(uploadFile, f => f.Length);
await ctx.ExecuteQueryAsync().ConfigureAwait(false);
// Check consistency and update fileoffset for the next slice.
if (bytesUploaded != null)
{
if (bytesUploaded.Value != fileoffset + bufCnt)
throw new InvalidDataException(string.Format("Reported uploaded file size ({0:N0}) does not match internal recording ({1:N0}) for '{2}'.", bytesUploaded.Value, fileoffset + bufCnt, uniqueFileName));
fileoffset = bytesUploaded.Value; // Update fileoffset for the next slice.
}
else fileoffset += bufCnt;
}
}
if (needsFinalize && uploadFile != null) // finalize file (should only occur if filesize is exactly a multiple of chunksize)
{
// End sliced upload by calling FinishUpload.
uploadFile = uploadFile.FinishUpload(uploadId, fileoffset, new MemoryStream(0));
ctx.Load(uploadFile, f => f.Length);
await Utility.Utility.WithTimeout(m_timeouts.ReadWriteTimeout, cancelToken, _ => ctx.ExecuteQueryAsync()).ConfigureAwait(false);
}
if (uploadFile == null)
throw new FileMissingException(Strings.SharePoint.MissingElementError(fileName, m_spWebUrl));
if (uploadFile.Length != fileoffset)
throw new InvalidDataException(string.Format("Reported final file size ({0:N0}) does not match internal recording ({1:N0}) for '{2}'.", uploadFile.Length, fileoffset, uniqueFileName));
return uploadFile;
}
public Task CreateFolderAsync(CancellationToken cancelToken) { return DoCreateFolderAsync(false, cancelToken); }
private async Task DoCreateFolderAsync(bool useNewContext, CancellationToken cancelToken)
{
var ctx = await GetSpClientContextAsync(useNewContext, cancelToken).ConfigureAwait(false);
try
{
if (string.IsNullOrWhiteSpace(m_spWebUrl))
throw new HttpRequestException(Strings.SharePoint.NoSharePointWebFoundError(m_orgUrl.ToString()));
var pathLengthToWeb = new Utility.Uri(m_spWebUrl).Path.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries).Length;
var folderNames = m_serverRelPath.Substring(0, m_serverRelPath.Length - 1).Split('/');
folderNames = Array.ConvertAll(folderNames, fold => System.Net.WebUtility.UrlDecode(fold));
var spfolders = new Folder[folderNames.Length];
var relativePathBuilder = new StringBuilder();
int fi = 0;
for (; fi < folderNames.Length; fi++)
{
relativePathBuilder.Append(System.Web.HttpUtility.UrlPathEncode(folderNames[fi])).Append("/");
if (fi < pathLengthToWeb) continue;
string folderRelPath = relativePathBuilder.ToString();
var folder = ctx.Web.GetFolderByServerRelativeUrl(folderRelPath);
spfolders[fi] = folder;
ctx.Load(folder, f => f.Exists);
try { await WrappedExecuteQueryOnContextAsync(ctx, folderRelPath, true, m_timeouts.ShortTimeout, cancelToken); }
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);
await WrappedExecuteQueryOnContextAsync(ctx, m_serverRelPath, true, m_timeouts.ShortTimeout, cancelToken).ConfigureAwait(false);
if (!spfolders[folderNames.Length - 1].Exists)
throw new FolderMissingException(Strings.SharePoint.MissingElementError(m_serverRelPath, m_spWebUrl));
}
catch (ServerException) { throw; /* rethrow if Server answered */ }
catch (FileMissingException) { throw; }
catch (FolderMissingException) { throw; }
catch
{
if (useNewContext)
throw;
await DoCreateFolderAsync(true, cancelToken).ConfigureAwait(false);
}
}
public Task DeleteAsync(string remotename, CancellationToken cancellationToken)
=> DoDeleteAsync(remotename, false, cancellationToken);
private async Task DoDeleteAsync(string remotename, bool useNewContext, CancellationToken cancellationToken)
{
var ctx = await GetSpClientContextAsync(useNewContext, cancellationToken).ConfigureAwait(false);
try
{
string fileurl = m_serverRelPath + System.Web.HttpUtility.UrlPathEncode(remotename);
SP.File remoteFile = ctx.Web.GetFileByServerRelativeUrl(fileurl);
ctx.Load(remoteFile);
await WrappedExecuteQueryOnContextAsync(ctx, fileurl, false, m_timeouts.ShortTimeout, cancellationToken).ConfigureAwait(false);
if (!remoteFile.Exists)
throw new FileMissingException(Strings.SharePoint.MissingElementError(fileurl, m_spWebUrl));
await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancellationToken, _ =>
{
if (m_deleteToRecycler)
remoteFile.Recycle();
else
remoteFile.DeleteObject();
return ctx.ExecuteQueryAsync();
}).ConfigureAwait(false);
}
catch (ServerException) { throw; /* rethrow if Server answered */ }
catch (FileMissingException) { throw; }
catch (FolderMissingException) { throw; }
catch
{
if (useNewContext)
throw;
await DoDeleteAsync(remotename, true, cancellationToken).ConfigureAwait(false);
}
}
#endregion
#region IDisposable Members
public void Dispose()
{
try
{
if (m_spContext != null)
m_spContext.Dispose();
}
catch { }
finally
{
m_spContext = null;
}
m_userInfo = null!;
}
public Task RenameAsync(string oldname, string newname, CancellationToken cancellationToken)
=> DoRenameAsync(oldname, newname, false, cancellationToken);
private async Task DoRenameAsync(string oldname, string newname, bool useNewContext, CancellationToken cancellationToken)
{
var ctx = await GetSpClientContextAsync(useNewContext, cancellationToken).ConfigureAwait(false);
try
{
string oldFileUrl = m_serverRelPath + System.Web.HttpUtility.UrlPathEncode(oldname);
string newFileUrl = m_serverRelPath + System.Web.HttpUtility.UrlPathEncode(newname);
SP.File remoteFile = ctx.Web.GetFileByServerRelativeUrl(oldFileUrl);
remoteFile.MoveTo(newFileUrl, MoveOperations.Overwrite);
await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancellationToken, _ => ctx.ExecuteQueryAsync()).ConfigureAwait(false);
}
catch (ServerException) { throw; /* rethrow if Server answered */ }
catch (FileMissingException) { throw; }
catch (FolderMissingException) { throw; }
catch
{
if (useNewContext)
throw;
await DoRenameAsync(oldname, newname, true, cancellationToken).ConfigureAwait(false);
}
}
#endregion
}
}