206 lines
8.3 KiB
C#
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}";
|
|
}
|
|
}
|