using Microsoft.Graph; using Microsoft.Graph.Models; using Microsoft.Kiota.Abstractions; using SharepointToolbox.Web.Core.Models; using AppGraphClientFactory = SharepointToolbox.Web.Infrastructure.Auth.GraphClientFactory; namespace SharepointToolbox.Web.Services; /// /// Delegated Graph implementation of . /// Uses the /sites?search=* endpoint, paging through every result. /// Requires the delegated Sites.Read.All scope. /// public class SiteDiscoveryService : ISiteDiscoveryService { private readonly AppGraphClientFactory _graphClientFactory; public SiteDiscoveryService(AppGraphClientFactory graphClientFactory) { _graphClientFactory = graphClientFactory; } public async Task> SearchSitesAsync( TenantProfile profile, string? query = null, CancellationToken ct = default) { var graphClient = await _graphClientFactory.CreateClientAsync(profile); // "*" is the Graph convention for "return all sites". var search = string.IsNullOrWhiteSpace(query) ? "*" : query!; // The typed Sites.GetAsync maps its Search property to OData "$search", // which routes "*" through KQL and fails ("'*' is not valid at position 0"). // The all-sites wildcard only works via the bare, non-OData "search" // query parameter, so build the request manually. var requestInfo = new RequestInformation { HttpMethod = Method.GET, UrlTemplate = "{+baseurl}/sites{?search,%24top}", PathParameters = new Dictionary { { "baseurl", graphClient.RequestAdapter.BaseUrl ?? "https://graph.microsoft.com/v1.0" } }, }; requestInfo.QueryParameters.Add("search", search); requestInfo.QueryParameters.Add("%24top", 999); requestInfo.Headers.Add("Accept", "application/json"); var response = await graphClient.RequestAdapter.SendAsync( requestInfo, SiteCollectionResponse.CreateFromDiscriminatorValue, cancellationToken: ct); if (response is null) return Array.Empty(); var results = new List(); var iter = PageIterator.CreatePageIterator( graphClient, response, site => { if (ct.IsCancellationRequested) return false; var url = site.WebUrl ?? string.Empty; if (string.IsNullOrEmpty(url)) return true; // Skip OneDrive personal sites — not useful for these scans. if (url.Contains("/personal/", StringComparison.OrdinalIgnoreCase)) return true; var title = site.DisplayName ?? site.Name ?? url; results.Add(new SiteInfo(url, title)); return true; }); await iter.IterateAsync(ct); return results .GroupBy(s => s.Url, StringComparer.OrdinalIgnoreCase) .Select(g => g.First()) .OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase) .ToList(); } }