Merge pull request 'Add report logos and configurable folder scan depth' (#2) from feat/report-logos-and-scan-depth into main

Reviewed-on: #2
This commit is contained in:
2026-06-02 15:02:52 +02:00
committed by kawa
26 changed files with 631 additions and 91 deletions
+76
View File
@@ -0,0 +1,76 @@
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;
/// <summary>
/// Delegated Graph implementation of <see cref="ISiteDiscoveryService"/>.
/// Uses the <c>/sites?search=*</c> endpoint, paging through every result.
/// Requires the delegated <c>Sites.Read.All</c> scope.
/// </summary>
public class SiteDiscoveryService : ISiteDiscoveryService
{
private readonly AppGraphClientFactory _graphClientFactory;
public SiteDiscoveryService(AppGraphClientFactory graphClientFactory)
{
_graphClientFactory = graphClientFactory;
}
public async Task<IReadOnlyList<SiteInfo>> 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<string, object>
{
{ "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<SiteCollectionResponse>(
requestInfo, SiteCollectionResponse.CreateFromDiscriminatorValue, cancellationToken: ct);
if (response is null) return Array.Empty<SiteInfo>();
var results = new List<SiteInfo>();
var iter = PageIterator<Site, SiteCollectionResponse>.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();
}
}