Compare commits
3 Commits
f56e8813e5
..
v2.4.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b51c8e3c3 | |||
| 1312dcdb1e | |||
| ecc7b329d4 |
@@ -48,6 +48,54 @@ public class CsvExportServiceTests
|
||||
Assert.Contains("Object", csv);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCsv_WithResolvedTarget_IncludesTargetColumns()
|
||||
{
|
||||
var entry = new PermissionEntry(
|
||||
ObjectType: "Site",
|
||||
Title: "HR",
|
||||
Url: "https://contoso.sharepoint.com/sites/HR",
|
||||
HasUniquePermissions: true,
|
||||
Users: "Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc",
|
||||
UserLogins: "Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc",
|
||||
PermissionLevels: "Limited Access: Edit",
|
||||
GrantedThrough: "SharePoint Group: Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc",
|
||||
PrincipalType: "SharePointGroup",
|
||||
TargetUrl: "https://contoso.sharepoint.com/sites/HR/Lists/Payroll",
|
||||
TargetLabel: "Payroll");
|
||||
|
||||
var svc = new CsvExportService();
|
||||
var csv = svc.BuildCsv(new[] { entry });
|
||||
|
||||
Assert.Contains("TargetLabel", csv);
|
||||
Assert.Contains("TargetUrl", csv);
|
||||
Assert.Contains("Payroll", csv);
|
||||
Assert.Contains("https://contoso.sharepoint.com/sites/HR/Lists/Payroll", csv);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCsv_WithSharingLink_IncludesLinkType()
|
||||
{
|
||||
var entry = new PermissionEntry(
|
||||
ObjectType: "Folder", Title: "Reports", Url: "https://contoso.sharepoint.com/sites/HR/Docs",
|
||||
HasUniquePermissions: true,
|
||||
Users: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
|
||||
UserLogins: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
|
||||
PermissionLevels: "Contribute",
|
||||
GrantedThrough: "SharePoint Group: SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
|
||||
PrincipalType: "SharePointGroup",
|
||||
TargetUrl: "https://contoso.sharepoint.com/sites/HR/Docs/Q4.xlsx",
|
||||
TargetLabel: "Q4.xlsx",
|
||||
SharingLinkType: "OrganizationEdit");
|
||||
|
||||
var svc = new CsvExportService();
|
||||
var csv = svc.BuildCsv(new[] { entry });
|
||||
|
||||
Assert.Contains("SharingLinkType", csv);
|
||||
Assert.Contains("OrganizationEdit", csv);
|
||||
Assert.Contains("Q4.xlsx", csv);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCsv_WithDuplicateUserPermissionGrantedThrough_MergesLocations()
|
||||
{
|
||||
|
||||
@@ -156,6 +156,146 @@ public class HtmlExportServiceTests
|
||||
Assert.Contains("getAttribute('data-group-target')", html);
|
||||
}
|
||||
|
||||
// ── System-group target rendering (Limited Access / SharingLinks) ────────
|
||||
|
||||
[Fact]
|
||||
public void BuildHtml_WithResolvedListTarget_RendersClickableLink()
|
||||
{
|
||||
var entry = new PermissionEntry(
|
||||
ObjectType: "Site",
|
||||
Title: "Contoso HR",
|
||||
Url: "https://contoso.sharepoint.com/sites/HR",
|
||||
HasUniquePermissions: true,
|
||||
Users: "Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc",
|
||||
UserLogins: "Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc",
|
||||
PermissionLevels: "Limited Access: Edit",
|
||||
GrantedThrough: "SharePoint Group: Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc",
|
||||
PrincipalType: "SharePointGroup",
|
||||
TargetUrl: "https://contoso.sharepoint.com/sites/HR/Lists/Payroll",
|
||||
TargetLabel: "Payroll");
|
||||
|
||||
var svc = new HtmlExportService();
|
||||
var html = svc.BuildHtml(new[] { entry });
|
||||
|
||||
Assert.Contains("https://contoso.sharepoint.com/sites/HR/Lists/Payroll", html);
|
||||
Assert.Contains(">Payroll</a>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHtml_WithSharingLinkTarget_RendersLinkTypeBadgeAndTarget()
|
||||
{
|
||||
var entry = new PermissionEntry(
|
||||
ObjectType: "Folder",
|
||||
Title: "Reports",
|
||||
Url: "https://contoso.sharepoint.com/sites/HR/Shared%20Documents/Reports",
|
||||
HasUniquePermissions: true,
|
||||
Users: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
|
||||
UserLogins: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
|
||||
PermissionLevels: "Contribute",
|
||||
GrantedThrough: "SharePoint Group: SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
|
||||
PrincipalType: "SharePointGroup",
|
||||
TargetUrl: "https://contoso.sharepoint.com/sites/HR/Shared%20Documents/Reports/Q4.xlsx",
|
||||
TargetLabel: "Q4.xlsx",
|
||||
SharingLinkType: "OrganizationEdit");
|
||||
|
||||
var svc = new HtmlExportService();
|
||||
var html = svc.BuildHtml(new[] { entry });
|
||||
|
||||
// Friendly label, raw code preserved as tooltip
|
||||
Assert.Contains("Org link", html);
|
||||
Assert.Contains("Edit", html);
|
||||
Assert.Contains("title=\"OrganizationEdit\"", html);
|
||||
Assert.Contains("Q4.xlsx", html);
|
||||
Assert.Contains("https://contoso.sharepoint.com/sites/HR/Shared%20Documents/Reports/Q4.xlsx", html);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("OrganizationView", "Org link", "View")]
|
||||
[InlineData("AnonymousEdit", "Anyone", "Edit")]
|
||||
[InlineData("Direct", "Specific people", null)]
|
||||
[InlineData("Flexible", "Custom link", null)]
|
||||
public void BuildHtml_FriendlyLinkLabel_ReplacesRawType(string raw, string expectedPart, string? expectedSecond)
|
||||
{
|
||||
var entry = new PermissionEntry(
|
||||
ObjectType: "File", Title: "Doc.docx",
|
||||
Url: "https://contoso.sharepoint.com/sites/HR/Docs/Doc.docx",
|
||||
HasUniquePermissions: true,
|
||||
Users: $"SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.{raw}.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
|
||||
UserLogins: $"SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.{raw}.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
|
||||
PermissionLevels: "Contribute",
|
||||
GrantedThrough: $"SharePoint Group: SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.{raw}.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
|
||||
PrincipalType: "SharePointGroup",
|
||||
TargetUrl: "https://contoso.sharepoint.com/sites/HR/Docs/Doc.docx",
|
||||
TargetLabel: "Doc.docx",
|
||||
SharingLinkType: raw);
|
||||
|
||||
var svc = new HtmlExportService();
|
||||
var html = svc.BuildHtml(new[] { entry }, null, null, hideSystemGroupRaw: true);
|
||||
|
||||
Assert.Contains(expectedPart, html);
|
||||
if (expectedSecond is not null)
|
||||
Assert.Contains(expectedSecond, html);
|
||||
Assert.Contains($"title=\"{raw}\"", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHtml_WithSharingLinkAndHideRaw_OmitsRawGroupString()
|
||||
{
|
||||
var entry = new PermissionEntry(
|
||||
ObjectType: "File", Title: "Q4.xlsx",
|
||||
Url: "https://contoso.sharepoint.com/sites/HR/Docs/Q4.xlsx",
|
||||
HasUniquePermissions: true,
|
||||
Users: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
|
||||
UserLogins: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
|
||||
PermissionLevels: "Contribute",
|
||||
GrantedThrough: "SharePoint Group: SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
|
||||
PrincipalType: "SharePointGroup",
|
||||
TargetUrl: "https://contoso.sharepoint.com/sites/HR/Docs/Q4.xlsx",
|
||||
TargetLabel: "Q4.xlsx",
|
||||
SharingLinkType: "OrganizationEdit");
|
||||
|
||||
var svc = new HtmlExportService();
|
||||
var html = svc.BuildHtml(new[] { entry }, null, null, hideSystemGroupRaw: true);
|
||||
|
||||
// Raw "SharingLinks.{guid}..." text suppressed when target resolved + flag on
|
||||
Assert.DoesNotContain("SharingLinks.e686221e", html);
|
||||
// Target link still rendered
|
||||
Assert.Contains("Q4.xlsx", html);
|
||||
Assert.Contains("OrganizationEdit", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHtml_WithSharingLinkButNoTargetAndHideRaw_KeepsRawAsFallback()
|
||||
{
|
||||
// Target resolution failed (deleted item) — flag on, but no TargetUrl → keep raw text.
|
||||
var entry = new PermissionEntry(
|
||||
ObjectType: "File", Title: "Q4.xlsx",
|
||||
Url: "https://contoso.sharepoint.com/sites/HR/Docs/Q4.xlsx",
|
||||
HasUniquePermissions: true,
|
||||
Users: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
|
||||
UserLogins: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
|
||||
PermissionLevels: "Contribute",
|
||||
GrantedThrough: "SharePoint Group: SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6",
|
||||
PrincipalType: "SharePointGroup",
|
||||
SharingLinkType: "OrganizationEdit");
|
||||
|
||||
var svc = new HtmlExportService();
|
||||
var html = svc.BuildHtml(new[] { entry }, null, null, hideSystemGroupRaw: true);
|
||||
|
||||
Assert.Contains("SharingLinks.e686221e", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHtml_WithoutTarget_RendersGrantedThroughTextOnly()
|
||||
{
|
||||
// Standard SP group with no resolved target — no extra link should appear.
|
||||
var entry = MakeEntry("Owners", "ownersgroup", principalType: "SharePointGroup");
|
||||
var svc = new HtmlExportService();
|
||||
var html = svc.BuildHtml(new[] { entry });
|
||||
|
||||
Assert.DoesNotContain("→", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHtml_Simplified_WithGroupMembers_RendersExpandablePill()
|
||||
{
|
||||
|
||||
@@ -86,20 +86,15 @@ public class UserAccessCsvExportServiceTests
|
||||
var svc = new UserAccessCsvExportService();
|
||||
var csv = svc.BuildCsv("Alice", "alice@contoso.com", new[] { DefaultEntry });
|
||||
|
||||
// Find the header row and count its quoted comma-separated fields
|
||||
// Header is: "Site","Object Type","Object","URL","Permission Level","Access Type","Granted Through"
|
||||
// That is 7 fields.
|
||||
// Header: Site, Object Type, Object, URL, Permission Level, Access Type,
|
||||
// Granted Through, TargetLabel, TargetUrl, SharingLinkType → 10 fields.
|
||||
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Find a data row (after the blank line separating summary from data)
|
||||
// Data rows contain the entry content (not the header line itself)
|
||||
// We want to count fields in the header row:
|
||||
var headerLine = lines.FirstOrDefault(l => l.Contains("\"Site\",\"Object Type\""));
|
||||
Assert.NotNull(headerLine);
|
||||
|
||||
// Count comma-separated quoted fields: split by "," boundary
|
||||
var fields = CountCsvFields(headerLine!);
|
||||
Assert.Equal(7, fields);
|
||||
Assert.Equal(10, fields);
|
||||
}
|
||||
|
||||
// ── Test 5: WriteSingleFileAsync includes entries for all users ───────────
|
||||
@@ -190,8 +185,8 @@ public class UserAccessCsvExportServiceTests
|
||||
await svc.WriteSingleFileAsync(entries, tmpFile, CancellationToken.None, mergePermissions: true);
|
||||
var content = await File.ReadAllTextAsync(tmpFile);
|
||||
|
||||
// Header must contain consolidated columns
|
||||
Assert.Contains("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"Locations\",\"Location Count\"", content);
|
||||
// Header must contain consolidated columns (now includes Target* + SharingLinkType)
|
||||
Assert.Contains("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\",\"Locations\",\"Location Count\"", content);
|
||||
|
||||
// Alice's two entries merged — locations column contains both site titles
|
||||
Assert.Contains("Contoso", content);
|
||||
|
||||
@@ -3,7 +3,8 @@ using SharepointToolbox.Core.Helpers;
|
||||
namespace SharepointToolbox.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for PERM-03: external user detection and permission-level filtering.
|
||||
/// Tests for PERM-03: external user detection, permission-level filtering,
|
||||
/// and SharePoint system-group classification (Limited Access / SharingLinks).
|
||||
/// Pure static logic — runs immediately without stubs.
|
||||
/// </summary>
|
||||
public class PermissionEntryClassificationTests
|
||||
@@ -13,7 +14,6 @@ public class PermissionEntryClassificationTests
|
||||
[Fact]
|
||||
public void IsExternalUser_WithExtHashInLoginName_ReturnsTrue()
|
||||
{
|
||||
// B2B guest login names contain the literal "#EXT#" fragment
|
||||
Assert.True(PermissionEntryHelper.IsExternalUser("ext_user_domain.com#EXT#@contoso.onmicrosoft.com"));
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ public class PermissionEntryClassificationTests
|
||||
[Fact]
|
||||
public void PermissionEntry_FiltersOutLimitedAccess_WhenOnlyPermissionIsLimitedAccess()
|
||||
{
|
||||
// A principal whose sole permission level is "Limited Access" should produce
|
||||
// an empty list after filtering — used to decide whether to include the entry.
|
||||
var result = PermissionEntryHelper.FilterPermissionLevels(new[] { "Limited Access" });
|
||||
Assert.Empty(result);
|
||||
}
|
||||
@@ -41,23 +39,100 @@ public class PermissionEntryClassificationTests
|
||||
Assert.Equal(new[] { "Contribute" }, result);
|
||||
}
|
||||
|
||||
// ── IsSharingLinksGroup ────────────────────────────────────────────────────
|
||||
// ── IsBareLimitedAccessSystemGroup ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void IsSharingLinksGroup_WithSharingLinksPrefix_ReturnsTrue()
|
||||
public void IsBareLimitedAccessSystemGroup_WithExactMatch_ReturnsTrue()
|
||||
{
|
||||
Assert.True(PermissionEntryHelper.IsSharingLinksGroup("SharingLinks.abc123.Edit"));
|
||||
Assert.True(PermissionEntryHelper.IsBareLimitedAccessSystemGroup("Limited Access System Group"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSharingLinksGroup_WithLimitedAccessSystemGroup_ReturnsTrue()
|
||||
public void IsBareLimitedAccessSystemGroup_WithForWebVariant_ReturnsFalse()
|
||||
{
|
||||
Assert.True(PermissionEntryHelper.IsSharingLinksGroup("Limited Access System Group"));
|
||||
// The "For Web {guid}" variant is enriched, not filtered.
|
||||
Assert.False(PermissionEntryHelper.IsBareLimitedAccessSystemGroup(
|
||||
"Limited Access System Group For Web e55a4f7a-b132-4baa-bd96-c63d8dc1fc80"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSharingLinksGroup_WithNormalGroup_ReturnsFalse()
|
||||
public void IsBareLimitedAccessSystemGroup_WithNormalGroup_ReturnsFalse()
|
||||
{
|
||||
Assert.False(PermissionEntryHelper.IsSharingLinksGroup("Owners"));
|
||||
Assert.False(PermissionEntryHelper.IsBareLimitedAccessSystemGroup("Owners"));
|
||||
}
|
||||
|
||||
// ── Classify ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Classify_WithNormalGroupTitle_ReturnsNone()
|
||||
{
|
||||
var c = PermissionEntryHelper.Classify("Site Owners");
|
||||
Assert.Equal(SystemGroupKind.None, c.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_WithBareLimitedAccess_ReturnsLimitedAccessBare()
|
||||
{
|
||||
var c = PermissionEntryHelper.Classify("Limited Access System Group");
|
||||
Assert.Equal(SystemGroupKind.LimitedAccessBare, c.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_WithLimitedAccessForWeb_ExtractsWebId()
|
||||
{
|
||||
var c = PermissionEntryHelper.Classify(
|
||||
"Limited Access System Group For Web e55a4f7a-b132-4baa-bd96-c63d8dc1fc80");
|
||||
Assert.Equal(SystemGroupKind.LimitedAccessWeb, c.Kind);
|
||||
Assert.Equal(Guid.Parse("e55a4f7a-b132-4baa-bd96-c63d8dc1fc80"), c.WebId);
|
||||
Assert.Null(c.ListId);
|
||||
Assert.Null(c.ItemUniqueId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_WithLimitedAccessForList_ExtractsListId()
|
||||
{
|
||||
var c = PermissionEntryHelper.Classify(
|
||||
"Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc");
|
||||
Assert.Equal(SystemGroupKind.LimitedAccessList, c.Kind);
|
||||
Assert.Equal(Guid.Parse("b20e3b22-2b09-4c99-9ba4-37b42f3a12dc"), c.ListId);
|
||||
Assert.Null(c.WebId);
|
||||
Assert.Null(c.ItemUniqueId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_WithSharingLink_ExtractsItemAndShareId()
|
||||
{
|
||||
var c = PermissionEntryHelper.Classify(
|
||||
"SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6");
|
||||
Assert.Equal(SystemGroupKind.SharingLink, c.Kind);
|
||||
Assert.Equal(Guid.Parse("e686221e-d1cb-43c5-8c68-04aa7f90f329"), c.ItemUniqueId);
|
||||
Assert.Equal(Guid.Parse("64c27910-66ea-421d-b0f0-8f0f72dcfaf6"), c.ShareId);
|
||||
Assert.Equal("OrganizationEdit", c.LinkType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.AnonymousView.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", "AnonymousView")]
|
||||
[InlineData("SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.Flexible.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", "Flexible")]
|
||||
[InlineData("SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationView.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", "OrganizationView")]
|
||||
public void Classify_WithVariousSharingLinkTypes_PreservesLinkType(string title, string expected)
|
||||
{
|
||||
var c = PermissionEntryHelper.Classify(title);
|
||||
Assert.Equal(SystemGroupKind.SharingLink, c.Kind);
|
||||
Assert.Equal(expected, c.LinkType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_WithMalformedSharingLink_ReturnsNone()
|
||||
{
|
||||
// Missing share GUID at the end
|
||||
var c = PermissionEntryHelper.Classify("SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.Edit");
|
||||
Assert.Equal(SystemGroupKind.None, c.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_WithEmptyString_ReturnsNone()
|
||||
{
|
||||
var c = PermissionEntryHelper.Classify("");
|
||||
Assert.Equal(SystemGroupKind.None, c.Kind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ public partial class App : Application
|
||||
services.AddTransient<VersionCleanupView>();
|
||||
|
||||
// Phase 2: Permissions
|
||||
services.AddTransient<ISystemGroupTargetResolver, SystemGroupTargetResolver>();
|
||||
services.AddTransient<IPermissionsService, PermissionsService>();
|
||||
services.AddTransient<ISiteListService, SiteListService>();
|
||||
services.AddTransient<CsvExportService>();
|
||||
|
||||
@@ -50,7 +50,10 @@ public static class PermissionConsolidator
|
||||
first.GrantedThrough,
|
||||
first.IsHighPrivilege,
|
||||
first.IsExternalUser,
|
||||
locations);
|
||||
locations,
|
||||
first.TargetUrl,
|
||||
first.TargetLabel,
|
||||
first.SharingLinkType);
|
||||
})
|
||||
.OrderBy(c => c.UserLogin)
|
||||
.ThenBy(c => c.PermissionLevel)
|
||||
|
||||
@@ -1,10 +1,56 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace SharepointToolbox.Core.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a SharePoint group name into one of the known system-group shapes.
|
||||
/// </summary>
|
||||
public enum SystemGroupKind
|
||||
{
|
||||
/// <summary>Not a system group — a normal SharePoint group, user, or external user.</summary>
|
||||
None,
|
||||
/// <summary>Bare "Limited Access System Group" pseudo-principal (no embedded target).</summary>
|
||||
LimitedAccessBare,
|
||||
/// <summary>"Limited Access System Group For Web {webId}".</summary>
|
||||
LimitedAccessWeb,
|
||||
/// <summary>"Limited Access System Group For List {listId}".</summary>
|
||||
LimitedAccessList,
|
||||
/// <summary>"SharingLinks.{itemUniqueId}.{linkType}.{shareId}".</summary>
|
||||
SharingLink
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of classifying a SharePoint group name.
|
||||
/// Carries the embedded GUIDs / link type so a resolver can look up the target.
|
||||
/// </summary>
|
||||
public readonly record struct SystemGroupClassification(
|
||||
SystemGroupKind Kind,
|
||||
Guid? WebId,
|
||||
Guid? ListId,
|
||||
Guid? ItemUniqueId,
|
||||
string? LinkType,
|
||||
Guid? ShareId);
|
||||
|
||||
/// <summary>
|
||||
/// Pure static helpers for classifying SharePoint permission entries.
|
||||
/// </summary>
|
||||
public static class PermissionEntryHelper
|
||||
{
|
||||
// "Limited Access System Group For Web {guid}"
|
||||
private static readonly Regex LimitedAccessWebRegex = new(
|
||||
@"^Limited Access System Group For Web\s+(?<id>[0-9a-fA-F-]{36})\s*$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
// "Limited Access System Group For List {guid}"
|
||||
private static readonly Regex LimitedAccessListRegex = new(
|
||||
@"^Limited Access System Group For List\s+(?<id>[0-9a-fA-F-]{36})\s*$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
// "SharingLinks.{itemUniqueId}.{linkType}.{shareId}"
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the login name is a B2B guest (contains #EXT#).
|
||||
/// </summary>
|
||||
@@ -21,10 +67,45 @@ public static class PermissionEntryHelper
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the login name represents an internal sharing-link group
|
||||
/// or the "Limited Access System Group" pseudo-principal.
|
||||
/// Returns true when the supplied name/login is the bare "Limited Access System Group"
|
||||
/// pseudo-principal — the noise entry with no embedded GUID. The
|
||||
/// per-Web/List and SharingLinks variants are NOT filtered: they are enriched.
|
||||
/// </summary>
|
||||
public static bool IsSharingLinksGroup(string loginName) =>
|
||||
loginName.StartsWith("SharingLinks.", StringComparison.OrdinalIgnoreCase)
|
||||
|| loginName.Equals("Limited Access System Group", StringComparison.OrdinalIgnoreCase);
|
||||
public static bool IsBareLimitedAccessSystemGroup(string name) =>
|
||||
name.Equals("Limited Access System Group", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a SharePoint group title (the value used in Granted Through). When the
|
||||
/// title matches a known system pattern, the embedded GUIDs / link type are parsed
|
||||
/// out so a resolver can look up the actual targeted Web/List/Item.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace SharepointToolbox.Core.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Risk tier of a SharePoint sharing link, used to color-code the link-type badge
|
||||
/// in HTML reports.
|
||||
/// </summary>
|
||||
public enum SharingLinkRisk
|
||||
{
|
||||
Low, // Read-only, scoped (Org view, Direct, Existing, Review)
|
||||
Medium, // Org-wide edit
|
||||
High, // Anonymous (anyone with link, no auth)
|
||||
Unknown // Unrecognized type — neutral styling
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the raw <c>linkType</c> segment of a <c>SharingLinks.{itemId}.{type}.{shareId}</c>
|
||||
/// group name to a human-readable label and a risk tier. The raw codes are SharePoint
|
||||
/// internals (OrganizationEdit / AnonymousView / Flexible / …); reports show the friendly
|
||||
/// label and tint the badge by tier.
|
||||
/// </summary>
|
||||
public static class SharingLinkLabels
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the friendly label and risk tier for a sharing-link type code.
|
||||
/// Unknown codes fall through with the raw value and <see cref="SharingLinkRisk.Unknown"/>.
|
||||
/// </summary>
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns inline-CSS colors (background, foreground) for a risk tier — matches the
|
||||
/// risk-card palette used elsewhere in the HTML reports.
|
||||
/// </summary>
|
||||
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"),
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,10 @@ public record ConsolidatedPermissionEntry(
|
||||
string GrantedThrough,
|
||||
bool IsHighPrivilege,
|
||||
bool IsExternalUser,
|
||||
IReadOnlyList<LocationInfo> Locations
|
||||
IReadOnlyList<LocationInfo> Locations,
|
||||
string? TargetUrl = null,
|
||||
string? TargetLabel = null,
|
||||
string? SharingLinkType = null
|
||||
)
|
||||
{
|
||||
/// <summary>Convenience count — equals Locations.Count.</summary>
|
||||
|
||||
@@ -14,5 +14,10 @@ public record PermissionEntry(
|
||||
string PermissionLevels, // Semicolon-joined role names (Limited Access already removed)
|
||||
string GrantedThrough, // "Direct Permissions" | "SharePoint Group: <name>"
|
||||
string PrincipalType, // "SharePointGroup" | "User" | "External User"
|
||||
bool WasAutoElevated = false // Set to true when site admin was auto-granted to access this entry
|
||||
bool WasAutoElevated = false, // Set to true when site admin was auto-granted to access this entry
|
||||
// Resolved target for Limited Access System Group For Web/List and SharingLinks
|
||||
// groups — populated when GrantedThrough is one of those system groups, null otherwise.
|
||||
string? TargetUrl = null,
|
||||
string? TargetLabel = null,
|
||||
string? SharingLinkType = null // OrganizationEdit | OrganizationView | AnonymousEdit | AnonymousView | etc.
|
||||
);
|
||||
|
||||
@@ -41,6 +41,9 @@ public class SimplifiedPermissionEntry
|
||||
public string PermissionLevels => Inner.PermissionLevels;
|
||||
public string GrantedThrough => Inner.GrantedThrough;
|
||||
public string PrincipalType => Inner.PrincipalType;
|
||||
public string? TargetUrl => Inner.TargetUrl;
|
||||
public string? TargetLabel => Inner.TargetLabel;
|
||||
public string? SharingLinkType => Inner.SharingLinkType;
|
||||
|
||||
public SimplifiedPermissionEntry(PermissionEntry entry)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using SharepointToolbox.Core.Helpers;
|
||||
|
||||
namespace SharepointToolbox.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Resolved target for a SharePoint system group (Limited Access For Web/List or SharingLinks).
|
||||
/// Carries the human-readable label, the navigable URL, and (for sharing links) the link type
|
||||
/// so the export layer can render a clickable link in the Granted Through cell.
|
||||
/// </summary>
|
||||
public record SystemGroupTarget(
|
||||
SystemGroupKind Kind,
|
||||
string Label, // e.g. "MyList" | "Shared Documents/Folder/File.docx" | "Site - HR"
|
||||
string Url, // Navigable URL of the targeted resource
|
||||
string? LinkType = null // For SharingLinks: "OrganizationEdit", "AnonymousView", etc.
|
||||
);
|
||||
@@ -29,5 +29,8 @@ public record UserAccessEntry(
|
||||
AccessType AccessType, // Direct | Group | Inherited
|
||||
string GrantedThrough, // "Direct Permissions" | "SharePoint Group: Members" etc.
|
||||
bool IsHighPrivilege, // True for Full Control, Site Collection Administrator
|
||||
bool IsExternalUser // True if login contains #EXT#
|
||||
bool IsExternalUser, // True if login contains #EXT#
|
||||
string? TargetUrl = null, // Resolved URL when GrantedThrough is a Limited Access / SharingLinks system group
|
||||
string? TargetLabel = null, // Resolved name (list title / file name / web title)
|
||||
string? SharingLinkType = null // OrganizationEdit | OrganizationView | AnonymousEdit | ...
|
||||
);
|
||||
|
||||
@@ -582,6 +582,7 @@ Cette action est irréversible.</value>
|
||||
<!-- Phase 16: Report Consolidation Toggle -->
|
||||
<data name="audit.grp.export" xml:space="preserve"><value>Options d'exportation</value></data>
|
||||
<data name="chk.merge.permissions" xml:space="preserve"><value>Fusionner les permissions en double</value></data>
|
||||
<data name="chk.hide.system.group.raw" xml:space="preserve"><value>Masquer les noms bruts (SharingLinks, Limited Access)</value></data>
|
||||
<!-- Phase 19: App Registration & Removal -->
|
||||
<data name="profile.register" xml:space="preserve"><value>Enregistrer l'app</value></data>
|
||||
<data name="profile.remove" xml:space="preserve"><value>Supprimer l'app</value></data>
|
||||
|
||||
@@ -582,6 +582,7 @@ This cannot be undone.</value>
|
||||
<!-- Phase 16: Report Consolidation Toggle -->
|
||||
<data name="audit.grp.export" xml:space="preserve"><value>Export Options</value></data>
|
||||
<data name="chk.merge.permissions" xml:space="preserve"><value>Merge duplicate permissions</value></data>
|
||||
<data name="chk.hide.system.group.raw" xml:space="preserve"><value>Hide raw system group names (SharingLinks, Limited Access)</value></data>
|
||||
<!-- Phase 19: App Registration & Removal -->
|
||||
<data name="profile.register" xml:space="preserve"><value>Register App</value></data>
|
||||
<data name="profile.remove" xml:space="preserve"><value>Remove App</value></data>
|
||||
|
||||
@@ -14,7 +14,7 @@ public class CsvExportService
|
||||
private static string BuildHeader()
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\"";
|
||||
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -39,7 +39,10 @@ public class CsvExportService
|
||||
UserLogins = g.First().UserLogins,
|
||||
PrincipalType = g.First().PrincipalType,
|
||||
Permissions = g.Key.PermissionLevels,
|
||||
GrantedThrough = g.Key.GrantedThrough
|
||||
GrantedThrough = g.Key.GrantedThrough,
|
||||
TargetLabel = g.First().TargetLabel ?? string.Empty,
|
||||
TargetUrl = g.First().TargetUrl ?? string.Empty,
|
||||
SharingLinkType = g.First().SharingLinkType ?? string.Empty
|
||||
});
|
||||
|
||||
foreach (var row in merged)
|
||||
@@ -47,7 +50,8 @@ public class CsvExportService
|
||||
{
|
||||
Csv(row.ObjectType), Csv(row.Title), Csv(row.Url),
|
||||
Csv(row.HasUnique.ToString()), Csv(row.Users), Csv(row.UserLogins),
|
||||
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.GrantedThrough)
|
||||
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.GrantedThrough),
|
||||
Csv(row.TargetLabel), Csv(row.TargetUrl), Csv(row.SharingLinkType)
|
||||
}));
|
||||
|
||||
return sb.ToString();
|
||||
@@ -68,7 +72,7 @@ public class CsvExportService
|
||||
private static string BuildSimplifiedHeader()
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\"";
|
||||
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -95,7 +99,10 @@ public class CsvExportService
|
||||
Permissions = g.Key.PermissionLevels,
|
||||
SimplifiedLabels = g.First().SimplifiedLabels,
|
||||
RiskLevel = g.First().RiskLevel.ToString(),
|
||||
GrantedThrough = g.Key.GrantedThrough
|
||||
GrantedThrough = g.Key.GrantedThrough,
|
||||
TargetLabel = g.First().TargetLabel ?? string.Empty,
|
||||
TargetUrl = g.First().TargetUrl ?? string.Empty,
|
||||
SharingLinkType = g.First().SharingLinkType ?? string.Empty
|
||||
});
|
||||
|
||||
foreach (var row in merged)
|
||||
@@ -104,7 +111,8 @@ public class CsvExportService
|
||||
Csv(row.ObjectType), Csv(row.Title), Csv(row.Url),
|
||||
Csv(row.HasUnique.ToString()), Csv(row.Users), Csv(row.UserLogins),
|
||||
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.SimplifiedLabels),
|
||||
Csv(row.RiskLevel), Csv(row.GrantedThrough)
|
||||
Csv(row.RiskLevel), Csv(row.GrantedThrough),
|
||||
Csv(row.TargetLabel), Csv(row.TargetUrl), Csv(row.SharingLinkType)
|
||||
}));
|
||||
|
||||
return sb.ToString();
|
||||
|
||||
@@ -24,4 +24,41 @@ internal static class ExportFileWriter
|
||||
/// <summary>Writes <paramref name="html"/> to <paramref name="filePath"/> as UTF-8 without BOM.</summary>
|
||||
public static Task WriteHtmlAsync(string filePath, string html, CancellationToken ct)
|
||||
=> File.WriteAllTextAsync(filePath, html, Utf8NoBom, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Streams a <see cref="StringBuilder"/> directly to disk as UTF-8 with
|
||||
/// BOM, chunk by chunk. Avoids the full-document <c>ToString()</c> copy
|
||||
/// and the separate UTF-8 byte buffer that <see cref="File.WriteAllTextAsync(string, string, Encoding, CancellationToken)"/>
|
||||
/// would otherwise allocate — meaningful for large CSV exports.
|
||||
/// </summary>
|
||||
public static Task WriteCsvChunksAsync(string filePath, StringBuilder builder, CancellationToken ct)
|
||||
=> WriteChunksAsync(filePath, builder, Utf8WithBom, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Streams a <see cref="StringBuilder"/> directly to disk as UTF-8 without
|
||||
/// BOM. Same rationale as <see cref="WriteCsvChunksAsync"/> — for large
|
||||
/// HTML reports it halves peak memory by skipping the intermediate string.
|
||||
/// </summary>
|
||||
public static Task WriteHtmlChunksAsync(string filePath, StringBuilder builder, CancellationToken ct)
|
||||
=> WriteChunksAsync(filePath, builder, Utf8NoBom, ct);
|
||||
|
||||
private static async Task WriteChunksAsync(string filePath, StringBuilder builder, Encoding encoding, CancellationToken ct)
|
||||
{
|
||||
// FileOptions.Asynchronous lets StreamWriter use true async I/O.
|
||||
await using var fs = new FileStream(
|
||||
filePath,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
bufferSize: 64 * 1024,
|
||||
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
await using var sw = new StreamWriter(fs, encoding, bufferSize: 64 * 1024);
|
||||
|
||||
foreach (var chunk in builder.GetChunks())
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await sw.WriteAsync(chunk, ct);
|
||||
}
|
||||
await sw.FlushAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ public class HtmlExportService
|
||||
public string BuildHtml(
|
||||
IReadOnlyList<PermissionEntry> entries,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
|
||||
bool hideSystemGroupRaw = false)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
|
||||
@@ -57,7 +58,9 @@ public class HtmlExportService
|
||||
|
||||
var (pills, subRows) = BuildUserPillsCell(
|
||||
entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers,
|
||||
colSpan: 7, grpMemIdx: ref grpMemIdx);
|
||||
colSpan: 7, grpMemIdx: ref grpMemIdx,
|
||||
targetLabel: entry.TargetLabel, sharingLinkType: entry.SharingLinkType,
|
||||
hideSystemGroupRaw: hideSystemGroupRaw);
|
||||
|
||||
sb.AppendLine("<tr>");
|
||||
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
||||
@@ -66,7 +69,7 @@ public class HtmlExportService
|
||||
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
|
||||
sb.AppendLine($" <td>{pills}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||
sb.AppendLine($" <td>{BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
if (subRows.Length > 0) sb.Append(subRows);
|
||||
}
|
||||
@@ -87,7 +90,8 @@ public class HtmlExportService
|
||||
public string BuildHtml(
|
||||
IReadOnlyList<SimplifiedPermissionEntry> entries,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
|
||||
bool hideSystemGroupRaw = false)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var summaries = PermissionSummaryBuilder.Build(entries);
|
||||
@@ -132,7 +136,9 @@ public class HtmlExportService
|
||||
|
||||
var (pills, subRows) = BuildUserPillsCell(
|
||||
entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers,
|
||||
colSpan: 9, grpMemIdx: ref grpMemIdx);
|
||||
colSpan: 9, grpMemIdx: ref grpMemIdx,
|
||||
targetLabel: entry.TargetLabel, sharingLinkType: entry.SharingLinkType,
|
||||
hideSystemGroupRaw: hideSystemGroupRaw);
|
||||
|
||||
sb.AppendLine("<tr>");
|
||||
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
||||
@@ -143,7 +149,7 @@ public class HtmlExportService
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
|
||||
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||
sb.AppendLine($" <td>{BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
if (subRows.Length > 0) sb.Append(subRows);
|
||||
}
|
||||
@@ -161,9 +167,10 @@ public class HtmlExportService
|
||||
string filePath,
|
||||
CancellationToken ct,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
|
||||
bool hideSystemGroupRaw = false)
|
||||
{
|
||||
var html = BuildHtml(entries, branding, groupMembers);
|
||||
var html = BuildHtml(entries, branding, groupMembers, hideSystemGroupRaw);
|
||||
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
|
||||
}
|
||||
|
||||
@@ -173,9 +180,10 @@ public class HtmlExportService
|
||||
string filePath,
|
||||
CancellationToken ct,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
|
||||
bool hideSystemGroupRaw = false)
|
||||
{
|
||||
var html = BuildHtml(entries, branding, groupMembers);
|
||||
var html = BuildHtml(entries, branding, groupMembers, hideSystemGroupRaw);
|
||||
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
|
||||
}
|
||||
|
||||
@@ -191,11 +199,12 @@ public class HtmlExportService
|
||||
HtmlSplitLayout layout,
|
||||
CancellationToken ct,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
|
||||
bool hideSystemGroupRaw = false)
|
||||
{
|
||||
if (splitMode != ReportSplitMode.BySite)
|
||||
{
|
||||
await WriteAsync(entries, basePath, ct, branding, groupMembers);
|
||||
await WriteAsync(entries, basePath, ct, branding, groupMembers, hideSystemGroupRaw);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -203,7 +212,7 @@ public class HtmlExportService
|
||||
if (layout == HtmlSplitLayout.SingleTabbed)
|
||||
{
|
||||
var parts = partitions
|
||||
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers)))
|
||||
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers, hideSystemGroupRaw)))
|
||||
.ToList();
|
||||
var title = TranslationSource.Instance["report.title.permissions"];
|
||||
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
|
||||
@@ -215,11 +224,11 @@ public class HtmlExportService
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
||||
await WriteAsync(partEntries, path, ct, branding, groupMembers);
|
||||
await WriteAsync(partEntries, path, ct, branding, groupMembers, hideSystemGroupRaw);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Simplified-entry split variant of <see cref="WriteAsync(IReadOnlyList{PermissionEntry}, string, ReportSplitMode, HtmlSplitLayout, CancellationToken, ReportBranding?, IReadOnlyDictionary{string, IReadOnlyList{ResolvedMember}}?)"/>.</summary>
|
||||
/// <summary>Simplified-entry split variant.</summary>
|
||||
public async Task WriteAsync(
|
||||
IReadOnlyList<SimplifiedPermissionEntry> entries,
|
||||
string basePath,
|
||||
@@ -227,11 +236,12 @@ public class HtmlExportService
|
||||
HtmlSplitLayout layout,
|
||||
CancellationToken ct,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
|
||||
bool hideSystemGroupRaw = false)
|
||||
{
|
||||
if (splitMode != ReportSplitMode.BySite)
|
||||
{
|
||||
await WriteAsync(entries, basePath, ct, branding, groupMembers);
|
||||
await WriteAsync(entries, basePath, ct, branding, groupMembers, hideSystemGroupRaw);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -239,7 +249,7 @@ public class HtmlExportService
|
||||
if (layout == HtmlSplitLayout.SingleTabbed)
|
||||
{
|
||||
var parts = partitions
|
||||
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers)))
|
||||
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers, hideSystemGroupRaw)))
|
||||
.ToList();
|
||||
var title = TranslationSource.Instance["report.title.permissions_simplified"];
|
||||
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
|
||||
@@ -251,7 +261,7 @@ public class HtmlExportService
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
||||
await WriteAsync(partEntries, path, ct, branding, groupMembers);
|
||||
await WriteAsync(partEntries, path, ct, branding, groupMembers, hideSystemGroupRaw);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using SharepointToolbox.Core.Helpers;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
|
||||
@@ -137,7 +138,10 @@ document.addEventListener('click', function(ev) {
|
||||
string? principalType,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers,
|
||||
int colSpan,
|
||||
ref int grpMemIdx)
|
||||
ref int grpMemIdx,
|
||||
string? targetLabel = null,
|
||||
string? sharingLinkType = null,
|
||||
bool hideSystemGroupRaw = false)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var logins = userLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
@@ -151,14 +155,40 @@ document.addEventListener('click', function(ev) {
|
||||
var name = i < names.Length ? names[i].Trim() : login;
|
||||
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
bool isExpandable = principalType == "SharePointGroup"
|
||||
// When the principal is a resolved system group and the user wants the raw
|
||||
// name hidden, replace the pill's visible text with the link-type badge
|
||||
// (sharing links) and/or the target label. Falls back to the raw name when
|
||||
// resolution failed (no targetLabel).
|
||||
var classification = principalType == "SharePointGroup"
|
||||
? PermissionEntryHelper.Classify(name)
|
||||
: new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null);
|
||||
bool isResolvedSystemGroup = hideSystemGroupRaw
|
||||
&& classification.Kind != SystemGroupKind.None
|
||||
&& classification.Kind != SystemGroupKind.LimitedAccessBare
|
||||
&& !string.IsNullOrEmpty(targetLabel);
|
||||
|
||||
bool hasResolvedMembers = principalType == "SharePointGroup"
|
||||
&& groupMembers != null
|
||||
&& groupMembers.TryGetValue(name, out _);
|
||||
|
||||
if (isExpandable && groupMembers != null && groupMembers.TryGetValue(name, out var resolved))
|
||||
if (hasResolvedMembers && groupMembers!.TryGetValue(name, out var resolved))
|
||||
{
|
||||
var grpId = $"grpmem{grpMemIdx}";
|
||||
pills.Append($"<span class=\"user-pill group-expandable\" data-group-target=\"{HtmlEncode(grpId)}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} ▼</span>");
|
||||
pills.Append("<span class=\"user-pill group-expandable\"");
|
||||
if (isResolvedSystemGroup)
|
||||
pills.Append(" data-system-group=\"1\"");
|
||||
pills.Append($" data-group-target=\"{HtmlEncode(grpId)}\" data-email=\"{HtmlEncode(login)}\">");
|
||||
if (isResolvedSystemGroup)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(sharingLinkType))
|
||||
pills.Append(BuildSharingLinkBadge(sharingLinkType!));
|
||||
pills.Append(HtmlEncode(targetLabel!));
|
||||
}
|
||||
else
|
||||
{
|
||||
pills.Append(HtmlEncode(name));
|
||||
}
|
||||
pills.Append(" ▼</span>");
|
||||
|
||||
string memberContent;
|
||||
if (resolved.Count > 0)
|
||||
@@ -173,6 +203,14 @@ document.addEventListener('click', function(ev) {
|
||||
subRows.AppendLine($"<tr data-group=\"{HtmlEncode(grpId)}\" style=\"display:none\"><td colspan=\"{colSpan}\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
|
||||
grpMemIdx++;
|
||||
}
|
||||
else if (isResolvedSystemGroup)
|
||||
{
|
||||
pills.Append("<span class=\"user-pill\" data-system-group=\"1\">");
|
||||
if (!string.IsNullOrEmpty(sharingLinkType))
|
||||
pills.Append(BuildSharingLinkBadge(sharingLinkType!));
|
||||
pills.Append(HtmlEncode(targetLabel!));
|
||||
pills.Append("</span>");
|
||||
}
|
||||
else
|
||||
{
|
||||
var cls = isExt ? "user-pill external-user" : "user-pill";
|
||||
@@ -183,6 +221,80 @@ document.addEventListener('click', function(ev) {
|
||||
return (pills.ToString(), subRows.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the Granted Through cell. When the entry carries a resolved system-group
|
||||
/// target (Limited Access For Web/List or SharingLinks), a clickable link to the
|
||||
/// targeted resource is appended on a second line. For sharing links the link type
|
||||
/// (OrganizationEdit / AnonymousView / …) is surfaced alongside the target.
|
||||
///
|
||||
/// When <paramref name="hideSystemGroupRaw"/> is true and a target was resolved, the
|
||||
/// raw "SharePoint Group: SharingLinks.{guid}…" / "Limited Access System Group For
|
||||
/// Web|List {guid}" prefix is suppressed and only the link-type badge + clickable
|
||||
/// target are shown — keeps the report readable without losing information.
|
||||
/// </summary>
|
||||
internal static string BuildGrantedThroughCell(
|
||||
string grantedThrough,
|
||||
string? targetUrl,
|
||||
string? targetLabel,
|
||||
string? sharingLinkType,
|
||||
bool hideSystemGroupRaw = false)
|
||||
{
|
||||
var hasTarget = !string.IsNullOrEmpty(targetUrl) && !string.IsNullOrEmpty(targetLabel);
|
||||
var hasLinkType = !string.IsNullOrEmpty(sharingLinkType);
|
||||
var suppressRaw = hideSystemGroupRaw && hasTarget;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
if (!suppressRaw)
|
||||
sb.Append(HtmlEncode(grantedThrough));
|
||||
|
||||
if (!hasTarget && !hasLinkType)
|
||||
return sb.ToString();
|
||||
|
||||
if (suppressRaw)
|
||||
{
|
||||
// Inline layout — no leading raw text to wrap under.
|
||||
if (hasLinkType)
|
||||
sb.Append(BuildSharingLinkBadge(sharingLinkType!));
|
||||
if (hasTarget)
|
||||
{
|
||||
sb.Append("<a href=\"");
|
||||
sb.Append(HtmlEncode(targetUrl!));
|
||||
sb.Append("\" target=\"_blank\">");
|
||||
sb.Append(HtmlEncode(targetLabel!));
|
||||
sb.Append("</a>");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
sb.Append("<div style=\"margin-top:4px;font-size:.75rem;color:#555\">");
|
||||
if (hasLinkType)
|
||||
sb.Append(BuildSharingLinkBadge(sharingLinkType!));
|
||||
if (hasTarget)
|
||||
{
|
||||
sb.Append("→ <a href=\"");
|
||||
sb.Append(HtmlEncode(targetUrl!));
|
||||
sb.Append("\" target=\"_blank\">");
|
||||
sb.Append(HtmlEncode(targetLabel!));
|
||||
sb.Append("</a>");
|
||||
}
|
||||
sb.Append("</div>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the colored badge for a SharePoint sharing-link type. Translates the
|
||||
/// raw <c>linkType</c> code (e.g. <c>OrganizationEdit</c>) into a human label
|
||||
/// (e.g. <c>Org link · Edit</c>) and tints by risk tier; raw code surfaces as a
|
||||
/// <c>title</c> tooltip so operators can still trace it back to the source.
|
||||
/// </summary>
|
||||
internal static string BuildSharingLinkBadge(string rawLinkType)
|
||||
{
|
||||
var (label, risk) = SharingLinkLabels.Describe(rawLinkType);
|
||||
var (bg, fg) = SharingLinkLabels.Colors(risk);
|
||||
return $"<span class=\"badge\" style=\"background:{bg};color:{fg};margin-right:6px\" " +
|
||||
$"title=\"{HtmlEncode(rawLinkType)}\">{HtmlEncode(label)}</span>";
|
||||
}
|
||||
|
||||
/// <summary>Returns the CSS class for the object-type badge.</summary>
|
||||
internal static string ObjectTypeCss(string t) => t switch
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
@@ -18,34 +19,58 @@ public class StorageCsvExportService
|
||||
/// </summary>
|
||||
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var sb = new StringBuilder();
|
||||
// Pre-size: ~110 chars/row + header avoids most StringBuilder growth.
|
||||
var sb = new StringBuilder(128 + nodes.Count * 110);
|
||||
WriteCsv(sb, nodes);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// Header
|
||||
sb.AppendLine($"{T["report.col.library"]},{T["stor.col.kind"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
|
||||
private static void WriteCsv(StringBuilder sb, IReadOnlyList<StorageNode> nodes)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
// Hoist resource lookups out of the row loop: ResourceManager.GetString
|
||||
// is a culture-aware dictionary probe — caching once per export saves
|
||||
// O(rows × columns) lookups on large tenants.
|
||||
string colLibrary = T["report.col.library"];
|
||||
string colKind = T["stor.col.kind"];
|
||||
string colSite = T["report.col.site"];
|
||||
string colFiles = T["report.stat.files"];
|
||||
string colTotalMb = T["report.col.total_size_mb"];
|
||||
string colVerMb = T["report.col.version_size_mb"];
|
||||
string colLastMod = T["report.col.last_modified"];
|
||||
|
||||
sb.Append(colLibrary).Append(',')
|
||||
.Append(colKind).Append(',')
|
||||
.Append(colSite).Append(',')
|
||||
.Append(colFiles).Append(',')
|
||||
.Append(colTotalMb).Append(',')
|
||||
.Append(colVerMb).Append(',')
|
||||
.AppendLine(colLastMod);
|
||||
|
||||
var kindLabels = BuildKindLabelCache();
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
sb.AppendLine(string.Join(",",
|
||||
Csv(node.Name),
|
||||
Csv(KindLabel(node.Kind)),
|
||||
Csv(node.SiteTitle),
|
||||
node.TotalFileCount.ToString(),
|
||||
FormatMb(node.TotalSizeBytes),
|
||||
FormatMb(node.VersionSizeBytes),
|
||||
node.LastModified.HasValue
|
||||
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd"))
|
||||
: string.Empty));
|
||||
AppendCsvField(sb, node.Name).Append(',');
|
||||
AppendCsvField(sb, kindLabels[(int)node.Kind]).Append(',');
|
||||
AppendCsvField(sb, node.SiteTitle).Append(',');
|
||||
sb.Append(node.TotalFileCount).Append(',');
|
||||
AppendMb(sb, node.TotalSizeBytes).Append(',');
|
||||
AppendMb(sb, node.VersionSizeBytes).Append(',');
|
||||
if (node.LastModified.HasValue)
|
||||
AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Writes the library-level CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
|
||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
|
||||
{
|
||||
var csv = BuildCsv(nodes);
|
||||
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
||||
// Stream straight to disk: skip the StringBuilder→string copy and the
|
||||
// separate UTF-8 buffer that File.WriteAllTextAsync materializes.
|
||||
var sb = new StringBuilder(128 + nodes.Count * 110);
|
||||
WriteCsv(sb, nodes);
|
||||
await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -53,44 +78,68 @@ public class StorageCsvExportService
|
||||
/// </summary>
|
||||
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var sb = new StringBuilder();
|
||||
var sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
|
||||
WriteCsv(sb, nodes, fileTypeMetrics);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void WriteCsv(StringBuilder sb, IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
string colLibrary = T["report.col.library"];
|
||||
string colSite = T["report.col.site"];
|
||||
string colFiles = T["report.stat.files"];
|
||||
string colTotalMb = T["report.col.total_size_mb"];
|
||||
string colVerMb = T["report.col.version_size_mb"];
|
||||
string colLastMod = T["report.col.last_modified"];
|
||||
|
||||
sb.Append(colLibrary).Append(',')
|
||||
.Append(colSite).Append(',')
|
||||
.Append(colFiles).Append(',')
|
||||
.Append(colTotalMb).Append(',')
|
||||
.Append(colVerMb).Append(',')
|
||||
.AppendLine(colLastMod);
|
||||
|
||||
// Library details
|
||||
sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
sb.AppendLine(string.Join(",",
|
||||
Csv(node.Name),
|
||||
Csv(node.SiteTitle),
|
||||
node.TotalFileCount.ToString(),
|
||||
FormatMb(node.TotalSizeBytes),
|
||||
FormatMb(node.VersionSizeBytes),
|
||||
node.LastModified.HasValue
|
||||
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd"))
|
||||
: string.Empty));
|
||||
AppendCsvField(sb, node.Name).Append(',');
|
||||
AppendCsvField(sb, node.SiteTitle).Append(',');
|
||||
sb.Append(node.TotalFileCount).Append(',');
|
||||
AppendMb(sb, node.TotalSizeBytes).Append(',');
|
||||
AppendMb(sb, node.VersionSizeBytes).Append(',');
|
||||
if (node.LastModified.HasValue)
|
||||
AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// File type breakdown
|
||||
if (fileTypeMetrics.Count > 0)
|
||||
{
|
||||
string colFileType = T["report.col.file_type"];
|
||||
string colSizeMb = T["report.col.size_mb"];
|
||||
string colFileCnt = T["report.col.file_count"];
|
||||
string noExtLabel = T["report.text.no_extension"];
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"{T["report.col.file_type"]},{T["report.col.size_mb"]},{T["report.col.file_count"]}");
|
||||
sb.Append(colFileType).Append(',')
|
||||
.Append(colSizeMb).Append(',')
|
||||
.AppendLine(colFileCnt);
|
||||
|
||||
foreach (var m in fileTypeMetrics)
|
||||
{
|
||||
string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_extension"] : m.Extension;
|
||||
sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString()));
|
||||
string label = string.IsNullOrEmpty(m.Extension) ? noExtLabel : m.Extension;
|
||||
AppendCsvField(sb, label).Append(',');
|
||||
AppendMb(sb, m.TotalSizeBytes).Append(',');
|
||||
sb.Append(m.FileCount).AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Writes the two-section CSV (libraries + file-type breakdown) with UTF-8 BOM.</summary>
|
||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
|
||||
{
|
||||
var csv = BuildCsv(nodes, fileTypeMetrics);
|
||||
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
|
||||
var sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
|
||||
WriteCsv(sb, nodes, fileTypeMetrics);
|
||||
await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -139,11 +188,27 @@ public class StorageCsvExportService
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static string FormatMb(long bytes)
|
||||
=> (bytes / (1024.0 * 1024.0)).ToString("F2");
|
||||
private static StringBuilder AppendMb(StringBuilder sb, long bytes)
|
||||
=> sb.Append((bytes / (1024.0 * 1024.0)).ToString("F2", CultureInfo.InvariantCulture));
|
||||
|
||||
/// <summary>RFC 4180 CSV field quoting with formula-injection guard.</summary>
|
||||
private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value);
|
||||
private static StringBuilder AppendCsvField(StringBuilder sb, string value)
|
||||
=> sb.Append(CsvSanitizer.EscapeMinimal(value));
|
||||
|
||||
/// <summary>
|
||||
/// Pre-resolves localized labels for every <see cref="StorageNodeKind"/>
|
||||
/// once per export, indexed by the enum's int value. Avoids a
|
||||
/// <c>ResourceManager.GetString</c> call per row in hot CSV loops.
|
||||
/// </summary>
|
||||
private static string[] BuildKindLabelCache()
|
||||
{
|
||||
var values = (StorageNodeKind[])Enum.GetValues(typeof(StorageNodeKind));
|
||||
int max = 0;
|
||||
foreach (var v in values) { int i = (int)v; if (i > max) max = i; }
|
||||
var cache = new string[max + 1];
|
||||
for (int i = 0; i < cache.Length; i++) cache[i] = ((StorageNodeKind)i).ToString();
|
||||
foreach (var v in values) cache[(int)v] = KindLabel(v);
|
||||
return cache;
|
||||
}
|
||||
|
||||
private static string KindLabel(StorageNodeKind kind)
|
||||
{
|
||||
|
||||
@@ -13,6 +13,8 @@ namespace SharepointToolbox.Services.Export;
|
||||
public class StorageHtmlExportService
|
||||
{
|
||||
private int _togIdx;
|
||||
private string[] _kindLabels = Array.Empty<string>();
|
||||
private string[] _kindLabelsHtml = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Builds a self-contained HTML report with one collapsible row per
|
||||
@@ -21,10 +23,18 @@ public class StorageHtmlExportService
|
||||
/// breakdown section is desired.
|
||||
/// </summary>
|
||||
public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null)
|
||||
{
|
||||
var sb = new StringBuilder(3072 + nodes.Count * 340);
|
||||
BuildHtmlCore(sb, nodes, branding);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private void BuildHtmlCore(StringBuilder sb, IReadOnlyList<StorageNode> nodes, ReportBranding? branding)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
_togIdx = 0;
|
||||
var sb = new StringBuilder();
|
||||
_kindLabels = BuildKindLabelCache();
|
||||
_kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels);
|
||||
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\">");
|
||||
@@ -60,11 +70,18 @@ public class StorageHtmlExportService
|
||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
|
||||
|
||||
// Summary cards
|
||||
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList();
|
||||
long siteTotal0 = rootNodes0.Sum(n => n.TotalSizeBytes);
|
||||
long versionTotal0 = rootNodes0.Sum(n => n.VersionSizeBytes);
|
||||
long fileTotal0 = rootNodes0.Sum(n => n.TotalFileCount);
|
||||
// Single-pass root aggregation: replaces 4 separate enumerations
|
||||
// (.Where().ToList() + 3× .Sum() + a final .Where() during render).
|
||||
var rootNodes0 = new List<StorageNode>(Math.Min(nodes.Count, 64));
|
||||
long siteTotal0 = 0, versionTotal0 = 0, fileTotal0 = 0;
|
||||
foreach (var n in nodes)
|
||||
{
|
||||
if (n.IndentLevel != 0) continue;
|
||||
rootNodes0.Add(n);
|
||||
siteTotal0 += n.TotalSizeBytes;
|
||||
versionTotal0 += n.VersionSizeBytes;
|
||||
fileTotal0 += n.TotalFileCount;
|
||||
}
|
||||
|
||||
sb.AppendLine($"""
|
||||
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
|
||||
@@ -90,10 +107,10 @@ public class StorageHtmlExportService
|
||||
<tbody>
|
||||
""");
|
||||
|
||||
// Only iterate root-level nodes; RenderNode recurses into Children
|
||||
// inline. Iterating the flat list would render every descendant a
|
||||
// second time as a top-level row.
|
||||
foreach (var node in nodes.Where(n => n.IndentLevel == 0))
|
||||
// Render only the pre-materialized root list — recursing into
|
||||
// Children handles descendants. Iterating the flat list would render
|
||||
// every descendant a second time as a top-level row.
|
||||
foreach (var node in rootNodes0)
|
||||
{
|
||||
RenderNode(sb, node);
|
||||
}
|
||||
@@ -105,18 +122,24 @@ public class StorageHtmlExportService
|
||||
|
||||
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
||||
sb.AppendLine("</body></html>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an HTML report including a file-type breakdown chart section.
|
||||
/// </summary>
|
||||
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null)
|
||||
{
|
||||
var sb = new StringBuilder(4096 + nodes.Count * 340 + fileTypeMetrics.Count * 220);
|
||||
BuildHtmlCore(sb, nodes, fileTypeMetrics, branding);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private void BuildHtmlCore(StringBuilder sb, IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
_togIdx = 0;
|
||||
var sb = new StringBuilder();
|
||||
_kindLabels = BuildKindLabelCache();
|
||||
_kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels);
|
||||
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\">");
|
||||
@@ -163,11 +186,17 @@ public class StorageHtmlExportService
|
||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
|
||||
|
||||
// ── Summary cards ──
|
||||
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
|
||||
long siteTotal = rootNodes.Sum(n => n.TotalSizeBytes);
|
||||
long versionTotal = rootNodes.Sum(n => n.VersionSizeBytes);
|
||||
long fileTotal = rootNodes.Sum(n => n.TotalFileCount);
|
||||
// ── Summary cards (single-pass aggregation) ──
|
||||
var rootNodes = new List<StorageNode>(Math.Min(nodes.Count, 64));
|
||||
long siteTotal = 0, versionTotal = 0, fileTotal = 0;
|
||||
foreach (var n in nodes)
|
||||
{
|
||||
if (n.IndentLevel != 0) continue;
|
||||
rootNodes.Add(n);
|
||||
siteTotal += n.TotalSizeBytes;
|
||||
versionTotal += n.VersionSizeBytes;
|
||||
fileTotal += n.TotalFileCount;
|
||||
}
|
||||
|
||||
sb.AppendLine("<div class=\"stats\">");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">{T["report.stat.total_size"]}</div></div>");
|
||||
@@ -227,10 +256,10 @@ public class StorageHtmlExportService
|
||||
<tbody>
|
||||
""");
|
||||
|
||||
// Only iterate root-level nodes; RenderNode recurses into Children
|
||||
// inline. Iterating the flat list would render every descendant a
|
||||
// second time as a top-level row.
|
||||
foreach (var node in nodes.Where(n => n.IndentLevel == 0))
|
||||
// Render only the pre-materialized root list — recursing into
|
||||
// Children handles descendants. Iterating the flat list would render
|
||||
// every descendant a second time as a top-level row.
|
||||
foreach (var node in rootNodes)
|
||||
{
|
||||
RenderNode(sb, node);
|
||||
}
|
||||
@@ -242,22 +271,24 @@ public class StorageHtmlExportService
|
||||
|
||||
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
||||
sb.AppendLine("</body></html>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Writes the library-only HTML report to <paramref name="filePath"/>.</summary>
|
||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||
{
|
||||
var html = BuildHtml(nodes, branding);
|
||||
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
||||
// Build into StringBuilder, stream chunks straight to disk —
|
||||
// skips a full-document char-array copy from sb.ToString().
|
||||
var sb = new StringBuilder(3072 + nodes.Count * 340);
|
||||
BuildHtmlCore(sb, nodes, branding);
|
||||
await ExportFileWriter.WriteHtmlChunksAsync(filePath, sb, ct);
|
||||
}
|
||||
|
||||
/// <summary>Writes the HTML report including the file-type breakdown chart.</summary>
|
||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||
{
|
||||
var html = BuildHtml(nodes, fileTypeMetrics, branding);
|
||||
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
||||
var sb = new StringBuilder(4096 + nodes.Count * 340 + fileTypeMetrics.Count * 220);
|
||||
BuildHtmlCore(sb, nodes, fileTypeMetrics, branding);
|
||||
await ExportFileWriter.WriteHtmlChunksAsync(filePath, sb, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -313,21 +344,7 @@ public class StorageHtmlExportService
|
||||
? $"<button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">▶</button>{HtmlEncode(node.Name)}"
|
||||
: $"<span style=\"margin-left:{node.IndentLevel * 16}px\">{HtmlEncode(node.Name)}</span>";
|
||||
|
||||
string lastMod = node.LastModified.HasValue
|
||||
? node.LastModified.Value.ToString("yyyy-MM-dd")
|
||||
: string.Empty;
|
||||
|
||||
sb.AppendLine($"""
|
||||
<tr>
|
||||
<td>{nameCell}</td>
|
||||
<td>{HtmlEncode(KindLabel(node.Kind))}</td>
|
||||
<td>{HtmlEncode(node.SiteTitle)}</td>
|
||||
<td class="num">{node.TotalFileCount:N0}</td>
|
||||
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
|
||||
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
|
||||
<td>{lastMod}</td>
|
||||
</tr>
|
||||
""");
|
||||
AppendRow(sb, node, nameCell);
|
||||
|
||||
if (hasChildren)
|
||||
{
|
||||
@@ -352,21 +369,7 @@ public class StorageHtmlExportService
|
||||
? $"<span style=\"{indent}\"><button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">▶</button>{HtmlEncode(node.Name)}</span>"
|
||||
: $"<span style=\"{indent}\">{HtmlEncode(node.Name)}</span>";
|
||||
|
||||
string lastMod = node.LastModified.HasValue
|
||||
? node.LastModified.Value.ToString("yyyy-MM-dd")
|
||||
: string.Empty;
|
||||
|
||||
sb.AppendLine($"""
|
||||
<tr>
|
||||
<td>{nameCell}</td>
|
||||
<td>{HtmlEncode(KindLabel(node.Kind))}</td>
|
||||
<td>{HtmlEncode(node.SiteTitle)}</td>
|
||||
<td class="num">{node.TotalFileCount:N0}</td>
|
||||
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
|
||||
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
|
||||
<td>{lastMod}</td>
|
||||
</tr>
|
||||
""");
|
||||
AppendRow(sb, node, nameCell);
|
||||
|
||||
if (hasChildren)
|
||||
{
|
||||
@@ -381,6 +384,35 @@ public class StorageHtmlExportService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends one data row given the pre-rendered name cell. Hot path:
|
||||
/// pulls localized kind labels from <see cref="_kindLabelsHtml"/> instead
|
||||
/// of going through <c>ResourceManager.GetString</c> + <c>HtmlEncode</c>
|
||||
/// per row.
|
||||
/// </summary>
|
||||
private void AppendRow(StringBuilder sb, StorageNode node, string nameCell)
|
||||
{
|
||||
int kindIdx = (int)node.Kind;
|
||||
string kindLabel = (uint)kindIdx < (uint)_kindLabelsHtml.Length
|
||||
? _kindLabelsHtml[kindIdx]
|
||||
: HtmlEncode(node.Kind.ToString());
|
||||
string lastMod = node.LastModified.HasValue
|
||||
? node.LastModified.Value.ToString("yyyy-MM-dd")
|
||||
: string.Empty;
|
||||
|
||||
sb.AppendLine($"""
|
||||
<tr>
|
||||
<td>{nameCell}</td>
|
||||
<td>{kindLabel}</td>
|
||||
<td>{HtmlEncode(node.SiteTitle)}</td>
|
||||
<td class="num">{node.TotalFileCount:N0}</td>
|
||||
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
|
||||
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
|
||||
<td>{lastMod}</td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
|
||||
@@ -406,4 +438,28 @@ public class StorageHtmlExportService
|
||||
_ => kind.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-resolves localized labels for every <see cref="StorageNodeKind"/>
|
||||
/// once per export. Cached array index lookup avoids
|
||||
/// <c>ResourceManager.GetString</c> per row in hot rendering loops.
|
||||
/// </summary>
|
||||
private static string[] BuildKindLabelCache()
|
||||
{
|
||||
var values = (StorageNodeKind[])Enum.GetValues(typeof(StorageNodeKind));
|
||||
int max = 0;
|
||||
foreach (var v in values) { int i = (int)v; if (i > max) max = i; }
|
||||
var cache = new string[max + 1];
|
||||
for (int i = 0; i < cache.Length; i++) cache[i] = ((StorageNodeKind)i).ToString();
|
||||
foreach (var v in values) cache[(int)v] = KindLabel(v);
|
||||
return cache;
|
||||
}
|
||||
|
||||
/// <summary>HTML-encodes each entry of <paramref name="raw"/> once.</summary>
|
||||
private static string[] BuildHtmlEncodedCache(string[] raw)
|
||||
{
|
||||
var encoded = new string[raw.Length];
|
||||
for (int i = 0; i < raw.Length; i++) encoded[i] = HtmlEncode(raw[i]);
|
||||
return encoded;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public class UserAccessCsvExportService
|
||||
private static string BuildDataHeader()
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
return $"\"{T["report.col.site"]}\",\"{T["report.col.object_type"]}\",\"{T["report.col.object"]}\",\"{T["report.col.url"]}\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\"";
|
||||
return $"\"{T["report.col.site"]}\",\"{T["report.col.object_type"]}\",\"{T["report.col.object"]}\",\"{T["report.col.url"]}\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -51,7 +51,10 @@ public class UserAccessCsvExportService
|
||||
Csv(entry.ObjectUrl),
|
||||
Csv(entry.PermissionLevel),
|
||||
Csv(entry.AccessType.ToString()),
|
||||
Csv(entry.GrantedThrough)
|
||||
Csv(entry.GrantedThrough),
|
||||
Csv(entry.TargetLabel ?? string.Empty),
|
||||
Csv(entry.TargetUrl ?? string.Empty),
|
||||
Csv(entry.SharingLinkType ?? string.Empty)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -179,7 +182,7 @@ public class UserAccessCsvExportService
|
||||
sb.AppendLine();
|
||||
|
||||
// Header
|
||||
sb.AppendLine($"\"{T["report.col.user"]}\",\"User Login\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"Locations\",\"Location Count\"");
|
||||
sb.AppendLine($"\"{T["report.col.user"]}\",\"User Login\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\",\"Locations\",\"Location Count\"");
|
||||
|
||||
// Data rows
|
||||
foreach (var entry in consolidated)
|
||||
@@ -187,13 +190,16 @@ public class UserAccessCsvExportService
|
||||
var locations = string.Join("; ", entry.Locations.Select(l => l.SiteTitle));
|
||||
sb.AppendLine(string.Join(",", new[]
|
||||
{
|
||||
$"\"{entry.UserDisplayName}\"",
|
||||
$"\"{entry.UserLogin}\"",
|
||||
$"\"{entry.PermissionLevel}\"",
|
||||
$"\"{entry.AccessType}\"",
|
||||
$"\"{entry.GrantedThrough}\"",
|
||||
$"\"{locations}\"",
|
||||
$"\"{entry.LocationCount}\""
|
||||
Csv(entry.UserDisplayName),
|
||||
Csv(entry.UserLogin),
|
||||
Csv(entry.PermissionLevel),
|
||||
Csv(entry.AccessType.ToString()),
|
||||
Csv(entry.GrantedThrough),
|
||||
Csv(entry.TargetLabel ?? string.Empty),
|
||||
Csv(entry.TargetUrl ?? string.Empty),
|
||||
Csv(entry.SharingLinkType ?? string.Empty),
|
||||
Csv(locations),
|
||||
Csv(entry.LocationCount.ToString())
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -226,7 +232,10 @@ public class UserAccessCsvExportService
|
||||
Csv(entry.ObjectUrl),
|
||||
Csv(entry.PermissionLevel),
|
||||
Csv(entry.AccessType.ToString()),
|
||||
Csv(entry.GrantedThrough)
|
||||
Csv(entry.GrantedThrough),
|
||||
Csv(entry.TargetLabel ?? string.Empty),
|
||||
Csv(entry.TargetUrl ?? string.Empty),
|
||||
Csv(entry.SharingLinkType ?? string.Empty)
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ a:hover { text-decoration: underline; }
|
||||
sb.AppendLine($" <td>{objectCell}</td>");
|
||||
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
|
||||
sb.AppendLine($" <td>{accessBadge}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
}
|
||||
}
|
||||
@@ -301,7 +301,7 @@ a:hover { text-decoration: underline; }
|
||||
sb.AppendLine($" <td>{objectCell}</td>");
|
||||
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
|
||||
sb.AppendLine($" <td>{accessBadge}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
}
|
||||
}
|
||||
@@ -579,7 +579,7 @@ a:hover { text-decoration: underline; }
|
||||
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.UserDisplayName)}{guestBadge}</td>");
|
||||
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
|
||||
sb.AppendLine($" <td>{accessBadge}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
|
||||
|
||||
if (entry.LocationCount == 1)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.SharePoint.Client;
|
||||
using SharepointToolbox.Core.Helpers;
|
||||
using SharepointToolbox.Core.Models;
|
||||
|
||||
namespace SharepointToolbox.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the targeted resource (List, Web, File, Folder) for a SharePoint system
|
||||
/// group whose name encodes a GUID — Limited Access System Group For Web/List and
|
||||
/// SharingLinks.{itemId}.{type}.{shareId}. Returns null when the target is missing
|
||||
/// or unreachable (deleted item, access denied, etc.) — callers fall back to the
|
||||
/// raw group name.
|
||||
/// </summary>
|
||||
public interface ISystemGroupTargetResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the target for a classified system group. Cached per (ctx site, guid).
|
||||
/// </summary>
|
||||
Task<SystemGroupTarget?> ResolveAsync(
|
||||
ClientContext ctx,
|
||||
SystemGroupClassification classification,
|
||||
CancellationToken ct);
|
||||
}
|
||||
@@ -11,6 +11,16 @@ namespace SharepointToolbox.Services;
|
||||
/// </summary>
|
||||
public class PermissionsService : IPermissionsService
|
||||
{
|
||||
private readonly ISystemGroupTargetResolver? _systemGroupResolver;
|
||||
|
||||
public PermissionsService() : this(null) { }
|
||||
|
||||
public PermissionsService(ISystemGroupTargetResolver? systemGroupResolver)
|
||||
{
|
||||
_systemGroupResolver = systemGroupResolver;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Detects the SharePoint server error raised when a RoleAssignment member
|
||||
/// refers to a user that no longer resolves (orphaned Azure AD account).
|
||||
@@ -336,9 +346,18 @@ public class PermissionsService : IPermissionsService
|
||||
|
||||
var member = ra.Member;
|
||||
var loginName = member.LoginName ?? string.Empty;
|
||||
var memberTitle = member.Title ?? string.Empty;
|
||||
|
||||
// Skip sharing links groups and limited access system groups
|
||||
if (PermissionEntryHelper.IsSharingLinksGroup(loginName))
|
||||
// Classify the member name. The bare "Limited Access System Group" is
|
||||
// pure noise; drop it. The For-Web/For-List and SharingLinks variants
|
||||
// are kept and enriched below with a resolved target URL.
|
||||
var classification = PermissionEntryHelper.Classify(memberTitle);
|
||||
if (classification.Kind == SystemGroupKind.None
|
||||
&& PermissionEntryHelper.IsBareLimitedAccessSystemGroup(loginName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (classification.Kind == SystemGroupKind.LimitedAccessBare)
|
||||
continue;
|
||||
|
||||
// Collect and filter permission levels
|
||||
@@ -362,19 +381,42 @@ public class PermissionsService : IPermissionsService
|
||||
|
||||
// Determine how the permission was granted
|
||||
string grantedThrough = principalType == "SharePointGroup"
|
||||
? $"SharePoint Group: {member.Title}"
|
||||
? $"SharePoint Group: {memberTitle}"
|
||||
: "Direct Permissions";
|
||||
|
||||
// Resolve system-group target (Limited Access For Web/List, SharingLinks)
|
||||
string? targetUrl = null;
|
||||
string? targetLabel = null;
|
||||
string? sharingLinkType = null;
|
||||
if (_systemGroupResolver is not null && classification.Kind != SystemGroupKind.None)
|
||||
{
|
||||
var target = await _systemGroupResolver.ResolveAsync(ctx, classification, ct);
|
||||
if (target is not null)
|
||||
{
|
||||
targetUrl = target.Url;
|
||||
targetLabel = target.Label;
|
||||
sharingLinkType = target.LinkType;
|
||||
}
|
||||
else if (classification.Kind == SystemGroupKind.SharingLink)
|
||||
{
|
||||
// Target lookup failed (deleted item / no access) — still surface link type.
|
||||
sharingLinkType = classification.LinkType;
|
||||
}
|
||||
}
|
||||
|
||||
entries.Add(new PermissionEntry(
|
||||
ObjectType: objectType,
|
||||
Title: title,
|
||||
Url: url,
|
||||
HasUniquePermissions: obj.HasUniqueRoleAssignments,
|
||||
Users: member.Title ?? string.Empty,
|
||||
Users: memberTitle,
|
||||
UserLogins: loginName,
|
||||
PermissionLevels: permLevels,
|
||||
GrantedThrough: grantedThrough,
|
||||
PrincipalType: principalType));
|
||||
PrincipalType: principalType,
|
||||
TargetUrl: targetUrl,
|
||||
TargetLabel: targetLabel,
|
||||
SharingLinkType: sharingLinkType));
|
||||
}
|
||||
|
||||
return entries;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
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}";
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,10 @@ public class UserAccessAuditService : IUserAccessAuditService
|
||||
AccessType: accessType,
|
||||
GrantedThrough: entry.GrantedThrough,
|
||||
IsHighPrivilege: HighPrivilegeLevels.Contains(trimmedLevel),
|
||||
IsExternalUser: PermissionEntryHelper.IsExternalUser(login));
|
||||
IsExternalUser: PermissionEntryHelper.IsExternalUser(login),
|
||||
TargetUrl: entry.TargetUrl,
|
||||
TargetLabel: entry.TargetLabel,
|
||||
SharingLinkType: entry.SharingLinkType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,14 @@
|
||||
<PackageReference Include="LiveChartsCore.SkiaSharpView.WPF" Version="2.0.0-rc5.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Graph" Version="5.74.0" />
|
||||
<!-- Override Kiota transitive deps to patch GHSA-7j59-v9qr-6fq9 (NU1903 in <1.20.0) -->
|
||||
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="1.22.2" />
|
||||
<PackageReference Include="Microsoft.Kiota.Authentication.Azure" Version="1.22.2" />
|
||||
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="1.22.2" />
|
||||
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="1.22.2" />
|
||||
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="1.22.2" />
|
||||
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="1.22.2" />
|
||||
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="1.22.2" />
|
||||
<PackageReference Include="Microsoft.Identity.Client" Version="4.83.3" />
|
||||
<PackageReference Include="Microsoft.Identity.Client.Broker" Version="4.82.1" />
|
||||
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.83.3" />
|
||||
|
||||
@@ -45,6 +45,15 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
[ObservableProperty]
|
||||
private bool _mergePermissions;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the HTML report hides the raw "SharePoint Group: SharingLinks.{guid}…"
|
||||
/// / "Limited Access System Group For Web|List {guid}" text for rows where the
|
||||
/// system-group target was resolved — only the link-type badge and target link are shown.
|
||||
/// Rows whose target could not be resolved still display the raw name as a fallback.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private bool _hideSystemGroupRaw = true;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _includeSubsites;
|
||||
|
||||
@@ -498,9 +507,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
}
|
||||
|
||||
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
|
||||
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers);
|
||||
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers, HideSystemGroupRaw);
|
||||
else
|
||||
await _htmlExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers);
|
||||
await _htmlExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers, HideSystemGroupRaw);
|
||||
OpenFile(dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -91,7 +91,9 @@
|
||||
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
|
||||
<StackPanel>
|
||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.merge.permissions]}"
|
||||
IsChecked="{Binding MergePermissions}" />
|
||||
IsChecked="{Binding MergePermissions}" Margin="0,0,0,4" />
|
||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.hide.system.group.raw]}"
|
||||
IsChecked="{Binding HideSystemGroupRaw}" />
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user