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();
}
}