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; /// /// Delegated CSOM implementation of . /// /// Enumerates every site collection via the SharePoint tenant admin endpoint /// (Tenant.GetSitePropertiesFromSharePointByFilters), paging through all /// results. Requires the signed-in user to be a SharePoint administrator. /// /// The Graph /sites?search=* 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. /sites/getAllSites 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. /// public class SiteDiscoveryService : ISiteDiscoveryService { private readonly ISessionManager _sessionManager; public SiteDiscoveryService(ISessionManager sessionManager) { _sessionManager = sessionManager; } public async Task> 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(); 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 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}"; } }