Files
Sharepoint-Toolbox/SharepointToolbox/Services/SystemGroupTargetResolver.cs
T

206 lines
8.3 KiB
C#

using Microsoft.SharePoint.Client;
using Microsoft.SharePoint.Client.Search.Query;
using Serilog;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
/// <summary>
/// CSOM-backed resolver that converts the GUID/identifier embedded in a SharePoint
/// system group name into a clickable target. Results are cached per
/// (site URL, guid) to avoid repeated round-trips when the same Limited Access
/// system group recurs on many objects.
///
/// Never throws — on any failure (not found, access denied, claims error), returns
/// <c>null</c> and the caller renders the raw group name.
/// </summary>
public class SystemGroupTargetResolver : ISystemGroupTargetResolver
{
private readonly Dictionary<string, SystemGroupTarget?> _cache =
new(StringComparer.OrdinalIgnoreCase);
public async Task<SystemGroupTarget?> ResolveAsync(
ClientContext ctx,
SystemGroupClassification classification,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var key = BuildCacheKey(ctx.Url, classification);
if (key is not null && _cache.TryGetValue(key, out var cached))
return cached;
SystemGroupTarget? result = null;
try
{
result = classification.Kind switch
{
SystemGroupKind.LimitedAccessWeb => await ResolveWebAsync(ctx, classification.WebId!.Value, ct),
SystemGroupKind.LimitedAccessList => await ResolveListAsync(ctx, classification.ListId!.Value, ct),
SystemGroupKind.SharingLink => await ResolveItemAsync(ctx, classification.ItemUniqueId!.Value, classification.LinkType, ct),
_ => null
};
}
catch (Exception ex)
{
Log.Debug("System group target resolution failed for {Kind} on {Site}: {Error}",
classification.Kind, ctx.Url, ex.Message);
}
if (key is not null)
_cache[key] = result;
return result;
}
private static string? BuildCacheKey(string siteUrl, SystemGroupClassification c) => c.Kind switch
{
SystemGroupKind.LimitedAccessWeb => $"{siteUrl}|web|{c.WebId}",
SystemGroupKind.LimitedAccessList => $"{siteUrl}|list|{c.ListId}",
SystemGroupKind.SharingLink => $"{siteUrl}|item|{c.ItemUniqueId}",
_ => null
};
private static async Task<SystemGroupTarget?> ResolveWebAsync(
ClientContext ctx, Guid webId, CancellationToken ct)
{
var web = ctx.Site.OpenWebById(webId);
ctx.Load(web, w => w.Title, w => w.Url);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
return new SystemGroupTarget(SystemGroupKind.LimitedAccessWeb, web.Title, web.Url);
}
private static async Task<SystemGroupTarget?> ResolveListAsync(
ClientContext ctx, Guid listId, CancellationToken ct)
{
var list = ctx.Web.Lists.GetById(listId);
ctx.Load(list, l => l.Title, l => l.DefaultViewUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
var url = BuildAbsoluteUrl(ctx.Url, list.DefaultViewUrl);
return new SystemGroupTarget(SystemGroupKind.LimitedAccessList, list.Title, url);
}
private static async Task<SystemGroupTarget?> ResolveItemAsync(
ClientContext ctx, Guid itemUniqueId, string? linkType, CancellationToken ct)
{
// 1. Try as file on current web (most sharing links target files).
try
{
var file = ctx.Web.GetFileById(itemUniqueId);
ctx.Load(file, f => f.Name, f => f.ServerRelativeUrl, f => f.Exists);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
if (file.Exists)
{
var url = BuildAbsoluteUrl(ctx.Url, file.ServerRelativeUrl);
return new SystemGroupTarget(SystemGroupKind.SharingLink, file.Name, url, linkType);
}
}
catch (ServerException) { /* fall through */ }
// 2. Try as folder on current web.
try
{
var folder = ctx.Web.GetFolderById(itemUniqueId);
ctx.Load(folder, f => f.Name, f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
var url = BuildAbsoluteUrl(ctx.Url, folder.ServerRelativeUrl);
return new SystemGroupTarget(SystemGroupKind.SharingLink, folder.Name, url, linkType);
}
catch (ServerException) { /* fall through */ }
// 3. Search-index fallback — covers items moved to a different subsite or
// deleted recently (the index may lag the deletion by minutes/hours).
// Search is permission-trimmed, so this only returns hits the caller
// can still see; results carry a "(via index)" hint in the label.
return await TryResolveViaSearchAsync(ctx, itemUniqueId, linkType, ct);
}
/// <summary>
/// Queries the SharePoint search index for an item by its UniqueId. Returns
/// a target with the last-indexed path and a label prefix flagging that the
/// hit came from the index (so admins know the live lookup failed).
/// </summary>
private static async Task<SystemGroupTarget?> TryResolveViaSearchAsync(
ClientContext ctx, Guid itemUniqueId, string? linkType, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
try
{
// KQL: UniqueId managed property. Braces around the GUID are how the
// SP search engine matches the canonical form for content unique ids.
var kq = new KeywordQuery(ctx)
{
QueryText = $"UniqueId:{{{itemUniqueId}}}",
RowLimit = 1,
TrimDuplicates = false
};
kq.SelectProperties.Add("Path");
kq.SelectProperties.Add("Title");
kq.SelectProperties.Add("FileExtension");
var executor = new SearchExecutor(ctx);
var result = executor.ExecuteQuery(kq);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
var table = result.Value
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
if (table is null || table.RowCount == 0)
return null;
var row = ToDict(table.ResultRows.First());
var path = row.TryGetValue("Path", out var p) ? p?.ToString() : null;
var title = row.TryGetValue("Title", out var t) ? t?.ToString() : null;
if (string.IsNullOrEmpty(path))
return null;
// Derive a leaf name if Title is empty (folders often have no Title).
var leaf = !string.IsNullOrWhiteSpace(title)
? title!
: Uri.UnescapeDataString(path.TrimEnd('/').Split('/').Last());
return new SystemGroupTarget(
SystemGroupKind.SharingLink,
$"{leaf} (via index)",
path,
linkType);
}
catch (Exception ex)
{
Log.Debug("UniqueId search fallback failed for {Item} on {Site}: {Error}",
itemUniqueId, ctx.Url, ex.Message);
return null;
}
}
/// <summary>
/// CSOM SearchExecutor returns ResultRows as either generic Dictionary or legacy
/// IDictionary depending on CSOM version — normalise to a single shape.
/// </summary>
private static IDictionary<string, object> ToDict(object rawRow)
{
if (rawRow is IDictionary<string, object> generic)
return generic;
var dict = new Dictionary<string, object>();
if (rawRow is System.Collections.IDictionary legacy)
{
foreach (System.Collections.DictionaryEntry e in legacy)
dict[e.Key.ToString()!] = e.Value ?? string.Empty;
}
return dict;
}
private static string BuildAbsoluteUrl(string contextUrl, string? serverRelative)
{
if (string.IsNullOrEmpty(serverRelative))
return contextUrl;
if (Uri.TryCreate(serverRelative, UriKind.Absolute, out _))
return serverRelative;
var uri = new Uri(contextUrl);
return $"{uri.Scheme}://{uri.Host}{serverRelative}";
}
}