Register created app as public client (fix connect AADSTS7000218) #1
@@ -3,6 +3,7 @@
|
|||||||
@inject IUserSessionService Session
|
@inject IUserSessionService Session
|
||||||
@inject IUserContextAccessor UserContext
|
@inject IUserContextAccessor UserContext
|
||||||
@inject ISessionManager SessionMgr
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IElevationCoordinator Elevation
|
||||||
@inject IBulkMemberService BulkSvc
|
@inject IBulkMemberService BulkSvc
|
||||||
@inject ICsvValidationService CsvValidation
|
@inject ICsvValidationService CsvValidation
|
||||||
@inject BulkResultCsvExportService ExportSvc
|
@inject BulkResultCsvExportService ExportSvc
|
||||||
@@ -97,8 +98,11 @@
|
|||||||
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
|
_summary = await Elevation.RunAsync(async c =>
|
||||||
_summary = await BulkSvc.AddMembersAsync(ctx, Session.CurrentProfile!, validRows, progress, _cts.Token);
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
|
||||||
|
return await BulkSvc.AddMembersAsync(ctx, Session.CurrentProfile!, validRows, progress, c);
|
||||||
|
}, _cts.Token);
|
||||||
_status = $"Complete: {_summary.SuccessCount} added, {_summary.FailedCount} failed.";
|
_status = $"Complete: {_summary.SuccessCount} added, {_summary.FailedCount} failed.";
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { _status = "Cancelled."; }
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject IUserSessionService Session
|
@inject IUserSessionService Session
|
||||||
@inject ISessionManager SessionMgr
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IElevationCoordinator Elevation
|
||||||
@inject IDuplicatesService DupSvc
|
@inject IDuplicatesService DupSvc
|
||||||
@inject DuplicatesCsvExportService CsvExport
|
@inject DuplicatesCsvExportService CsvExport
|
||||||
@inject DuplicatesHtmlExportService HtmlExport
|
@inject DuplicatesHtmlExportService HtmlExport
|
||||||
@@ -95,9 +96,12 @@
|
|||||||
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
|
|
||||||
var opts = new DuplicateScanOptions(_mode, _matchSize, _matchCreated, _matchModified, _matchFolderCount, _matchFileCount, Library: _library.TrimOrNull());
|
var opts = new DuplicateScanOptions(_mode, _matchSize, _matchCreated, _matchModified, _matchFolderCount, _matchFileCount, Library: _library.TrimOrNull());
|
||||||
_results = (await DupSvc.ScanDuplicatesAsync(ctx, opts, progress, _cts.Token)).ToList();
|
_results = (await Elevation.RunAsync(async c =>
|
||||||
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
|
||||||
|
return await DupSvc.ScanDuplicatesAsync(ctx, opts, progress, c);
|
||||||
|
}, _cts.Token)).ToList();
|
||||||
_status = $"Found {_results.Count} duplicate groups.";
|
_status = $"Found {_results.Count} duplicate groups.";
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { _status = "Cancelled."; }
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@inject IUserSessionService Session
|
@inject IUserSessionService Session
|
||||||
@inject IUserContextAccessor UserContext
|
@inject IUserContextAccessor UserContext
|
||||||
@inject ISessionManager SessionMgr
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IElevationCoordinator Elevation
|
||||||
@inject IFileTransferService TransferSvc
|
@inject IFileTransferService TransferSvc
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
@@ -119,8 +120,6 @@
|
|||||||
{
|
{
|
||||||
var srcUrl = string.IsNullOrWhiteSpace(_srcSiteUrl) ? Session.CurrentProfile!.TenantUrl : _srcSiteUrl.Trim();
|
var srcUrl = string.IsNullOrWhiteSpace(_srcSiteUrl) ? Session.CurrentProfile!.TenantUrl : _srcSiteUrl.Trim();
|
||||||
var dstUrl = string.IsNullOrWhiteSpace(_dstSiteUrl) ? Session.CurrentProfile!.TenantUrl : _dstSiteUrl.Trim();
|
var dstUrl = string.IsNullOrWhiteSpace(_dstSiteUrl) ? Session.CurrentProfile!.TenantUrl : _dstSiteUrl.Trim();
|
||||||
var srcCtx = await SessionMgr.GetOrCreateContextAsync(srcUrl, Session.CurrentProfile!, _cts.Token);
|
|
||||||
var dstCtx = await SessionMgr.GetOrCreateContextAsync(dstUrl, Session.CurrentProfile!, _cts.Token);
|
|
||||||
var job = new TransferJob
|
var job = new TransferJob
|
||||||
{
|
{
|
||||||
SourceSiteUrl = srcUrl, SourceLibrary = _srcLibrary, SourceFolderPath = _srcFolder,
|
SourceSiteUrl = srcUrl, SourceLibrary = _srcLibrary, SourceFolderPath = _srcFolder,
|
||||||
@@ -129,7 +128,13 @@
|
|||||||
ConflictPolicy = _conflict == "Overwrite" ? ConflictPolicy.Overwrite : _conflict == "Rename" ? ConflictPolicy.Rename : ConflictPolicy.Skip,
|
ConflictPolicy = _conflict == "Overwrite" ? ConflictPolicy.Overwrite : _conflict == "Rename" ? ConflictPolicy.Rename : ConflictPolicy.Skip,
|
||||||
IncludeSourceFolder = _includeSourceFolder
|
IncludeSourceFolder = _includeSourceFolder
|
||||||
};
|
};
|
||||||
_summary = await TransferSvc.TransferAsync(srcCtx, dstCtx, job, progress, _cts.Token);
|
_summary = await Elevation.RunAsync(async c =>
|
||||||
|
{
|
||||||
|
// Closure rebuilds both contexts each attempt so an elevated retry re-issues cleanly.
|
||||||
|
var srcCtx = await SessionMgr.GetOrCreateContextAsync(srcUrl, Session.CurrentProfile!, c);
|
||||||
|
var dstCtx = await SessionMgr.GetOrCreateContextAsync(dstUrl, Session.CurrentProfile!, c);
|
||||||
|
return await TransferSvc.TransferAsync(srcCtx, dstCtx, job, progress, c);
|
||||||
|
}, _cts.Token);
|
||||||
_status = $"Complete: {_summary.SuccessCount} transferred.";
|
_status = $"Complete: {_summary.SuccessCount} transferred.";
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { _status = "Cancelled."; }
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@inject IUserSessionService Session
|
@inject IUserSessionService Session
|
||||||
@inject IUserContextAccessor UserContext
|
@inject IUserContextAccessor UserContext
|
||||||
@inject ISessionManager SessionMgr
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IElevationCoordinator Elevation
|
||||||
@inject IFolderStructureService FolderSvc
|
@inject IFolderStructureService FolderSvc
|
||||||
@inject ICsvValidationService CsvValidation
|
@inject ICsvValidationService CsvValidation
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
@@ -78,8 +79,11 @@
|
|||||||
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
|
_summary = await Elevation.RunAsync(async c =>
|
||||||
_summary = await FolderSvc.CreateFoldersAsync(ctx, _libraryTitle, validRows, progress, _cts.Token);
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
|
||||||
|
return await FolderSvc.CreateFoldersAsync(ctx, _libraryTitle, validRows, progress, c);
|
||||||
|
}, _cts.Token);
|
||||||
_status = $"Complete: {_summary.SuccessCount} folders created.";
|
_status = $"Complete: {_summary.SuccessCount} folders created.";
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { _status = "Cancelled."; }
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject IUserSessionService Session
|
@inject IUserSessionService Session
|
||||||
@inject ISessionManager SessionMgr
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IElevationCoordinator Elevation
|
||||||
@inject IPermissionsService PermSvc
|
@inject IPermissionsService PermSvc
|
||||||
@inject CsvExportService CsvExport
|
@inject CsvExportService CsvExport
|
||||||
@inject HtmlExportService HtmlExport
|
@inject HtmlExportService HtmlExport
|
||||||
@@ -109,9 +110,12 @@
|
|||||||
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
|
|
||||||
var opts = new ScanOptions(_includeInherited, _scanFolders, _folderDepth, _includeSubsites);
|
var opts = new ScanOptions(_includeInherited, _scanFolders, _folderDepth, _includeSubsites);
|
||||||
_results = (await PermSvc.ScanSiteAsync(ctx, opts, progress, _cts.Token)).ToList();
|
_results = (await Elevation.RunAsync(async c =>
|
||||||
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
|
||||||
|
return await PermSvc.ScanSiteAsync(ctx, opts, progress, c);
|
||||||
|
}, _cts.Token)).ToList();
|
||||||
_status = $"Scan complete: {_results.Count} entries found.";
|
_status = $"Scan complete: {_results.Count} entries found.";
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { _status = "Cancelled."; }
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject IUserSessionService Session
|
@inject IUserSessionService Session
|
||||||
@inject ISessionManager SessionMgr
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IElevationCoordinator Elevation
|
||||||
@inject ISearchService SearchSvc
|
@inject ISearchService SearchSvc
|
||||||
@inject SearchCsvExportService CsvExport
|
@inject SearchCsvExportService CsvExport
|
||||||
@inject SearchHtmlExportService HtmlExport
|
@inject SearchHtmlExportService HtmlExport
|
||||||
@@ -107,10 +108,13 @@
|
|||||||
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
|
|
||||||
var exts = _extensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
var exts = _extensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
var opts = new SearchOptions(exts, _regex, null, null, null, null, _createdBy.TrimOrNull(), _modifiedBy.TrimOrNull(), _library.TrimOrNull(), _maxResults, siteUrl);
|
var opts = new SearchOptions(exts, _regex, null, null, null, null, _createdBy.TrimOrNull(), _modifiedBy.TrimOrNull(), _library.TrimOrNull(), _maxResults, siteUrl);
|
||||||
_results = (await SearchSvc.SearchFilesAsync(ctx, opts, progress, _cts.Token)).ToList();
|
_results = (await Elevation.RunAsync(async c =>
|
||||||
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
|
||||||
|
return await SearchSvc.SearchFilesAsync(ctx, opts, progress, c);
|
||||||
|
}, _cts.Token)).ToList();
|
||||||
_status = $"Found {_results.Count} files.";
|
_status = $"Found {_results.Count} files.";
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { _status = "Cancelled."; }
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject IUserSessionService Session
|
@inject IUserSessionService Session
|
||||||
@inject ISessionManager SessionMgr
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IElevationCoordinator Elevation
|
||||||
@inject IStorageService StorageSvc
|
@inject IStorageService StorageSvc
|
||||||
@inject StorageCsvExportService CsvExport
|
@inject StorageCsvExportService CsvExport
|
||||||
@inject StorageHtmlExportService HtmlExport
|
@inject StorageHtmlExportService HtmlExport
|
||||||
@@ -93,9 +94,12 @@
|
|||||||
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
|
|
||||||
var opts = new StorageScanOptions(IncludeSubsites: _includeSubsites, IncludeHiddenLibraries: _includeHidden, IncludeRecycleBin: _includeRecycleBin);
|
var opts = new StorageScanOptions(IncludeSubsites: _includeSubsites, IncludeHiddenLibraries: _includeHidden, IncludeRecycleBin: _includeRecycleBin);
|
||||||
_results = (await StorageSvc.CollectStorageAsync(ctx, opts, progress, _cts.Token)).ToList();
|
_results = (await Elevation.RunAsync(async c =>
|
||||||
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
|
||||||
|
return await StorageSvc.CollectStorageAsync(ctx, opts, progress, c);
|
||||||
|
}, _cts.Token)).ToList();
|
||||||
_status = $"Complete: {_results.Count} nodes.";
|
_status = $"Complete: {_results.Count} nodes.";
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { _status = "Cancelled."; }
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@inject IUserSessionService Session
|
@inject IUserSessionService Session
|
||||||
@inject IUserContextAccessor UserContext
|
@inject IUserContextAccessor UserContext
|
||||||
@inject ISessionManager SessionMgr
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IElevationCoordinator Elevation
|
||||||
@inject ITemplateService TemplateSvc
|
@inject ITemplateService TemplateSvc
|
||||||
@inject SharepointToolbox.Web.Infrastructure.Persistence.TemplateRepository TemplateRepo
|
@inject SharepointToolbox.Web.Infrastructure.Persistence.TemplateRepository TemplateRepo
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
@@ -108,9 +109,12 @@
|
|||||||
var progress = new Progress<OperationProgress>(p => { _status = p.Message; InvokeAsync(StateHasChanged); });
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; InvokeAsync(StateHasChanged); });
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
|
|
||||||
var opts = new SiteTemplateOptions { CaptureLibraries = _capLibraries, CaptureFolders = _capFolders, CapturePermissionGroups = _capGroups };
|
var opts = new SiteTemplateOptions { CaptureLibraries = _capLibraries, CaptureFolders = _capFolders, CapturePermissionGroups = _capGroups };
|
||||||
var template = await TemplateSvc.CaptureTemplateAsync(ctx, opts, progress, _cts.Token);
|
var template = await Elevation.RunAsync(async c =>
|
||||||
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
|
||||||
|
return await TemplateSvc.CaptureTemplateAsync(ctx, opts, progress, c);
|
||||||
|
}, _cts.Token);
|
||||||
template.Name = string.IsNullOrWhiteSpace(_captureName) ? $"Template-{DateTime.Now:yyyyMMdd}" : _captureName;
|
template.Name = string.IsNullOrWhiteSpace(_captureName) ? $"Template-{DateTime.Now:yyyyMMdd}" : _captureName;
|
||||||
await TemplateRepo.SaveAsync(template);
|
await TemplateRepo.SaveAsync(template);
|
||||||
_templates = (await TemplateRepo.GetAllAsync()).ToList();
|
_templates = (await TemplateRepo.GetAllAsync()).ToList();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@inject IUserSessionService Session
|
@inject IUserSessionService Session
|
||||||
@inject IUserContextAccessor UserContext
|
@inject IUserContextAccessor UserContext
|
||||||
@inject ISessionManager SessionMgr
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IElevationCoordinator Elevation
|
||||||
@inject IVersionCleanupService VersionSvc
|
@inject IVersionCleanupService VersionSvc
|
||||||
@inject VersionCleanupHtmlExportService HtmlExport
|
@inject VersionCleanupHtmlExportService HtmlExport
|
||||||
@inject WebExportService WebExport
|
@inject WebExportService WebExport
|
||||||
@@ -109,8 +110,11 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim();
|
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim();
|
||||||
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, CancellationToken.None);
|
_libraries = (await Elevation.RunAsync(async c =>
|
||||||
_libraries = (await VersionSvc.ListLibraryTitlesAsync(ctx, CancellationToken.None)).ToList();
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
|
||||||
|
return await VersionSvc.ListLibraryTitlesAsync(ctx, c);
|
||||||
|
}, CancellationToken.None)).ToList();
|
||||||
}
|
}
|
||||||
catch (Exception ex) { _error = ex.Message; }
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
finally { _loading = false; }
|
finally { _loading = false; }
|
||||||
@@ -126,9 +130,12 @@
|
|||||||
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
|
|
||||||
var opts = new VersionCleanupOptions(_selectedLibs, _keepLast, _keepFirst);
|
var opts = new VersionCleanupOptions(_selectedLibs, _keepLast, _keepFirst);
|
||||||
_results = (await VersionSvc.DeleteOldVersionsAsync(ctx, opts, progress, _cts.Token)).ToList();
|
_results = (await Elevation.RunAsync(async c =>
|
||||||
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
|
||||||
|
return await VersionSvc.DeleteOldVersionsAsync(ctx, opts, progress, c);
|
||||||
|
}, _cts.Token)).ToList();
|
||||||
_status = $"Complete: {_results.Sum(r => r.VersionsDeleted)} versions deleted.";
|
_status = $"Complete: {_results.Sum(r => r.VersionsDeleted)} versions deleted.";
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { _status = "Cancelled."; }
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using System.Net;
|
||||||
using Microsoft.SharePoint.Client;
|
using Microsoft.SharePoint.Client;
|
||||||
|
using Serilog;
|
||||||
using SharepointToolbox.Web.Core.Models;
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
namespace SharepointToolbox.Web.Core.Helpers;
|
namespace SharepointToolbox.Web.Core.Helpers;
|
||||||
@@ -29,8 +31,82 @@ public static class ExecuteQueryRetryHelper
|
|||||||
$"Throttled by SharePoint — retrying in {delaySeconds}s (attempt {attempt}/{MaxRetries})…"));
|
$"Throttled by SharePoint — retrying in {delaySeconds}s (attempt {attempt}/{MaxRetries})…"));
|
||||||
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct);
|
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw EnrichException(ctx, ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSOM surfaces a 403 as a bare "The remote server returned an error: (403) FORBIDDEN." WebException,
|
||||||
|
// which hides the actual SharePoint reason. Pull the server's response body / correlation id so the
|
||||||
|
// root cause (token scope, tenant policy, access-denied vs endpoint-blocked) is visible.
|
||||||
|
private static Exception EnrichException(ClientContext ctx, Exception ex)
|
||||||
|
{
|
||||||
|
var detail = new System.Text.StringBuilder();
|
||||||
|
detail.Append(ex.Message);
|
||||||
|
detail.Append($" [site={ctx.Url}]");
|
||||||
|
|
||||||
|
if (ex is ServerException se)
|
||||||
|
{
|
||||||
|
detail.Append($" [serverErrorType={se.ServerErrorTypeName}; value={se.ServerErrorValue}; " +
|
||||||
|
$"correlationId={se.ServerErrorTraceCorrelationId}; details={se.ServerErrorDetails}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk inner exceptions for a WebException carrying the raw HTTP response body.
|
||||||
|
for (Exception? cur = ex; cur is not null; cur = cur.InnerException)
|
||||||
|
{
|
||||||
|
if (cur is WebException we && we.Response is HttpWebResponse resp)
|
||||||
|
{
|
||||||
|
detail.Append($" [httpStatus={(int)resp.StatusCode} {resp.StatusCode}]");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = resp.GetResponseStream();
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
var body = reader.ReadToEnd();
|
||||||
|
if (!string.IsNullOrWhiteSpace(body))
|
||||||
|
detail.Append($" [responseBody={body.Trim()}]");
|
||||||
|
}
|
||||||
|
catch { /* body already consumed or unavailable */ }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var enriched = detail.ToString();
|
||||||
|
Log.Error("CSOM ExecuteQuery failed: {Detail}", enriched);
|
||||||
|
|
||||||
|
// Preserve access-denied as a typed exception so the elevation coordinator can
|
||||||
|
// detect it, take site-collection admin ownership, and retry. Everything else
|
||||||
|
// stays a generic InvalidOperationException carrying the enriched diagnostic.
|
||||||
|
if (IsAccessDenied(ex))
|
||||||
|
return new SharePointAccessDeniedException(enriched, ctx.Url, ex);
|
||||||
|
|
||||||
|
return new InvalidOperationException(enriched, ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access-denied reaches us in two shapes: a typed CSOM ServerException
|
||||||
|
// (ServerErrorTypeName = System.UnauthorizedAccessException), and — notably on
|
||||||
|
// Microsoft 365 Group / Teams-connected sites — a bare HTTP (403) FORBIDDEN
|
||||||
|
// WebException carrying "Access is denied ... 0x80070005 (E_ACCESSDENIED)".
|
||||||
|
// Both are ownership issues elevation can fix, so classify either as access-denied.
|
||||||
|
private static bool IsAccessDenied(Exception ex)
|
||||||
|
{
|
||||||
|
for (Exception? cur = ex; cur is not null; cur = cur.InnerException)
|
||||||
|
{
|
||||||
|
if (cur is ServerUnauthorizedAccessException)
|
||||||
|
return true;
|
||||||
|
if (cur is ServerException se &&
|
||||||
|
string.Equals(se.ServerErrorTypeName, "System.UnauthorizedAccessException", StringComparison.Ordinal))
|
||||||
|
return true;
|
||||||
|
if (cur is WebException we && we.Response is HttpWebResponse resp &&
|
||||||
|
resp.StatusCode == HttpStatusCode.Forbidden)
|
||||||
|
return true;
|
||||||
|
if (cur.Message.Contains("0x80070005", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
cur.Message.Contains("E_ACCESSDENIED", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
internal static bool IsThrottleException(Exception ex)
|
internal static bool IsThrottleException(Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a CSOM operation fails with a SharePoint "access denied"
|
||||||
|
/// (System.UnauthorizedAccessException / ServerUnauthorizedAccessException).
|
||||||
|
/// Carries the failing site URL so the elevation coordinator can take site-collection
|
||||||
|
/// admin ownership and retry. Message is the enriched diagnostic from EnrichException.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SharePointAccessDeniedException : Exception
|
||||||
|
{
|
||||||
|
public string SiteUrl { get; }
|
||||||
|
|
||||||
|
public SharePointAccessDeniedException(string message, string siteUrl, Exception inner)
|
||||||
|
: base(message, inner)
|
||||||
|
{
|
||||||
|
SiteUrl = siteUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,6 +152,7 @@ builder.Services.AddScoped<ITemplateService, TemplateService>();
|
|||||||
builder.Services.AddScoped<ICsvValidationService, CsvValidationService>();
|
builder.Services.AddScoped<ICsvValidationService, CsvValidationService>();
|
||||||
builder.Services.AddScoped<ISharePointGroupResolver, SharePointGroupResolver>();
|
builder.Services.AddScoped<ISharePointGroupResolver, SharePointGroupResolver>();
|
||||||
builder.Services.AddScoped<IOwnershipElevationService, OwnershipElevationService>();
|
builder.Services.AddScoped<IOwnershipElevationService, OwnershipElevationService>();
|
||||||
|
builder.Services.AddScoped<IElevationCoordinator, ElevationCoordinator>();
|
||||||
|
|
||||||
// ── Export services (Scoped) ──────────────────────────────────────────────────
|
// ── Export services (Scoped) ──────────────────────────────────────────────────
|
||||||
builder.Services.AddScoped<CsvExportService>();
|
builder.Services.AddScoped<CsvExportService>();
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ public class AppRegistrationService : IAppRegistrationService
|
|||||||
displayName = $"SP Toolbox — {tenantName}",
|
displayName = $"SP Toolbox — {tenantName}",
|
||||||
signInAudience = "AzureADMyOrg",
|
signInAudience = "AzureADMyOrg",
|
||||||
isFallbackPublicClient = true,
|
isFallbackPublicClient = true,
|
||||||
web = new { redirectUris = new[] { redirectUri } },
|
// Register the redirect under the PUBLIC client platform so the connect
|
||||||
|
// flow can redeem the auth code with PKCE only (no client secret). A
|
||||||
|
// redirect under `web` makes Entra treat the app as confidential and the
|
||||||
|
// token exchange fails with AADSTS7000218 (secret required).
|
||||||
|
publicClient = new { redirectUris = new[] { redirectUri } },
|
||||||
requiredResourceAccess = new[]
|
requiredResourceAccess = new[]
|
||||||
{
|
{
|
||||||
new
|
new
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using Serilog;
|
||||||
|
using SharepointToolbox.Web.Core.Helpers;
|
||||||
|
using SharepointToolbox.Web.Services.Session;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scoped per Blazor circuit. Catches <see cref="SharePointAccessDeniedException"/> from any
|
||||||
|
/// wrapped operation and, when AutoTakeOwnership is enabled, grants the current user
|
||||||
|
/// site-collection admin on the failing site (via the tenant admin endpoint) before retrying.
|
||||||
|
///
|
||||||
|
/// Retry is safe because the wrapped operation closure re-issues its own CSOM loads on each
|
||||||
|
/// attempt; the granted permission is server-side and takes effect for the existing delegated
|
||||||
|
/// token without re-authentication. Each site is elevated at most once per circuit to prevent loops.
|
||||||
|
///
|
||||||
|
/// Both the admin-endpoint grant and the post-grant operation are retried with backoff: the
|
||||||
|
/// tenant admin endpoint can transiently 403 on a cold token, and the site-collection admin grant
|
||||||
|
/// is eventually consistent (notably on Group/Teams-connected sites), taking a few seconds to apply.
|
||||||
|
/// </summary>
|
||||||
|
public class ElevationCoordinator : IElevationCoordinator
|
||||||
|
{
|
||||||
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly IOwnershipElevationService _ownership;
|
||||||
|
private readonly IUserSessionService _session;
|
||||||
|
private readonly HashSet<string> _elevatedSites = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public ElevationCoordinator(
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
IOwnershipElevationService ownership,
|
||||||
|
IUserSessionService session)
|
||||||
|
{
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_ownership = ownership;
|
||||||
|
_session = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RunAsync(Func<CancellationToken, Task> operation, CancellationToken ct) =>
|
||||||
|
await RunAsync<object?>(async c => { await operation(c); return null; }, ct);
|
||||||
|
|
||||||
|
public async Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> operation, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await operation(ct);
|
||||||
|
}
|
||||||
|
catch (SharePointAccessDeniedException ex)
|
||||||
|
{
|
||||||
|
if (!_session.Settings.AutoTakeOwnership)
|
||||||
|
throw;
|
||||||
|
|
||||||
|
var siteUrl = ex.SiteUrl.TrimEnd('/');
|
||||||
|
var key = siteUrl.ToLowerInvariant();
|
||||||
|
|
||||||
|
// Already elevated this site and still denied → elevation can't fix it. Surface original.
|
||||||
|
if (_elevatedSites.Contains(key))
|
||||||
|
throw;
|
||||||
|
|
||||||
|
// Elevation targets the tenant admin endpoint; denials there aren't site-ownership issues.
|
||||||
|
if (siteUrl.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw;
|
||||||
|
|
||||||
|
await ElevateAsync(siteUrl, ct);
|
||||||
|
_elevatedSites.Add(key);
|
||||||
|
|
||||||
|
// Verify the grant actually took effect for this delegated token before retrying,
|
||||||
|
// so the logs distinguish "grant failed/no-op" from "scan still fails for another reason".
|
||||||
|
await VerifyAdminAsync(siteUrl, ct);
|
||||||
|
|
||||||
|
// The site-collection admin grant is eventually consistent — on Group/Teams sites it
|
||||||
|
// can take a few seconds to propagate to the content endpoint. Retry with backoff.
|
||||||
|
for (int attempt = 1; ; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await operation(ct);
|
||||||
|
}
|
||||||
|
catch (SharePointAccessDeniedException) when (attempt < MaxBackoffAttempts)
|
||||||
|
{
|
||||||
|
var delay = TimeSpan.FromSeconds(BackoffBaseSeconds * attempt);
|
||||||
|
Log.Warning("Post-elevation scan still denied for {Site} (attempt {N}/{Max}); retrying in {Delay}s.",
|
||||||
|
siteUrl, attempt, MaxBackoffAttempts, delay.TotalSeconds);
|
||||||
|
await Task.Delay(delay, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int MaxBackoffAttempts = 4;
|
||||||
|
private const int BackoffBaseSeconds = 3;
|
||||||
|
|
||||||
|
private async Task ElevateAsync(string siteUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var profile = _session.CurrentProfile
|
||||||
|
?? throw new InvalidOperationException("Cannot elevate ownership: no active profile.");
|
||||||
|
|
||||||
|
var adminProfile = new Core.Models.TenantProfile
|
||||||
|
{
|
||||||
|
Id = profile.Id,
|
||||||
|
Name = profile.Name,
|
||||||
|
TenantUrl = BuildAdminUrl(siteUrl),
|
||||||
|
TenantId = profile.TenantId,
|
||||||
|
ClientId = profile.ClientId,
|
||||||
|
ClientLogo = profile.ClientLogo,
|
||||||
|
};
|
||||||
|
|
||||||
|
var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
|
||||||
|
Log.Information("Auto-elevating site-collection admin ownership for {Site} via {Admin}",
|
||||||
|
siteUrl, adminProfile.TenantUrl);
|
||||||
|
|
||||||
|
for (int attempt = 1; ; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// loginName empty → ElevateAsync resolves the current (delegated) user from the admin context.
|
||||||
|
await _ownership.ElevateAsync(adminCtx, siteUrl, loginName: string.Empty, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The admin endpoint can transiently 403 on a cold token / first call; it clears within
|
||||||
|
// seconds. A genuine lack of tenant-admin rights keeps failing and surfaces after retries.
|
||||||
|
catch (SharePointAccessDeniedException ex) when (attempt < MaxBackoffAttempts)
|
||||||
|
{
|
||||||
|
var delay = TimeSpan.FromSeconds(BackoffBaseSeconds * attempt);
|
||||||
|
Log.Warning("Admin endpoint denied for {Site} (attempt {N}/{Max}); retrying in {Delay}s. {Err}",
|
||||||
|
siteUrl, attempt, MaxBackoffAttempts, delay.TotalSeconds, ex.Message);
|
||||||
|
await Task.Delay(delay, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Auto-elevate ownership failed for {Site}", siteUrl);
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Auto-elevate ownership failed for {siteUrl}. Granting site-collection admin requires " +
|
||||||
|
$"SharePoint tenant administrator rights on the signed-in account. ({ex.Message})", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads the current user's site-admin flag on the target site right after elevation.
|
||||||
|
// Diagnostic only — never throws into the operation flow.
|
||||||
|
private async Task VerifyAdminAsync(string siteUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctx = await _sessionManager.GetOrCreateContextAsync(siteUrl, _session.CurrentProfile!, ct);
|
||||||
|
ctx.Load(ctx.Web.CurrentUser, u => u.LoginName, u => u.IsSiteAdmin);
|
||||||
|
await ctx.ExecuteQueryAsync();
|
||||||
|
Log.Information("Post-elevation check {Site}: user={Login} IsSiteAdmin={IsAdmin}",
|
||||||
|
siteUrl, ctx.Web.CurrentUser.LoginName, ctx.Web.CurrentUser.IsSiteAdmin);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning("Post-elevation check failed for {Site}: {Error}", siteUrl, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://abcube.sharepoint.com/sites/Foo → https://abcube-admin.sharepoint.com
|
||||||
|
private static string BuildAdminUrl(string siteUrl)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(siteUrl, UriKind.Absolute, out var uri))
|
||||||
|
return siteUrl;
|
||||||
|
var adminHost = uri.Host.Replace(".sharepoint.com", "-admin.sharepoint.com",
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
return $"{uri.Scheme}://{adminHost}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace SharepointToolbox.Web.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps a SharePoint operation so that, when the "Auto-elevate ownership when permission
|
||||||
|
/// scan is denied" setting is enabled, an access-denied failure triggers taking
|
||||||
|
/// site-collection admin ownership of the failing site and re-running the operation once.
|
||||||
|
/// When the setting is off (or elevation is impossible/unsuccessful) the original
|
||||||
|
/// access-denied error propagates unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public interface IElevationCoordinator
|
||||||
|
{
|
||||||
|
Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> operation, CancellationToken ct);
|
||||||
|
Task RunAsync(Func<CancellationToken, Task> operation, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -11,6 +11,5 @@ public interface IUserAccessAuditService
|
|||||||
IReadOnlyList<SiteInfo> sites,
|
IReadOnlyList<SiteInfo> sites,
|
||||||
ScanOptions options,
|
ScanOptions options,
|
||||||
IProgress<OperationProgress> progress,
|
IProgress<OperationProgress> progress,
|
||||||
CancellationToken ct,
|
CancellationToken ct);
|
||||||
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Microsoft.Online.SharePoint.TenantAdministration;
|
using Microsoft.Online.SharePoint.TenantAdministration;
|
||||||
using Microsoft.SharePoint.Client;
|
using Microsoft.SharePoint.Client;
|
||||||
|
using Serilog;
|
||||||
|
using SharepointToolbox.Web.Core.Helpers;
|
||||||
using SharepointToolbox.Web.Services.Audit;
|
using SharepointToolbox.Web.Services.Audit;
|
||||||
|
|
||||||
namespace SharepointToolbox.Web.Services;
|
namespace SharepointToolbox.Web.Services;
|
||||||
@@ -15,12 +17,20 @@ public class OwnershipElevationService : IOwnershipElevationService
|
|||||||
if (string.IsNullOrWhiteSpace(loginName))
|
if (string.IsNullOrWhiteSpace(loginName))
|
||||||
{
|
{
|
||||||
tenantAdminCtx.Load(tenantAdminCtx.Web.CurrentUser, u => u.LoginName);
|
tenantAdminCtx.Load(tenantAdminCtx.Web.CurrentUser, u => u.LoginName);
|
||||||
await tenantAdminCtx.ExecuteQueryAsync();
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(tenantAdminCtx, null, ct);
|
||||||
loginName = tenantAdminCtx.Web.CurrentUser.LoginName;
|
loginName = tenantAdminCtx.Web.CurrentUser.LoginName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.Information("SetSiteAdmin: granting {Login} site-collection admin on {Site} via admin endpoint {Admin}",
|
||||||
|
loginName, siteUrl, tenantAdminCtx.Url);
|
||||||
|
|
||||||
var tenant = new Tenant(tenantAdminCtx);
|
var tenant = new Tenant(tenantAdminCtx);
|
||||||
tenant.SetSiteAdmin(siteUrl, loginName, isSiteAdmin: true);
|
tenant.SetSiteAdmin(siteUrl, loginName, isSiteAdmin: true);
|
||||||
await tenantAdminCtx.ExecuteQueryAsync();
|
// Route through the enricher so a denial on the admin endpoint surfaces the real reason
|
||||||
|
// (and gets logged) instead of a bare 403 the caller has to guess at.
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(tenantAdminCtx, null, ct);
|
||||||
|
|
||||||
|
Log.Information("SetSiteAdmin call accepted for {Site} (login {Login})", siteUrl, loginName);
|
||||||
await _audit.LogAsync("ElevateOwnership", tenantAdminCtx.Url, new[] { siteUrl },
|
await _audit.LogAsync("ElevateOwnership", tenantAdminCtx.Url, new[] { siteUrl },
|
||||||
$"Site admin granted to {loginName}");
|
$"Site admin granted to {loginName}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,11 +122,39 @@ public class StorageService : IStorageService
|
|||||||
string url = list.RootFolder.ServerRelativeUrl.TrimEnd('/') + "/Attachments";
|
string url = list.RootFolder.ServerRelativeUrl.TrimEnd('/') + "/Attachments";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Enumerate attachment files directly instead of reading folder.StorageMetrics (admin-gated → 403
|
||||||
|
// for delegated non-admin tokens). SP list attachments are flat: /Attachments/{itemId}/<file>.
|
||||||
|
// Pass 1: item folders + any files at the Attachments root. Pass 2: batch-load every item folder's
|
||||||
|
// files in a single round trip. Bounded to 2 server calls regardless of attachment count.
|
||||||
var folder = ctx.Web.GetFolderByServerRelativeUrl(url);
|
var folder = ctx.Web.GetFolderByServerRelativeUrl(url);
|
||||||
ctx.Load(folder, f => f.Exists, f => f.StorageMetrics, f => f.TimeLastModified, f => f.ServerRelativeUrl);
|
ctx.Load(folder, f => f.Exists);
|
||||||
|
ctx.Load(folder.Folders, fs => fs.Include(f => f.ServerRelativeUrl));
|
||||||
|
ctx.Load(folder.Files, fs => fs.Include(f => f.Length, f => f.TimeLastModified));
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
if (!folder.Exists || folder.StorageMetrics.TotalFileCount == 0) return null;
|
if (!folder.Exists) return null;
|
||||||
return new StorageNode { Name = $"[Attachments] {list.Title}", Url = ctx.Url.TrimEnd('/') + url, SiteTitle = siteTitle, Library = list.Title, Kind = StorageNodeKind.ListAttachments, TotalSizeBytes = folder.StorageMetrics.TotalSize, FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize, TotalFileCount = folder.StorageMetrics.TotalFileCount, LastModified = folder.StorageMetrics.LastModified > DateTime.MinValue ? folder.StorageMetrics.LastModified : (DateTime?)null };
|
|
||||||
|
long size = 0; int count = 0; DateTime? last = null;
|
||||||
|
void Accumulate(IEnumerable<Microsoft.SharePoint.Client.File> files)
|
||||||
|
{
|
||||||
|
foreach (var f in files)
|
||||||
|
{
|
||||||
|
size += f.Length; count++;
|
||||||
|
if (f.TimeLastModified > DateTime.MinValue && (last is null || f.TimeLastModified > last)) last = f.TimeLastModified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Accumulate(folder.Files);
|
||||||
|
|
||||||
|
var itemFolders = folder.Folders.ToList();
|
||||||
|
if (itemFolders.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var itemFolder in itemFolders)
|
||||||
|
ctx.Load(itemFolder.Files, fs => fs.Include(f => f.Length, f => f.TimeLastModified));
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
foreach (var itemFolder in itemFolders) Accumulate(itemFolder.Files);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count == 0) return null;
|
||||||
|
return new StorageNode { Name = $"[Attachments] {list.Title}", Url = ctx.Url.TrimEnd('/') + url, SiteTitle = siteTitle, Library = list.Title, Kind = StorageNodeKind.ListAttachments, TotalSizeBytes = size, FileStreamSizeBytes = size, TotalFileCount = count, LastModified = last };
|
||||||
}
|
}
|
||||||
catch { return null; }
|
catch { return null; }
|
||||||
}
|
}
|
||||||
@@ -289,10 +317,14 @@ public class StorageService : IStorageService
|
|||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
var folder = ctx.Web.GetFolderByServerRelativeUrl(serverRelativeUrl);
|
var folder = ctx.Web.GetFolderByServerRelativeUrl(serverRelativeUrl);
|
||||||
ctx.Load(folder, f => f.StorageMetrics, f => f.TimeLastModified, f => f.ServerRelativeUrl, f => f.Name);
|
// NOTE: deliberately NOT loading folder.StorageMetrics — that property is site-collection-admin
|
||||||
|
// gated in SharePoint Online and returns (403) FORBIDDEN for delegated non-admin tokens. Its values
|
||||||
|
// are discarded anyway: callers run ResetNodeCounts + BackfillLibFromFilesAsync, which recompute all
|
||||||
|
// sizes/counts from per-file CAML enumeration. Only TimeLastModified is kept.
|
||||||
|
ctx.Load(folder, f => f.TimeLastModified, f => f.ServerRelativeUrl, f => f.Name);
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
DateTime? lastMod = folder.StorageMetrics.LastModified > DateTime.MinValue ? folder.StorageMetrics.LastModified : folder.TimeLastModified > DateTime.MinValue ? folder.TimeLastModified : (DateTime?)null;
|
DateTime? lastMod = folder.TimeLastModified > DateTime.MinValue ? folder.TimeLastModified : (DateTime?)null;
|
||||||
return new StorageNode { Name = name, Url = ctx.Url.TrimEnd('/') + serverRelativeUrl, SiteTitle = siteTitle, Library = library, Kind = kind, TotalSizeBytes = folder.StorageMetrics.TotalSize, FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize, TotalFileCount = folder.StorageMetrics.TotalFileCount, LastModified = lastMod, IndentLevel = indentLevel, Children = new List<StorageNode>() };
|
return new StorageNode { Name = name, Url = ctx.Url.TrimEnd('/') + serverRelativeUrl, SiteTitle = siteTitle, Library = library, Kind = kind, TotalSizeBytes = 0, FileStreamSizeBytes = 0, TotalFileCount = 0, LastModified = lastMod, IndentLevel = indentLevel, Children = new List<StorageNode>() };
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task CollectSubfoldersAsync(ClientContext ctx, List list, string parentServerRelativeUrl, StorageNode parentNode, int currentDepth, int maxDepth, string siteTitle, string library, StorageNodeKind kind, IProgress<OperationProgress> progress, CancellationToken ct)
|
private static async Task CollectSubfoldersAsync(ClientContext ctx, List list, string parentServerRelativeUrl, StorageNode parentNode, int currentDepth, int maxDepth, string siteTitle, string library, StorageNodeKind kind, IProgress<OperationProgress> progress, CancellationToken ct)
|
||||||
|
|||||||
@@ -6,15 +6,17 @@ namespace SharepointToolbox.Web.Services;
|
|||||||
public class UserAccessAuditService : IUserAccessAuditService
|
public class UserAccessAuditService : IUserAccessAuditService
|
||||||
{
|
{
|
||||||
private readonly IPermissionsService _permissionsService;
|
private readonly IPermissionsService _permissionsService;
|
||||||
|
private readonly IElevationCoordinator _elevation;
|
||||||
|
|
||||||
private static readonly HashSet<string> HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
"Full Control", "Site Collection Administrator"
|
"Full Control", "Site Collection Administrator"
|
||||||
};
|
};
|
||||||
|
|
||||||
public UserAccessAuditService(IPermissionsService permissionsService)
|
public UserAccessAuditService(IPermissionsService permissionsService, IElevationCoordinator elevation)
|
||||||
{
|
{
|
||||||
_permissionsService = permissionsService;
|
_permissionsService = permissionsService;
|
||||||
|
_elevation = elevation;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
|
public async Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
|
||||||
@@ -24,8 +26,7 @@ public class UserAccessAuditService : IUserAccessAuditService
|
|||||||
IReadOnlyList<SiteInfo> sites,
|
IReadOnlyList<SiteInfo> sites,
|
||||||
ScanOptions options,
|
ScanOptions options,
|
||||||
IProgress<OperationProgress> progress,
|
IProgress<OperationProgress> progress,
|
||||||
CancellationToken ct,
|
CancellationToken ct)
|
||||||
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null)
|
|
||||||
{
|
{
|
||||||
var targets = targetUserLogins
|
var targets = targetUserLogins
|
||||||
.Select(l => l.Trim().ToLowerInvariant())
|
.Select(l => l.Trim().ToLowerInvariant())
|
||||||
@@ -50,19 +51,13 @@ public class UserAccessAuditService : IUserAccessAuditService
|
|||||||
Name = site.Title
|
Name = site.Title
|
||||||
};
|
};
|
||||||
|
|
||||||
var ctx = await sessionManager.GetOrCreateContextAsync(profile, ct);
|
// Auto-elevates site-collection admin ownership and retries when a scan is denied,
|
||||||
IReadOnlyList<PermissionEntry> permEntries;
|
// if the AutoTakeOwnership setting is enabled (otherwise the access-denied propagates).
|
||||||
try
|
var permEntries = await _elevation.RunAsync(async c =>
|
||||||
{
|
{
|
||||||
permEntries = await _permissionsService.ScanSiteAsync(ctx, options, progress, ct);
|
var ctx = await sessionManager.GetOrCreateContextAsync(profile, c);
|
||||||
}
|
return await _permissionsService.ScanSiteAsync(ctx, options, progress, c);
|
||||||
catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) when (onAccessDenied != null)
|
}, ct);
|
||||||
{
|
|
||||||
var elevated = await onAccessDenied(site.Url, ct);
|
|
||||||
if (!elevated) throw;
|
|
||||||
var retryCtx = await sessionManager.GetOrCreateContextAsync(profile, ct);
|
|
||||||
permEntries = await _permissionsService.ScanSiteAsync(retryCtx, options, progress, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
allEntries.AddRange(TransformEntries(permEntries, targets, site));
|
allEntries.AddRange(TransformEntries(permEntries, targets, site));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user