using Microsoft.Online.SharePoint.TenantAdministration; using Microsoft.SharePoint.Client; using SharepointToolbox.Web.Core.Models; namespace SharepointToolbox.Web.Core.Helpers; /// /// Enumerates every site collection in a tenant via the SharePoint tenant-admin /// endpoint (Tenant.GetSitePropertiesFromSharePointByFilters), paging through /// all results. Shared by the delegated SiteDiscoveryService and the app-only /// background report scheduler so both produce the identical, complete site set. /// /// The caller supplies a already pointed at the tenant /// admin host (see ) and authenticated by whichever model /// applies. The cold-token 403 retry handles the transient denial a freshly minted /// delegated token hits against the admin host; it is harmless under app-only auth. /// public static class TenantSiteEnumerator { private const int MaxColdTokenAttempts = 4; private const int BackoffBaseSeconds = 3; public static async Task> EnumerateAsync(ClientContext adminCtx, CancellationToken ct) { var tenant = new Tenant(adminCtx); var filter = new SPOSitePropertiesEnumerableFilter { IncludeDetail = false, IncludePersonalSite = PersonalSiteFilter.Exclude, StartIndex = null, Template = null, }; var results = new List(); SPOSitePropertiesEnumerable page; do { ct.ThrowIfCancellationRequested(); page = await FetchPageWithColdTokenRetryAsync(adminCtx, tenant, filter, ct); foreach (var sp in page) { var url = sp.Url ?? string.Empty; if (string.IsNullOrEmpty(url)) continue; if (url.Contains("/personal/", StringComparison.OrdinalIgnoreCase)) continue; var title = string.IsNullOrEmpty(sp.Title) ? url : sp.Title; results.Add(new SiteInfo(url, title)); } 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 static async Task 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 enumeration (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 public 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}"; } }