Files
SharepointToolbox-Web/Services/SiteDiscoveryService.cs
T

138 lines
5.8 KiB
C#

using Microsoft.Online.SharePoint.TenantAdministration;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Services.Session;
namespace SharepointToolbox.Web.Services;
/// <summary>
/// Delegated CSOM implementation of <see cref="ISiteDiscoveryService"/>.
///
/// Enumerates every site collection via the SharePoint tenant admin endpoint
/// (<c>Tenant.GetSitePropertiesFromSharePointByFilters</c>), paging through all
/// results. Requires the signed-in user to be a SharePoint administrator.
///
/// The Graph <c>/sites?search=*</c> endpoint was deliberately abandoned: it ranks
/// by relevance and is capped server-side, so it silently dropped sites and
/// returned varying counts run-to-run. <c>/sites/getAllSites</c> is app-only and
/// 403s on a delegated user token. The tenant admin enumeration is the only path
/// that returns the complete, stable set under the app's delegated auth model.
/// </summary>
public class SiteDiscoveryService : ISiteDiscoveryService
{
private readonly ISessionManager _sessionManager;
public SiteDiscoveryService(ISessionManager sessionManager)
{
_sessionManager = sessionManager;
}
public async Task<IReadOnlyList<SiteInfo>> SearchSitesAsync(
TenantProfile profile,
string? query = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrEmpty(profile.TenantUrl);
// Site enumeration only exists on the tenant admin endpoint.
var adminProfile = new TenantProfile
{
Id = profile.Id,
Name = profile.Name,
TenantUrl = BuildAdminUrl(profile.TenantUrl),
TenantId = profile.TenantId,
ClientId = profile.ClientId,
ClientLogo = profile.ClientLogo,
};
var ctx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
var tenant = new Tenant(ctx);
var filter = new SPOSitePropertiesEnumerableFilter
{
IncludeDetail = false,
IncludePersonalSite = PersonalSiteFilter.Exclude,
StartIndex = null,
Template = null,
};
var results = new List<SiteInfo>();
SPOSitePropertiesEnumerable page;
do
{
ct.ThrowIfCancellationRequested();
page = await FetchPageWithColdTokenRetryAsync(ctx, tenant, filter, ct);
foreach (var sp in page)
{
var url = sp.Url ?? string.Empty;
if (string.IsNullOrEmpty(url)) continue;
// Belt-and-braces: PersonalSiteFilter.Exclude already drops OneDrive.
if (url.Contains("/personal/", StringComparison.OrdinalIgnoreCase)) continue;
var title = string.IsNullOrEmpty(sp.Title) ? url : sp.Title;
results.Add(new SiteInfo(url, title));
}
// NextStartIndexFromSharePoint is empty/null once the last page is returned.
filter.StartIndex = page.NextStartIndexFromSharePoint;
}
while (!string.IsNullOrEmpty(filter.StartIndex));
return results
.GroupBy(s => s.Url, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private const int MaxColdTokenAttempts = 4;
private const int BackoffBaseSeconds = 3;
// The tenant admin endpoint transiently 403s on a cold delegated token (the same
// behaviour the elevation coordinator handles): the first call against the admin
// host can be denied while the token warms, then clears within seconds. Retry the
// admin query on access-denied with backoff. A genuine lack of SharePoint tenant
// administrator rights keeps failing and surfaces the enriched 403 after retries —
// elevation cannot self-grant tenant-admin, so there is nothing to auto-correct.
//
// The request (GetSiteProperties + Load) MUST be re-issued inside the loop: a failed
// CSOM ExecuteQuery clears the context's pending-request queue, so retrying the
// execute alone would run an empty batch, leave the page uninitialized, and throw
// "The collection has not been initialized" on iteration.
private static async Task<SPOSitePropertiesEnumerable> FetchPageWithColdTokenRetryAsync(
ClientContext ctx, Tenant tenant, SPOSitePropertiesEnumerableFilter filter, CancellationToken ct)
{
for (int attempt = 1; ; attempt++)
{
try
{
var page = tenant.GetSitePropertiesFromSharePointByFilters(filter);
ctx.Load(page);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
return page;
}
catch (SharePointAccessDeniedException ex) when (attempt < MaxColdTokenAttempts)
{
var delay = TimeSpan.FromSeconds(BackoffBaseSeconds * attempt);
Serilog.Log.Warning(
"Tenant admin endpoint denied during site discovery (attempt {N}/{Max}); " +
"retrying in {Delay}s. {Err}",
attempt, MaxColdTokenAttempts, delay.TotalSeconds, ex.Message);
await Task.Delay(delay, ct);
}
}
}
// https://contoso.sharepoint.com[/sites/Foo] → https://contoso-admin.sharepoint.com
private static string BuildAdminUrl(string tenantUrl)
{
if (!Uri.TryCreate(tenantUrl, UriKind.Absolute, out var uri))
return tenantUrl;
var adminHost = uri.Host.Replace(".sharepoint.com", "-admin.sharepoint.com",
StringComparison.OrdinalIgnoreCase);
return $"{uri.Scheme}://{adminHost}";
}
}