Initial commit

This commit is contained in:
2026-06-02 10:51:14 +02:00
committed by kawa
commit d19092c84e
182 changed files with 13757 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Core.Helpers;
public static class ExecuteQueryRetryHelper
{
private const int MaxRetries = 5;
public static async Task ExecuteQueryRetryAsync(
ClientContext ctx,
IProgress<OperationProgress>? progress = null,
CancellationToken ct = default)
{
int attempt = 0;
while (true)
{
ct.ThrowIfCancellationRequested();
try
{
await ctx.ExecuteQueryAsync();
return;
}
catch (Exception ex) when (IsThrottleException(ex) && attempt < MaxRetries)
{
attempt++;
int delaySeconds = (int)Math.Pow(2, attempt) * 5;
progress?.Report(OperationProgress.Indeterminate(
$"Throttled by SharePoint — retrying in {delaySeconds}s (attempt {attempt}/{MaxRetries})…"));
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct);
}
}
}
internal static bool IsThrottleException(Exception ex)
{
var msg = ex.Message;
return msg.Contains("429") || msg.Contains("503") ||
msg.Contains("throttl", StringComparison.OrdinalIgnoreCase);
}
}
+32
View File
@@ -0,0 +1,32 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Core.Helpers;
public static class PermissionConsolidator
{
internal static string MakeKey(UserAccessEntry entry)
=> string.Join("|",
entry.UserLogin.ToLowerInvariant(),
entry.PermissionLevel.ToLowerInvariant(),
entry.AccessType.ToString(),
entry.GrantedThrough.ToLowerInvariant());
public static IReadOnlyList<ConsolidatedPermissionEntry> Consolidate(IReadOnlyList<UserAccessEntry> entries)
{
if (entries.Count == 0) return Array.Empty<ConsolidatedPermissionEntry>();
return entries
.GroupBy(e => MakeKey(e))
.Select(g =>
{
var first = g.First();
var locations = g.Select(e => new LocationInfo(
e.SiteUrl, e.SiteTitle, e.ObjectTitle, e.ObjectUrl, e.ObjectType)).ToList();
return new ConsolidatedPermissionEntry(
first.UserDisplayName, first.UserLogin, first.PermissionLevel,
first.AccessType, first.GrantedThrough, first.IsHighPrivilege,
first.IsExternalUser, locations,
first.TargetUrl, first.TargetLabel, first.SharingLinkType);
})
.OrderBy(c => c.UserLogin).ThenBy(c => c.PermissionLevel).ToList();
}
}
+74
View File
@@ -0,0 +1,74 @@
using System.Text.RegularExpressions;
namespace SharepointToolbox.Web.Core.Helpers;
public static class PermissionEntryHelper
{
private static readonly Regex LimitedAccessWebRegex = new(
@"^Limited Access System Group For Web\s+(?<id>[0-9a-fA-F-]{36})\s*$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex LimitedAccessListRegex = new(
@"^Limited Access System Group For List\s+(?<id>[0-9a-fA-F-]{36})\s*$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex SharingLinkRegex = new(
@"^SharingLinks\.(?<item>[0-9a-fA-F-]{36})\.(?<type>[^.]+)\.(?<share>[0-9a-fA-F-]{36})\s*$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static bool IsExternalUser(string loginName) =>
loginName.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
public static IReadOnlyList<string> FilterPermissionLevels(IEnumerable<string> levels) =>
levels.Where(l => !string.Equals(l, "Limited Access", StringComparison.OrdinalIgnoreCase)).ToList();
public static bool IsBareLimitedAccessSystemGroup(string name) =>
name.Equals("Limited Access System Group", StringComparison.OrdinalIgnoreCase);
public static SystemGroupClassification Classify(string groupTitle)
{
if (string.IsNullOrWhiteSpace(groupTitle))
return new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null);
var trimmed = groupTitle.Trim();
if (IsBareLimitedAccessSystemGroup(trimmed))
return new SystemGroupClassification(SystemGroupKind.LimitedAccessBare, null, null, null, null, null);
var mWeb = LimitedAccessWebRegex.Match(trimmed);
if (mWeb.Success && Guid.TryParse(mWeb.Groups["id"].Value, out var webId))
return new SystemGroupClassification(SystemGroupKind.LimitedAccessWeb, webId, null, null, null, null);
var mList = LimitedAccessListRegex.Match(trimmed);
if (mList.Success && Guid.TryParse(mList.Groups["id"].Value, out var listId))
return new SystemGroupClassification(SystemGroupKind.LimitedAccessList, null, listId, null, null, null);
var mShare = SharingLinkRegex.Match(trimmed);
if (mShare.Success
&& Guid.TryParse(mShare.Groups["item"].Value, out var itemId)
&& Guid.TryParse(mShare.Groups["share"].Value, out var shareId))
{
return new SystemGroupClassification(
SystemGroupKind.SharingLink, null, null, itemId, mShare.Groups["type"].Value, shareId);
}
return new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null);
}
}
public enum SystemGroupKind
{
None,
LimitedAccessBare,
LimitedAccessWeb,
LimitedAccessList,
SharingLink
}
public readonly record struct SystemGroupClassification(
SystemGroupKind Kind,
Guid? WebId,
Guid? ListId,
Guid? ItemUniqueId,
string? LinkType,
Guid? ShareId);
+46
View File
@@ -0,0 +1,46 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Core.Helpers;
public static class PermissionLevelMapping
{
public record MappingResult(string Label, RiskLevel RiskLevel);
private static readonly Dictionary<string, MappingResult> Mappings = new(StringComparer.OrdinalIgnoreCase)
{
["Full Control"] = new("Full control (can manage everything)", RiskLevel.High),
["Site Collection Administrator"] = new("Site collection admin (full control)", RiskLevel.High),
["Contribute"] = new("Can edit files and list items", RiskLevel.Medium),
["Edit"] = new("Can edit files, lists, and pages", RiskLevel.Medium),
["Design"] = new("Can edit pages and use design tools", RiskLevel.Medium),
["Approve"] = new("Can approve content and list items", RiskLevel.Medium),
["Manage Hierarchy"] = new("Can create sites and manage pages", RiskLevel.Medium),
["Read"] = new("Can view files and pages", RiskLevel.Low),
["Restricted Read"] = new("Can view pages only (no download)", RiskLevel.Low),
["View Only"] = new("Can view files in browser only", RiskLevel.ReadOnly),
["Restricted View"] = new("Restricted view access", RiskLevel.ReadOnly),
};
public static MappingResult GetMapping(string roleName)
{
if (string.IsNullOrWhiteSpace(roleName)) return new(roleName, RiskLevel.Low);
return Mappings.TryGetValue(roleName.Trim(), out var result) ? result : new(roleName.Trim(), RiskLevel.Medium);
}
public static IReadOnlyList<MappingResult> GetMappings(string permissionLevels)
{
if (string.IsNullOrWhiteSpace(permissionLevels)) return Array.Empty<MappingResult>();
return permissionLevels.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(GetMapping).ToList();
}
public static RiskLevel GetHighestRisk(string permissionLevels)
{
var mappings = GetMappings(permissionLevels);
if (mappings.Count == 0) return RiskLevel.Low;
return mappings.Min(m => m.RiskLevel);
}
public static string GetSimplifiedLabels(string permissionLevels)
=> string.Join("; ", GetMappings(permissionLevels).Select(m => m.Label));
}
@@ -0,0 +1,88 @@
using System.Runtime.CompilerServices;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Core.Helpers;
public static class SharePointPaginationHelper
{
private const int DefaultRowLimit = 5000;
public static async IAsyncEnumerable<ListItem> GetAllItemsAsync(
ClientContext ctx,
List list,
CamlQuery? baseQuery = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var query = baseQuery ?? CamlQuery.CreateAllItemsQuery();
query.ViewXml = BuildPagedViewXml(query.ViewXml, DefaultRowLimit);
query.ListItemCollectionPosition = null;
do
{
ct.ThrowIfCancellationRequested();
var items = list.GetItems(query);
ctx.Load(items);
await ctx.ExecuteQueryAsync();
foreach (var item in items) yield return item;
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
}
while (query.ListItemCollectionPosition != null);
}
public static async IAsyncEnumerable<ListItem> GetItemsInFolderAsync(
ClientContext ctx,
List list,
string folderServerRelativeUrl,
bool recursive,
string[]? viewFields = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var fields = viewFields ?? new[]
{
"FSObjType", "FileRef", "FileLeafRef", "FileDirRef", "File_x0020_Size"
};
var viewFieldsXml = string.Join(string.Empty, fields.Select(f => $"<FieldRef Name='{f}' />"));
var scope = recursive ? " Scope='RecursiveAll'" : string.Empty;
var viewXml =
$"<View{scope}><Query></Query>" +
$"<ViewFields>{viewFieldsXml}</ViewFields>" +
$"<RowLimit Paged='TRUE'>{DefaultRowLimit}</RowLimit></View>";
var query = new CamlQuery
{
ViewXml = viewXml,
FolderServerRelativeUrl = folderServerRelativeUrl,
ListItemCollectionPosition = null
};
do
{
ct.ThrowIfCancellationRequested();
var items = list.GetItems(query);
ctx.Load(items);
await ctx.ExecuteQueryAsync();
foreach (var item in items) yield return item;
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
}
while (query.ListItemCollectionPosition != null);
}
internal static string BuildPagedViewXml(string? existingXml, int rowLimit)
{
if (string.IsNullOrWhiteSpace(existingXml))
return $"<View><RowLimit Paged='TRUE'>{rowLimit}</RowLimit></View>";
if (System.Text.RegularExpressions.Regex.IsMatch(
existingXml, @"<RowLimit[^>]*>\d+</RowLimit>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{
return System.Text.RegularExpressions.Regex.Replace(
existingXml, @"<RowLimit[^>]*>\d+</RowLimit>",
$"<RowLimit Paged='TRUE'>{rowLimit}</RowLimit>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
}
return existingXml.Replace("</View>",
$"<RowLimit Paged='TRUE'>{rowLimit}</RowLimit></View>",
StringComparison.OrdinalIgnoreCase);
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace SharepointToolbox.Web.Core.Helpers;
public enum SharingLinkRisk { Low, Medium, High, Unknown }
public static class SharingLinkLabels
{
public static (string Label, SharingLinkRisk Risk) Describe(string? rawLinkType)
{
if (string.IsNullOrWhiteSpace(rawLinkType)) return (string.Empty, SharingLinkRisk.Unknown);
return rawLinkType.Trim() switch
{
"OrganizationView" => ("Org link · View", SharingLinkRisk.Low),
"OrganizationEdit" => ("Org link · Edit", SharingLinkRisk.Medium),
"AnonymousView" => ("Anyone · View", SharingLinkRisk.High),
"AnonymousEdit" => ("Anyone · Edit", SharingLinkRisk.High),
"Flexible" => ("Custom link", SharingLinkRisk.Medium),
"Direct" => ("Specific people", SharingLinkRisk.Low),
"Existing" => ("Existing access", SharingLinkRisk.Low),
"Review" => ("Review only", SharingLinkRisk.Low),
"Embed" => ("Embedded link", SharingLinkRisk.Medium),
_ => (rawLinkType, SharingLinkRisk.Unknown)
};
}
public static (string Background, string Foreground) Colors(SharingLinkRisk risk) => risk switch
{
SharingLinkRisk.Low => ("#D1FAE5", "#065F46"),
SharingLinkRisk.Medium => ("#FEF3C7", "#92400E"),
SharingLinkRisk.High => ("#FEE2E2", "#991B1B"),
_ => ("#F3F4F6", "#374151"),
};
}
+7
View File
@@ -0,0 +1,7 @@
namespace SharepointToolbox.Web.Core.Helpers;
public static class StringExtensions
{
public static string? TrimOrNull(this string? s)
=> string.IsNullOrWhiteSpace(s) ? null : s.Trim();
}
+2
View File
@@ -0,0 +1,2 @@
// SystemGroupKind, SystemGroupClassification, and PermissionEntryHelper are defined in PermissionEntryHelper.cs