Initial commit

This commit is contained in:
2026-06-02 10:51:14 +02:00
committed by kawa
commit d19092c84e
182 changed files with 13757 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
using System.Text;
using Microsoft.AspNetCore.Authentication;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
using SharepointToolbox.Web.Services.Session;
namespace SharepointToolbox.Web.Services.Audit;
public class AuditService : IAuditService
{
private readonly AuditRepository _repo;
private readonly IUserContextAccessor _userContext;
public AuditService(AuditRepository repo, IUserContextAccessor userContext)
{
_repo = repo;
_userContext = userContext;
}
public async Task LogAsync(string action, string clientName, IEnumerable<string> sites, string details = "")
{
var entry = new AuditEntry
{
Action = action,
ClientName = clientName,
Sites = sites.ToList(),
Details = details,
UserEmail = _userContext.Email,
UserDisplay = _userContext.DisplayName,
UserRole = _userContext.Role
};
await _repo.AppendAsync(entry);
}
public Task<IReadOnlyList<AuditEntry>> GetAllAsync() => _repo.LoadAllAsync();
public async Task<string> ExportCsvAsync()
{
var entries = await _repo.LoadAllAsync();
var sb = new StringBuilder();
sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details");
foreach (var e in entries.OrderByDescending(x => x.Timestamp))
{
sb.AppendLine(string.Join(",",
CsvEscape(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")),
CsvEscape(e.UserEmail),
CsvEscape(e.UserDisplay),
CsvEscape(e.UserRole.ToString()),
CsvEscape(e.Action),
CsvEscape(e.ClientName),
CsvEscape(string.Join("; ", e.Sites)),
CsvEscape(e.Details)));
}
return sb.ToString();
}
private static string CsvEscape(string value)
{
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
+10
View File
@@ -0,0 +1,10 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Audit;
public interface IAuditService
{
Task LogAsync(string action, string clientName, IEnumerable<string> sites, string details = "");
Task<IReadOnlyList<AuditEntry>> GetAllAsync();
Task<string> ExportCsvAsync();
}
+141
View File
@@ -0,0 +1,141 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace SharepointToolbox.Web.Services.Auth;
public class AppRegistrationService : IAppRegistrationService
{
private const string GraphAppId = "00000003-0000-0000-c000-000000000000";
private const string SharePointAppId = "00000003-0000-0ff1-ce00-000000000000";
// Graph delegated scopes to request + consent
private static readonly string[] GraphScopes =
[
"User.Read", // signed-in user basic profile
"User.Read.All", // look up users by email/UPN (GraphUserDirectoryService, BulkMemberService)
"Group.ReadWrite.All", // read group members + add members/owners (BulkMemberService, SharePointGroupResolver)
"Sites.Read.All", // resolve site groupId from siteId (BulkMemberService)
];
// SharePoint delegated scopes to request + consent
private static readonly string[] SpScopes =
[
"AllSites.FullControl", // CSOM — site permissions, content, admin operations
];
private readonly HttpClient _http;
public AppRegistrationService(HttpClient http) { _http = http; }
public async Task<string> CreateAsync(
string adminAccessToken,
string tenantName,
string redirectUri,
CancellationToken ct = default)
{
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", adminAccessToken);
// 1. Resolve Graph + SharePoint service principals in the target tenant
var (graphSpId, graphPermIds) = await ResolveServicePrincipalAsync(GraphAppId, GraphScopes, ct);
var (spSpId, spPermIds) = await ResolveServicePrincipalAsync(SharePointAppId, SpScopes, ct);
// 2. Create app registration
var appBody = new
{
displayName = $"SP Toolbox — {tenantName}",
signInAudience = "AzureADMyOrg",
isFallbackPublicClient = true,
web = new { redirectUris = new[] { redirectUri } },
requiredResourceAccess = new[]
{
new
{
resourceAppId = GraphAppId,
resourceAccess = graphPermIds.Select(id => new { id, type = "Scope" }).ToArray(),
},
new
{
resourceAppId = SharePointAppId,
resourceAccess = spPermIds.Select(id => new { id, type = "Scope" }).ToArray(),
},
},
};
var appJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/applications",
appBody, ct);
var clientId = appJson.GetProperty("appId").GetString()!;
// 3. Create service principal for the new app
var spJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/servicePrincipals",
new { appId = clientId }, ct);
var newSpId = spJson.GetProperty("id").GetString()!;
// 4. Grant org-wide admin consent for Graph
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants",
new
{
clientId = newSpId,
consentType = "AllPrincipals",
resourceId = graphSpId,
scope = string.Join(" ", GraphScopes),
}, ct);
// 5. Grant org-wide admin consent for SharePoint
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants",
new
{
clientId = newSpId,
consentType = "AllPrincipals",
resourceId = spSpId,
scope = string.Join(" ", SpScopes),
}, ct);
return clientId;
}
// Returns (servicePrincipalObjectId, [permissionIds matching requested scopes])
private async Task<(string SpObjectId, string[] PermissionIds)> ResolveServicePrincipalAsync(
string appId, string[] scopeNames, CancellationToken ct)
{
var url = $"https://graph.microsoft.com/v1.0/servicePrincipals" +
$"?$filter=appId eq '{appId}'&$select=id,oauth2PermissionScopes";
var resp = await _http.GetAsync(url, ct);
var json = await resp.Content.ReadAsStringAsync(ct);
resp.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(json);
var values = doc.RootElement.GetProperty("value");
var sp = values.EnumerateArray().First();
var spId = sp.GetProperty("id").GetString()!;
var allScopes = sp.GetProperty("oauth2PermissionScopes");
var ids = new List<string>();
foreach (var scope in allScopes.EnumerateArray())
{
var value = scope.GetProperty("value").GetString();
if (scopeNames.Contains(value, StringComparer.OrdinalIgnoreCase))
ids.Add(scope.GetProperty("id").GetString()!);
}
return (spId, ids.ToArray());
}
private async Task<JsonElement> PostGraphAsync(string url, object body, CancellationToken ct)
{
var content = new StringContent(
JsonSerializer.Serialize(body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }),
Encoding.UTF8,
"application/json");
var resp = await _http.PostAsync(url, content, ct);
var json = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException(
$"Graph API error {resp.StatusCode} calling {url}: {json}");
return JsonDocument.Parse(json).RootElement.Clone();
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace SharepointToolbox.Web.Services.Auth;
public interface IAppRegistrationService
{
/// <summary>
/// Creates an Entra ID app registration in the target tenant using a delegated admin token
/// (requires Application.ReadWrite.All + DelegatedPermissionGrant.ReadWrite.All scope).
/// Grants org-wide admin consent for SharePoint + Graph delegated permissions.
/// Returns the new app's client ID (appId).
/// </summary>
Task<string> CreateAsync(
string adminAccessToken,
string tenantName,
string redirectUri,
CancellationToken ct = default);
}
+17
View File
@@ -0,0 +1,17 @@
namespace SharepointToolbox.Web.Services.Auth;
public interface ITokenRefreshService
{
/// <summary>
/// Exchanges a refresh token for a new access token using the public-client flow (no secret).
/// ClientId is per-tenant (from TenantProfile) — no global secret required.
/// </summary>
Task<TokenRefreshResult> RefreshAsync(string refreshToken, string tenantId, string clientId, string scope);
}
public class TokenRefreshResult
{
public string AccessToken { get; set; } = string.Empty;
public string RefreshToken { get; set; } = string.Empty;
public DateTimeOffset ExpiresAt { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
using System.Security.Claims;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Auth;
public interface IUserService
{
/// <summary>Auto-provision on first OIDC login; update LastLogin on subsequent logins.
/// First user ever becomes Admin automatically.</summary>
Task<AppUser> ProvisionAsync(ClaimsPrincipal principal);
Task<AppUser?> GetByEmailAsync(string email);
Task<IReadOnlyList<AppUser>> GetAllAsync();
Task UpdateRoleAsync(string userId, UserRole role);
Task DeleteAsync(string userId);
}
+42
View File
@@ -0,0 +1,42 @@
using System.Text.Json;
namespace SharepointToolbox.Web.Services.Auth;
public class TokenRefreshService : ITokenRefreshService
{
private readonly HttpClient _http;
public TokenRefreshService(HttpClient http) { _http = http; }
public async Task<TokenRefreshResult> RefreshAsync(
string refreshToken, string tenantId, string clientId, string scope)
{
var body = new Dictionary<string, string>
{
["grant_type"] = "refresh_token",
["client_id"] = clientId,
["refresh_token"] = refreshToken,
["scope"] = scope,
};
var url = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token";
var resp = await _http.PostAsync(url, new FormUrlEncodedContent(body));
var json = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"Token refresh failed ({resp.StatusCode}): {json}");
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var expiresIn = root.GetProperty("expires_in").GetInt32();
return new TokenRefreshResult
{
AccessToken = root.GetProperty("access_token").GetString()!,
RefreshToken = root.TryGetProperty("refresh_token", out var rt)
? rt.GetString()!
: refreshToken,
ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(expiresIn - 30),
};
}
}
+62
View File
@@ -0,0 +1,62 @@
using System.Security.Claims;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
namespace SharepointToolbox.Web.Services.Auth;
public class UserService : IUserService
{
private readonly UserRepository _repo;
public UserService(UserRepository repo) { _repo = repo; }
public async Task<AppUser> ProvisionAsync(ClaimsPrincipal principal)
{
var email = principal.FindFirstValue(ClaimTypes.Email)
?? principal.FindFirstValue("preferred_username")
?? throw new InvalidOperationException("OIDC token has no email claim.");
var display = principal.FindFirstValue("name")
?? principal.FindFirstValue(ClaimTypes.Name)
?? email;
var existing = await _repo.FindByEmailAsync(email);
if (existing is not null)
{
existing.LastLogin = DateTimeOffset.UtcNow;
existing.DisplayName = display;
await _repo.UpsertAsync(existing);
return existing;
}
// First user ever → Admin; subsequent → TechN0
var all = await _repo.LoadAsync();
var role = all.Count == 0 ? UserRole.Admin : UserRole.TechN0;
var user = new AppUser
{
Email = email,
DisplayName = display,
Role = role,
CreatedAt = DateTimeOffset.UtcNow,
LastLogin = DateTimeOffset.UtcNow
};
await _repo.UpsertAsync(user);
return user;
}
public Task<AppUser?> GetByEmailAsync(string email) => _repo.FindByEmailAsync(email);
public Task<IReadOnlyList<AppUser>> GetAllAsync() => _repo.LoadAsync();
public async Task UpdateRoleAsync(string userId, UserRole role)
{
var users = (await _repo.LoadAsync()).ToList();
var user = users.FirstOrDefault(u => u.Id == userId)
?? throw new KeyNotFoundException($"User {userId} not found.");
user.Role = role;
await _repo.UpsertAsync(user);
}
public Task DeleteAsync(string userId) => _repo.DeleteAsync(userId);
}
+107
View File
@@ -0,0 +1,107 @@
using Microsoft.Graph;
using Microsoft.Graph.Models;
using Microsoft.SharePoint.Client;
using Serilog;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Services.Audit;
using AppGraphClientFactory = SharepointToolbox.Web.Infrastructure.Auth.GraphClientFactory;
namespace SharepointToolbox.Web.Services;
public class BulkMemberService : IBulkMemberService
{
private readonly AppGraphClientFactory _graphClientFactory;
private readonly IAuditService _audit;
public BulkMemberService(AppGraphClientFactory graphClientFactory, IAuditService audit)
{
_graphClientFactory = graphClientFactory;
_audit = audit;
}
public async Task<BulkOperationSummary<BulkMemberRow>> AddMembersAsync(
ClientContext ctx, TenantProfile profile,
IReadOnlyList<BulkMemberRow> rows,
IProgress<OperationProgress> progress, CancellationToken ct)
{
var result = await BulkOperationRunner.RunAsync(rows,
async (row, idx, token) => await AddSingleMemberAsync(ctx, profile, row, progress, token),
progress, ct);
var sites = rows.Select(r => r.GroupUrl ?? ctx.Url).Distinct().ToList();
await _audit.LogAsync("BulkAddMembers", profile.Name, sites, $"{result.SuccessCount} succeeded, {(result.TotalCount - result.SuccessCount)} failed");
return result;
}
private async Task AddSingleMemberAsync(
ClientContext ctx, TenantProfile profile, BulkMemberRow row,
IProgress<OperationProgress> progress, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(row.GroupUrl))
{
await AddToClassicGroupAsync(ctx, row.GroupName, row.Email, row.Role, progress, ct);
return;
}
try
{
var graphClient = await _graphClientFactory.CreateClientAsync(profile);
var groupId = await ResolveGroupIdAsync(graphClient, row.GroupUrl, ct);
if (groupId != null)
{
await AddViaGraphAsync(graphClient, groupId, row.Email, row.Role, ct);
Log.Information("Added {Email} to M365 group {Group} via Graph", row.Email, row.GroupName);
return;
}
}
catch (OperationCanceledException) { throw; }
catch (Exception ex) { Log.Warning("Graph API failed for {Url}, falling back to CSOM: {Error}", row.GroupUrl, ex.Message); }
await AddToClassicGroupAsync(ctx, row.GroupName, row.Email, row.Role, progress, ct);
}
private static async Task AddViaGraphAsync(GraphServiceClient graphClient, string groupId, string email, string role, CancellationToken ct)
{
var user = await graphClient.Users[email].GetAsync(cancellationToken: ct);
if (user?.Id == null) throw new InvalidOperationException($"User not found: {email}");
var userRef = $"https://graph.microsoft.com/v1.0/directoryObjects/{user.Id}";
var body = new ReferenceCreate { OdataId = userRef };
if (role.Equals("Owner", StringComparison.OrdinalIgnoreCase))
await graphClient.Groups[groupId].Owners.Ref.PostAsync(body, cancellationToken: ct);
else
await graphClient.Groups[groupId].Members.Ref.PostAsync(body, cancellationToken: ct);
}
private static async Task<string?> ResolveGroupIdAsync(GraphServiceClient graphClient, string siteUrl, CancellationToken ct)
{
try
{
var uri = new Uri(siteUrl);
var site = await graphClient.Sites[$"{uri.Host}:{uri.AbsolutePath.TrimEnd('/')}"].GetAsync(cancellationToken: ct);
if (site?.Id == null) return null;
var groups = await graphClient.Groups.GetAsync(r =>
{
r.QueryParameters.Filter = "resourceProvisioningOptions/any(x:x eq 'Team')";
r.QueryParameters.Select = new[] { "id" };
}, cancellationToken: ct);
return groups?.Value?.FirstOrDefault()?.Id;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex) { Log.Debug("Group resolve failed for {Url}: {Error}", siteUrl, ex.Message); return null; }
}
private static async Task AddToClassicGroupAsync(
ClientContext ctx, string groupName, string email, string role,
IProgress<OperationProgress> progress, CancellationToken ct)
{
ctx.Load(ctx.Web.SiteGroups);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
Microsoft.SharePoint.Client.Group? targetGroup = null;
foreach (var group in ctx.Web.SiteGroups)
if (group.Title.Equals(groupName, StringComparison.OrdinalIgnoreCase)) { targetGroup = group; break; }
if (targetGroup == null) throw new InvalidOperationException($"SharePoint group not found: {groupName}");
var user = ctx.Web.EnsureUser(email);
ctx.Load(user);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
targetGroup.Users.AddUser(user);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
}
+61
View File
@@ -0,0 +1,61 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public static class BulkOperationRunner
{
public static async Task<BulkOperationSummary<TItem>> RunAsync<TItem>(
IReadOnlyList<TItem> items,
Func<TItem, int, CancellationToken, Task> processItem,
IProgress<OperationProgress> progress,
CancellationToken ct,
int maxConcurrency = 1)
{
if (items.Count == 0)
{
progress.Report(new OperationProgress(0, 0, "Nothing to do."));
return new BulkOperationSummary<TItem>(Array.Empty<BulkItemResult<TItem>>());
}
progress.Report(new OperationProgress(0, items.Count, $"Processing 1/{items.Count}..."));
var results = new BulkItemResult<TItem>[items.Count];
int completed = 0;
async Task RunOne(int i, CancellationToken token)
{
try
{
await processItem(items[i], i, token);
results[i] = BulkItemResult<TItem>.Success(items[i]);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
results[i] = BulkItemResult<TItem>.Failed(items[i], ex.Message);
}
finally
{
int done = Interlocked.Increment(ref completed);
progress.Report(new OperationProgress(done, items.Count, $"Processed {done}/{items.Count}"));
}
}
if (maxConcurrency <= 1)
{
for (int i = 0; i < items.Count; i++)
{
ct.ThrowIfCancellationRequested();
await RunOne(i, ct);
}
}
else
{
var options = new ParallelOptions { MaxDegreeOfParallelism = maxConcurrency, CancellationToken = ct };
await Parallel.ForEachAsync(Enumerable.Range(0, items.Count), options,
async (i, token) => await RunOne(i, token));
}
progress.Report(new OperationProgress(items.Count, items.Count, "Complete."));
return new BulkOperationSummary<TItem>(results);
}
}
+85
View File
@@ -0,0 +1,85 @@
using Microsoft.SharePoint.Client;
using PnP.Framework.Sites;
using Serilog;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Services.Audit;
namespace SharepointToolbox.Web.Services;
public class BulkSiteService : IBulkSiteService
{
private readonly IAuditService _audit;
public BulkSiteService(IAuditService audit) { _audit = audit; }
public async Task<BulkOperationSummary<BulkSiteRow>> CreateSitesAsync(
ClientContext adminCtx, IReadOnlyList<BulkSiteRow> rows,
IProgress<OperationProgress> progress, CancellationToken ct)
{
var createdUrls = new System.Collections.Concurrent.ConcurrentBag<string>();
var result = await BulkOperationRunner.RunAsync(rows,
async (row, idx, token) =>
{
var siteUrl = await CreateSingleSiteAsync(adminCtx, row, progress, token);
createdUrls.Add(siteUrl);
Log.Information("Created site: {Name} ({Type}) at {Url}", row.Name, row.Type, siteUrl);
},
progress, ct);
var tenantHost = Uri.TryCreate(adminCtx.Url, UriKind.Absolute, out var u) ? u.Host : adminCtx.Url;
await _audit.LogAsync("BulkCreateSites", tenantHost, createdUrls, $"{result.SuccessCount} created, {(result.TotalCount - result.SuccessCount)} failed");
return result;
}
private static async Task<string> CreateSingleSiteAsync(ClientContext adminCtx, BulkSiteRow row, IProgress<OperationProgress> progress, CancellationToken ct) =>
row.Type.Equals("Team", StringComparison.OrdinalIgnoreCase) ? await CreateTeamSiteAsync(adminCtx, row, progress, ct)
: row.Type.Equals("Communication", StringComparison.OrdinalIgnoreCase) ? await CreateCommunicationSiteAsync(adminCtx, row, progress, ct)
: throw new InvalidOperationException($"Unknown site type: {row.Type}");
private static async Task<string> CreateTeamSiteAsync(ClientContext adminCtx, BulkSiteRow row, IProgress<OperationProgress> progress, CancellationToken ct)
{
var owners = ParseEmails(row.Owners);
if (owners.Count == 0) throw new InvalidOperationException($"Team site '{row.Name}' requires at least one owner.");
var creationInfo = new TeamSiteCollectionCreationInformation { DisplayName = row.Name, Alias = row.Alias, Description = string.Empty, IsPublic = false, Owners = owners.ToArray() };
progress.Report(new OperationProgress(0, 0, $"Creating Team site: {row.Name}..."));
using var siteCtx = await adminCtx.CreateSiteAsync(creationInfo);
siteCtx.Load(siteCtx.Web, w => w.Url);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
var siteUrl = siteCtx.Web.Url;
foreach (var memberEmail in ParseEmails(row.Members))
{
ct.ThrowIfCancellationRequested();
try { var user = siteCtx.Web.EnsureUser(memberEmail); siteCtx.Load(user); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct); siteCtx.Web.AssociatedMemberGroup.Users.AddUser(user); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct); }
catch (OperationCanceledException) { throw; }
catch (Exception ex) { Log.Warning("Failed to add member {Email} to {Site}: {Error}", memberEmail, row.Name, ex.Message); }
}
return siteUrl;
}
private static async Task<string> CreateCommunicationSiteAsync(ClientContext adminCtx, BulkSiteRow row, IProgress<OperationProgress> progress, CancellationToken ct)
{
var alias = !string.IsNullOrWhiteSpace(row.Alias) ? row.Alias : SanitizeAlias(row.Name);
var tenantUrl = new Uri(adminCtx.Url);
var creationInfo = new CommunicationSiteCollectionCreationInformation { Title = row.Name, Url = $"https://{tenantUrl.Host}/sites/{alias}", Description = string.Empty };
progress.Report(new OperationProgress(0, 0, $"Creating Communication site: {row.Name}..."));
using var siteCtx = await adminCtx.CreateSiteAsync(creationInfo);
siteCtx.Load(siteCtx.Web, w => w.Url);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
var createdUrl = siteCtx.Web.Url;
foreach (var ownerEmail in ParseEmails(row.Owners))
{
ct.ThrowIfCancellationRequested();
try { var user = siteCtx.Web.EnsureUser(ownerEmail); siteCtx.Load(user); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct); siteCtx.Web.AssociatedOwnerGroup.Users.AddUser(user); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct); }
catch (OperationCanceledException) { throw; }
catch (Exception ex) { Log.Warning("Failed to add owner {Email}: {Error}", ownerEmail, ex.Message); }
}
return createdUrl;
}
private static List<string> ParseEmails(string commaSeparated) =>
string.IsNullOrWhiteSpace(commaSeparated) ? new List<string>() :
commaSeparated.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Where(e => !string.IsNullOrWhiteSpace(e)).ToList();
private static string SanitizeAlias(string name) =>
new string(name.Where(c => char.IsLetterOrDigit(c) || c == ' ' || c == '-').ToArray()).Replace(' ', '-').ToLowerInvariant();
}
+107
View File
@@ -0,0 +1,107 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using CsvHelper;
using CsvHelper.Configuration;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public class CsvValidationService : ICsvValidationService
{
private static readonly Regex EmailRegex = new(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled);
public List<CsvValidationRow<T>> ParseAndValidate<T>(Stream csvStream) where T : class
{
using var reader = new StreamReader(csvStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
{
HasHeaderRecord = true,
MissingFieldFound = null,
HeaderValidated = null,
DetectDelimiter = true,
TrimOptions = TrimOptions.Trim,
});
var rows = new List<CsvValidationRow<T>>();
csv.Read(); csv.ReadHeader();
while (csv.Read())
{
try
{
var record = csv.GetRecord<T>();
if (record == null)
{
rows.Add(CsvValidationRow<T>.ParseError(csv.Context.Parser?.RawRecord, "Failed to parse row"));
continue;
}
rows.Add(new CsvValidationRow<T>(record, new List<string>()));
}
catch (Exception ex)
{
rows.Add(CsvValidationRow<T>.ParseError(csv.Context.Parser?.RawRecord, ex.Message));
}
}
return rows;
}
public List<CsvValidationRow<BulkMemberRow>> ParseAndValidateMembers(Stream csvStream)
{
var rows = ParseAndValidate<BulkMemberRow>(csvStream);
foreach (var row in rows.Where(r => r.IsValid && r.Record != null))
row.Errors.AddRange(ValidateMemberRow(row.Record!));
return rows;
}
public List<CsvValidationRow<BulkSiteRow>> ParseAndValidateSites(Stream csvStream)
{
var rows = ParseAndValidate<BulkSiteRow>(csvStream);
foreach (var row in rows.Where(r => r.IsValid && r.Record != null))
row.Errors.AddRange(ValidateSiteRow(row.Record!));
return rows;
}
public List<CsvValidationRow<FolderStructureRow>> ParseAndValidateFolders(Stream csvStream)
{
var rows = ParseAndValidate<FolderStructureRow>(csvStream);
foreach (var row in rows.Where(r => r.IsValid && r.Record != null))
row.Errors.AddRange(ValidateFolderRow(row.Record!));
return rows;
}
private static List<string> ValidateMemberRow(BulkMemberRow row)
{
var e = new List<string>();
if (string.IsNullOrWhiteSpace(row.Email)) e.Add("Email is required");
else if (!EmailRegex.IsMatch(row.Email.Trim())) e.Add($"Invalid email: {row.Email}");
if (string.IsNullOrWhiteSpace(row.GroupName) && string.IsNullOrWhiteSpace(row.GroupUrl))
e.Add("GroupName or GroupUrl is required");
if (!string.IsNullOrWhiteSpace(row.Role) &&
!row.Role.Equals("Member", StringComparison.OrdinalIgnoreCase) &&
!row.Role.Equals("Owner", StringComparison.OrdinalIgnoreCase))
e.Add($"Role must be 'Member' or 'Owner', got: {row.Role}");
return e;
}
private static List<string> ValidateSiteRow(BulkSiteRow row)
{
var e = new List<string>();
if (string.IsNullOrWhiteSpace(row.Name)) e.Add("Name is required");
if (string.IsNullOrWhiteSpace(row.Type)) e.Add("Type is required");
else if (!row.Type.Equals("Team", StringComparison.OrdinalIgnoreCase) &&
!row.Type.Equals("Communication", StringComparison.OrdinalIgnoreCase))
e.Add($"Type must be 'Team' or 'Communication', got: {row.Type}");
if (row.Type.Equals("Team", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(row.Owners))
e.Add("Team sites require at least one owner");
if (row.Type.Equals("Team", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(row.Alias))
e.Add("Team sites require an alias");
return e;
}
private static List<string> ValidateFolderRow(FolderStructureRow row)
{
var e = new List<string>();
if (string.IsNullOrWhiteSpace(row.Level1)) e.Add("Level1 is required");
return e;
}
}
+139
View File
@@ -0,0 +1,139 @@
using System.Diagnostics;
using Microsoft.SharePoint.Client;
using Microsoft.SharePoint.Client.Search.Query;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Services.Export;
namespace SharepointToolbox.Web.Services;
public class DuplicatesService : IDuplicatesService
{
private const int BatchSize = 500;
private const int MaxStartRow = 50_000;
public async Task<IReadOnlyList<DuplicateGroup>> ScanDuplicatesAsync(
ClientContext ctx, DuplicateScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
List<DuplicateItem> allItems = options.Mode == "Folders"
? await CollectFolderItemsAsync(ctx, options, progress, ct)
: await CollectFileItemsAsync(ctx, options, progress, ct);
progress.Report(OperationProgress.Indeterminate($"Grouping {allItems.Count:N0} items…"));
var groups = allItems
.GroupBy(item => MakeKey(item, options))
.Where(g => g.Count() >= 2)
.Select(g =>
{
var items = g.ToList();
var libraries = items.Select(i => i.Library).Where(l => !string.IsNullOrEmpty(l)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(l => l).ToList();
return new DuplicateGroup { GroupKey = g.Key, Name = libraries.Count > 0 ? $"{items[0].Name} ({string.Join(", ", libraries)})" : items[0].Name, Items = items };
})
.OrderByDescending(g => g.Items.Count).ThenBy(g => g.Name).ToList();
return groups;
}
private static async Task<List<DuplicateItem>> CollectFileItemsAsync(ClientContext ctx, DuplicateScanOptions options, IProgress<OperationProgress> progress, CancellationToken ct)
{
var (siteUrl, siteTitle) = await LoadSiteIdentityAsync(ctx, progress, ct);
var kqlParts = new List<string> { "ContentType:Document" };
if (!string.IsNullOrEmpty(options.Library)) kqlParts.Add($"Path:\"{ctx.Url.TrimEnd('/')}/{options.Library.TrimStart('/')}*\"");
string kql = string.Join(" AND ", kqlParts);
var allItems = new List<DuplicateItem>();
int startRow = 0;
do
{
ct.ThrowIfCancellationRequested();
var kq = new KeywordQuery(ctx) { QueryText = kql, StartRow = startRow, RowLimit = BatchSize, TrimDuplicates = false };
foreach (var prop in new[] { "Title", "Path", "FileExtension", "Created", "LastModifiedTime", "Size", "ParentLink" }) kq.SelectProperties.Add(prop);
var executor = new SearchExecutor(ctx);
var clientResult = executor.ExecuteQuery(kq);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var table = clientResult.Value.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
if (table == null || table.RowCount == 0) break;
foreach (var rawRow in table.ResultRows)
{
IDictionary<string, object> dict;
if (rawRow is IDictionary<string, object> generic) dict = generic;
else if (rawRow is System.Collections.IDictionary legacy) { dict = new Dictionary<string, object>(); foreach (System.Collections.DictionaryEntry e in legacy) dict[e.Key.ToString()!] = e.Value ?? string.Empty; }
else continue;
string path = GetStr(dict, "Path");
if (path.Contains("/_vti_history/", StringComparison.OrdinalIgnoreCase)) continue;
string name = System.IO.Path.GetFileName(path);
if (string.IsNullOrEmpty(name)) name = GetStr(dict, "Title");
string raw = GetStr(dict, "Size");
string digits = System.Text.RegularExpressions.Regex.Replace(raw, "[^0-9]", "");
long size = long.TryParse(digits, out var sv) ? sv : 0L;
allItems.Add(new DuplicateItem { Name = name, Path = path, Library = ExtractLibraryFromPath(path, ctx.Url), SizeBytes = size, Created = ParseDate(GetStr(dict, "Created")), Modified = ParseDate(GetStr(dict, "LastModifiedTime")), SiteUrl = siteUrl, SiteTitle = siteTitle });
}
progress.Report(new OperationProgress(allItems.Count, MaxStartRow, $"Collected {allItems.Count:N0} files…"));
startRow += BatchSize;
}
while (startRow <= MaxStartRow);
return allItems;
}
private static async Task<List<DuplicateItem>> CollectFolderItemsAsync(ClientContext ctx, DuplicateScanOptions options, IProgress<OperationProgress> progress, CancellationToken ct)
{
ctx.Load(ctx.Web, w => w.Title, w => w.Lists.Include(l => l.Title, l => l.Hidden, l => l.BaseType));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
string siteUrl = ctx.Url;
string siteTitle = string.IsNullOrWhiteSpace(ctx.Web.Title) ? ReportSplitHelper.DeriveSiteLabel(siteUrl) : ctx.Web.Title;
var libs = ctx.Web.Lists.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary).ToList();
if (!string.IsNullOrEmpty(options.Library)) libs = libs.Where(l => l.Title.Equals(options.Library, StringComparison.OrdinalIgnoreCase)).ToList();
var camlQuery = new CamlQuery { ViewXml = "<View Scope='RecursiveAll'><Query></Query><RowLimit Paged='TRUE'>5000</RowLimit></View>" };
var allItems = new List<DuplicateItem>();
foreach (var lib in libs)
{
ct.ThrowIfCancellationRequested();
progress.Report(OperationProgress.Indeterminate($"Scanning folders in {lib.Title}…"));
await foreach (var item in SharePointPaginationHelper.GetAllItemsAsync(ctx, lib, camlQuery, ct))
{
if (item.FileSystemObjectType != FileSystemObjectType.Folder) continue;
var fv = item.FieldValues;
string name = fv["FileLeafRef"]?.ToString() ?? string.Empty;
string fileRef = fv["FileRef"]?.ToString() ?? string.Empty;
int subCount = Convert.ToInt32(fv["FolderChildCount"] ?? 0);
int childCount = Convert.ToInt32(fv["ItemChildCount"] ?? 0);
allItems.Add(new DuplicateItem { Name = name, Path = fileRef, Library = lib.Title, FolderCount = subCount, FileCount = Math.Max(0, childCount - subCount), Created = fv["Created"] is DateTime cr ? cr : (DateTime?)null, Modified = fv["Modified"] is DateTime md ? md : (DateTime?)null, SiteUrl = siteUrl, SiteTitle = siteTitle });
}
}
return allItems;
}
internal static string MakeKey(DuplicateItem item, DuplicateScanOptions opts)
{
var parts = new List<string> { item.Name.ToLowerInvariant() };
if (opts.MatchSize && item.SizeBytes.HasValue) parts.Add(item.SizeBytes.Value.ToString());
if (opts.MatchCreated && item.Created.HasValue) parts.Add(item.Created.Value.Date.ToString("yyyy-MM-dd"));
if (opts.MatchModified && item.Modified.HasValue) parts.Add(item.Modified.Value.Date.ToString("yyyy-MM-dd"));
if (opts.MatchSubfolderCount && item.FolderCount.HasValue) parts.Add(item.FolderCount.Value.ToString());
if (opts.MatchFileCount && item.FileCount.HasValue) parts.Add(item.FileCount.Value.ToString());
return string.Join("|", parts);
}
private static string GetStr(IDictionary<string, object> r, string key) => r.TryGetValue(key, out var v) ? v?.ToString() ?? string.Empty : string.Empty;
private static DateTime? ParseDate(string s) => DateTime.TryParse(s, out var dt) ? dt : (DateTime?)null;
private static string ExtractLibraryFromPath(string path, string siteUrl)
{
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(siteUrl)) return string.Empty;
string relative = path.StartsWith(siteUrl.TrimEnd('/'), StringComparison.OrdinalIgnoreCase) ? path[(siteUrl.TrimEnd('/').Length)..].TrimStart('/') : path;
int slash = relative.IndexOf('/');
return slash > 0 ? relative[..slash] : relative;
}
private static async Task<(string Url, string Title)> LoadSiteIdentityAsync(ClientContext ctx, IProgress<OperationProgress> progress, CancellationToken ct)
{
try { ctx.Load(ctx.Web, w => w.Title); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); } catch (OperationCanceledException) { throw; } catch (Exception ex) { Debug.WriteLine($"LoadSiteIdentityAsync: {ex.Message}"); }
var url = ctx.Url ?? string.Empty;
string title; try { title = ctx.Web.Title; } catch { title = string.Empty; }
if (string.IsNullOrWhiteSpace(title)) title = ReportSplitHelper.DeriveSiteLabel(url);
return (url, title);
}
}
+37
View File
@@ -0,0 +1,37 @@
using System.Text;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Generates the branding header HTML fragment for HTML reports.
/// Called by each HTML export service between &lt;body&gt; and &lt;h1&gt;.
/// Returns empty string when no logos are configured (no broken images).
/// </summary>
internal static class BrandingHtmlHelper
{
public static string BuildBrandingHeader(ReportBranding? branding)
{
if (branding is null) return string.Empty;
var msp = branding.MspLogo;
var client = branding.ClientLogo;
if (msp is null && client is null) return string.Empty;
var sb = new StringBuilder();
sb.AppendLine("<div style=\"display:flex;gap:16px;align-items:center;padding:12px 24px 0;\">");
if (msp is not null)
sb.AppendLine($" <img src=\"data:{msp.MimeType};base64,{msp.Base64}\" alt=\"\" style=\"max-height:60px;max-width:200px;object-fit:contain;\">");
if (msp is not null && client is not null)
sb.AppendLine(" <div style=\"flex:1\"></div>");
if (client is not null)
sb.AppendLine($" <img src=\"data:{client.MimeType};base64,{client.Base64}\" alt=\"\" style=\"max-height:60px;max-width:200px;object-fit:contain;\">");
sb.AppendLine("</div>");
return sb.ToString();
}
}
@@ -0,0 +1,61 @@
using System.Globalization;
using System.IO;
using System.Text;
using CsvHelper;
using CsvHelper.Configuration;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Localization;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Exports the failed subset of a <see cref="BulkOperationSummary{T}"/> run
/// to CSV. CsvHelper is used so the <typeparamref name="T"/> payload's
/// properties become columns automatically, plus one error-message and one
/// timestamp column appended at the end.
/// </summary>
public class BulkResultCsvExportService
{
private static readonly CsvConfiguration CsvConfig = new(CultureInfo.InvariantCulture)
{
// Prevent CSV formula injection: prefix =, +, -, @, tab, CR with single quote
InjectionOptions = InjectionOptions.Escape,
};
/// <summary>
/// Builds a CSV containing only items whose <see cref="BulkItemResult{T}.IsSuccess"/>
/// is <c>false</c>. Columns: every public property of <typeparamref name="T"/>
/// followed by Error and Timestamp (ISO-8601).
/// </summary>
public string BuildFailedItemsCsv<T>(IReadOnlyList<BulkItemResult<T>> failedItems)
{
var TL = TranslationSource.Instance;
using var writer = new StringWriter();
using var csv = new CsvWriter(writer, CsvConfig);
csv.WriteHeader<T>();
csv.WriteField(TL["report.col.error"]);
csv.WriteField(TL["report.col.timestamp"]);
csv.NextRecord();
foreach (var item in failedItems.Where(r => !r.IsSuccess))
{
csv.WriteRecord(item.Item);
csv.WriteField(item.ErrorMessage);
csv.WriteField(item.Timestamp.ToString("o"));
csv.NextRecord();
}
return writer.ToString();
}
/// <summary>Writes the failed-items CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
public async Task WriteFailedItemsCsvAsync<T>(
IReadOnlyList<BulkItemResult<T>> failedItems,
string filePath,
CancellationToken ct)
{
var content = BuildFailedItemsCsv(failedItems);
await ExportFileWriter.WriteCsvAsync(filePath, content, ct);
}
}
+180
View File
@@ -0,0 +1,180 @@
using System.IO;
using System.Text;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Localization;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Exports permission entries to CSV format.
/// Ports PowerShell Merge-PermissionRows + Export-Csv functionality.
/// </summary>
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\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
}
/// <summary>
/// Builds a CSV string from the supplied permission entries.
/// Merges rows with identical (Users, PermissionLevels, GrantedThrough) by pipe-joining URLs and Titles.
/// </summary>
public string BuildCsv(IReadOnlyList<PermissionEntry> entries)
{
var sb = new StringBuilder();
sb.AppendLine(BuildHeader());
// Merge: group by (Users, PermissionLevels, GrantedThrough) — port of PS Merge-PermissionRows
var merged = entries
.GroupBy(e => (e.Users, e.PermissionLevels, e.GrantedThrough))
.Select(g => new
{
ObjectType = g.First().ObjectType,
Title = string.Join(" | ", g.Select(e => e.Title).Distinct()),
Url = string.Join(" | ", g.Select(e => e.Url).Distinct()),
HasUnique = g.First().HasUniquePermissions,
Users = g.Key.Users,
UserLogins = g.First().UserLogins,
PrincipalType = g.First().PrincipalType,
Permissions = g.Key.PermissionLevels,
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)
sb.AppendLine(string.Join(",", new[]
{
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.TargetLabel), Csv(row.TargetUrl), Csv(row.SharingLinkType)
}));
return sb.ToString();
}
/// <summary>
/// Writes the CSV to the specified file path using UTF-8 with BOM (for Excel compatibility).
/// </summary>
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct)
{
var csv = BuildCsv(entries);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
}
/// <summary>
/// Header for simplified CSV export — includes "SimplifiedLabels" and "RiskLevel" columns.
/// </summary>
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\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
}
/// <summary>
/// Builds a CSV string from simplified permission entries.
/// Includes SimplifiedLabels and RiskLevel columns after raw Permissions.
/// Uses the same merge logic as the standard BuildCsv.
/// </summary>
public string BuildCsv(IReadOnlyList<SimplifiedPermissionEntry> entries)
{
var sb = new StringBuilder();
sb.AppendLine(BuildSimplifiedHeader());
var merged = entries
.GroupBy(e => (e.Users, e.PermissionLevels, e.GrantedThrough))
.Select(g => new
{
ObjectType = g.First().ObjectType,
Title = string.Join(" | ", g.Select(e => e.Title).Distinct()),
Url = string.Join(" | ", g.Select(e => e.Url).Distinct()),
HasUnique = g.First().HasUniquePermissions,
Users = g.Key.Users,
UserLogins = g.First().UserLogins,
PrincipalType = g.First().PrincipalType,
Permissions = g.Key.PermissionLevels,
SimplifiedLabels = g.First().SimplifiedLabels,
RiskLevel = g.First().RiskLevel.ToString(),
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)
sb.AppendLine(string.Join(",", new[]
{
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.TargetLabel), Csv(row.TargetUrl), Csv(row.SharingLinkType)
}));
return sb.ToString();
}
/// <summary>
/// Writes simplified CSV to the specified file path.
/// </summary>
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct)
{
var csv = BuildCsv(entries);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
}
/// <summary>
/// Writes permission entries with optional per-site partitioning.
/// Single → writes one file at <paramref name="basePath"/>.
/// BySite → one file per site-collection URL, suffixed on the base path.
/// </summary>
public Task WriteAsync(
IReadOnlyList<PermissionEntry> entries,
string basePath,
ReportSplitMode splitMode,
CancellationToken ct)
=> ReportSplitHelper.WritePartitionedAsync(
entries, basePath, splitMode,
PartitionBySite,
(part, path, c) => WriteAsync(part, path, c),
ct);
/// <summary>Simplified-entry split variant.</summary>
public Task WriteAsync(
IReadOnlyList<SimplifiedPermissionEntry> entries,
string basePath,
ReportSplitMode splitMode,
CancellationToken ct)
=> ReportSplitHelper.WritePartitionedAsync(
entries, basePath, splitMode,
PartitionBySite,
(part, path, c) => WriteAsync(part, path, c),
ct);
internal static IEnumerable<(string Label, IReadOnlyList<PermissionEntry> Partition)> PartitionBySite(
IReadOnlyList<PermissionEntry> entries)
{
return entries
.GroupBy(e => ReportSplitHelper.DeriveSiteCollectionUrl(e.Url), StringComparer.OrdinalIgnoreCase)
.Select(g => (
Label: ReportSplitHelper.DeriveSiteLabel(g.Key),
Partition: (IReadOnlyList<PermissionEntry>)g.ToList()));
}
internal static IEnumerable<(string Label, IReadOnlyList<SimplifiedPermissionEntry> Partition)> PartitionBySite(
IReadOnlyList<SimplifiedPermissionEntry> entries)
{
return entries
.GroupBy(e => ReportSplitHelper.DeriveSiteCollectionUrl(e.Url), StringComparer.OrdinalIgnoreCase)
.Select(g => (
Label: ReportSplitHelper.DeriveSiteLabel(g.Key),
Partition: (IReadOnlyList<SimplifiedPermissionEntry>)g.ToList()));
}
/// <summary>RFC 4180 CSV field escaping with formula-injection guard.</summary>
private static string Csv(string value) => CsvSanitizer.Escape(value);
}
+47
View File
@@ -0,0 +1,47 @@
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// CSV field sanitization. Adds RFC 4180 quoting plus formula-injection
/// protection: Excel and other spreadsheet apps treat cells starting with
/// '=', '+', '-', '@', tab, or CR as formulas. Prefixing with a single
/// quote neutralizes the formula while remaining readable.
/// </summary>
internal static class CsvSanitizer
{
/// <summary>
/// Escapes a value for inclusion in a CSV row. Always wraps in double
/// quotes. Doubles internal quotes per RFC 4180. Prepends an apostrophe
/// when the value begins with a character a spreadsheet would evaluate.
/// </summary>
public static string Escape(string? value)
{
if (string.IsNullOrEmpty(value)) return "\"\"";
var safe = NeutralizeFormulaPrefix(value).Replace("\"", "\"\"");
return $"\"{safe}\"";
}
/// <summary>
/// Minimal quoting variant: only wraps in quotes when the value contains
/// a delimiter, quote, or newline. Still guards against formula injection.
/// </summary>
public static string EscapeMinimal(string? value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
var safe = NeutralizeFormulaPrefix(value);
if (safe.Contains(',') || safe.Contains('"') || safe.Contains('\n') || safe.Contains('\r'))
return $"\"{safe.Replace("\"", "\"\"")}\"";
return safe;
}
private static string NeutralizeFormulaPrefix(string value)
{
if (value.Length == 0) return value;
char first = value[0];
if (first == '=' || first == '+' || first == '-' || first == '@'
|| first == '\t' || first == '\r')
{
return "'" + value;
}
return value;
}
}
@@ -0,0 +1,112 @@
using System.IO;
using System.Text;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Localization;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Exports DuplicateGroup list to CSV. Each duplicate item becomes one row;
/// the Group column ties copies together and a Copies column gives the group size.
/// Header row is built at write-time so culture switches are honoured.
/// </summary>
public class DuplicatesCsvExportService
{
/// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM (Excel-compatible).</summary>
public async Task WriteAsync(
IReadOnlyList<DuplicateGroup> groups,
string filePath,
CancellationToken ct)
{
var csv = BuildCsv(groups);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
}
/// <summary>
/// Writes one or more CSVs depending on <paramref name="splitMode"/>.
/// Single → <paramref name="basePath"/> as-is. BySite → one file per site,
/// filenames derived from <paramref name="basePath"/> with a site suffix.
/// </summary>
public Task WriteAsync(
IReadOnlyList<DuplicateGroup> groups,
string basePath,
ReportSplitMode splitMode,
CancellationToken ct)
=> ReportSplitHelper.WritePartitionedAsync(
groups, basePath, splitMode,
PartitionBySite,
(part, path, c) => WriteAsync(part, path, c),
ct);
internal static IEnumerable<(string Label, IReadOnlyList<DuplicateGroup> Partition)> PartitionBySite(
IReadOnlyList<DuplicateGroup> groups)
{
return groups
.GroupBy(g =>
{
var first = g.Items.FirstOrDefault();
return (Url: first?.SiteUrl ?? string.Empty, Title: first?.SiteTitle ?? string.Empty);
})
.Select(g => (
Label: ReportSplitHelper.DeriveSiteLabel(g.Key.Url, g.Key.Title),
Partition: (IReadOnlyList<DuplicateGroup>)g.ToList()));
}
/// <summary>
/// Builds the CSV payload. Emits a header summary (group count, generated
/// timestamp), then one row per duplicate item with its group index and
/// group size. CSV fields are escaped via <see cref="CsvSanitizer.Escape"/>.
/// </summary>
public string BuildCsv(IReadOnlyList<DuplicateGroup> groups)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Summary
sb.AppendLine($"\"{T["report.title.duplicates_short"]}\"");
sb.AppendLine($"\"{T["report.text.duplicate_groups_found"]}\",\"{groups.Count}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine();
// Header
sb.AppendLine(string.Join(",", new[]
{
Csv(T["report.col.number"]),
Csv(T["report.col.group"]),
Csv(T["report.text.copies"]),
Csv(T["report.col.site"]),
Csv(T["report.col.name"]),
Csv(T["report.col.library"]),
Csv(T["report.col.path"]),
Csv(T["report.col.size_bytes"]),
Csv(T["report.col.created"]),
Csv(T["report.col.modified"]),
}));
foreach (var g in groups)
{
int i = 0;
foreach (var item in g.Items)
{
i++;
sb.AppendLine(string.Join(",", new[]
{
Csv(i.ToString()),
Csv(g.Name),
Csv(g.Items.Count.ToString()),
Csv(item.SiteTitle),
Csv(item.Name),
Csv(item.Library),
Csv(item.Path),
Csv(item.SizeBytes?.ToString() ?? string.Empty),
Csv(item.Created?.ToString("yyyy-MM-dd") ?? string.Empty),
Csv(item.Modified?.ToString("yyyy-MM-dd") ?? string.Empty),
}));
}
}
return sb.ToString();
}
private static string Csv(string value) => CsvSanitizer.Escape(value);
}
@@ -0,0 +1,187 @@
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Localization;
using System.Text;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Exports DuplicateGroup list to a self-contained HTML with collapsible group cards.
/// Port of PS Export-DuplicatesToHTML (PS lines 2235-2406).
/// Each group gets a card showing item count badge and a table of paths.
/// </summary>
public class DuplicatesHtmlExportService
{
/// <summary>
/// Builds a self-contained HTML string rendering one collapsible card per
/// <see cref="DuplicateGroup"/>. The document ships with inline CSS and a
/// tiny JS toggle so no external assets are needed.
/// </summary>
public string BuildHtml(IReadOnlyList<DuplicateGroup> groups, ReportBranding? branding = null)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.duplicates"]}</title>");
sb.AppendLine("""
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
.summary { margin-bottom: 16px; font-size: 12px; color: #444; }
.group-card { background: #fff; border: 1px solid #ddd; border-radius: 6px;
margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.1); overflow: hidden; }
.group-header { background: #0078d4; color: #fff; padding: 8px 14px;
display: flex; align-items: center; justify-content: space-between;
cursor: pointer; user-select: none; }
.group-header:hover { background: #106ebe; }
.group-name { font-weight: 600; font-size: 14px; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px;
font-size: 11px; font-weight: 700; }
.badge-dup { background: #e53935; color: #fff; }
.group-body { padding: 0; }
table { width: 100%; border-collapse: collapse; }
th { background: #f0f7ff; color: #333; padding: 6px 12px; text-align: left;
font-weight: 600; border-bottom: 1px solid #ddd; font-size: 12px; }
td { padding: 5px 12px; border-bottom: 1px solid #eee; font-size: 12px; word-break: break-all; }
tr:last-child td { border-bottom: none; }
.collapsed { display: none; }
.generated { font-size: 11px; color: #888; margin-top: 16px; }
</style>
<script>
function toggleGroup(id) {
var body = document.getElementById('gb-' + id);
if (body) body.classList.toggle('collapsed');
}
</script>
</head>
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.duplicates_short"]}</h1>");
sb.AppendLine($"<p class=\"summary\">{groups.Count:N0} {T["report.text.duplicate_groups_found"]}</p>");
for (int i = 0; i < groups.Count; i++)
{
var g = groups[i];
int count = g.Items.Count;
string badgeClass = "badge-dup";
sb.AppendLine($"""
<div class="group-card">
<div class="group-header" onclick="toggleGroup({i})">
<span class="group-name">{H(g.Name)}</span>
<span class="badge {badgeClass}">{count} {T["report.text.copies"]}</span>
</div>
<div class="group-body" id="gb-{i}">
<table>
<thead>
<tr>
<th>{T["report.col.number"]}</th>
<th>{T["report.col.name"]}</th>
<th>{T["report.col.library"]}</th>
<th>{T["report.col.path"]}</th>
<th>{T["report.col.size"]}</th>
<th>{T["report.col.created"]}</th>
<th>{T["report.col.modified"]}</th>
</tr>
</thead>
<tbody>
""");
for (int j = 0; j < g.Items.Count; j++)
{
var item = g.Items[j];
string size = item.SizeBytes.HasValue ? FormatSize(item.SizeBytes.Value) : string.Empty;
string created = item.Created.HasValue ? item.Created.Value.ToString("yyyy-MM-dd") : string.Empty;
string modified = item.Modified.HasValue ? item.Modified.Value.ToString("yyyy-MM-dd") : string.Empty;
sb.AppendLine($"""
<tr>
<td>{j + 1}</td>
<td>{H(item.Name)}</td>
<td>{H(item.Library)}</td>
<td><a href="{H(item.Path)}" target="_blank">{H(item.Path)}</a></td>
<td>{size}</td>
<td>{created}</td>
<td>{modified}</td>
</tr>
""");
}
sb.AppendLine("""
</tbody>
</table>
</div>
</div>
""");
}
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 HTML report to the specified file path using UTF-8.</summary>
public async Task WriteAsync(IReadOnlyList<DuplicateGroup> groups, string filePath, CancellationToken ct, ReportBranding? branding = null)
{
var html = BuildHtml(groups, branding);
await System.IO.File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
}
/// <summary>
/// Writes one or more HTML reports depending on <paramref name="splitMode"/> and
/// <paramref name="layout"/>. Single → one file. BySite + SeparateFiles → one
/// file per site. BySite + SingleTabbed → one file with per-site iframe tabs.
/// </summary>
public async Task WriteAsync(
IReadOnlyList<DuplicateGroup> groups,
string basePath,
ReportSplitMode splitMode,
HtmlSplitLayout layout,
CancellationToken ct,
ReportBranding? branding = null)
{
if (splitMode != ReportSplitMode.BySite)
{
await WriteAsync(groups, basePath, ct, branding);
return;
}
var partitions = DuplicatesCsvExportService.PartitionBySite(groups).ToList();
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partitions
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding)))
.ToList();
var T = TranslationSource.Instance;
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, T["report.title.duplicates_short"]);
await System.IO.File.WriteAllTextAsync(basePath, tabbed, Encoding.UTF8, ct);
return;
}
foreach (var partition in partitions)
{
ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, partition.Label);
await WriteAsync(partition.Partition, path, ct, branding);
}
}
private static string H(string value) =>
System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
private static string FormatSize(long bytes)
{
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
return $"{bytes} B";
}
}
+64
View File
@@ -0,0 +1,64 @@
using System.IO;
using System.Text;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Central file-write plumbing for export services so every CSV and HTML
/// artefact gets a consistent encoding: CSV files are written with a UTF-8
/// BOM (required for Excel to detect the encoding when opening a
/// double-clicked .csv), HTML files are written without a BOM (some browsers
/// and iframe <c>srcdoc</c> paths render the BOM as a visible character).
/// Export services should call these helpers rather than constructing
/// <see cref="UTF8Encoding"/> inline.
/// </summary>
internal static class ExportFileWriter
{
private static readonly UTF8Encoding Utf8WithBom = new(encoderShouldEmitUTF8Identifier: true);
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
/// <summary>Writes <paramref name="csv"/> to <paramref name="filePath"/> as UTF-8 with BOM.</summary>
public static Task WriteCsvAsync(string filePath, string csv, CancellationToken ct)
=> File.WriteAllTextAsync(filePath, csv, Utf8WithBom, ct);
/// <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);
}
}
+314
View File
@@ -0,0 +1,314 @@
using System.IO;
using System.Text;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Localization;
using static SharepointToolbox.Web.Services.Export.PermissionHtmlFragments;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Exports permission entries to a self-contained interactive HTML report.
/// Ports PowerShell <c>Export-PermissionsToHTML</c> functionality.
/// No external CSS/JS dependencies — everything is inline so the file can be
/// emailed or served from any static host. The standard and simplified
/// variants share their document shell, stats cards, CSS, pill rendering, and
/// inline script via <see cref="PermissionHtmlFragments"/>; this class only
/// owns the table column sets and the simplified risk summary.
/// </summary>
public class HtmlExportService
{
/// <summary>
/// Builds a self-contained HTML string from the supplied permission
/// entries. Standard report: columns are Object / Title / URL / Unique /
/// Users / Permission / Granted Through. When
/// <paramref name="groupMembers"/> is provided, SharePoint group pills
/// become expandable rows listing resolved members.
/// </summary>
public string BuildHtml(
IReadOnlyList<PermissionEntry> entries,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
var T = TranslationSource.Instance;
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
entries.Count,
entries.Select(e => e.PermissionLevels),
entries.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries)));
var sb = new StringBuilder();
AppendHead(sb, T["report.title.permissions"], includeRiskCss: false);
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.permissions"]}</h1>");
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
AppendFilterInput(sb);
AppendTableOpen(sb);
sb.AppendLine("<thead><tr>");
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>");
int grpMemIdx = 0;
foreach (var entry in entries)
{
var typeCss = ObjectTypeCss(entry.ObjectType);
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
var (pills, subRows) = BuildUserPillsCell(
entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers,
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>");
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
sb.AppendLine($" <td>{pills}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</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);
}
AppendTableClose(sb);
AppendInlineJs(sb);
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
/// <summary>
/// Builds a self-contained HTML string from simplified permission entries.
/// Adds a risk-level summary card strip plus two columns (Simplified,
/// Risk) relative to <see cref="BuildHtml(IReadOnlyList{PermissionEntry}, ReportBranding?, IReadOnlyDictionary{string, IReadOnlyList{ResolvedMember}}?)"/>.
/// Color-coded risk badges use <see cref="RiskLevelColors(RiskLevel)"/>.
/// </summary>
public string BuildHtml(
IReadOnlyList<SimplifiedPermissionEntry> entries,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
var T = TranslationSource.Instance;
var summaries = PermissionSummaryBuilder.Build(entries);
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
entries.Count,
entries.Select(e => e.PermissionLevels),
entries.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries)));
var sb = new StringBuilder();
AppendHead(sb, T["report.title.permissions_simplified"], includeRiskCss: true);
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.permissions_simplified"]}</h1>");
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
sb.AppendLine("<div class=\"risk-cards\">");
foreach (var s in summaries)
{
var (bg, text, border) = RiskLevelColors(s.RiskLevel);
sb.AppendLine($" <div class=\"risk-card\" style=\"background:{bg};color:{text};border-color:{border}\">");
sb.AppendLine($" <div class=\"count\">{s.Count}</div>");
sb.AppendLine($" <div class=\"rlabel\">{HtmlEncode(s.Label)}</div>");
sb.AppendLine($" <div class=\"users\">{s.DistinctUsers} {T["report.text.users_parens"]}</div>");
sb.AppendLine(" </div>");
}
sb.AppendLine("</div>");
AppendFilterInput(sb);
AppendTableOpen(sb);
sb.AppendLine("<thead><tr>");
sb.AppendLine($" <th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>");
int grpMemIdx = 0;
int sectionIdx = 0;
var groups = entries.GroupBy(e => (e.ObjectType, e.Title, e.Url)).ToList();
foreach (var group in groups)
{
var sectionId = $"sec{sectionIdx++}";
var first = group.First();
var typeCss = ObjectTypeCss(group.Key.ObjectType);
var uniqueCss = first.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = first.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
var count = group.Count();
sb.AppendLine($"<tr class=\"section-header collapsed\" data-section=\"{sectionId}\">");
sb.AppendLine($" <td colspan=\"5\"><span class=\"chevron\">&#9660;</span><span class=\"{typeCss}\">{HtmlEncode(group.Key.ObjectType)}</span> <strong>{HtmlEncode(group.Key.Title)}</strong> <a href=\"{HtmlEncode(group.Key.Url)}\" target=\"_blank\">&#8599;</a> <span class=\"{uniqueCss}\">{uniqueLbl}</span><span class=\"entry-badge\">{count} {T["report.text.entries_unit"]}</span></td>");
sb.AppendLine("</tr>");
foreach (var entry in group)
{
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
var (pills, subRows) = BuildUserPillsCell(
entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers,
colSpan: 5, grpMemIdx: ref grpMemIdx,
targetLabel: entry.TargetLabel, sharingLinkType: entry.SharingLinkType,
hideSystemGroupRaw: hideSystemGroupRaw,
sectionId: sectionId);
sb.AppendLine($"<tr data-section-member=\"{sectionId}\" style=\"display:none\">");
sb.AppendLine($" <td>{pills}</td>");
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>{BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}</td>");
sb.AppendLine("</tr>");
if (subRows.Length > 0) sb.Append(subRows);
}
}
AppendTableClose(sb);
AppendInlineJs(sb);
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
/// <summary>Writes the HTML report to the specified file path using UTF-8 without BOM.</summary>
public async Task WriteAsync(
IReadOnlyList<PermissionEntry> entries,
string filePath,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
var html = BuildHtml(entries, branding, groupMembers, hideSystemGroupRaw);
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
}
/// <summary>Writes the simplified HTML report to the specified file path using UTF-8 without BOM.</summary>
public async Task WriteAsync(
IReadOnlyList<SimplifiedPermissionEntry> entries,
string filePath,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
var html = BuildHtml(entries, branding, groupMembers, hideSystemGroupRaw);
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
}
/// <summary>
/// Split-aware write for permission entries.
/// Single → one file. BySite + SeparateFiles → one file per site.
/// BySite + SingleTabbed → one file with per-site iframe tabs.
/// </summary>
public async Task WriteAsync(
IReadOnlyList<PermissionEntry> entries,
string basePath,
ReportSplitMode splitMode,
HtmlSplitLayout layout,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
if (splitMode != ReportSplitMode.BySite)
{
await WriteAsync(entries, basePath, ct, branding, groupMembers, hideSystemGroupRaw);
return;
}
var partitions = CsvExportService.PartitionBySite(entries).ToList();
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partitions
.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);
await ExportFileWriter.WriteHtmlAsync(basePath, tabbed, ct);
return;
}
foreach (var (label, partEntries) in partitions)
{
ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteAsync(partEntries, path, ct, branding, groupMembers, hideSystemGroupRaw);
}
}
/// <summary>Simplified-entry split variant.</summary>
public async Task WriteAsync(
IReadOnlyList<SimplifiedPermissionEntry> entries,
string basePath,
ReportSplitMode splitMode,
HtmlSplitLayout layout,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
if (splitMode != ReportSplitMode.BySite)
{
await WriteAsync(entries, basePath, ct, branding, groupMembers, hideSystemGroupRaw);
return;
}
var partitions = CsvExportService.PartitionBySite(entries).ToList();
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partitions
.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);
await ExportFileWriter.WriteHtmlAsync(basePath, tabbed, ct);
return;
}
foreach (var (label, partEntries) in partitions)
{
ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteAsync(partEntries, path, ct, branding, groupMembers, hideSystemGroupRaw);
}
}
private static (int total, int uniquePerms, int distinctUsers) ComputeStats(
int totalEntries,
IEnumerable<string> permissionLevels,
IEnumerable<string> userLogins)
{
var uniquePermSets = permissionLevels.Distinct().Count();
var distinctUsers = userLogins
.Select(u => u.Trim())
.Where(u => u.Length > 0)
.Distinct()
.Count();
return (totalEntries, uniquePermSets, distinctUsers);
}
private static void AppendTableOpen(StringBuilder sb)
{
sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">");
}
private static void AppendTableClose(StringBuilder sb)
{
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
}
/// <summary>Returns inline CSS background, text, and border colors for a risk level.</summary>
private static (string bg, string text, string border) RiskLevelColors(RiskLevel level) => level switch
{
RiskLevel.High => ("#FEE2E2", "#991B1B", "#FECACA"),
RiskLevel.Medium => ("#FEF3C7", "#92400E", "#FDE68A"),
RiskLevel.Low => ("#D1FAE5", "#065F46", "#A7F3D0"),
RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"),
_ => ("#F3F4F6", "#374151", "#E5E7EB")
};
}
+378
View File
@@ -0,0 +1,378 @@
using System.Text;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Localization;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Shared HTML-rendering fragments for the permission exports (standard and
/// simplified). Extracted so the two <see cref="HtmlExportService"/> variants
/// share the document shell, stats cards, filter input, user-pill logic, and
/// inline script — leaving each caller only its own table headers and row
/// cells to render.
/// </summary>
internal static class PermissionHtmlFragments
{
internal const string BaseCss = @"
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
.filter-wrap { padding: 0 24px 12px; }
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; }
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafafa; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
.badge.site-coll { background: #dbeafe; color: #1e40af; }
.badge.site { background: #dcfce7; color: #166534; }
.badge.list { background: #fef9c3; color: #854d0e; }
.badge.folder { background: #f3f4f6; color: #374151; }
.badge.unique { background: #dcfce7; color: #166534; }
.badge.inherited { background: #f3f4f6; color: #374151; }
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
.group-expandable { cursor: pointer; }
.group-expandable:hover { opacity: 0.8; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
";
internal const string RiskCardsCss = @"
.risk-cards { display: flex; gap: 12px; padding: 0 24px 16px; flex-wrap: wrap; }
.risk-card { border-radius: 8px; padding: 12px 18px; min-width: 140px; border: 1px solid; }
.risk-card .count { font-size: 1.5rem; font-weight: 700; }
.risk-card .rlabel { font-size: .8rem; margin-top: 2px; }
.risk-card .users { font-size: .7rem; margin-top: 2px; opacity: 0.8; }
.risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; }
.section-header td { background: #edf2f7; font-weight: 600; cursor: pointer; padding: 8px 14px; border-bottom: 2px solid #cbd5e0; user-select: none; }
.section-header:hover td { background: #e2e8f0; }
.section-header .chevron { margin-right: 8px; display: inline-block; transition: transform 0.15s; }
.section-header.collapsed .chevron { transform: rotate(-90deg); }
.entry-badge { display: inline-block; background: #e2e8f0; color: #4a5568; border-radius: 10px; padding: 1px 8px; font-size: .75rem; font-weight: 600; margin-left: 8px; }
";
internal const string InlineJs = @"function filterTable() {
var input = document.getElementById('filter').value.toLowerCase();
var sections = document.querySelectorAll('#permTable tbody tr.section-header');
if (sections.length === 0) {
document.querySelectorAll('#permTable tbody tr').forEach(function(row) {
if (row.hasAttribute('data-group')) return;
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
});
return;
}
if (!input) {
sections.forEach(function(hdr) {
hdr.style.display = '';
var sid = hdr.getAttribute('data-section');
var collapsed = hdr.classList.contains('collapsed');
document.querySelectorAll('[data-section-member=' + sid + ']:not([data-group])').forEach(function(r) {
r.style.display = collapsed ? 'none' : '';
});
});
return;
}
sections.forEach(function(hdr) {
var sid = hdr.getAttribute('data-section');
var members = document.querySelectorAll('[data-section-member=' + sid + ']:not([data-group])');
var anyMatch = false;
members.forEach(function(r) {
var match = r.textContent.toLowerCase().indexOf(input) > -1;
r.style.display = match ? '' : 'none';
if (match) anyMatch = true;
});
if (!anyMatch && hdr.textContent.toLowerCase().indexOf(input) > -1) {
anyMatch = true;
members.forEach(function(r) { r.style.display = ''; });
}
hdr.style.display = anyMatch ? '' : 'none';
});
}
document.addEventListener('click', function(ev) {
var hdr = ev.target.closest('.section-header');
if (hdr) {
var sid = hdr.getAttribute('data-section');
hdr.classList.toggle('collapsed');
var collapsed = hdr.classList.contains('collapsed');
document.querySelectorAll('[data-section-member=' + sid + ']').forEach(function(r) {
if (r.hasAttribute('data-group')) { r.style.display = 'none'; return; }
r.style.display = collapsed ? 'none' : '';
});
return;
}
var trigger = ev.target.closest('.group-expandable');
if (!trigger) return;
var id = trigger.getAttribute('data-group-target');
if (!id) return;
document.querySelectorAll('#permTable tbody tr').forEach(function(r) {
if (r.getAttribute('data-group') === id) {
r.style.display = r.style.display === 'none' ? '' : 'none';
}
});
});";
/// <summary>
/// Appends the shared HTML head (doctype, meta, inline CSS, title) to
/// <paramref name="sb"/>. Pass <paramref name="includeRiskCss"/> when the
/// caller renders risk cards/badges (simplified report only).
/// </summary>
internal static void AppendHead(StringBuilder sb, string title, bool includeRiskCss)
{
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{title}</title>");
sb.AppendLine("<style>");
sb.AppendLine(BaseCss);
if (includeRiskCss)
sb.AppendLine(RiskCardsCss);
sb.AppendLine("</style>");
sb.AppendLine("</head>");
}
/// <summary>
/// Appends the three stat cards (total entries, unique permission sets,
/// distinct users/groups) inside a single <c>.stats</c> row.
/// </summary>
internal static void AppendStatsCards(StringBuilder sb, int totalEntries, int uniquePermSets, int distinctUsers)
{
var T = TranslationSource.Instance;
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">{T["report.stat.total_entries"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">{T["report.stat.unique_permission_sets"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">{T["report.stat.distinct_users_groups"]}</div></div>");
sb.AppendLine("</div>");
}
/// <summary>Appends the live-filter input bound to <c>#permTable</c>.</summary>
internal static void AppendFilterInput(StringBuilder sb)
{
var T = TranslationSource.Instance;
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_permissions"]}\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>");
}
/// <summary>Appends the inline &lt;script&gt; that powers filter and group toggle.</summary>
internal static void AppendInlineJs(StringBuilder sb)
{
sb.AppendLine("<script>");
sb.AppendLine(InlineJs);
sb.AppendLine("</script>");
}
/// <summary>
/// Renders the user-pill cell content plus any group-member sub-rows for a
/// single permission entry. Callers pass their row colspan so sub-rows
/// span the full table; <paramref name="grpMemIdx"/> must be mutated
/// across rows so sub-row IDs stay unique.
/// </summary>
internal static (string Pills, string MemberSubRows) BuildUserPillsCell(
string userLogins,
string userNames,
string? principalType,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers,
int colSpan,
ref int grpMemIdx,
string? targetLabel = null,
string? sharingLinkType = null,
bool hideSystemGroupRaw = false,
string? sectionId = null)
{
var T = TranslationSource.Instance;
var logins = userLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = userNames.Split(';', StringSplitOptions.RemoveEmptyEntries);
var pills = new StringBuilder();
var subRows = new StringBuilder();
for (int i = 0; i < logins.Length; i++)
{
var login = logins[i].Trim();
var name = i < names.Length ? names[i].Trim() : login;
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
// 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 (hasResolvedMembers && groupMembers!.TryGetValue(name, out var resolved))
{
if (resolved.Count == 0)
{
// Members unavailable — render plain pill, skip expandable sub-row.
var cls2 = isResolvedSystemGroup ? "user-pill\" data-system-group=\"1" : "user-pill";
pills.Append($"<span class=\"{cls2}\" title=\"{HtmlEncode(T["report.text.empty_group"])}\" 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>");
}
else
{
var grpId = $"grpmem{grpMemIdx}";
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(" &#9660;</span>");
var parts = resolved.Select(m => $"{HtmlEncode(m.DisplayName)} &lt;{HtmlEncode(m.Login)}&gt;");
var memberContent = string.Join(" &bull; ", parts);
var sectionAttr = sectionId != null ? $" data-section-member=\"{HtmlEncode(sectionId)}\"" : "";
subRows.AppendLine($"<tr data-group=\"{HtmlEncode(grpId)}\"{sectionAttr} 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";
pills.Append($"<span class=\"{cls}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
}
}
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("&rarr; <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
{
"Site Collection" => "badge site-coll",
"Site" => "badge site",
"List" => "badge list",
"Folder" => "badge folder",
_ => "badge"
};
/// <summary>Minimal HTML encoding for text content and attribute values.</summary>
internal static string HtmlEncode(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return value
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
}
+199
View File
@@ -0,0 +1,199 @@
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Text;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Shared helpers for split report exports: filename partitioning, site label
/// derivation, and bundling per-partition HTML into a single tabbed document.
/// </summary>
public static class ReportSplitHelper
{
/// <summary>
/// Returns a file-safe variant of <paramref name="name"/>. Invalid filename
/// characters are replaced with underscores; whitespace runs are collapsed.
/// </summary>
public static string SanitizeFileName(string name)
{
if (string.IsNullOrWhiteSpace(name)) return "part";
var invalid = Path.GetInvalidFileNameChars();
var sb = new StringBuilder(name.Length);
foreach (var c in name)
sb.Append(invalid.Contains(c) || c == ' ' ? '_' : c);
var trimmed = sb.ToString().Trim('_');
if (trimmed.Length > 80) trimmed = trimmed.Substring(0, 80);
return trimmed.Length == 0 ? "part" : trimmed;
}
/// <summary>
/// Given a user-selected <paramref name="basePath"/> (e.g. "C:\reports\duplicates.csv"),
/// returns a partitioned path like "C:\reports\duplicates_{label}.csv".
/// </summary>
public static string BuildPartitionPath(string basePath, string partitionLabel)
{
var dir = Path.GetDirectoryName(basePath);
var stem = Path.GetFileNameWithoutExtension(basePath);
var ext = Path.GetExtension(basePath);
var safe = SanitizeFileName(partitionLabel);
var file = $"{stem}_{safe}{ext}";
return string.IsNullOrEmpty(dir) ? file : Path.Combine(dir, file);
}
/// <summary>
/// Extracts the site-collection root URL from an arbitrary SharePoint object URL.
/// e.g. https://t.sharepoint.com/sites/hr/Shared%20Documents/foo.docx →
/// https://t.sharepoint.com/sites/hr
/// Falls back to scheme+host for root site collections.
/// </summary>
public static string DeriveSiteCollectionUrl(string objectUrl)
{
if (string.IsNullOrWhiteSpace(objectUrl)) return string.Empty;
if (!Uri.TryCreate(objectUrl, UriKind.Absolute, out var uri))
return objectUrl.TrimEnd('/');
var baseUrl = $"{uri.Scheme}://{uri.Host}";
var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length >= 2 &&
(segments[0].Equals("sites", StringComparison.OrdinalIgnoreCase) ||
segments[0].Equals("teams", StringComparison.OrdinalIgnoreCase)))
{
return $"{baseUrl}/{segments[0]}/{segments[1]}";
}
return baseUrl;
}
/// <summary>
/// Derives a short, human-friendly site label from a SharePoint site URL.
/// Falls back to the raw URL (sanitized) when parsing fails.
/// </summary>
public static string DeriveSiteLabel(string siteUrl, string? siteTitle = null)
{
if (!string.IsNullOrWhiteSpace(siteTitle)) return siteTitle!;
if (string.IsNullOrWhiteSpace(siteUrl)) return "site";
try
{
var uri = new Uri(siteUrl);
var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length >= 2 &&
(segments[0].Equals("sites", StringComparison.OrdinalIgnoreCase) ||
segments[0].Equals("teams", StringComparison.OrdinalIgnoreCase)))
{
return segments[1];
}
return uri.Host;
}
catch (Exception ex) when (ex is UriFormatException or ArgumentException)
{
Debug.WriteLine($"[ReportSplitHelper] DeriveSiteLabel: malformed URL '{siteUrl}' ({ex.GetType().Name}: {ex.Message}) — falling back to raw value.");
return siteUrl;
}
}
/// <summary>
/// Generic dispatcher for split-aware export: if
/// <paramref name="splitMode"/> is not BySite, writes a single file via
/// <paramref name="writer"/>; otherwise partitions via
/// <paramref name="partitioner"/> and writes one file per partition,
/// each at a filename derived from <paramref name="basePath"/> plus the
/// partition label.
/// </summary>
public static async Task WritePartitionedAsync<T>(
IReadOnlyList<T> items,
string basePath,
ReportSplitMode splitMode,
Func<IReadOnlyList<T>, IEnumerable<(string Label, IReadOnlyList<T> Partition)>> partitioner,
Func<IReadOnlyList<T>, string, CancellationToken, Task> writer,
CancellationToken ct)
{
if (splitMode != ReportSplitMode.BySite)
{
await writer(items, basePath, ct);
return;
}
foreach (var (label, partition) in partitioner(items))
{
ct.ThrowIfCancellationRequested();
var path = BuildPartitionPath(basePath, label);
await writer(partition, path, ct);
}
}
/// <summary>
/// Bundles per-partition HTML documents into one self-contained tabbed
/// HTML. Each partition HTML is embedded in an &lt;iframe srcdoc&gt; so
/// their inline styles and scripts remain isolated.
/// </summary>
public static string BuildTabbedHtml(
IReadOnlyList<(string Label, string Html)> parts,
string title)
{
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{WebUtility.HtmlEncode(title)}</title>");
sb.AppendLine("""
<style>
html, body { margin: 0; padding: 0; height: 100%; font-family: 'Segoe UI', Arial, sans-serif; background: #1a1a2e; }
.tabbar { display: flex; flex-wrap: wrap; gap: 4px; background: #1a1a2e; padding: 8px; position: sticky; top: 0; z-index: 10; }
.tab { padding: 6px 12px; background: #2d2d4e; color: #fff; cursor: pointer; border-radius: 4px;
font-size: 13px; user-select: none; white-space: nowrap; }
.tab:hover { background: #3d3d6e; }
.tab.active { background: #0078d4; }
.frame-host { background: #f5f5f5; }
iframe { width: 100%; height: calc(100vh - 52px); border: 0; display: none; background: #f5f5f5; }
iframe.active { display: block; }
</style>
</head>
<body>
""");
sb.Append("<div class=\"tabbar\">");
for (int i = 0; i < parts.Count; i++)
{
var cls = i == 0 ? "tab active" : "tab";
sb.Append($"<div class=\"{cls}\" onclick=\"showTab({i})\">{WebUtility.HtmlEncode(parts[i].Label)}</div>");
}
sb.AppendLine("</div>");
for (int i = 0; i < parts.Count; i++)
{
var cls = i == 0 ? "active" : string.Empty;
var escaped = EscapeForSrcdoc(parts[i].Html);
sb.AppendLine($"<iframe class=\"{cls}\" srcdoc=\"{escaped}\"></iframe>");
}
sb.AppendLine("""
<script>
function showTab(i) {
var frames = document.querySelectorAll('iframe');
var tabs = document.querySelectorAll('.tab');
for (var j = 0; j < frames.length; j++) {
frames[j].classList.toggle('active', i === j);
tabs[j].classList.toggle('active', i === j);
}
}
</script>
</body></html>
""");
return sb.ToString();
}
/// <summary>
/// Escapes an HTML document so it can safely appear inside an
/// &lt;iframe srcdoc="..."&gt; attribute. Only ampersands and double
/// quotes must be encoded; angle brackets are kept literal because the
/// parser treats srcdoc as CDATA-like content.
/// </summary>
private static string EscapeForSrcdoc(string html)
{
if (string.IsNullOrEmpty(html)) return string.Empty;
return html
.Replace("&", "&amp;")
.Replace("\"", "&quot;");
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace SharepointToolbox.Web.Services.Export;
/// <summary>How a report export is partitioned.</summary>
public enum ReportSplitMode
{
Single,
BySite,
ByUser
}
/// <summary>When a report is split, how HTML output is laid out.</summary>
public enum HtmlSplitLayout
{
SeparateFiles,
SingleTabbed
}
+53
View File
@@ -0,0 +1,53 @@
using System.IO;
using System.Text;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Localization;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Exports SearchResult list to a UTF-8 BOM CSV file.
/// Header matches the column order in SearchHtmlExportService for consistency.
/// </summary>
public class SearchCsvExportService
{
/// <summary>
/// Builds the CSV payload. Column order mirrors
/// <see cref="SearchHtmlExportService.BuildHtml(IReadOnlyList{SearchResult}, ReportBranding?)"/>.
/// </summary>
public string BuildCsv(IReadOnlyList<SearchResult> results)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Header
sb.AppendLine($"{T["report.col.file_name"]},{T["report.col.extension"]},{T["report.col.path"]},{T["report.col.created"]},{T["report.col.created_by"]},{T["report.col.modified"]},{T["report.col.modified_by"]},{T["report.col.size_bytes"]}");
foreach (var r in results)
{
sb.AppendLine(string.Join(",",
Csv(IfEmpty(System.IO.Path.GetFileName(r.Path), r.Title)),
Csv(r.FileExtension),
Csv(r.Path),
r.Created.HasValue ? Csv(r.Created.Value.ToString("yyyy-MM-dd")) : string.Empty,
Csv(r.Author),
r.LastModified.HasValue ? Csv(r.LastModified.Value.ToString("yyyy-MM-dd")) : string.Empty,
Csv(r.ModifiedBy),
r.SizeBytes.ToString()));
}
return sb.ToString();
}
/// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
{
var csv = BuildCsv(results);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
}
private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value);
private static string IfEmpty(string? value, string fallback = "")
=> string.IsNullOrEmpty(value) ? fallback : value!;
}
+165
View File
@@ -0,0 +1,165 @@
using System.IO;
using System.Text;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Localization;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Exports SearchResult list to a self-contained sortable/filterable HTML report.
/// Port of PS Export-SearchToHTML (PS lines 2112-2233).
/// Columns are sortable by clicking the header. A filter input narrows rows by text match.
/// </summary>
public class SearchHtmlExportService
{
/// <summary>
/// Builds a self-contained HTML table with inline sort/filter scripts.
/// Each <see cref="SearchResult"/> becomes one row; the document has no
/// external dependencies.
/// </summary>
public string BuildHtml(IReadOnlyList<SearchResult> results, ReportBranding? branding = null)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.search"]}</title>");
sb.AppendLine("""
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
.toolbar { margin-bottom: 10px; display: flex; gap: 12px; align-items: center; }
.toolbar label { font-weight: 600; }
#filterInput { padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; width: 280px; font-size: 13px; }
#resultCount { font-size: 12px; color: #666; }
table { border-collapse: collapse; width: 100%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
th { background: #0078d4; color: #fff; padding: 8px 12px; text-align: left; cursor: pointer;
font-weight: 600; user-select: none; white-space: nowrap; }
th:hover { background: #106ebe; }
th.sorted-asc::after { content: ' '; font-size: 10px; }
th.sorted-desc::after { content: ' '; font-size: 10px; }
td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; word-break: break-all; }
tr:hover td { background: #f0f7ff; }
tr.hidden { display: none; }
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.generated { font-size: 11px; color: #888; margin-top: 12px; }
</style>
</head>
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"""
<h1>{T["report.title.search_short"]}</h1>
<div class="toolbar">
<label for="filterInput">{T["report.filter.label"]}</label>
<input id="filterInput" type="text" placeholder="{T["report.filter.placeholder_rows"]}" oninput="filterTable()" />
<span id="resultCount"></span>
</div>
""");
sb.AppendLine($"""
<table id="resultsTable">
<thead>
<tr>
<th onclick="sortTable(0)">{T["report.col.file_name"]}</th>
<th onclick="sortTable(1)">{T["report.col.extension"]}</th>
<th onclick="sortTable(2)">{T["report.col.path"]}</th>
<th onclick="sortTable(3)">{T["report.col.created"]}</th>
<th onclick="sortTable(4)">{T["report.col.created_by"]}</th>
<th onclick="sortTable(5)">{T["report.col.modified"]}</th>
<th onclick="sortTable(6)">{T["report.col.modified_by"]}</th>
<th class="num" onclick="sortTable(7)">{T["report.col.size"]}</th>
</tr>
</thead>
<tbody>
""");
foreach (var r in results)
{
string fileName = System.IO.Path.GetFileName(r.Path);
if (string.IsNullOrEmpty(fileName)) fileName = r.Title;
sb.AppendLine($"""
<tr>
<td>{H(fileName)}</td>
<td>{H(r.FileExtension)}</td>
<td><a href="{H(r.Path)}" target="_blank">{H(r.Path)}</a></td>
<td>{(r.Created.HasValue ? r.Created.Value.ToString("yyyy-MM-dd") : string.Empty)}</td>
<td>{H(r.Author)}</td>
<td>{(r.LastModified.HasValue ? r.LastModified.Value.ToString("yyyy-MM-dd") : string.Empty)}</td>
<td>{H(r.ModifiedBy)}</td>
<td class="num" data-sort="{r.SizeBytes}">{FormatSize(r.SizeBytes)}</td>
</tr>
""");
}
sb.AppendLine(" </tbody>\n</table>");
int count = results.Count;
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} {T["report.text.results_parens"]}</p>");
sb.AppendLine($$"""
<script>
var sortDir = {};
function sortTable(col) {
var tbl = document.getElementById('resultsTable');
var tbody = tbl.tBodies[0];
var rows = Array.from(tbody.rows);
var asc = sortDir[col] !== 'asc';
sortDir[col] = asc ? 'asc' : 'desc';
rows.sort(function(a, b) {
var av = a.cells[col].dataset.sort || a.cells[col].innerText;
var bv = b.cells[col].dataset.sort || b.cells[col].innerText;
var an = parseFloat(av), bn = parseFloat(bv);
if (!isNaN(an) && !isNaN(bn)) return asc ? an - bn : bn - an;
return asc ? av.localeCompare(bv) : bv.localeCompare(av);
});
rows.forEach(function(r) { tbody.appendChild(r); });
var ths = tbl.tHead.rows[0].cells;
for (var i = 0; i < ths.length; i++) {
ths[i].className = (i === col) ? (asc ? 'sorted-asc' : 'sorted-desc') : '';
}
}
function filterTable() {
var q = document.getElementById('filterInput').value.toLowerCase();
var rows = document.getElementById('resultsTable').tBodies[0].rows;
var visible = 0;
for (var i = 0; i < rows.length; i++) {
var match = rows[i].innerText.toLowerCase().indexOf(q) >= 0;
rows[i].className = match ? '' : 'hidden';
if (match) visible++;
}
document.getElementById('resultCount').innerText = q ? (visible + ' {{T["report.text.of"]}} {{count:N0}} {{T["report.text.shown"]}}') : '';
}
window.onload = function() {
document.getElementById('resultCount').innerText = '{{count:N0}} {{T["report.text.results_parens"]}}';
};
</script>
</body></html>
""");
return sb.ToString();
}
/// <summary>Writes the HTML report to <paramref name="filePath"/>.</summary>
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct, ReportBranding? branding = null)
{
var html = BuildHtml(results, branding);
await System.IO.File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
}
private static string H(string value) =>
System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
private static string FormatSize(long bytes)
{
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
return $"{bytes} B";
}
}
+227
View File
@@ -0,0 +1,227 @@
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Localization;
using System.Globalization;
using System.IO;
using System.Text;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Exports a flat list of StorageNode objects to a UTF-8 BOM CSV.
/// Compatible with Microsoft Excel (BOM signals UTF-8 encoding).
/// </summary>
public class StorageCsvExportService
{
/// <summary>
/// Builds a single-section CSV: header row plus one row per
/// <see cref="StorageNode"/> with library, site, file count, total size
/// (MB), version size (MB), and last-modified date.
/// </summary>
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
{
// Pre-size: ~110 chars/row + header avoids most StringBuilder growth.
var sb = new StringBuilder(128 + nodes.Count * 110);
WriteCsv(sb, nodes);
return sb.ToString();
}
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)
{
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();
}
}
/// <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)
{
// 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>
/// Builds a CSV with library details followed by a file-type breakdown section.
/// </summary>
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
{
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);
foreach (var node in nodes)
{
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();
}
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.Append(colFileType).Append(',')
.Append(colSizeMb).Append(',')
.AppendLine(colFileCnt);
foreach (var m in fileTypeMetrics)
{
string label = string.IsNullOrEmpty(m.Extension) ? noExtLabel : m.Extension;
AppendCsvField(sb, label).Append(',');
AppendMb(sb, m.TotalSizeBytes).Append(',');
sb.Append(m.FileCount).AppendLine();
}
}
}
/// <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 sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
WriteCsv(sb, nodes, fileTypeMetrics);
await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, ct);
}
/// <summary>
/// Writes storage metrics with optional per-site partitioning.
/// Single → one file. BySite → one file per SiteTitle. File-type metrics
/// are replicated across all partitions because the tenant-level scan
/// does not retain per-site breakdowns.
/// </summary>
public Task WriteAsync(
IReadOnlyList<StorageNode> nodes,
IReadOnlyList<FileTypeMetric> fileTypeMetrics,
string basePath,
ReportSplitMode splitMode,
CancellationToken ct)
=> ReportSplitHelper.WritePartitionedAsync(
nodes, basePath, splitMode,
PartitionBySite,
(part, path, c) => WriteAsync(part, fileTypeMetrics, path, c),
ct);
/// <summary>
/// Splits the flat StorageNode list into per-site slices while preserving
/// the DFS hierarchy (each root library followed by its indented descendants).
/// Siblings sharing a SiteTitle roll up into the same partition.
/// </summary>
internal static IEnumerable<(string Label, IReadOnlyList<StorageNode> Partition)> PartitionBySite(
IReadOnlyList<StorageNode> nodes)
{
var buckets = new Dictionary<string, List<StorageNode>>(StringComparer.OrdinalIgnoreCase);
string currentSite = string.Empty;
foreach (var node in nodes)
{
if (node.IndentLevel == 0)
currentSite = string.IsNullOrWhiteSpace(node.SiteTitle)
? ReportSplitHelper.DeriveSiteLabel(node.Url)
: node.SiteTitle;
if (!buckets.TryGetValue(currentSite, out var list))
{
list = new List<StorageNode>();
buckets[currentSite] = list;
}
list.Add(node);
}
return buckets.Select(kv => (kv.Key, (IReadOnlyList<StorageNode>)kv.Value));
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static StringBuilder AppendMb(StringBuilder sb, long bytes)
=> sb.Append((bytes / (1024.0 * 1024.0)).ToString("F2", CultureInfo.InvariantCulture));
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)
{
var T = TranslationSource.Instance;
return kind switch
{
StorageNodeKind.Library => T["stor.kind.library"],
StorageNodeKind.HiddenLibrary => T["stor.kind.hidden"],
StorageNodeKind.PreservationHold => T["stor.kind.preservation"],
StorageNodeKind.ListAttachments => T["stor.kind.attachments"],
StorageNodeKind.RecycleBin => T["stor.kind.recyclebin"],
StorageNodeKind.Subsite => T["stor.kind.subsite"],
_ => kind.ToString()
};
}
}
+465
View File
@@ -0,0 +1,465 @@
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Localization;
using System.IO;
using System.Text;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Exports StorageNode tree to a self-contained HTML file with collapsible subfolder rows.
/// Port of PS Export-StorageToHTML (PS lines 1621-1780).
/// Uses a toggle(i) JS pattern where each collapsible row has id="sf-{i}".
/// </summary>
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
/// library and indented child folders. Library-only variant — use the
/// overload that accepts <see cref="FileTypeMetric"/>s when a file-type
/// 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;
_kindLabels = BuildKindLabelCache();
_kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels);
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.storage"]}</title>");
sb.AppendLine("""
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
table { border-collapse: collapse; width: 100%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
th { background: #0078d4; color: #fff; padding: 8px 12px; text-align: left; font-weight: 600; }
td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; vertical-align: top; }
tr:hover { background: #f0f7ff; }
.toggle-btn { background: none; border: 1px solid #0078d4; color: #0078d4; border-radius: 3px;
cursor: pointer; font-size: 11px; padding: 1px 6px; margin-right: 6px; }
.toggle-btn:hover { background: #e5f1fb; }
.sf-tbl { width: 100%; border: none; box-shadow: none; margin: 0; }
.sf-tbl td { background: #fafcff; font-size: 12px; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.generated { font-size: 11px; color: #888; margin-top: 12px; }
</style>
<script>
function toggle(i) {
var row = document.getElementById('sf-' + i);
if (row) row.style.display = (row.style.display === 'none' || row.style.display === '') ? 'table-row' : 'none';
}
</script>
</head>
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
// 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">
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(siteTotal0)}</div><div style="font-size:.8rem;color:#666">{T["report.stat.total_size"]}</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(versionTotal0)}</div><div style="font-size:.8rem;color:#666">{T["report.stat.version_size"]}</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{fileTotal0:N0}</div><div style="font-size:.8rem;color:#666">{T["report.stat.files"]}</div></div>
</div>
""");
sb.AppendLine($"""
<table>
<thead>
<tr>
<th>{T["report.col.library_folder"]}</th>
<th>{T["stor.col.kind"]}</th>
<th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th>
<th class="num">{T["report.stat.version_size"]}</th>
<th>{T["report.col.last_modified"]}</th>
</tr>
</thead>
<tbody>
""");
// 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);
}
sb.AppendLine("""
</tbody>
</table>
""");
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>");
}
/// <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;
_kindLabels = BuildKindLabelCache();
_kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels);
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.storage"]}</title>");
sb.AppendLine("""
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
h2 { color: #333; margin-top: 24px; }
table { border-collapse: collapse; width: 100%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
th { background: #0078d4; color: #fff; padding: 8px 12px; text-align: left; font-weight: 600; }
td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; vertical-align: top; }
tr:hover { background: #f0f7ff; }
.toggle-btn { background: none; border: 1px solid #0078d4; color: #0078d4; border-radius: 3px;
cursor: pointer; font-size: 11px; padding: 1px 6px; margin-right: 6px; }
.toggle-btn:hover { background: #e5f1fb; }
.sf-tbl { width: 100%; border: none; box-shadow: none; margin: 0; }
.sf-tbl td { background: #fafcff; font-size: 12px; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.generated { font-size: 11px; color: #888; margin-top: 12px; }
.chart-section { margin: 20px 0; padding: 16px; background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
.bar-row { display: flex; align-items: center; margin: 4px 0; }
.bar-label { width: 80px; font-size: 12px; font-weight: 600; text-align: right; padding-right: 10px; }
.bar-track { flex: 1; background: #eee; border-radius: 4px; height: 22px; position: relative; }
.bar-fill { height: 100%; border-radius: 4px; background: #0078d4; min-width: 2px; }
.bar-value { font-size: 11px; color: #555; padding-left: 8px; white-space: nowrap; min-width: 140px; }
.stats { display: flex; gap: 16px; margin: 16px 0; flex-wrap: wrap; }
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.stat-card .value { font-size: 1.8rem; font-weight: 700; color: #0078d4; }
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
</style>
<script>
function toggle(i) {
var row = document.getElementById('sf-' + i);
if (row) row.style.display = (row.style.display === 'none' || row.style.display === '') ? 'table-row' : 'none';
}
</script>
</head>
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
// ── 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>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(versionTotal)}</div><div class=\"label\">{T["report.stat.version_size"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{fileTotal:N0}</div><div class=\"label\">{T["report.stat.files"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{rootNodes.Count}</div><div class=\"label\">{T["report.stat.libraries"]}</div></div>");
sb.AppendLine("</div>");
// ── File type chart section ──
if (fileTypeMetrics.Count > 0)
{
var maxSize = fileTypeMetrics.Max(m => m.TotalSizeBytes);
var totalSize = fileTypeMetrics.Sum(m => m.TotalSizeBytes);
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
sb.AppendLine("<div class=\"chart-section\">");
sb.AppendLine($"<h2>{T["report.section.storage_by_file_type"]} ({totalFiles:N0} {T["report.text.files_unit"]}, {FormatSize(totalSize)})</h2>");
var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
int idx = 0;
foreach (var m in fileTypeMetrics.Take(15))
{
double pct = maxSize > 0 ? m.TotalSizeBytes * 100.0 / maxSize : 0;
string color = colors[idx % colors.Length];
string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_ext"] : m.Extension;
sb.AppendLine($"""
<div class="bar-row">
<span class="bar-label">{HtmlEncode(label)}</span>
<div class="bar-track"><div class="bar-fill" style="width:{pct:F1}%;background:{color}"></div></div>
<span class="bar-value">{FormatSize(m.TotalSizeBytes)} &middot; {m.FileCount:N0} {T["report.text.files_unit"]}</span>
</div>
""");
idx++;
}
sb.AppendLine("</div>");
}
// ── Storage table ──
sb.AppendLine($"<h2>{T["report.section.library_details"]}</h2>");
sb.AppendLine($"""
<table>
<thead>
<tr>
<th>{T["report.col.library_folder"]}</th>
<th>{T["stor.col.kind"]}</th>
<th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th>
<th class="num">{T["report.stat.version_size"]}</th>
<th>{T["report.col.last_modified"]}</th>
</tr>
</thead>
<tbody>
""");
// 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);
}
sb.AppendLine("""
</tbody>
</table>
""");
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>");
}
/// <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)
{
// 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 sb = new StringBuilder(4096 + nodes.Count * 340 + fileTypeMetrics.Count * 220);
BuildHtmlCore(sb, nodes, fileTypeMetrics, branding);
await ExportFileWriter.WriteHtmlChunksAsync(filePath, sb, ct);
}
/// <summary>
/// Split-aware HTML export for storage metrics.
/// Single → one file. BySite + SeparateFiles → one file per site.
/// BySite + SingleTabbed → one HTML with per-site iframe tabs. File-type
/// metrics are replicated across partitions because they are not
/// attributed per-site by the scanner.
/// </summary>
public async Task WriteAsync(
IReadOnlyList<StorageNode> nodes,
IReadOnlyList<FileTypeMetric> fileTypeMetrics,
string basePath,
ReportSplitMode splitMode,
HtmlSplitLayout layout,
CancellationToken ct,
ReportBranding? branding = null)
{
if (splitMode != ReportSplitMode.BySite)
{
await WriteAsync(nodes, fileTypeMetrics, basePath, ct, branding);
return;
}
var partitions = StorageCsvExportService.PartitionBySite(nodes).ToList();
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partitions
.Select(p => (p.Label, Html: BuildHtml(p.Partition, fileTypeMetrics, branding)))
.ToList();
var title = TranslationSource.Instance["report.title.storage"];
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
await File.WriteAllTextAsync(basePath, tabbed, Encoding.UTF8, ct);
return;
}
foreach (var (label, partNodes) in partitions)
{
ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteAsync(partNodes, fileTypeMetrics, path, ct, branding);
}
}
// ── Private rendering ────────────────────────────────────────────────────
private void RenderNode(StringBuilder sb, StorageNode node)
{
bool hasChildren = node.Children.Count > 0;
int myIdx = hasChildren ? ++_togIdx : 0;
string nameCell = hasChildren
? $"<button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}"
: $"<span style=\"margin-left:{node.IndentLevel * 16}px\">{HtmlEncode(node.Name)}</span>";
AppendRow(sb, node, nameCell);
if (hasChildren)
{
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"7\" style=\"padding:0\">");
sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
foreach (var child in node.Children)
{
RenderChildNode(sb, child);
}
sb.AppendLine("</tbody></table>");
sb.AppendLine("</td></tr>");
}
}
private void RenderChildNode(StringBuilder sb, StorageNode node)
{
bool hasChildren = node.Children.Count > 0;
int myIdx = hasChildren ? ++_togIdx : 0;
string indent = $"margin-left:{(node.IndentLevel + 1) * 16}px";
string nameCell = hasChildren
? $"<span style=\"{indent}\"><button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}</span>"
: $"<span style=\"{indent}\">{HtmlEncode(node.Name)}</span>";
AppendRow(sb, node, nameCell);
if (hasChildren)
{
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"7\" style=\"padding:0\">");
sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
foreach (var child in node.Children)
{
RenderChildNode(sb, child);
}
sb.AppendLine("</tbody></table>");
sb.AppendLine("</td></tr>");
}
}
/// <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";
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
return $"{bytes} B";
}
private static string HtmlEncode(string value)
=> System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
private static string KindLabel(StorageNodeKind kind)
{
var T = TranslationSource.Instance;
return kind switch
{
StorageNodeKind.Library => T["stor.kind.library"],
StorageNodeKind.HiddenLibrary => T["stor.kind.hidden"],
StorageNodeKind.PreservationHold => T["stor.kind.preservation"],
StorageNodeKind.ListAttachments => T["stor.kind.attachments"],
StorageNodeKind.RecycleBin => T["stor.kind.recyclebin"],
StorageNodeKind.Subsite => T["stor.kind.subsite"],
_ => 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;
}
}
@@ -0,0 +1,257 @@
using System.IO;
using System.Text;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Localization;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Exports user access audit results to CSV format.
/// Produces one CSV file per audited user with a summary section at the top.
/// </summary>
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"]}\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
}
/// <summary>
/// Builds a CSV string for a single user's access entries.
/// Includes a summary section at the top followed by data rows.
/// </summary>
public string BuildCsv(string userDisplayName, string userLogin, IReadOnlyList<UserAccessEntry> entries)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Summary section
var sitesCount = entries.Select(e => e.SiteUrl).Distinct().Count();
var highPrivCount = entries.Count(e => e.IsHighPrivilege);
sb.AppendLine($"\"{T["report.title.user_access"]}\"");
sb.AppendLine($"\"{T["report.col.user"]}\",\"{Csv(userDisplayName)} ({Csv(userLogin)})\"");
sb.AppendLine($"\"{T["report.stat.total_accesses"]}\",\"{entries.Count}\"");
sb.AppendLine($"\"{T["report.col.sites"]}\",\"{sitesCount}\"");
sb.AppendLine($"\"{T["report.stat.high_privilege"]}\",\"{highPrivCount}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine(); // Blank line separating summary from data
// Data rows
sb.AppendLine(BuildDataHeader());
foreach (var entry in entries)
{
sb.AppendLine(string.Join(",", new[]
{
Csv(entry.SiteTitle),
Csv(entry.ObjectType),
Csv(entry.ObjectTitle),
Csv(entry.ObjectUrl),
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)
}));
}
return sb.ToString();
}
/// <summary>
/// Writes one CSV file per user to the specified directory.
/// File names: audit_{email}_{date}.csv
/// </summary>
public async Task WriteAsync(
IReadOnlyList<UserAccessEntry> allEntries,
string directoryPath,
CancellationToken ct)
{
Directory.CreateDirectory(directoryPath);
var dateStr = DateTime.Now.ToString("yyyy-MM-dd");
// Group by user
var byUser = allEntries.GroupBy(e => e.UserLogin);
foreach (var group in byUser)
{
ct.ThrowIfCancellationRequested();
var userLogin = group.Key;
var displayName = group.First().UserDisplayName;
var entries = group.ToList();
// Sanitize email for filename (replace @ and other invalid chars)
var safeLogin = SanitizeFileName(userLogin);
var fileName = $"audit_{safeLogin}_{dateStr}.csv";
var filePath = Path.Combine(directoryPath, fileName);
var csv = BuildCsv(displayName, userLogin, entries);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
}
}
/// <summary>
/// Writes all entries split per site. File naming: "{base}_{siteLabel}.csv".
/// </summary>
public async Task WriteBySiteAsync(
IReadOnlyList<UserAccessEntry> allEntries,
string basePath,
CancellationToken ct,
bool mergePermissions = false)
{
foreach (var group in allEntries.GroupBy(e => (e.SiteUrl, e.SiteTitle)))
{
ct.ThrowIfCancellationRequested();
var label = ReportSplitHelper.DeriveSiteLabel(group.Key.SiteUrl, group.Key.SiteTitle);
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteSingleFileAsync(group.ToList(), path, ct, mergePermissions);
}
}
/// <summary>
/// Split-aware export dispatcher.
/// Single → one file at <paramref name="basePath"/>.
/// BySite → one file per site. ByUser → one file per user.
/// </summary>
public async Task WriteAsync(
IReadOnlyList<UserAccessEntry> allEntries,
string basePath,
ReportSplitMode splitMode,
CancellationToken ct,
bool mergePermissions = false)
{
switch (splitMode)
{
case ReportSplitMode.Single:
await WriteSingleFileAsync(allEntries, basePath, ct, mergePermissions);
break;
case ReportSplitMode.BySite:
await WriteBySiteAsync(allEntries, basePath, ct, mergePermissions);
break;
case ReportSplitMode.ByUser:
await WriteByUserAsync(allEntries, basePath, ct, mergePermissions);
break;
}
}
/// <summary>
/// Writes one CSV per user using <paramref name="basePath"/> as a filename template.
/// </summary>
public async Task WriteByUserAsync(
IReadOnlyList<UserAccessEntry> allEntries,
string basePath,
CancellationToken ct,
bool mergePermissions = false)
{
foreach (var group in allEntries.GroupBy(e => e.UserLogin))
{
ct.ThrowIfCancellationRequested();
var label = ReportSplitHelper.SanitizeFileName(group.Key);
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteSingleFileAsync(group.ToList(), path, ct, mergePermissions);
}
}
/// <summary>
/// Writes all entries to a single CSV file (alternative for single-file export).
/// Used when the ViewModel export command picks a single file path.
/// When <paramref name="mergePermissions"/> is true, entries are consolidated using
/// <see cref="PermissionConsolidator"/> and written in a compact multi-location format.
/// </summary>
public async Task WriteSingleFileAsync(
IReadOnlyList<UserAccessEntry> entries,
string filePath,
CancellationToken ct,
bool mergePermissions = false)
{
var T = TranslationSource.Instance;
if (mergePermissions)
{
var consolidated = PermissionConsolidator.Consolidate(entries);
var sb = new StringBuilder();
// Summary section
sb.AppendLine($"\"{T["report.title.user_access_consolidated"]}\"");
sb.AppendLine($"\"{T["report.stat.users_audited"]}\",\"{consolidated.Select(e => e.UserLogin).Distinct().Count()}\"");
sb.AppendLine($"\"{T["report.stat.total_entries"]}\",\"{consolidated.Count}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
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"]}\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\",\"Locations\",\"Location Count\"");
// Data rows
foreach (var entry in consolidated)
{
var locations = string.Join("; ", entry.Locations.Select(l => l.SiteTitle));
sb.AppendLine(string.Join(",", new[]
{
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())
}));
}
await File.WriteAllTextAsync(filePath, sb.ToString(), new UTF8Encoding(false), ct);
return;
}
{
var sb = new StringBuilder();
var fullHeader = $"\"{T["report.col.user"]}\",\"User Login\"," + BuildDataHeader();
// Summary
var users = entries.Select(e => e.UserLogin).Distinct().ToList();
sb.AppendLine($"\"{T["report.title.user_access"]}\"");
sb.AppendLine($"\"{T["report.stat.users_audited"]}\",\"{users.Count}\"");
sb.AppendLine($"\"{T["report.stat.total_accesses"]}\",\"{entries.Count}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine();
sb.AppendLine(fullHeader);
foreach (var entry in entries)
{
sb.AppendLine(string.Join(",", new[]
{
Csv(entry.UserDisplayName),
Csv(entry.UserLogin),
Csv(entry.SiteTitle),
Csv(entry.ObjectType),
Csv(entry.ObjectTitle),
Csv(entry.ObjectUrl),
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)
}));
}
await ExportFileWriter.WriteCsvAsync(filePath, sb.ToString(), ct);
}
}
/// <summary>RFC 4180 CSV field escaping with formula-injection guard.</summary>
private static string Csv(string value) => CsvSanitizer.Escape(value);
private static string SanitizeFileName(string name)
{
var invalid = Path.GetInvalidFileNameChars();
var sb = new StringBuilder(name.Length);
foreach (var c in name)
sb.Append(invalid.Contains(c) ? '_' : c);
return sb.ToString();
}
}
@@ -0,0 +1,708 @@
using System.IO;
using System.Text;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Localization;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Exports user access audit results to a self-contained interactive HTML report.
/// Produces a single HTML file with dual-view toggle (by-user / by-site),
/// collapsible groups, sortable columns, filter input, and risk highlighting.
/// No external CSS/JS dependencies — everything is inline.
/// </summary>
public class UserAccessHtmlExportService
{
/// <summary>
/// Builds a self-contained HTML string from the supplied user access entries.
/// When <paramref name="mergePermissions"/> is true, renders a consolidated by-user
/// report with an expandable Sites column instead of the dual by-user/by-site view.
/// </summary>
/// <summary>
/// Split-aware HTML export. Single → one file.
/// BySite/ByUser + SeparateFiles → one file per site/user.
/// BySite/ByUser + SingleTabbed → one file with per-partition iframe tabs.
/// </summary>
public async Task WriteAsync(
IReadOnlyList<UserAccessEntry> entries,
string basePath,
ReportSplitMode splitMode,
HtmlSplitLayout layout,
CancellationToken ct,
bool mergePermissions = false,
ReportBranding? branding = null)
{
if (splitMode == ReportSplitMode.Single)
{
await WriteAsync(entries, basePath, ct, mergePermissions, branding);
return;
}
IEnumerable<(string Label, IReadOnlyList<UserAccessEntry> Entries)> partitions;
if (splitMode == ReportSplitMode.BySite)
{
partitions = entries
.GroupBy(e => (e.SiteUrl, e.SiteTitle))
.Select(g => (
Label: ReportSplitHelper.DeriveSiteLabel(g.Key.SiteUrl, g.Key.SiteTitle),
Entries: (IReadOnlyList<UserAccessEntry>)g.ToList()));
}
else // ByUser
{
partitions = entries
.GroupBy(e => e.UserLogin)
.Select(g => (
Label: ReportSplitHelper.SanitizeFileName(g.Key),
Entries: (IReadOnlyList<UserAccessEntry>)g.ToList()));
}
var partList = partitions.ToList();
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partList
.Select(p => (p.Label, Html: BuildHtml(p.Entries, mergePermissions, branding)))
.ToList();
var title = TranslationSource.Instance["report.title.user_access"];
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
await ExportFileWriter.WriteHtmlAsync(basePath, tabbed, ct);
return;
}
foreach (var (label, partEntries) in partList)
{
ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteAsync(partEntries, path, ct, mergePermissions, branding);
}
}
/// <summary>
/// Builds the user-access HTML report. Default layout is a per-entry
/// grouped-by-user table; when <paramref name="mergePermissions"/> is true
/// entries are consolidated via <see cref="PermissionConsolidator"/> into
/// a single-row-per-user format with a Locations column.
/// </summary>
public string BuildHtml(IReadOnlyList<UserAccessEntry> entries, bool mergePermissions = false, ReportBranding? branding = null)
{
if (mergePermissions)
{
var consolidated = PermissionConsolidator.Consolidate(entries);
return BuildConsolidatedHtml(consolidated, entries, branding);
}
var T = TranslationSource.Instance;
// Compute stats
var totalAccesses = entries.Count;
var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count();
var sitesScanned = entries.Select(e => e.SiteUrl).Distinct().Count();
var highPrivCount = entries.Count(e => e.IsHighPrivilege);
var externalCount = entries.Count(e => e.IsExternalUser);
var sb = new StringBuilder();
// ── HTML HEAD ──────────────────────────────────────────────────────────
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.user_access"]}</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
/* View toggle */
.view-toggle { display: flex; gap: 8px; padding: 0 24px 12px; }
.view-toggle button { padding: 7px 18px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer; font-size: .9rem; }
.view-toggle button.active { background: #1a1a2e; color: #fff; border-color: #1a1a2e; }
/* Filter */
.filter-wrap { padding: 0 24px 12px; }
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
/* Per-user summary cards */
.user-summary { padding: 0 24px 16px; display: flex; gap: 12px; flex-wrap: wrap; }
.user-card { background: #fff; border-radius: 8px; padding: 12px 16px; min-width: 200px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.user-card .user-name { font-weight: 600; font-size: .95rem; color: #1a1a2e; margin-bottom: 4px; }
.user-card .user-stats { font-size: .8rem; color: #555; }
.user-card.has-high-priv { border-left: 4px solid #dc2626; }
/* Table wrap */
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; cursor: pointer; user-select: none; }
th:hover { background: #2d2d4e; }
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafafa; }
/* Group headers */
.group-header { cursor: pointer; background: #f0f0f0; padding: 10px 14px; font-weight: 600; font-size: .875rem; }
.group-header:hover { background: #e8e8e8; }
.group-header td { background: inherit !important; font-weight: 600; }
/* Badges */
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
/* Access type badges */
.access-direct { background: #dbeafe; color: #1e40af; }
.access-group { background: #dcfce7; color: #166534; }
.access-inherited { background: #f3f4f6; color: #374151; }
/* High privilege */
.high-priv { font-weight: 700; }
/* Guest badge */
.guest-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: .75rem; font-weight: 600; background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; margin-left: 4px; }
/* User pills */
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
.hidden { display: none; }
");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
// ── BODY ───────────────────────────────────────────────────────────────
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.user_access"]}</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">{T["report.stat.total_accesses"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">{T["report.stat.users_audited"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">{T["report.stat.sites_scanned"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">{T["report.stat.high_privilege"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">{T["report.stat.external_users"]}</div></div>");
sb.AppendLine("</div>");
// Per-user summary cards
sb.AppendLine("<div class=\"user-summary\">");
var userGroups = entries.GroupBy(e => e.UserLogin).OrderBy(g => g.Key).ToList();
foreach (var ug in userGroups)
{
var uName = HtmlEncode(ug.First().UserDisplayName);
var uLogin = HtmlEncode(ug.Key);
var uTotal = ug.Count();
var uSites = ug.Select(e => e.SiteUrl).Distinct().Count();
var uHighPriv = ug.Count(e => e.IsHighPrivilege);
var uIsExt = ug.First().IsExternalUser;
var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card";
sb.AppendLine($" <div class=\"{cardClass}\">");
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uLogin}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uTotal} {T["report.text.accesses"]} &bull; {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} {T["report.text.high_priv"]}</span>" : "")}</div>");
sb.AppendLine(" </div>");
}
sb.AppendLine("</div>");
// View toggle buttons
sb.AppendLine("<div class=\"view-toggle\">");
sb.AppendLine($" <button id=\"btn-user\" class=\"active\" onclick=\"toggleView('user')\">{T["report.view.by_user"]}</button>");
sb.AppendLine($" <button id=\"btn-site\" onclick=\"toggleView('site')\">{T["report.view.by_site"]}</button>");
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_results"]}\" oninput=\"filterTable()\" />");
sb.AppendLine("</div>");
// ── BY-USER VIEW ───────────────────────────────────────────────────────
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
sb.AppendLine("<table id=\"tbl-user\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine($" <th onclick=\"sortTable('user',0)\">{T["report.col.site"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',1)\">{T["report.col.object_type"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',2)\">{T["report.col.object"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',3)\">{T["report.col.permission_level"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',4)\">{T["report.col.access_type"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',5)\">{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody id=\"tbody-user\">");
int userGroupIdx = 0;
foreach (var ug in userGroups)
{
var groupId = $"ugrp{userGroupIdx++}";
var uName = HtmlEncode(ug.First().UserDisplayName);
var uIsExt = ug.First().IsExternalUser;
var uCount = ug.Count();
var guestBadge = uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
sb.AppendLine($" <td colspan=\"6\">{uName}{guestBadge} &mdash; {uCount} {T["report.text.access_es"]}</td>");
sb.AppendLine("</tr>");
foreach (var entry in ug)
{
var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : "";
var accessBadge = AccessTypeBadge(entry.AccessType);
var highIcon = entry.IsHighPrivilege ? " &#9888;" : "";
var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle)
? "&mdash;"
: HtmlEncode(entry.ObjectTitle);
sb.AppendLine($"<tr data-group=\"{groupId}\">");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.SiteTitle)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</td>");
sb.AppendLine($" <td>{objectCell}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
sb.AppendLine("</tr>");
}
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
// ── BY-SITE VIEW ───────────────────────────────────────────────────────
sb.AppendLine("<div id=\"view-site\" class=\"table-wrap hidden\">");
sb.AppendLine("<table id=\"tbl-site\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine($" <th onclick=\"sortTable('site',0)\">{T["report.col.user"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',1)\">{T["report.col.object_type"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',2)\">{T["report.col.object"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',3)\">{T["report.col.permission_level"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',4)\">{T["report.col.access_type"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',5)\">{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody id=\"tbody-site\">");
var siteGroups = entries.GroupBy(e => e.SiteUrl).OrderBy(g => g.Key).ToList();
int siteGroupIdx = 0;
foreach (var sg in siteGroups)
{
var groupId = $"sgrp{siteGroupIdx++}";
var siteTitle = HtmlEncode(sg.First().SiteTitle);
var sCount = sg.Count();
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
sb.AppendLine($" <td colspan=\"6\">{siteTitle} &mdash; {sCount} {T["report.text.access_es"]}</td>");
sb.AppendLine("</tr>");
foreach (var entry in sg)
{
var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : "";
var accessBadge = AccessTypeBadge(entry.AccessType);
var highIcon = entry.IsHighPrivilege ? " &#9888;" : "";
var guestBadge = entry.IsExternalUser ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle)
? "&mdash;"
: HtmlEncode(entry.ObjectTitle);
sb.AppendLine($"<tr data-group=\"{groupId}\">");
sb.AppendLine($" <td>{HtmlEncode(entry.UserDisplayName)}{guestBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</td>");
sb.AppendLine($" <td>{objectCell}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
sb.AppendLine("</tr>");
}
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
// ── INLINE JS ─────────────────────────────────────────────────────────
sb.AppendLine("<script>");
sb.AppendLine(@"
var _currentView = 'user';
var _sortState = {};
function toggleView(view) {
_currentView = view;
document.getElementById('view-user').classList.toggle('hidden', view !== 'user');
document.getElementById('view-site').classList.toggle('hidden', view !== 'site');
document.getElementById('btn-user').classList.toggle('active', view === 'user');
document.getElementById('btn-site').classList.toggle('active', view === 'site');
// Re-apply filter to new view
filterTable();
}
function filterTable() {
var input = document.getElementById('filter').value.toLowerCase();
var tbodyId = _currentView === 'user' ? 'tbody-user' : 'tbody-site';
var tbody = document.getElementById(tbodyId);
var rows = tbody.querySelectorAll('tr[data-group]');
var groupsWithVisible = {};
rows.forEach(function(row) {
var matches = row.textContent.toLowerCase().indexOf(input) > -1;
row.style.display = matches ? '' : 'none';
if (matches) {
groupsWithVisible[row.getAttribute('data-group')] = true;
}
});
// Show/hide group headers based on whether they have visible children
var headers = tbody.querySelectorAll('tr.group-header');
headers.forEach(function(hdr) {
// find next sibling rows until next header
var next = hdr.nextElementSibling;
var groupId = null;
while (next && next.getAttribute('data-group')) {
groupId = next.getAttribute('data-group');
break;
}
if (groupId) {
hdr.style.display = groupsWithVisible[groupId] ? '' : 'none';
}
});
}
function toggleGroup(id) {
var rows = document.querySelectorAll('tr[data-group=""' + id + '""]');
var isHidden = rows.length > 0 && rows[0].style.display === 'none';
rows.forEach(function(r) { r.style.display = isHidden ? '' : 'none'; });
}
function sortTable(view, col) {
var tbodyId = view === 'user' ? 'tbody-user' : 'tbody-site';
var tbody = document.getElementById(tbodyId);
var key = view + '_' + col;
var asc = _sortState[key] !== true;
_sortState[key] = asc;
// Sort within each group separately
var groupHeaders = Array.from(tbody.querySelectorAll('tr.group-header'));
groupHeaders.forEach(function(hdr) {
var groupId = null;
var next = hdr.nextElementSibling;
while (next && next.getAttribute('data-group')) {
groupId = next.getAttribute('data-group');
break;
}
if (!groupId) return;
var groupRows = Array.from(tbody.querySelectorAll('tr[data-group=""' + groupId + '""]'));
groupRows.sort(function(a, b) {
var aText = (a.cells[col] ? a.cells[col].textContent : '').trim().toLowerCase();
var bText = (b.cells[col] ? b.cells[col].textContent : '').trim().toLowerCase();
return asc ? aText.localeCompare(bText) : bText.localeCompare(aText);
});
// Re-insert sorted rows after the group header
var insertAfter = hdr;
groupRows.forEach(function(row) {
tbody.insertBefore(row, insertAfter.nextSibling);
insertAfter = row;
});
});
}
");
sb.AppendLine("</script>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
/// <summary>
/// Writes the HTML report to the specified file path using UTF-8 without BOM.
/// </summary>
public async Task WriteAsync(IReadOnlyList<UserAccessEntry> entries, string filePath, CancellationToken ct, bool mergePermissions = false, ReportBranding? branding = null)
{
var html = BuildHtml(entries, mergePermissions, branding);
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
}
/// <summary>
/// Builds the consolidated HTML report: single by-user table with a Sites column.
/// By-site view and view-toggle are omitted. Uses the same CSS shell as BuildHtml.
/// </summary>
private string BuildConsolidatedHtml(
IReadOnlyList<ConsolidatedPermissionEntry> consolidated,
IReadOnlyList<UserAccessEntry> entries,
ReportBranding? branding)
{
var T = TranslationSource.Instance;
// Stats computed from the original flat list for accurate counts
var totalAccesses = entries.Count;
var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count();
var sitesScanned = entries.Select(e => e.SiteUrl).Distinct().Count();
var highPrivCount = entries.Count(e => e.IsHighPrivilege);
var externalCount = entries.Count(e => e.IsExternalUser);
var sb = new StringBuilder();
// ── HTML HEAD (same as BuildHtml) ──────────────────────────────────────
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.user_access_consolidated"]}</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
/* View toggle */
.view-toggle { display: flex; gap: 8px; padding: 0 24px 12px; }
.view-toggle button { padding: 7px 18px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer; font-size: .9rem; }
.view-toggle button.active { background: #1a1a2e; color: #fff; border-color: #1a1a2e; }
/* Filter */
.filter-wrap { padding: 0 24px 12px; }
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
/* Per-user summary cards */
.user-summary { padding: 0 24px 16px; display: flex; gap: 12px; flex-wrap: wrap; }
.user-card { background: #fff; border-radius: 8px; padding: 12px 16px; min-width: 200px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.user-card .user-name { font-weight: 600; font-size: .95rem; color: #1a1a2e; margin-bottom: 4px; }
.user-card .user-stats { font-size: .8rem; color: #555; }
.user-card.has-high-priv { border-left: 4px solid #dc2626; }
/* Table wrap */
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; cursor: pointer; user-select: none; }
th:hover { background: #2d2d4e; }
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafafa; }
/* Group headers */
.group-header { cursor: pointer; background: #f0f0f0; padding: 10px 14px; font-weight: 600; font-size: .875rem; }
.group-header:hover { background: #e8e8e8; }
.group-header td { background: inherit !important; font-weight: 600; }
/* Badges */
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
/* Access type badges */
.access-direct { background: #dbeafe; color: #1e40af; }
.access-group { background: #dcfce7; color: #166534; }
.access-inherited { background: #f3f4f6; color: #374151; }
/* High privilege */
.high-priv { font-weight: 700; }
/* Guest badge */
.guest-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: .75rem; font-weight: 600; background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; margin-left: 4px; }
/* User pills */
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
.hidden { display: none; }
");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
// ── BODY ───────────────────────────────────────────────────────────────
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.user_access_consolidated"]}</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">{T["report.stat.total_accesses"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">{T["report.stat.users_audited"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">{T["report.stat.sites_scanned"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">{T["report.stat.high_privilege"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">{T["report.stat.external_users"]}</div></div>");
sb.AppendLine("</div>");
// Per-user summary cards (from original flat entries)
sb.AppendLine("<div class=\"user-summary\">");
var userGroups = entries.GroupBy(e => e.UserLogin).OrderBy(g => g.Key).ToList();
foreach (var ug in userGroups)
{
var uName = HtmlEncode(ug.First().UserDisplayName);
var uLogin = HtmlEncode(ug.Key);
var uTotal = ug.Count();
var uSites = ug.Select(e => e.SiteUrl).Distinct().Count();
var uHighPriv = ug.Count(e => e.IsHighPrivilege);
var uIsExt = ug.First().IsExternalUser;
var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card";
sb.AppendLine($" <div class=\"{cardClass}\">");
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uLogin}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uTotal} {T["report.text.accesses"]} &bull; {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} {T["report.text.high_priv"]}</span>" : "")}</div>");
sb.AppendLine(" </div>");
}
sb.AppendLine("</div>");
// View toggle — only By User (By Site is suppressed for consolidated view)
sb.AppendLine("<div class=\"view-toggle\">");
sb.AppendLine($" <button id=\"btn-user\" class=\"active\">{T["report.view.by_user"]}</button>");
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_results"]}\" oninput=\"filterTable()\" />");
sb.AppendLine("</div>");
// ── CONSOLIDATED BY-USER TABLE ────────────────────────────────────────
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
sb.AppendLine("<table id=\"tbl-user\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine($" <th>{T["report.col.user"]}</th>");
sb.AppendLine($" <th>{T["report.col.permission_level"]}</th>");
sb.AppendLine($" <th>{T["report.col.access_type"]}</th>");
sb.AppendLine($" <th>{T["report.col.granted_through"]}</th>");
sb.AppendLine($" <th>{T["report.col.sites"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody id=\"tbody-user\">");
// Group consolidated entries by UserLogin for group headers
var consolidatedByUser = consolidated
.GroupBy(c => c.UserLogin)
.OrderBy(g => g.Key)
.ToList();
int grpIdx = 0;
int locIdx = 0; // SEPARATE counter for location group IDs — Pitfall 2
foreach (var cug in consolidatedByUser)
{
var groupId = $"ugrp{grpIdx++}";
var cuName = HtmlEncode(cug.First().UserDisplayName);
var cuIsExt = cug.First().IsExternalUser;
var cuCount = cug.Count();
var guestBadge = cuIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
sb.AppendLine($" <td colspan=\"5\">{cuName}{guestBadge} &mdash; {cuCount} {T["report.text.permissions_parens"]}</td>");
sb.AppendLine("</tr>");
foreach (var entry in cug)
{
var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : "";
var accessBadge = AccessTypeBadge(entry.AccessType);
var highIcon = entry.IsHighPrivilege ? " &#9888;" : "";
sb.AppendLine($"<tr data-group=\"{groupId}\">");
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>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
if (entry.LocationCount == 1)
{
// Single location — inline site title + object title
var loc0 = entry.Locations[0];
var locLabel = IsRedundantObjectTitle(loc0.SiteTitle, loc0.ObjectTitle)
? HtmlEncode(loc0.SiteTitle)
: $"{HtmlEncode(loc0.SiteTitle)} &rsaquo; {HtmlEncode(loc0.ObjectTitle)}";
sb.AppendLine($" <td>{locLabel}</td>");
sb.AppendLine("</tr>");
}
else
{
// Multiple locations — expandable badge
var currentLocId = $"loc{locIdx++}";
sb.AppendLine($" <td><span class=\"badge\" onclick=\"toggleGroup('{currentLocId}')\" style=\"cursor:pointer\">{entry.LocationCount} {TranslationSource.Instance["report.text.sites_unit"]}</span></td>");
sb.AppendLine("</tr>");
// Hidden sub-rows — one per location
foreach (var loc in entry.Locations)
{
var subLabel = IsRedundantObjectTitle(loc.SiteTitle, loc.ObjectTitle)
? $"<a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a>"
: $"<a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a> &rsaquo; {HtmlEncode(loc.ObjectTitle)}";
sb.AppendLine($"<tr data-group=\"{currentLocId}\" style=\"display:none\">");
sb.AppendLine($" <td colspan=\"5\" style=\"padding-left:2em\">");
sb.AppendLine($" {subLabel}");
sb.AppendLine(" </td>");
sb.AppendLine("</tr>");
}
}
}
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
// ── INLINE JS ─────────────────────────────────────────────────────────
sb.AppendLine("<script>");
sb.AppendLine(@"
var _sortState = {};
function filterTable() {
var input = document.getElementById('filter').value.toLowerCase();
var tbody = document.getElementById('tbody-user');
var rows = tbody.querySelectorAll('tr[data-group]');
var groupsWithVisible = {};
rows.forEach(function(row) {
// Skip hidden sub-rows that belong to location groups (loc...)
if (row.getAttribute('data-group').indexOf('loc') === 0) return;
var matches = row.textContent.toLowerCase().indexOf(input) > -1;
row.style.display = matches ? '' : 'none';
if (matches) {
groupsWithVisible[row.getAttribute('data-group')] = true;
}
});
// Show/hide group headers based on whether they have visible children
var headers = tbody.querySelectorAll('tr.group-header');
headers.forEach(function(hdr) {
var next = hdr.nextElementSibling;
var groupId = null;
while (next && next.getAttribute('data-group')) {
groupId = next.getAttribute('data-group');
break;
}
if (groupId) {
hdr.style.display = groupsWithVisible[groupId] ? '' : 'none';
}
});
}
function toggleGroup(id) {
var rows = document.querySelectorAll('tr[data-group=""' + id + '""]');
var isHidden = rows.length > 0 && rows[0].style.display === 'none';
rows.forEach(function(r) { r.style.display = isHidden ? '' : 'none'; });
}
");
sb.AppendLine("</script>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
/// <summary>Returns a colored badge span for the given access type.</summary>
private static string AccessTypeBadge(AccessType accessType)
{
var T = TranslationSource.Instance;
return accessType switch
{
AccessType.Direct => $"<span class=\"badge access-direct\">{T["report.badge.direct"]}</span>",
AccessType.Group => $"<span class=\"badge access-group\">{T["report.badge.group"]}</span>",
AccessType.Inherited => $"<span class=\"badge access-inherited\">{T["report.badge.inherited"]}</span>",
_ => $"<span class=\"badge\">{HtmlEncode(accessType.ToString())}</span>"
};
}
/// <summary>
/// Returns true when the ObjectTitle adds no information beyond the SiteTitle:
/// empty, identical (case-insensitive), or one is a whitespace-trimmed duplicate
/// of the other. Used to collapse "All Company &rsaquo; All Company" to "All Company".
/// </summary>
private static bool IsRedundantObjectTitle(string siteTitle, string objectTitle)
{
if (string.IsNullOrWhiteSpace(objectTitle)) return true;
return string.Equals(
(siteTitle ?? string.Empty).Trim(),
objectTitle.Trim(),
StringComparison.OrdinalIgnoreCase);
}
/// <summary>Minimal HTML encoding for text content and attribute values.</summary>
private static string HtmlEncode(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return value
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
}
@@ -0,0 +1,180 @@
using System.Text;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Localization;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Exports VersionCleanupResult list to a self-contained sortable/filterable HTML report.
/// Summary header shows totals (files trimmed, versions deleted, bytes freed); a single
/// table lists every processed file with sort/filter controls. No external assets.
/// </summary>
public class VersionCleanupHtmlExportService
{
public string BuildHtml(IReadOnlyList<VersionCleanupResult> results, ReportBranding? branding = null)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
long totalBytes = results.Sum(r => r.BytesFreed);
int totalDeleted = results.Sum(r => r.VersionsDeleted);
int totalFiles = results.Count(r => r.VersionsDeleted > 0);
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.versions"]}</title>");
sb.AppendLine("""
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
.summary { display: flex; gap: 24px; margin: 12px 0 16px 0; padding: 12px 16px;
background: #e8f1fb; border-radius: 6px; }
.summary .item { display: flex; flex-direction: column; }
.summary .label { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .5px; }
.summary .value { font-size: 18px; font-weight: 600; color: #0078d4; }
.toolbar { margin-bottom: 10px; display: flex; gap: 12px; align-items: center; }
.toolbar label { font-weight: 600; }
#filterInput { padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; width: 280px; font-size: 13px; }
#resultCount { font-size: 12px; color: #666; }
table { border-collapse: collapse; width: 100%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
th { background: #0078d4; color: #fff; padding: 8px 12px; text-align: left; cursor: pointer;
font-weight: 600; user-select: none; white-space: nowrap; }
th:hover { background: #106ebe; }
th.sorted-asc::after { content: ' '; font-size: 10px; }
th.sorted-desc::after { content: ' '; font-size: 10px; }
td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; word-break: break-all; }
tr:hover td { background: #f0f7ff; }
tr.hidden { display: none; }
tr.err td { background: #fff4f4; }
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.err-cell { color: #b00020; }
.generated { font-size: 11px; color: #888; margin-top: 12px; }
</style>
</head>
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.versions_short"]}</h1>");
sb.AppendLine($"""
<div class="summary">
<div class="item"><span class="label">{T["versions.summary.files"]}</span><span class="value">{totalFiles:N0}</span></div>
<div class="item"><span class="label">{T["versions.summary.deleted"]}</span><span class="value">{totalDeleted:N0}</span></div>
<div class="item"><span class="label">{T["versions.summary.freed"]}</span><span class="value">{FormatSize(totalBytes)}</span></div>
</div>
<div class="toolbar">
<label for="filterInput">{T["report.filter.label"]}</label>
<input id="filterInput" type="text" placeholder="{T["report.filter.placeholder_rows"]}" oninput="filterTable()" />
<span id="resultCount"></span>
</div>
""");
sb.AppendLine($"""
<table id="resultsTable">
<thead>
<tr>
<th onclick="sortTable(0)">{T["report.col.site"]}</th>
<th onclick="sortTable(1)">{T["versions.col.library"]}</th>
<th onclick="sortTable(2)">{T["versions.col.file"]}</th>
<th onclick="sortTable(3)">{T["versions.col.path"]}</th>
<th class="num" onclick="sortTable(4)">{T["versions.col.before"]}</th>
<th class="num" onclick="sortTable(5)">{T["versions.col.deleted"]}</th>
<th class="num" onclick="sortTable(6)">{T["versions.col.remaining"]}</th>
<th class="num" onclick="sortTable(7)">{T["versions.col.freed"]}</th>
<th onclick="sortTable(8)">{T["versions.col.error"]}</th>
</tr>
</thead>
<tbody>
""");
foreach (var r in results)
{
string rowClass = string.IsNullOrEmpty(r.Error) ? string.Empty : " class=\"err\"";
string errCell = string.IsNullOrEmpty(r.Error)
? string.Empty
: $"<span class=\"err-cell\">{H(r.Error)}</span>";
sb.AppendLine($"""
<tr{rowClass}>
<td>{H(r.SiteUrl)}</td>
<td>{H(r.Library)}</td>
<td>{H(r.FileName)}</td>
<td>{H(r.FileServerRelativeUrl)}</td>
<td class="num" data-sort="{r.VersionsBefore}">{r.VersionsBefore:N0}</td>
<td class="num" data-sort="{r.VersionsDeleted}">{r.VersionsDeleted:N0}</td>
<td class="num" data-sort="{r.VersionsRemaining}">{r.VersionsRemaining:N0}</td>
<td class="num" data-sort="{r.BytesFreed}">{FormatSize(r.BytesFreed)}</td>
<td>{errCell}</td>
</tr>
""");
}
sb.AppendLine(" </tbody>\n</table>");
int count = results.Count;
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} {T["report.text.results_parens"]}</p>");
sb.AppendLine($$"""
<script>
var sortDir = {};
function sortTable(col) {
var tbl = document.getElementById('resultsTable');
var tbody = tbl.tBodies[0];
var rows = Array.from(tbody.rows);
var asc = sortDir[col] !== 'asc';
sortDir[col] = asc ? 'asc' : 'desc';
rows.sort(function(a, b) {
var av = a.cells[col].dataset.sort || a.cells[col].innerText;
var bv = b.cells[col].dataset.sort || b.cells[col].innerText;
var an = parseFloat(av), bn = parseFloat(bv);
if (!isNaN(an) && !isNaN(bn)) return asc ? an - bn : bn - an;
return asc ? av.localeCompare(bv) : bv.localeCompare(av);
});
rows.forEach(function(r) { tbody.appendChild(r); });
var ths = tbl.tHead.rows[0].cells;
for (var i = 0; i < ths.length; i++) {
ths[i].className = (i === col) ? (asc ? 'sorted-asc' : 'sorted-desc') : '';
}
}
function filterTable() {
var q = document.getElementById('filterInput').value.toLowerCase();
var rows = document.getElementById('resultsTable').tBodies[0].rows;
var visible = 0;
for (var i = 0; i < rows.length; i++) {
var match = rows[i].innerText.toLowerCase().indexOf(q) >= 0;
rows[i].className = (rows[i].className.indexOf('err') >= 0 ? 'err ' : '') + (match ? '' : 'hidden');
if (match) visible++;
}
document.getElementById('resultCount').innerText = q ? (visible + ' {{T["report.text.of"]}} {{count:N0}} {{T["report.text.shown"]}}') : '';
}
window.onload = function() {
document.getElementById('resultCount').innerText = '{{count:N0}} {{T["report.text.results_parens"]}}';
};
</script>
</body></html>
""");
return sb.ToString();
}
/// <summary>Writes the HTML report to <paramref name="filePath"/> as UTF-8.</summary>
public async Task WriteAsync(IReadOnlyList<VersionCleanupResult> results, string filePath, CancellationToken ct, ReportBranding? branding = null)
{
var html = BuildHtml(results, branding);
await System.IO.File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
}
private static string H(string value) =>
System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
private static string FormatSize(long bytes)
{
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
return $"{bytes} B";
}
}
+28
View File
@@ -0,0 +1,28 @@
using System.Text;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Triggers browser file downloads from Blazor Server components.
/// Converts string export outputs to bytes and invokes JS download.
/// </summary>
public class WebExportService
{
private readonly IJSRuntime _js;
public WebExportService(IJSRuntime js) { _js = js; }
public async Task DownloadCsvAsync(string content, string fileName)
{
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true).GetBytes(content);
await _js.InvokeVoidAsync("sptb.downloadFile", fileName, "text/csv;charset=utf-8", Convert.ToBase64String(bytes));
}
public async Task DownloadHtmlAsync(string content, string fileName)
{
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content);
await _js.InvokeVoidAsync("sptb.downloadFile", fileName, "text/html;charset=utf-8", Convert.ToBase64String(bytes));
}
}
+221
View File
@@ -0,0 +1,221 @@
using System.IO;
using Microsoft.SharePoint.Client;
using Serilog;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Services.Audit;
namespace SharepointToolbox.Web.Services;
public class FileTransferService : IFileTransferService
{
private const int ListViewThresholdItemCount = 5000;
private readonly IAuditService _audit;
public FileTransferService(IAuditService audit) { _audit = audit; }
public async Task<BulkOperationSummary<string>> TransferAsync(
ClientContext sourceCtx, ClientContext destCtx,
TransferJob job, IProgress<OperationProgress> progress, CancellationToken ct)
{
var srcItemCount = await TryGetListItemCountAsync(sourceCtx, job.SourceLibrary, progress, ct);
var dstItemCount = await TryGetListItemCountAsync(destCtx, job.DestinationLibrary, progress, ct);
Log.Information("Transfer pre-flight: source={SrcLib} ({SrcCount} items), dest={DstLib} ({DstCount} items)", job.SourceLibrary, srcItemCount, job.DestinationLibrary, dstItemCount);
if (srcItemCount > ListViewThresholdItemCount || dstItemCount > ListViewThresholdItemCount)
progress.Report(OperationProgress.Indeterminate($"Large library detected (source: {srcItemCount}, dest: {dstItemCount}). Using paged enumeration."));
IReadOnlyList<string> files = job.CopyFolderContents
? await EnumerateFilesAsync(sourceCtx, job, srcItemCount, progress, ct)
: Array.Empty<string>();
if (files.Count == 0 && !job.IncludeSourceFolder)
{
progress.Report(new OperationProgress(0, 0, "No files found to transfer."));
return new BulkOperationSummary<string>(new List<BulkItemResult<string>>());
}
var srcBasePath = await ResolveLibraryPathAsync(sourceCtx, job.SourceLibrary, job.SourceFolderPath, progress, ct);
var dstBasePath = await ResolveLibraryPathAsync(destCtx, job.DestinationLibrary, job.DestinationFolderPath, progress, ct);
var ensuredFolders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (job.IncludeSourceFolder)
{
var srcFolderName = !string.IsNullOrEmpty(job.SourceFolderPath) ? Path.GetFileName(job.SourceFolderPath.TrimEnd('/')) : job.SourceLibrary;
if (!string.IsNullOrEmpty(srcFolderName)) { dstBasePath = $"{dstBasePath}/{srcFolderName}"; await EnsureFolderCachedAsync(destCtx, dstBasePath, ensuredFolders, progress, ct); }
}
var result = await BulkOperationRunner.RunAsync(files,
async (fileRelUrl, idx, token) =>
{
var relativePart = fileRelUrl;
if (fileRelUrl.StartsWith(srcBasePath, StringComparison.OrdinalIgnoreCase))
relativePart = fileRelUrl[srcBasePath.Length..].TrimStart('/');
var destFolderRelative = dstBasePath;
var fileFolder = Path.GetDirectoryName(relativePart)?.Replace('\\', '/');
if (!string.IsNullOrEmpty(fileFolder)) { destFolderRelative = $"{dstBasePath}/{fileFolder}"; await EnsureFolderCachedAsync(destCtx, destFolderRelative, ensuredFolders, progress, token); }
var destFileUrl = $"{destFolderRelative}/{Path.GetFileName(relativePart)}";
await TransferSingleFileAsync(sourceCtx, destCtx, fileRelUrl, destFileUrl, job, progress, token);
Log.Information("Transferred: {Source} -> {Dest}", fileRelUrl, destFileUrl);
},
progress, ct);
await _audit.LogAsync("FileTransfer",
sourceCtx.Url,
new[] { sourceCtx.Url, destCtx.Url },
$"{result.SuccessCount} files transferred ({job.Mode}), {(result.TotalCount - result.SuccessCount)} failed");
return result;
}
private async Task TransferSingleFileAsync(ClientContext sourceCtx, ClientContext destCtx, string srcFileUrl, string dstFileUrl, TransferJob job, IProgress<OperationProgress> progress, CancellationToken ct)
{
try { await ServerSideTransferAsync(sourceCtx, destCtx, srcFileUrl, dstFileUrl, job, progress, ct); }
catch (ServerException ex) when (IsListViewThresholdException(ex)) { Log.Warning("Server-side transfer hit LVT — falling back to stream copy for {File}.", srcFileUrl); await StreamTransferAsync(sourceCtx, destCtx, srcFileUrl, dstFileUrl, job, progress, ct); }
}
private async Task ServerSideTransferAsync(ClientContext sourceCtx, ClientContext destCtx, string srcFileUrl, string dstFileUrl, TransferJob job, IProgress<OperationProgress> progress, CancellationToken ct)
{
var srcAbs = ToAbsoluteUrl(sourceCtx, srcFileUrl);
var dstAbs = ToAbsoluteUrl(destCtx, dstFileUrl);
var srcPath = ResourcePath.FromDecodedUrl(srcAbs);
var dstPath = ResourcePath.FromDecodedUrl(dstAbs);
bool overwrite = job.ConflictPolicy == ConflictPolicy.Overwrite;
var options = new MoveCopyOptions { KeepBoth = job.ConflictPolicy == ConflictPolicy.Rename, ResetAuthorAndCreatedOnCopy = false };
try
{
if (job.Mode == TransferMode.Copy) { MoveCopyUtil.CopyFileByPath(sourceCtx, srcPath, dstPath, overwrite, options); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(sourceCtx, progress, ct); }
else { MoveCopyUtil.MoveFileByPath(sourceCtx, srcPath, dstPath, overwrite, options); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(sourceCtx, progress, ct); }
}
catch (ServerException ex) when (job.ConflictPolicy == ConflictPolicy.Skip && ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase)) { Log.Warning("Skipped (already exists): {File}", srcFileUrl); }
}
private async Task StreamTransferAsync(ClientContext sourceCtx, ClientContext destCtx, string srcFileUrl, string dstFileUrl, TransferJob job, IProgress<OperationProgress> progress, CancellationToken ct)
{
var effectiveDestUrl = await ResolveDestinationOnConflictAsync(destCtx, dstFileUrl, job, progress, ct);
if (effectiveDestUrl == null) { Log.Warning("Skipped (already exists, stream fallback): {File}", srcFileUrl); return; }
bool overwrite = job.ConflictPolicy == ConflictPolicy.Overwrite;
ct.ThrowIfCancellationRequested();
var srcFile = sourceCtx.Web.GetFileByServerRelativeUrl(srcFileUrl);
var streamResult = srcFile.OpenBinaryStream();
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(sourceCtx, progress, ct);
using var buffer = new MemoryStream();
await streamResult.Value.CopyToAsync(buffer, 81920, ct);
buffer.Position = 0;
var slash = effectiveDestUrl.LastIndexOf('/');
var destFolder = destCtx.Web.GetFolderByServerRelativeUrl(effectiveDestUrl[..slash]);
destFolder.Files.Add(new FileCreationInformation { Url = effectiveDestUrl[(slash + 1)..], Overwrite = overwrite, ContentStream = buffer });
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(destCtx, progress, ct);
if (job.Mode == TransferMode.Move) { sourceCtx.Web.GetFileByServerRelativeUrl(srcFileUrl).DeleteObject(); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(sourceCtx, progress, ct); }
}
private static async Task<string?> ResolveDestinationOnConflictAsync(ClientContext destCtx, string dstFileUrl, TransferJob job, IProgress<OperationProgress> progress, CancellationToken ct)
{
if (job.ConflictPolicy == ConflictPolicy.Overwrite) return dstFileUrl;
bool exists = await FileExistsAsync(destCtx, dstFileUrl, progress, ct);
if (!exists) return dstFileUrl;
if (job.ConflictPolicy == ConflictPolicy.Skip) return null;
var dir = dstFileUrl[..dstFileUrl.LastIndexOf('/')];
var leaf = dstFileUrl[(dstFileUrl.LastIndexOf('/') + 1)..];
var stem = Path.GetFileNameWithoutExtension(leaf);
var ext = Path.GetExtension(leaf);
for (int n = 1; n <= 999; n++) { var candidate = $"{dir}/{stem} ({n}){ext}"; if (!await FileExistsAsync(destCtx, candidate, progress, ct)) return candidate; }
throw new InvalidOperationException($"Could not find unused filename for {dstFileUrl} after 999 attempts.");
}
private static async Task<bool> FileExistsAsync(ClientContext ctx, string fileServerRelativeUrl, IProgress<OperationProgress> progress, CancellationToken ct)
{
try { var file = ctx.Web.GetFileByServerRelativeUrl(fileServerRelativeUrl); ctx.Load(file, f => f.Exists); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); return file.Exists; }
catch { return false; }
}
internal static bool IsListViewThresholdException(Exception ex)
{
var msg = ex.Message ?? string.Empty;
return msg.Contains("list view threshold", StringComparison.OrdinalIgnoreCase) || msg.Contains("seuil d'affichage", StringComparison.OrdinalIgnoreCase) || msg.Contains("Listenansichtsschwellenwert", StringComparison.OrdinalIgnoreCase);
}
private async Task<IReadOnlyList<string>> EnumerateFilesAsync(ClientContext ctx, TransferJob job, int sourceItemCount, IProgress<OperationProgress> progress, CancellationToken ct)
{
var list = ctx.Web.Lists.GetByTitle(job.SourceLibrary);
var rootFolder = list.RootFolder;
ctx.Load(rootFolder, f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var libraryRoot = rootFolder.ServerRelativeUrl.TrimEnd('/');
if (job.SelectedFilePaths.Count > 0)
return job.SelectedFilePaths.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => $"{libraryRoot}/{p.TrimStart('/')}").ToList();
var baseFolderUrl = libraryRoot;
if (!string.IsNullOrEmpty(job.SourceFolderPath)) baseFolderUrl = $"{baseFolderUrl}/{job.SourceFolderPath.TrimStart('/')}";
var files = new List<string>();
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(ctx, list, baseFolderUrl, recursive: true, viewFields: new[] { "FSObjType", "FileRef", "FileDirRef" }, ct: ct))
{
ct.ThrowIfCancellationRequested();
if (item["FSObjType"]?.ToString() != "0") continue;
var fileRef = item["FileRef"]?.ToString();
if (string.IsNullOrEmpty(fileRef)) continue;
var dir = item["FileDirRef"]?.ToString() ?? string.Empty;
if (HasSystemFolderSegment(dir, baseFolderUrl)) continue;
files.Add(fileRef);
}
return files;
}
private static bool HasSystemFolderSegment(string fileDirRef, string baseFolderUrl)
{
if (string.IsNullOrEmpty(fileDirRef)) return false;
var baseTrim = baseFolderUrl.TrimEnd('/');
if (!fileDirRef.StartsWith(baseTrim, StringComparison.OrdinalIgnoreCase)) return false;
var tail = fileDirRef[baseTrim.Length..].Trim('/');
if (string.IsNullOrEmpty(tail)) return false;
foreach (var seg in tail.Split('/', StringSplitOptions.RemoveEmptyEntries))
if (seg.StartsWith("_") || seg.Equals("Forms", StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
private async Task<int> TryGetListItemCountAsync(ClientContext ctx, string libraryTitle, IProgress<OperationProgress> progress, CancellationToken ct)
{
try { var list = ctx.Web.Lists.GetByTitle(libraryTitle); ctx.Load(list, l => l.ItemCount); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); return list.ItemCount; }
catch (Exception ex) { Log.Warning("Failed to read ItemCount for {Library}: {Error}", libraryTitle, ex.Message); return -1; }
}
private async Task EnsureFolderCachedAsync(ClientContext ctx, string folderServerRelativeUrl, HashSet<string> cache, IProgress<OperationProgress> progress, CancellationToken ct)
{
var normalized = folderServerRelativeUrl.TrimEnd('/');
if (!cache.Add(normalized)) return;
await EnsureFolderAsync(ctx, normalized, progress, ct);
}
private async Task EnsureFolderAsync(ClientContext ctx, string folderServerRelativeUrl, IProgress<OperationProgress> progress, CancellationToken ct)
{
folderServerRelativeUrl = folderServerRelativeUrl.TrimEnd('/');
try { var existing = ctx.Web.GetFolderByServerRelativeUrl(folderServerRelativeUrl); ctx.Load(existing, f => f.Exists); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); if (existing.Exists) return; }
catch { }
int slash = folderServerRelativeUrl.LastIndexOf('/');
if (slash <= 0) return;
var parentUrl = folderServerRelativeUrl[..slash];
var leafName = folderServerRelativeUrl[(slash + 1)..];
if (string.IsNullOrEmpty(leafName)) return;
await EnsureFolderAsync(ctx, parentUrl, progress, ct);
ctx.Web.GetFolderByServerRelativeUrl(parentUrl).Folders.Add(leafName);
try { await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); }
catch (Exception ex) { Log.Warning("EnsureFolder failed at {Parent}/{Leaf}: {Error}", parentUrl, leafName, ex.Message); throw; }
}
private static string ToAbsoluteUrl(ClientContext ctx, string pathOrUrl)
{
if (pathOrUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || pathOrUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) return pathOrUrl;
var uri = new Uri(ctx.Url);
return $"{uri.Scheme}://{uri.Host}{(pathOrUrl.StartsWith("/") ? "" : "/")}{pathOrUrl}";
}
private static async Task<string> ResolveLibraryPathAsync(ClientContext ctx, string libraryTitle, string relativeFolderPath, IProgress<OperationProgress> progress, CancellationToken ct)
{
var list = ctx.Web.Lists.GetByTitle(libraryTitle);
ctx.Load(list, l => l.RootFolder.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var basePath = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
if (!string.IsNullOrEmpty(relativeFolderPath)) basePath = $"{basePath}/{relativeFolderPath.TrimStart('/')}";
return basePath;
}
}
+56
View File
@@ -0,0 +1,56 @@
using Microsoft.SharePoint.Client;
using Serilog;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Services.Audit;
namespace SharepointToolbox.Web.Services;
public class FolderStructureService : IFolderStructureService
{
private readonly IAuditService _audit;
public FolderStructureService(IAuditService audit) { _audit = audit; }
public async Task<BulkOperationSummary<string>> CreateFoldersAsync(
ClientContext ctx, string libraryTitle,
IReadOnlyList<FolderStructureRow> rows,
IProgress<OperationProgress> progress, CancellationToken ct)
{
var list = ctx.Web.Lists.GetByTitle(libraryTitle);
ctx.Load(list, l => l.RootFolder.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var baseUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
var folderPaths = BuildUniquePaths(rows);
var result = await BulkOperationRunner.RunAsync(
folderPaths,
async (path, idx, token) =>
{
ctx.Web.Folders.Add($"{baseUrl}/{path}");
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, token);
Log.Information("Created folder: {Path}", $"{baseUrl}/{path}");
},
progress, ct);
await _audit.LogAsync("CreateFolderStructure", ctx.Url, new[] { ctx.Url },
$"{result.SuccessCount} folders created in '{libraryTitle}', {(result.TotalCount - result.SuccessCount)} failed");
return result;
}
internal static IReadOnlyList<string> BuildUniquePaths(IReadOnlyList<FolderStructureRow> rows)
{
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
var parts = new[] { row.Level1, row.Level2, row.Level3, row.Level4 }
.Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
var current = string.Empty;
foreach (var part in parts)
{
current = string.IsNullOrEmpty(current) ? part.Trim() : $"{current}/{part.Trim()}";
paths.Add(current);
}
}
return paths.OrderBy(p => p.Count(c => c == '/')).ThenBy(p => p, StringComparer.OrdinalIgnoreCase).ToList();
}
}
+53
View File
@@ -0,0 +1,53 @@
using Microsoft.Graph;
using Microsoft.Graph.Models;
using SharepointToolbox.Web.Core.Models;
using AppGraphClientFactory = SharepointToolbox.Web.Infrastructure.Auth.GraphClientFactory;
namespace SharepointToolbox.Web.Services;
public class GraphUserDirectoryService : IGraphUserDirectoryService
{
private readonly AppGraphClientFactory _graphClientFactory;
public GraphUserDirectoryService(AppGraphClientFactory graphClientFactory)
{
_graphClientFactory = graphClientFactory;
}
public async Task<IReadOnlyList<GraphDirectoryUser>> GetUsersAsync(
TenantProfile profile,
bool includeGuests = false,
IProgress<int>? progress = null,
CancellationToken ct = default)
{
var graphClient = await _graphClientFactory.CreateClientAsync(profile);
var response = await graphClient.Users.GetAsync(config =>
{
config.QueryParameters.Filter = includeGuests
? "accountEnabled eq true"
: "accountEnabled eq true and userType eq 'Member'";
config.QueryParameters.Select = new[]
{ "displayName", "userPrincipalName", "mail", "department", "jobTitle", "userType" };
config.QueryParameters.Top = 999;
}, ct);
if (response is null) return Array.Empty<GraphDirectoryUser>();
var results = new List<GraphDirectoryUser>();
var iter = PageIterator<User, UserCollectionResponse>.CreatePageIterator(
graphClient, response,
user =>
{
if (ct.IsCancellationRequested) return false;
results.Add(new GraphDirectoryUser(
user.DisplayName ?? user.UserPrincipalName ?? string.Empty,
user.UserPrincipalName ?? string.Empty,
user.Mail, user.Department, user.JobTitle, user.UserType));
progress?.Report(results.Count);
return true;
});
await iter.IterateAsync(ct);
return results;
}
}
+14
View File
@@ -0,0 +1,14 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public interface IBulkMemberService
{
Task<BulkOperationSummary<BulkMemberRow>> AddMembersAsync(
ClientContext ctx,
TenantProfile profile,
IReadOnlyList<BulkMemberRow> rows,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public interface IBulkSiteService
{
Task<BulkOperationSummary<BulkSiteRow>> CreateSitesAsync(
ClientContext adminCtx, IReadOnlyList<BulkSiteRow> rows,
IProgress<OperationProgress> progress, CancellationToken ct);
}
+11
View File
@@ -0,0 +1,11 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public interface ICsvValidationService
{
List<CsvValidationRow<T>> ParseAndValidate<T>(Stream csvStream) where T : class;
List<CsvValidationRow<BulkMemberRow>> ParseAndValidateMembers(Stream csvStream);
List<CsvValidationRow<BulkSiteRow>> ParseAndValidateSites(Stream csvStream);
List<CsvValidationRow<FolderStructureRow>> ParseAndValidateFolders(Stream csvStream);
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public interface IDuplicatesService
{
Task<IReadOnlyList<DuplicateGroup>> ScanDuplicatesAsync(
ClientContext ctx, DuplicateScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct);
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public interface IFileTransferService
{
Task<BulkOperationSummary<string>> TransferAsync(
ClientContext sourceCtx, ClientContext destCtx,
TransferJob job, IProgress<OperationProgress> progress, CancellationToken ct);
}
+12
View File
@@ -0,0 +1,12 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public interface IFolderStructureService
{
Task<BulkOperationSummary<string>> CreateFoldersAsync(
ClientContext ctx, string libraryTitle,
IReadOnlyList<FolderStructureRow> rows,
IProgress<OperationProgress> progress, CancellationToken ct);
}
+12
View File
@@ -0,0 +1,12 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public interface IGraphUserDirectoryService
{
Task<IReadOnlyList<GraphDirectoryUser>> GetUsersAsync(
TenantProfile profile,
bool includeGuests = false,
IProgress<int>? progress = null,
CancellationToken ct = default);
}
+8
View File
@@ -0,0 +1,8 @@
using Microsoft.SharePoint.Client;
namespace SharepointToolbox.Web.Services;
public interface IOwnershipElevationService
{
Task ElevateAsync(ClientContext tenantAdminCtx, string siteUrl, string loginName, CancellationToken ct);
}
+13
View File
@@ -0,0 +1,13 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public interface IPermissionsService
{
Task<IReadOnlyList<PermissionEntry>> ScanSiteAsync(
ClientContext ctx,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public interface ISearchService
{
Task<IReadOnlyList<SearchResult>> SearchFilesAsync(
ClientContext ctx, SearchOptions options,
IProgress<OperationProgress> progress, CancellationToken ct);
}
+14
View File
@@ -0,0 +1,14 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public interface ISessionManager
{
Task<ClientContext> GetOrCreateContextAsync(TenantProfile profile, CancellationToken ct = default);
Task<ClientContext> GetOrCreateContextAsync(string siteUrl, TenantProfile profile, CancellationToken ct = default);
Task<(string Token, DateTimeOffset ExpiresAt)> GetAccessTokenWithExpiryAsync(string scope, CancellationToken ct = default);
Task ClearSessionAsync(string tenantUrl);
Task ClearAllAsync();
bool IsAuthenticated(string tenantUrl);
}
+13
View File
@@ -0,0 +1,13 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public interface ISharePointGroupResolver
{
Task<IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>> ResolveGroupsAsync(
ClientContext ctx,
TenantProfile profile,
IReadOnlyList<string> groupNames,
CancellationToken ct);
}
+21
View File
@@ -0,0 +1,21 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public interface IStorageService
{
Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx, StorageScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct);
Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
ClientContext ctx, IProgress<OperationProgress> progress, CancellationToken ct);
Task BackfillZeroNodesAsync(
ClientContext ctx, IReadOnlyList<StorageNode> nodes,
IProgress<OperationProgress> progress, CancellationToken ct);
Task<long> GetSiteUsageStorageBytesAsync(
ClientContext ctx, IProgress<OperationProgress> progress, CancellationToken ct);
}
+13
View File
@@ -0,0 +1,13 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public interface ISystemGroupTargetResolver
{
Task<SystemGroupTarget?> ResolveAsync(
ClientContext ctx,
SystemGroupClassification classification,
CancellationToken ct);
}
+17
View File
@@ -0,0 +1,17 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
using ModelSiteTemplate = SharepointToolbox.Web.Core.Models.SiteTemplate;
namespace SharepointToolbox.Web.Services;
public interface ITemplateService
{
Task<ModelSiteTemplate> CaptureTemplateAsync(
ClientContext ctx, SiteTemplateOptions options,
IProgress<OperationProgress> progress, CancellationToken ct);
Task<string> ApplyTemplateAsync(
ClientContext adminCtx, ModelSiteTemplate template,
string newSiteTitle, string newSiteAlias,
IProgress<OperationProgress> progress, CancellationToken ct);
}
+16
View File
@@ -0,0 +1,16 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public interface IUserAccessAuditService
{
Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
ISessionManager sessionManager,
TenantProfile currentProfile,
IReadOnlyList<string> targetUserLogins,
IReadOnlyList<SiteInfo> sites,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct,
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null);
}
+14
View File
@@ -0,0 +1,14 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public interface IVersionCleanupService
{
Task<IReadOnlyList<VersionCleanupResult>> DeleteOldVersionsAsync(
ClientContext ctx, VersionCleanupOptions options,
IProgress<OperationProgress> progress, CancellationToken ct);
Task<IReadOnlyList<string>> ListLibraryTitlesAsync(
ClientContext ctx, CancellationToken ct);
}
+10
View File
@@ -0,0 +1,10 @@
namespace SharepointToolbox.Web.Services.OAuth;
public class AppRegistrationResult
{
public string ClientId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string TenantUrl { get; set; } = string.Empty;
public string TenantName { get; set; } = string.Empty;
}
+13
View File
@@ -0,0 +1,13 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.OAuth;
public interface IOAuthFlowCache
{
void StoreFlowState(string state, OAuthFlowState flowState);
OAuthFlowState? GetAndRemoveFlowState(string state);
void StoreTokens(string tokenKey, SessionTokens tokens);
SessionTokens? GetAndRemoveTokens(string tokenKey);
void StoreRegistrationResult(string key, AppRegistrationResult result);
AppRegistrationResult? GetAndRemoveRegistrationResult(string key);
}
+44
View File
@@ -0,0 +1,44 @@
using Microsoft.Extensions.Caching.Memory;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.OAuth;
public class OAuthFlowCache : IOAuthFlowCache
{
private readonly IMemoryCache _cache;
public OAuthFlowCache(IMemoryCache cache) { _cache = cache; }
public void StoreFlowState(string state, OAuthFlowState flowState) =>
_cache.Set($"oauth_state_{state}", flowState, TimeSpan.FromMinutes(10));
public OAuthFlowState? GetAndRemoveFlowState(string state)
{
var key = $"oauth_state_{state}";
var value = _cache.Get<OAuthFlowState>(key);
if (value is not null) _cache.Remove(key);
return value;
}
public void StoreTokens(string tokenKey, SessionTokens tokens) =>
_cache.Set($"oauth_tokens_{tokenKey}", tokens, TimeSpan.FromMinutes(2));
public SessionTokens? GetAndRemoveTokens(string tokenKey)
{
var key = $"oauth_tokens_{tokenKey}";
var value = _cache.Get<SessionTokens>(key);
if (value is not null) _cache.Remove(key);
return value;
}
public void StoreRegistrationResult(string key, AppRegistrationResult result) =>
_cache.Set($"oauth_reg_{key}", result, TimeSpan.FromMinutes(5));
public AppRegistrationResult? GetAndRemoveRegistrationResult(string key)
{
var cacheKey = $"oauth_reg_{key}";
var value = _cache.Get<AppRegistrationResult>(cacheKey);
if (value is not null) _cache.Remove(cacheKey);
return value;
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace SharepointToolbox.Web.Services.OAuth;
public class OAuthFlowState
{
public string CodeVerifier { get; set; } = string.Empty;
public string ProfileId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string SpHost { get; set; } = string.Empty;
public string ReturnUrl { get; set; } = "/";
// Registration flow only
public bool IsRegistration { get; set; }
public string TenantName { get; set; } = string.Empty;
public string TenantUrl { get; set; } = string.Empty;
}
+27
View File
@@ -0,0 +1,27 @@
using Microsoft.Online.SharePoint.TenantAdministration;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Services.Audit;
namespace SharepointToolbox.Web.Services;
public class OwnershipElevationService : IOwnershipElevationService
{
private readonly IAuditService _audit;
public OwnershipElevationService(IAuditService audit) { _audit = audit; }
public async Task ElevateAsync(ClientContext tenantAdminCtx, string siteUrl, string loginName, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(loginName))
{
tenantAdminCtx.Load(tenantAdminCtx.Web.CurrentUser, u => u.LoginName);
await tenantAdminCtx.ExecuteQueryAsync();
loginName = tenantAdminCtx.Web.CurrentUser.LoginName;
}
var tenant = new Tenant(tenantAdminCtx);
tenant.SetSiteAdmin(siteUrl, loginName, isSiteAdmin: true);
await tenantAdminCtx.ExecuteQueryAsync();
await _audit.LogAsync("ElevateOwnership", tenantAdminCtx.Url, new[] { siteUrl },
$"Site admin granted to {loginName}");
}
}
+214
View File
@@ -0,0 +1,214 @@
using Microsoft.SharePoint.Client;
using Serilog;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SpWeb = Microsoft.SharePoint.Client.Web;
namespace SharepointToolbox.Web.Services;
public class PermissionsService : IPermissionsService
{
private readonly ISystemGroupTargetResolver? _systemGroupResolver;
public PermissionsService() : this(null) { }
public PermissionsService(ISystemGroupTargetResolver? systemGroupResolver) { _systemGroupResolver = systemGroupResolver; }
private static bool IsClaimsResolutionError(ServerException ex)
{
var msg = ex.Message ?? string.Empty;
return msg.Contains("Claims", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("Revendications", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("Org ID", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("OrgIdToClaims", StringComparison.OrdinalIgnoreCase);
}
private static readonly HashSet<string> ExcludedLists = new(StringComparer.OrdinalIgnoreCase)
{
"Access Requests","App Packages","appdata","appfiles","Apps in Testing","Cache Profiles",
"Composed Looks","Content and Structure Reports","Content type publishing error log",
"Converted Forms","Device Channels","Form Templates","fpdatasources","List Template Gallery",
"Long Running Operation Status","Maintenance Log Library","Images","site collection images",
"Master Docs","Master Page Gallery","MicroFeed","NintexFormXml","Quick Deploy Items",
"Relationships List","Reusable Content","Reporting Metadata","Reporting Templates",
"Search Config List","Site Assets","Preservation Hold Library","Site Pages",
"Solution Gallery","Style Library","Suggested Content Browser Locations","Theme Gallery",
"TaxonomyHiddenList","User Information List","Web Part Gallery","wfpub","wfsvc",
"Workflow History","Workflow Tasks","Pages"
};
public async Task<IReadOnlyList<PermissionEntry>> ScanSiteAsync(
ClientContext ctx, ScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var results = new List<PermissionEntry>();
progress.Report(OperationProgress.Indeterminate("Scanning site collection admins…"));
results.AddRange(await GetSiteCollectionAdminsAsync(ctx, progress, ct));
ctx.Load(ctx.Web,
w => w.Title, w => w.Url,
w => w.Lists.Include(l => l.Title, l => l.DefaultViewUrl, l => l.Hidden, l => l.BaseType, l => l.IsSystemList),
w => w.Webs.Include(sw => sw.Title, sw => sw.Url));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
progress.Report(OperationProgress.Indeterminate($"Scanning web: {ctx.Web.Url}…"));
results.AddRange(await GetWebPermissionsAsync(ctx, ctx.Web, options, progress, ct));
foreach (var list in ctx.Web.Lists)
{
ct.ThrowIfCancellationRequested();
if (list.Hidden || list.IsSystemList || ExcludedLists.Contains(list.Title)) continue;
progress.Report(OperationProgress.Indeterminate($"Scanning list: {list.Title}…"));
results.AddRange(await GetListPermissionsAsync(ctx, list, options, progress, ct));
if (options.ScanFolders && list.BaseType == BaseType.DocumentLibrary)
results.AddRange(await GetFolderPermissionsAsync(ctx, list, options, progress, ct));
}
if (options.IncludeSubsites)
{
foreach (var subweb in ctx.Web.Webs)
{
ct.ThrowIfCancellationRequested();
using var subCtx = ctx.Clone(subweb.Url);
subCtx.Load(subCtx.Web,
w => w.Title, w => w.Url,
w => w.Lists.Include(l => l.Title, l => l.DefaultViewUrl, l => l.Hidden, l => l.BaseType, l => l.IsSystemList),
w => w.Webs.Include(sw => sw.Title, sw => sw.Url));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(subCtx, progress, ct);
results.AddRange(await GetWebPermissionsAsync(subCtx, subCtx.Web, options, progress, ct));
foreach (var list in subCtx.Web.Lists)
{
ct.ThrowIfCancellationRequested();
if (list.Hidden || list.IsSystemList || ExcludedLists.Contains(list.Title)) continue;
results.AddRange(await GetListPermissionsAsync(subCtx, list, options, progress, ct));
if (options.ScanFolders && list.BaseType == BaseType.DocumentLibrary)
results.AddRange(await GetFolderPermissionsAsync(subCtx, list, options, progress, ct));
}
}
}
return results;
}
private async Task<IEnumerable<PermissionEntry>> GetSiteCollectionAdminsAsync(
ClientContext ctx, IProgress<OperationProgress> progress, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
ctx.Load(ctx.Web, w => w.Url, w => w.Title);
ctx.Load(ctx.Web.SiteUsers, users => users.Include(u => u.Title, u => u.LoginName, u => u.IsSiteAdmin));
try { await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); }
catch (ServerException ex) when (IsClaimsResolutionError(ex)) { Log.Warning("Skipped admins for {Url}: {Error}", ctx.Web.Url, ex.Message); return Enumerable.Empty<PermissionEntry>(); }
var admins = ctx.Web.SiteUsers.Where(u => u.IsSiteAdmin).ToList();
if (admins.Count == 0) return Enumerable.Empty<PermissionEntry>();
return new[] { new PermissionEntry("Site Collection", ctx.Web.Title, ctx.Web.Url, true,
string.Join(";", admins.Select(u => u.Title)), string.Join(";", admins.Select(u => u.LoginName)),
"Site Collection Administrator", "Direct Permissions", "User") };
}
private Task<IEnumerable<PermissionEntry>> GetWebPermissionsAsync(
ClientContext ctx, SpWeb web, ScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct) =>
ExtractPermissionsAsync(ctx, web, "Site", web.Title, web.Url, options, progress, ct);
private async Task<IEnumerable<PermissionEntry>> GetListPermissionsAsync(
ClientContext ctx, List list, ScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var listUrl = list.DefaultViewUrl;
if (!string.IsNullOrEmpty(listUrl)) { var uri = new Uri(ctx.Url); listUrl = $"{uri.Scheme}://{uri.Host}{listUrl}"; }
return await ExtractPermissionsAsync(ctx, list, "List", list.Title, listUrl ?? ctx.Url, options, progress, ct);
}
private async Task<IEnumerable<PermissionEntry>> GetFolderPermissionsAsync(
ClientContext ctx, List list, ScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var results = new List<PermissionEntry>();
var camlQuery = new CamlQuery { ViewXml = @"<View Scope='RecursiveAll'><Query><OrderBy><FieldRef Name='ID'/></OrderBy></Query><RowLimit>500</RowLimit></View>" };
ctx.Load(list, l => l.RootFolder.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var rootUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
var rootDepth = rootUrl.Split('/').Length;
await foreach (var item in SharePointPaginationHelper.GetAllItemsAsync(ctx, list, camlQuery, ct))
{
ct.ThrowIfCancellationRequested();
if (item.FileSystemObjectType != FileSystemObjectType.Folder) continue;
var fileRef = item["FileRef"]?.ToString() ?? string.Empty;
if (string.IsNullOrEmpty(fileRef)) continue;
if (options.FolderDepth != 999)
{
var depth = fileRef.TrimEnd('/').Split('/').Length - rootDepth;
if (depth > options.FolderDepth) continue;
}
var folder = item.Folder;
ctx.Load(folder, f => f.ServerRelativeUrl, f => f.Name);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var uri = new Uri(ctx.Url);
var folderEntries = await ExtractPermissionsAsync(ctx, item, "Folder", folder.Name,
$"{uri.Scheme}://{uri.Host}{folder.ServerRelativeUrl}", options, progress, ct);
results.AddRange(folderEntries);
}
return results;
}
private async Task<IEnumerable<PermissionEntry>> ExtractPermissionsAsync(
ClientContext ctx, SecurableObject obj, string objectType, string title, string url,
ScanOptions options, IProgress<OperationProgress> progress, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
ctx.Load(obj,
o => o.HasUniqueRoleAssignments,
o => o.RoleAssignments.Include(
ra => ra.Member.Title, ra => ra.Member.LoginName, ra => ra.Member.PrincipalType,
ra => ra.RoleDefinitionBindings.Include(rdb => rdb.Name)));
try { await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); }
catch (ServerException ex) when (IsClaimsResolutionError(ex))
{
Log.Warning("Skipped {Type} '{Title}' — orphaned user: {Error}", objectType, title, ex.Message);
return Enumerable.Empty<PermissionEntry>();
}
if (!options.IncludeInherited && !obj.HasUniqueRoleAssignments)
return Enumerable.Empty<PermissionEntry>();
var entries = new List<PermissionEntry>();
foreach (var ra in obj.RoleAssignments)
{
ct.ThrowIfCancellationRequested();
var member = ra.Member;
var loginName = member.LoginName ?? string.Empty;
var memberTitle = member.Title ?? string.Empty;
var classification = PermissionEntryHelper.Classify(memberTitle);
if (PermissionEntryHelper.IsBareLimitedAccessSystemGroup(loginName)) continue;
if (classification.Kind == SystemGroupKind.LimitedAccessBare) continue;
var filteredLevels = PermissionEntryHelper.FilterPermissionLevels(ra.RoleDefinitionBindings.Select(rdb => rdb.Name));
if (filteredLevels.Count == 0) continue;
var permLevels = string.Join(";", filteredLevels);
string principalType = PermissionEntryHelper.IsExternalUser(loginName) ? "External User"
: member.PrincipalType == Microsoft.SharePoint.Client.Utilities.PrincipalType.SharePointGroup ? "SharePointGroup"
: "User";
string grantedThrough = principalType == "SharePointGroup" ? $"SharePoint Group: {memberTitle}" : "Direct Permissions";
string? targetUrl = null, targetLabel = null, 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) sharingLinkType = classification.LinkType;
}
entries.Add(new PermissionEntry(objectType, title, url, obj.HasUniqueRoleAssignments,
memberTitle, loginName, permLevels, grantedThrough, principalType,
TargetUrl: targetUrl, TargetLabel: targetLabel, SharingLinkType: sharingLinkType));
}
return entries;
}
}
+93
View File
@@ -0,0 +1,93 @@
using Microsoft.SharePoint.Client;
using Microsoft.SharePoint.Client.Search.Query;
using System.Text.RegularExpressions;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public class SearchService : ISearchService
{
private const int BatchSize = 500;
private const int MaxStartRow = 50_000;
public async Task<IReadOnlyList<SearchResult>> SearchFilesAsync(
ClientContext ctx, SearchOptions options,
IProgress<OperationProgress> progress, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
string kql = BuildKql(options);
if (kql.Length > 4096) throw new InvalidOperationException($"KQL query exceeds 4096-char limit ({kql.Length} chars).");
Regex? regexFilter = null;
if (!string.IsNullOrWhiteSpace(options.Regex))
regexFilter = new Regex(options.Regex, RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(2));
var allResults = new List<SearchResult>();
int startRow = 0;
int maxResults = Math.Min(options.MaxResults, MaxStartRow);
do
{
ct.ThrowIfCancellationRequested();
var kq = new KeywordQuery(ctx) { QueryText = kql, StartRow = startRow, RowLimit = BatchSize, TrimDuplicates = false };
foreach (var prop in new[] { "Title", "Path", "Author", "LastModifiedTime", "FileExtension", "Created", "ModifiedBy", "Size" })
kq.SelectProperties.Add(prop);
var executor = new SearchExecutor(ctx);
var clientResult = executor.ExecuteQuery(kq);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var table = clientResult.Value.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
if (table == null || table.RowCount == 0) break;
foreach (var rawRow in table.ResultRows)
{
IDictionary<string, object> dict;
if (rawRow is IDictionary<string, object> generic) dict = generic;
else if (rawRow is System.Collections.IDictionary legacy) { dict = new Dictionary<string, object>(); foreach (System.Collections.DictionaryEntry e in legacy) dict[e.Key.ToString()!] = e.Value ?? string.Empty; }
else continue;
string path = Str(dict, "Path");
if (path.Contains("/_vti_history/", StringComparison.OrdinalIgnoreCase)) continue;
var result = ParseRow(dict);
if (regexFilter != null)
{
string fileName = System.IO.Path.GetFileName(result.Path);
if (!regexFilter.IsMatch(fileName) && !regexFilter.IsMatch(result.Title)) continue;
}
allResults.Add(result);
if (allResults.Count >= maxResults) goto done;
}
progress.Report(new OperationProgress(allResults.Count, maxResults, $"Retrieved {allResults.Count:N0} results…"));
startRow += BatchSize;
}
while (startRow <= MaxStartRow && allResults.Count < maxResults);
done:
return allResults;
}
internal static string BuildKql(SearchOptions opts)
{
var parts = new List<string> { "ContentType:Document" };
if (opts.Extensions.Length > 0)
parts.Add($"({string.Join(" OR ", opts.Extensions.Select(e => $"FileExtension:{e.TrimStart('.').ToLowerInvariant()}"))})");
if (opts.CreatedAfter.HasValue) parts.Add($"Created>={opts.CreatedAfter.Value:yyyy-MM-dd}");
if (opts.CreatedBefore.HasValue) parts.Add($"Created<={opts.CreatedBefore.Value:yyyy-MM-dd}");
if (opts.ModifiedAfter.HasValue) parts.Add($"Write>={opts.ModifiedAfter.Value:yyyy-MM-dd}");
if (opts.ModifiedBefore.HasValue) parts.Add($"Write<={opts.ModifiedBefore.Value:yyyy-MM-dd}");
if (!string.IsNullOrEmpty(opts.CreatedBy)) parts.Add($"Author:\"{opts.CreatedBy}\"");
if (!string.IsNullOrEmpty(opts.ModifiedBy)) parts.Add($"ModifiedBy:\"{opts.ModifiedBy}\"");
if (!string.IsNullOrEmpty(opts.Library) && !string.IsNullOrEmpty(opts.SiteUrl))
parts.Add($"Path:\"{opts.SiteUrl.TrimEnd('/')}/{opts.Library.TrimStart('/')}*\"");
return string.Join(" AND ", parts);
}
private static SearchResult ParseRow(IDictionary<string, object> row)
{
static string S(IDictionary<string, object> r, string k) => r.TryGetValue(k, out var v) ? v?.ToString() ?? string.Empty : string.Empty;
static DateTime? D(IDictionary<string, object> r, string k) { var s = S(r, k); return DateTime.TryParse(s, out var dt) ? dt : (DateTime?)null; }
static long L(IDictionary<string, object> r, string k) { var raw = S(r, k); var digits = Regex.Replace(raw, "[^0-9]", ""); return long.TryParse(digits, out var v) ? v : 0L; }
return new SearchResult { Title = S(row, "Title"), Path = S(row, "Path"), FileExtension = S(row, "FileExtension"), Created = D(row, "Created"), LastModified = D(row, "LastModifiedTime"), Author = S(row, "Author"), ModifiedBy = S(row, "ModifiedBy"), SizeBytes = L(row, "Size") };
}
private static string Str(IDictionary<string, object> r, string key) => r.TryGetValue(key, out var v) ? v?.ToString() ?? string.Empty : string.Empty;
}
@@ -0,0 +1,14 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Session;
/// <summary>Stores OAuth tokens in ProtectedSessionStorage (browser-side, encrypted).
/// Nothing written to server disk.</summary>
public interface ISessionCredentialStore
{
Task<SessionTokens?> GetAsync();
Task SetAsync(SessionTokens tokens);
Task UpdateRefreshTokenAsync(string newRefreshToken);
Task ClearAsync();
Task<bool> HasCredentialsAsync();
}
+16
View File
@@ -0,0 +1,16 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Session;
/// <summary>Scoped per Blazor circuit. Set once on circuit init from auth state.</summary>
public interface IUserContextAccessor
{
string Email { get; }
string DisplayName { get; }
UserRole Role { get; }
bool IsAuthenticated { get; }
event Action? Initialized;
void Initialize(AppUser user);
}
+19
View File
@@ -0,0 +1,19 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Session;
/// <summary>
/// Scoped per Blazor circuit. Holds the active tenant profile for the current user.
/// All feature pages read the profile from here instead of asking the user per-request.
/// </summary>
public interface IUserSessionService
{
TenantProfile? CurrentProfile { get; }
bool HasProfile { get; }
AppSettings Settings { get; }
void SetProfile(TenantProfile profile);
Task ClearSessionAsync();
void UpdateSettings(AppSettings settings);
event Action? ProfileChanged;
}
@@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Session;
public class SessionCredentialStore : ISessionCredentialStore
{
private const string Key = "sp-session-tokens";
private readonly ProtectedSessionStorage _storage;
public SessionCredentialStore(ProtectedSessionStorage storage) { _storage = storage; }
public async Task<SessionTokens?> GetAsync()
{
try
{
var result = await _storage.GetAsync<SessionTokens>(Key);
return result.Success ? result.Value : null;
}
catch { return null; }
}
public async Task SetAsync(SessionTokens tokens) =>
await _storage.SetAsync(Key, tokens);
public async Task UpdateRefreshTokenAsync(string newRefreshToken)
{
var tokens = await GetAsync();
if (tokens is null) return;
tokens.RefreshToken = newRefreshToken;
await _storage.SetAsync(Key, tokens);
}
public async Task ClearAsync() =>
await _storage.DeleteAsync(Key);
public async Task<bool> HasCredentialsAsync()
{
var tokens = await GetAsync();
return tokens is not null && !string.IsNullOrEmpty(tokens.RefreshToken);
}
}
+21
View File
@@ -0,0 +1,21 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Session;
public class UserContextAccessor : IUserContextAccessor
{
private AppUser? _user;
public string Email => _user?.Email ?? string.Empty;
public string DisplayName => _user?.DisplayName ?? string.Empty;
public UserRole Role => _user?.Role ?? UserRole.TechN0;
public bool IsAuthenticated => _user is not null;
public event Action? Initialized;
public void Initialize(AppUser user)
{
_user = user;
Initialized?.Invoke();
}
}
+52
View File
@@ -0,0 +1,52 @@
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
namespace SharepointToolbox.Web.Services.Session;
public class UserSessionService : IUserSessionService
{
private readonly ISessionManager _sessionManager;
private readonly SettingsRepository _settingsRepo;
private TenantProfile? _currentProfile;
private AppSettings _settings = new();
public TenantProfile? CurrentProfile => _currentProfile;
public bool HasProfile => _currentProfile is not null;
public AppSettings Settings => _settings;
public event Action? ProfileChanged;
public UserSessionService(ISessionManager sessionManager, SettingsRepository settingsRepo)
{
_sessionManager = sessionManager;
_settingsRepo = settingsRepo;
_ = LoadSettingsAsync();
}
public void SetProfile(TenantProfile profile)
{
_currentProfile = profile;
ProfileChanged?.Invoke();
}
public async Task ClearSessionAsync()
{
if (_currentProfile is not null)
await _sessionManager.ClearSessionAsync(_currentProfile.TenantUrl);
_currentProfile = null;
ProfileChanged?.Invoke();
}
public void UpdateSettings(AppSettings settings)
{
_settings = settings;
_ = _settingsRepo.SaveAsync(settings);
}
private async Task LoadSettingsAsync()
{
try { _settings = await _settingsRepo.LoadAsync(); }
catch { /* use defaults */ }
}
}
+125
View File
@@ -0,0 +1,125 @@
using Microsoft.Graph;
using Microsoft.Graph.Models;
using Microsoft.SharePoint.Client;
using Serilog;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using AppGraphClientFactory = SharepointToolbox.Web.Infrastructure.Auth.GraphClientFactory;
using GraphUser = Microsoft.Graph.Models.User;
using GraphUserCollectionResponse = Microsoft.Graph.Models.UserCollectionResponse;
namespace SharepointToolbox.Web.Services;
public class SharePointGroupResolver : ISharePointGroupResolver
{
private readonly AppGraphClientFactory _graphClientFactory;
public SharePointGroupResolver(AppGraphClientFactory graphClientFactory)
{
_graphClientFactory = graphClientFactory;
}
public async Task<IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>> ResolveGroupsAsync(
ClientContext ctx,
TenantProfile profile,
IReadOnlyList<string> groupNames,
CancellationToken ct)
{
var result = new Dictionary<string, IReadOnlyList<ResolvedMember>>(StringComparer.OrdinalIgnoreCase);
if (groupNames.Count == 0) return result;
GraphServiceClient? graphClient = null;
var groupTitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
ctx.Load(ctx.Web.SiteGroups, gs => gs.Include(g => g.Title));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
foreach (var g in ctx.Web.SiteGroups) groupTitles.Add(g.Title);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex) { Log.Warning("Could not enumerate SiteGroups on {Url}: {Error}", ctx.Url, ex.Message); }
foreach (var groupName in groupNames.Distinct(StringComparer.OrdinalIgnoreCase))
{
ct.ThrowIfCancellationRequested();
if (!groupTitles.Contains(groupName))
{
Log.Debug("SP group '{Group}' not present on {Url}; skipping.", groupName, ctx.Url);
result[groupName] = Array.Empty<ResolvedMember>();
continue;
}
try
{
var group = ctx.Web.SiteGroups.GetByName(groupName);
ctx.Load(group.Users, users => users.Include(u => u.Title, u => u.LoginName, u => u.PrincipalType));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
var members = new List<ResolvedMember>();
foreach (var user in group.Users)
{
if (IsAadGroup(user.LoginName))
{
graphClient ??= await _graphClientFactory.CreateClientAsync(profile);
var aadId = ExtractAadGroupId(user.LoginName);
var leafUsers = await ResolveAadGroupAsync(graphClient, aadId, ct);
members.AddRange(leafUsers);
}
else
{
members.Add(new ResolvedMember(user.Title ?? user.LoginName, StripClaims(user.LoginName)));
}
}
result[groupName] = members.DistinctBy(m => m.Login, StringComparer.OrdinalIgnoreCase).ToList();
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Warning("Could not resolve SP group '{Group}': {Error}", groupName, ex.Message);
result[groupName] = Array.Empty<ResolvedMember>();
}
}
return result;
}
internal static bool IsAadGroup(string login) =>
login.StartsWith("c:0t.c|", StringComparison.OrdinalIgnoreCase);
internal static string ExtractAadGroupId(string login) => login[(login.LastIndexOf('|') + 1)..];
internal static string StripClaims(string login) => login[(login.LastIndexOf('|') + 1)..];
private static async Task<IEnumerable<ResolvedMember>> ResolveAadGroupAsync(
GraphServiceClient graphClient, string aadGroupId, CancellationToken ct)
{
try
{
var response = await graphClient.Groups[aadGroupId].TransitiveMembers.GraphUser.GetAsync(config =>
{
config.QueryParameters.Select = new[] { "displayName", "userPrincipalName" };
config.QueryParameters.Top = 999;
}, ct);
if (response?.Value is null) return Enumerable.Empty<ResolvedMember>();
var members = new List<ResolvedMember>();
var iter = PageIterator<GraphUser, GraphUserCollectionResponse>.CreatePageIterator(
graphClient, response,
user =>
{
if (ct.IsCancellationRequested) return false;
members.Add(new ResolvedMember(
user.DisplayName ?? user.UserPrincipalName ?? "Unknown",
user.UserPrincipalName ?? string.Empty));
return true;
});
await iter.IterateAsync(ct);
return members;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Warning("Could not resolve AAD group '{Id}' transitively: {Error}", aadGroupId, ex.Message);
return Enumerable.Empty<ResolvedMember>();
}
}
}
+320
View File
@@ -0,0 +1,320 @@
using System.IO;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SpWeb = Microsoft.SharePoint.Client.Web;
namespace SharepointToolbox.Web.Services;
public class StorageService : IStorageService
{
private const int PreservationHoldTemplate = 851;
public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx, StorageScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct)
{
var result = new List<StorageNode>();
await CollectForWebAsync(ctx, ctx.Web, options, result, progress, ct);
return result;
}
private async Task CollectForWebAsync(ClientContext ctx, SpWeb web, StorageScanOptions options,
List<StorageNode> result, IProgress<OperationProgress> progress, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
ctx.Load(web, w => w.Title, w => w.Url, w => w.ServerRelativeUrl,
w => w.Lists.Include(l => l.Title, l => l.Hidden, l => l.BaseType, l => l.BaseTemplate,
l => l.ItemCount, l => l.RootFolder.ServerRelativeUrl));
if (options.IncludeSubsites) ctx.Load(web.Webs, ws => ws.Include(w => w.ServerRelativeUrl, w => w.Title));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
string siteTitle = web.Title;
var lists = web.Lists.ToList();
var docLibs = lists.Where(l => l.BaseType == BaseType.DocumentLibrary).ToList();
var libsByRoot = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
int idx = 0;
foreach (var lib in docLibs)
{
ct.ThrowIfCancellationRequested();
idx++;
var kind = ClassifyLibrary(lib);
if (kind == StorageNodeKind.HiddenLibrary && !options.IncludeHiddenLibraries) continue;
if (kind == StorageNodeKind.PreservationHold && !options.IncludePreservationHold) continue;
progress.Report(new OperationProgress(idx, docLibs.Count, $"Loading storage: {lib.Title} ({idx}/{docLibs.Count})"));
var libNode = await LoadFolderNodeAsync(ctx, lib.RootFolder.ServerRelativeUrl, lib.Title, siteTitle, lib.Title, 0, kind, progress, ct);
if (options.FolderDepth > 0)
await CollectSubfoldersAsync(ctx, lib, lib.RootFolder.ServerRelativeUrl, libNode, 1, options.FolderDepth, siteTitle, lib.Title, kind, progress, ct);
ResetNodeCounts(libNode);
await BackfillLibFromFilesAsync(ctx, lib, libNode, progress, ct);
result.Add(libNode);
libsByRoot[NormalizeServerRelative(lib.RootFolder.ServerRelativeUrl)] = libNode;
}
if (options.IncludeListAttachments)
{
var nonDocLists = lists.Where(l => l.BaseType != BaseType.DocumentLibrary && !l.Hidden && l.ItemCount > 0).ToList();
int aIdx = 0;
foreach (var list in nonDocLists)
{
ct.ThrowIfCancellationRequested();
aIdx++;
progress.Report(new OperationProgress(aIdx, nonDocLists.Count, $"Scanning attachments: {list.Title}"));
var attachNode = await TryLoadAttachmentsNodeAsync(ctx, list, siteTitle, progress, ct);
if (attachNode != null && attachNode.TotalSizeBytes > 0) result.Add(attachNode);
}
}
if (options.IncludeRecycleBin)
{
progress.Report(OperationProgress.Indeterminate($"Scanning recycle bin: {siteTitle}..."));
var (rbNodes, perDir) = await LoadRecycleBinNodesAsync(ctx, web, siteTitle, progress, ct);
if (perDir.Count > 0 && libsByRoot.Count > 0)
{
var libRootsByLength = libsByRoot.OrderByDescending(kv => kv.Key.Length).ToList();
foreach (var kv in perDir)
{
string dirNorm = NormalizeServerRelative(kv.Key);
foreach (var lib in libRootsByLength)
{
if (dirNorm.Equals(lib.Key, StringComparison.OrdinalIgnoreCase) ||
dirNorm.StartsWith(lib.Key + "/", StringComparison.OrdinalIgnoreCase))
{
lib.Value.TotalSizeBytes += kv.Value.Size;
lib.Value.TotalFileCount += kv.Value.Count;
break;
}
}
}
}
result.AddRange(rbNodes);
}
if (options.IncludeSubsites)
{
foreach (var sub in web.Webs.ToList())
{
ct.ThrowIfCancellationRequested();
var subResult = new List<StorageNode>();
await CollectForWebAsync(ctx, sub, options, subResult, progress, ct);
if (subResult.Count == 0) continue;
result.Add(new StorageNode
{
Name = sub.Title, Url = ctx.Url.TrimEnd('/') + sub.ServerRelativeUrl,
SiteTitle = sub.Title, Kind = StorageNodeKind.Subsite, IndentLevel = 0,
Children = subResult,
TotalSizeBytes = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.TotalSizeBytes),
FileStreamSizeBytes = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.FileStreamSizeBytes),
TotalFileCount = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.TotalFileCount)
});
}
}
}
private static StorageNodeKind ClassifyLibrary(List lib) =>
lib.BaseTemplate == PreservationHoldTemplate || lib.Title.Equals("Preservation Hold Library", StringComparison.OrdinalIgnoreCase)
? StorageNodeKind.PreservationHold : lib.Hidden ? StorageNodeKind.HiddenLibrary : StorageNodeKind.Library;
private static async Task<StorageNode?> TryLoadAttachmentsNodeAsync(ClientContext ctx, List list, string siteTitle, IProgress<OperationProgress> progress, CancellationToken ct)
{
string url = list.RootFolder.ServerRelativeUrl.TrimEnd('/') + "/Attachments";
try
{
var folder = ctx.Web.GetFolderByServerRelativeUrl(url);
ctx.Load(folder, f => f.Exists, f => f.StorageMetrics, f => f.TimeLastModified, f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
if (!folder.Exists || folder.StorageMetrics.TotalFileCount == 0) return null;
return new StorageNode { Name = $"[Attachments] {list.Title}", Url = ctx.Url.TrimEnd('/') + url, SiteTitle = siteTitle, Library = list.Title, Kind = StorageNodeKind.ListAttachments, TotalSizeBytes = folder.StorageMetrics.TotalSize, FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize, TotalFileCount = folder.StorageMetrics.TotalFileCount, LastModified = folder.StorageMetrics.LastModified > DateTime.MinValue ? folder.StorageMetrics.LastModified : (DateTime?)null };
}
catch { return null; }
}
private static async Task<(List<StorageNode> Nodes, Dictionary<string, (long Size, int Count)> PerDir)> LoadRecycleBinNodesAsync(ClientContext ctx, SpWeb web, string siteTitle, IProgress<OperationProgress> progress, CancellationToken ct)
{
var nodes = new List<StorageNode>();
var perDir = new Dictionary<string, (long Size, int Count)>(StringComparer.OrdinalIgnoreCase);
try
{
var bin = web.RecycleBin;
ctx.Load(bin, b => b.Include(i => i.Size, i => i.ItemState, i => i.DeletedDate, i => i.DirName));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
string webSrl = NormalizeServerRelative(web.ServerRelativeUrl);
long stage1Size = 0, stage2Size = 0; int stage1Count = 0, stage2Count = 0;
DateTime? stage1Last = null, stage2Last = null;
foreach (var item in bin)
{
if (item.ItemState == RecycleBinItemState.SecondStageRecycleBin) { stage2Size += item.Size; stage2Count++; if (stage2Last is null || item.DeletedDate > stage2Last) stage2Last = item.DeletedDate; }
else { stage1Size += item.Size; stage1Count++; if (stage1Last is null || item.DeletedDate > stage1Last) stage1Last = item.DeletedDate; }
string raw = item.DirName ?? string.Empty;
string dirSrl = raw.StartsWith('/') ? NormalizeServerRelative(raw) : string.IsNullOrEmpty(raw) ? webSrl : NormalizeServerRelative(webSrl + "/" + raw);
if (perDir.TryGetValue(dirSrl, out var tally)) perDir[dirSrl] = (tally.Size + item.Size, tally.Count + 1);
else perDir[dirSrl] = (item.Size, 1);
}
if (stage1Count > 0) nodes.Add(new StorageNode { Name = "[Recycle Bin] First-stage", SiteTitle = siteTitle, Library = "RecycleBin", Kind = StorageNodeKind.RecycleBin, TotalSizeBytes = stage1Size, FileStreamSizeBytes = stage1Size, TotalFileCount = stage1Count, LastModified = stage1Last });
if (stage2Count > 0) nodes.Add(new StorageNode { Name = "[Recycle Bin] Second-stage", SiteTitle = siteTitle, Library = "RecycleBin", Kind = StorageNodeKind.RecycleBin, TotalSizeBytes = stage2Size, FileStreamSizeBytes = stage2Size, TotalFileCount = stage2Count, LastModified = stage2Last });
}
catch { }
return (nodes, perDir);
}
private static string NormalizeServerRelative(string? path)
{
if (string.IsNullOrEmpty(path)) return string.Empty;
string t = path.Trim().TrimEnd('/');
if (t.Length == 0) return string.Empty;
return t.StartsWith('/') ? t : "/" + t;
}
public async Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(ClientContext ctx, IProgress<OperationProgress> progress, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
ctx.Load(ctx.Web, w => w.Lists.Include(l => l.Title, l => l.Hidden, l => l.BaseType, l => l.ItemCount));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var libs = ctx.Web.Lists.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary).ToList();
var extensionMap = new Dictionary<string, (long totalSize, int count)>(StringComparer.OrdinalIgnoreCase);
int libIdx = 0;
foreach (var lib in libs)
{
ct.ThrowIfCancellationRequested();
libIdx++;
progress.Report(new OperationProgress(libIdx, libs.Count, $"Scanning files by type: {lib.Title}"));
var query = new CamlQuery { ViewXml = "<View Scope='RecursiveAll'><Query></Query><ViewFields><FieldRef Name='FSObjType' /><FieldRef Name='FileLeafRef' /></ViewFields><RowLimit Paged='TRUE'>500</RowLimit></View>" };
ListItemCollection items;
do
{
ct.ThrowIfCancellationRequested();
items = lib.GetItems(query);
ctx.Load(items, ic => ic.ListItemCollectionPosition, ic => ic.Include(i => i["FSObjType"], i => i["FileLeafRef"]));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var fileRows = new List<(ListItem Item, string Name)>();
foreach (var item in items)
{
if (item["FSObjType"]?.ToString() != "0") continue;
string fileName = item["FileLeafRef"]?.ToString() ?? string.Empty;
fileRows.Add((item, fileName));
ctx.Load(item.File, f => f.Length);
ctx.Load(item.File.Versions, vc => vc.Include(v => v.Size));
}
if (fileRows.Count > 0) await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (var row in fileRows)
{
long current; try { current = row.Item.File.Length; } catch { continue; }
long versions = 0; try { foreach (var v in row.Item.File.Versions) versions += v.Size; } catch { }
string ext = Path.GetExtension(row.Name).ToLowerInvariant();
if (extensionMap.TryGetValue(ext, out var existing)) extensionMap[ext] = (existing.totalSize + current + versions, existing.count + 1);
else extensionMap[ext] = (current + versions, 1);
}
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
}
while (items.ListItemCollectionPosition != null);
}
return extensionMap.Select(kvp => new FileTypeMetric(kvp.Key, kvp.Value.totalSize, kvp.Value.count)).OrderByDescending(m => m.TotalSizeBytes).ToList();
}
private static async Task BackfillLibFromFilesAsync(ClientContext ctx, List lib, StorageNode libNode, IProgress<OperationProgress> progress, CancellationToken ct)
{
progress.Report(OperationProgress.Indeterminate($"Counting files: {libNode.Name}..."));
string libRootSrl = NormalizeServerRelative(lib.RootFolder.ServerRelativeUrl);
var folderLookup = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
BuildFolderLookup(libNode, libRootSrl, folderLookup);
var query = new CamlQuery { ViewXml = "<View Scope='RecursiveAll'><Query></Query><ViewFields><FieldRef Name='FSObjType' /><FieldRef Name='FileDirRef' /></ViewFields><RowLimit Paged='TRUE'>500</RowLimit></View>" };
ListItemCollection items;
do
{
ct.ThrowIfCancellationRequested();
items = lib.GetItems(query);
ctx.Load(items, ic => ic.ListItemCollectionPosition, ic => ic.Include(i => i["FSObjType"], i => i["FileDirRef"]));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var fileRows = new List<(ListItem Item, string DirRef)>();
foreach (var item in items)
{
if (item["FSObjType"]?.ToString() != "0") continue;
fileRows.Add((item, item["FileDirRef"]?.ToString() ?? string.Empty));
ctx.Load(item.File, f => f.Length);
ctx.Load(item.File.Versions, vc => vc.Include(v => v.Size));
}
if (fileRows.Count > 0) await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (var row in fileRows)
{
long current; try { current = row.Item.File.Length; } catch { continue; }
long versions = 0; try { foreach (var v in row.Item.File.Versions) versions += v.Size; } catch { }
var target = FindDeepestFolder(row.DirRef, folderLookup) ?? libNode;
target.TotalSizeBytes += current + versions;
target.FileStreamSizeBytes += current;
target.TotalFileCount++;
}
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
}
while (items.ListItemCollectionPosition != null);
RollupFolderTotals(libNode);
}
private static void RollupFolderTotals(StorageNode node)
{
foreach (var child in node.Children)
{
RollupFolderTotals(child);
node.TotalSizeBytes += child.TotalSizeBytes;
node.FileStreamSizeBytes += child.FileStreamSizeBytes;
node.TotalFileCount += child.TotalFileCount;
}
}
public Task BackfillZeroNodesAsync(ClientContext ctx, IReadOnlyList<StorageNode> nodes, IProgress<OperationProgress> progress, CancellationToken ct) => Task.CompletedTask;
public async Task<long> GetSiteUsageStorageBytesAsync(ClientContext ctx, IProgress<OperationProgress> progress, CancellationToken ct)
{
try { ctx.Load(ctx.Site, s => s.Usage); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); return ctx.Site.Usage.Storage; }
catch { return 0L; }
}
private static void ResetNodeCounts(StorageNode node) { node.TotalSizeBytes = 0; node.FileStreamSizeBytes = 0; node.TotalFileCount = 0; foreach (var c in node.Children) ResetNodeCounts(c); }
private static void BuildFolderLookup(StorageNode node, string parentPath, Dictionary<string, StorageNode> lookup)
{
string nodePath = node.IndentLevel == 0 ? parentPath : parentPath + "/" + node.Name;
lookup[nodePath] = node;
foreach (var child in node.Children) BuildFolderLookup(child, nodePath, lookup);
}
private static StorageNode? FindDeepestFolder(string fileDirRef, Dictionary<string, StorageNode> lookup)
{
string path = fileDirRef.TrimEnd('/');
while (!string.IsNullOrEmpty(path)) { if (lookup.TryGetValue(path, out var node)) return node; int last = path.LastIndexOf('/'); if (last <= 0) break; path = path[..last]; }
return null;
}
private static async Task<StorageNode> LoadFolderNodeAsync(ClientContext ctx, string serverRelativeUrl, string name, string siteTitle, string library, int indentLevel, StorageNodeKind kind, IProgress<OperationProgress> progress, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var folder = ctx.Web.GetFolderByServerRelativeUrl(serverRelativeUrl);
ctx.Load(folder, f => f.StorageMetrics, f => f.TimeLastModified, f => f.ServerRelativeUrl, f => f.Name);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
DateTime? lastMod = folder.StorageMetrics.LastModified > DateTime.MinValue ? folder.StorageMetrics.LastModified : folder.TimeLastModified > DateTime.MinValue ? folder.TimeLastModified : (DateTime?)null;
return new StorageNode { Name = name, Url = ctx.Url.TrimEnd('/') + serverRelativeUrl, SiteTitle = siteTitle, Library = library, Kind = kind, TotalSizeBytes = folder.StorageMetrics.TotalSize, FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize, TotalFileCount = folder.StorageMetrics.TotalFileCount, LastModified = lastMod, IndentLevel = indentLevel, Children = new List<StorageNode>() };
}
private static async Task CollectSubfoldersAsync(ClientContext ctx, List list, string parentServerRelativeUrl, StorageNode parentNode, int currentDepth, int maxDepth, string siteTitle, string library, StorageNodeKind kind, IProgress<OperationProgress> progress, CancellationToken ct)
{
if (currentDepth > maxDepth) return;
ct.ThrowIfCancellationRequested();
var subfolders = new List<(string Name, string ServerRelativeUrl)>();
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(ctx, list, parentServerRelativeUrl, recursive: false, viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef" }, ct: ct))
{
if (item["FSObjType"]?.ToString() != "1") continue;
string name = item["FileLeafRef"]?.ToString() ?? string.Empty;
string url = item["FileRef"]?.ToString() ?? string.Empty;
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(url)) continue;
if (name.Equals("Forms", StringComparison.OrdinalIgnoreCase) || name.StartsWith("_")) continue;
subfolders.Add((name, url));
}
foreach (var sub in subfolders)
{
ct.ThrowIfCancellationRequested();
var childNode = await LoadFolderNodeAsync(ctx, sub.ServerRelativeUrl, sub.Name, siteTitle, library, currentDepth, kind, progress, ct);
if (currentDepth < maxDepth) await CollectSubfoldersAsync(ctx, list, sub.ServerRelativeUrl, childNode, currentDepth + 1, maxDepth, siteTitle, library, kind, progress, ct);
parentNode.Children.Add(childNode);
}
}
}
+127
View File
@@ -0,0 +1,127 @@
using Microsoft.SharePoint.Client;
using Microsoft.SharePoint.Client.Search.Query;
using Serilog;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
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 (OperationCanceledException) { throw; }
catch (Exception ex) { Log.Debug("System group resolve failed for {Kind}: {Error}", classification.Kind, 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);
return new SystemGroupTarget(SystemGroupKind.LimitedAccessList, list.Title, BuildAbsoluteUrl(ctx.Url, list.DefaultViewUrl));
}
private static async Task<SystemGroupTarget?> ResolveItemAsync(ClientContext ctx, Guid itemUniqueId, string? linkType, CancellationToken ct)
{
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) return new SystemGroupTarget(SystemGroupKind.SharingLink, file.Name, BuildAbsoluteUrl(ctx.Url, file.ServerRelativeUrl), linkType);
}
catch (ServerException ex) { Log.Debug("File by ID not found: {Error}", ex.Message); }
try
{
var folder = ctx.Web.GetFolderById(itemUniqueId);
ctx.Load(folder, f => f.Name, f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
return new SystemGroupTarget(SystemGroupKind.SharingLink, folder.Name, BuildAbsoluteUrl(ctx.Url, folder.ServerRelativeUrl), linkType);
}
catch (ServerException ex) { Log.Debug("Folder by ID not found: {Error}", ex.Message); }
return await TryResolveViaSearchAsync(ctx, itemUniqueId, linkType, ct);
}
private static async Task<SystemGroupTarget?> TryResolveViaSearchAsync(ClientContext ctx, Guid itemUniqueId, string? linkType, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
try
{
var kq = new KeywordQuery(ctx) { QueryText = $"UniqueId:{{{itemUniqueId}}}", RowLimit = 1, TrimDuplicates = false };
kq.SelectProperties.Add("Path"); kq.SelectProperties.Add("Title");
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;
var leaf = !string.IsNullOrWhiteSpace(title) ? title! : Uri.UnescapeDataString(path.TrimEnd('/').Split('/').Last());
return new SystemGroupTarget(SystemGroupKind.SharingLink, $"{leaf} (via index)", path, linkType);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex) { Log.Debug("UniqueId search fallback failed: {Error}", ex.Message); return null; }
}
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}";
}
}
+227
View File
@@ -0,0 +1,227 @@
using Microsoft.SharePoint.Client;
using PnP.Framework.Sites;
using Serilog;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Services.Audit;
using ModelSiteTemplate = SharepointToolbox.Web.Core.Models.SiteTemplate;
using SpWeb = Microsoft.SharePoint.Client.Web;
namespace SharepointToolbox.Web.Services;
public class TemplateService : ITemplateService
{
private readonly IAuditService _audit;
public TemplateService(IAuditService audit) { _audit = audit; }
private static readonly HashSet<string> SystemListNames = new(StringComparer.OrdinalIgnoreCase)
{
"Style Library","Form Templates","Site Assets","Site Pages","Composed Looks",
"Master Page Gallery","Web Part Gallery","Theme Gallery","Solution Gallery",
"List Template Gallery","Converted Forms","Customized Reports",
"Content type publishing error log","TaxonomyHiddenList","appdata","appfiles"
};
public async Task<ModelSiteTemplate> CaptureTemplateAsync(
ClientContext ctx, SiteTemplateOptions options,
IProgress<OperationProgress> progress, CancellationToken ct)
{
progress.Report(new OperationProgress(0, 0, "Loading site properties..."));
var web = ctx.Web;
ctx.Load(web, w => w.Title, w => w.Description, w => w.Language,
w => w.SiteLogoUrl, w => w.WebTemplate, w => w.Configuration, w => w.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var siteType = (web.WebTemplate == "GROUP" || web.WebTemplate == "GROUP#0") ? "Team" : "Communication";
var template = new ModelSiteTemplate
{
Name = string.Empty, SourceUrl = ctx.Url, CapturedAt = DateTime.UtcNow,
SiteType = siteType, Options = options,
};
if (options.CaptureSettings)
template.Settings = new TemplateSettings { Title = web.Title, Description = web.Description, Language = (int)web.Language };
if (options.CaptureLogo)
template.Logo = new TemplateLogo { LogoUrl = web.SiteLogoUrl ?? string.Empty };
if (options.CaptureLibraries || options.CaptureFolders)
{
progress.Report(new OperationProgress(0, 0, "Enumerating libraries..."));
var lists = ctx.LoadQuery(web.Lists
.Include(l => l.Title, l => l.Hidden, l => l.BaseType, l => l.BaseTemplate, l => l.RootFolder)
.Where(l => !l.Hidden));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var filtered = lists.Where(l => !SystemListNames.Contains(l.Title))
.Where(l => l.BaseType == BaseType.DocumentLibrary || l.BaseType == BaseType.GenericList).ToList();
for (int i = 0; i < filtered.Count; i++)
{
ct.ThrowIfCancellationRequested();
var list = filtered[i];
progress.Report(new OperationProgress(i + 1, filtered.Count, $"Capturing library: {list.Title}"));
var libInfo = new TemplateLibraryInfo { Name = list.Title, BaseType = list.BaseType.ToString(), BaseTemplate = (int)list.BaseTemplate };
if (options.CaptureFolders)
{
ctx.Load(list.RootFolder, f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
libInfo.Folders = await EnumerateLibraryFoldersAsync(ctx, list, ct);
}
template.Libraries.Add(libInfo);
}
}
if (options.CapturePermissionGroups)
{
progress.Report(new OperationProgress(0, 0, "Capturing permission groups..."));
var groups = web.SiteGroups;
ctx.Load(groups, gs => gs.Include(g => g.Title, g => g.Description));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var roleAssignments = web.RoleAssignments;
ctx.Load(roleAssignments, ras => ras.Include(ra => ra.Member.Title, ra => ra.RoleDefinitionBindings.Include(rd => rd.Name)));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (var group in groups)
{
ct.ThrowIfCancellationRequested();
var roles = roleAssignments.Where(ra => ra.Member.Title == group.Title)
.SelectMany(ra => ra.RoleDefinitionBindings.Select(rd => rd.Name)).ToList();
template.PermissionGroups.Add(new TemplatePermissionGroup { Name = group.Title, Description = group.Description ?? string.Empty, RoleDefinitions = roles });
}
}
progress.Report(new OperationProgress(1, 1, "Template capture complete."));
return template;
}
public async Task<string> ApplyTemplateAsync(
ClientContext adminCtx, ModelSiteTemplate template,
string newSiteTitle, string newSiteAlias,
IProgress<OperationProgress> progress, CancellationToken ct)
{
progress.Report(new OperationProgress(0, 0, $"Creating {template.SiteType} site: {newSiteTitle}..."));
string siteUrl;
if (template.SiteType.Equals("Team", StringComparison.OrdinalIgnoreCase))
{
var info = new TeamSiteCollectionCreationInformation { DisplayName = newSiteTitle, Alias = newSiteAlias, Description = template.Settings?.Description ?? string.Empty, IsPublic = false };
using var siteCtx = await adminCtx.CreateSiteAsync(info);
siteCtx.Load(siteCtx.Web, w => w.Url);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
siteUrl = siteCtx.Web.Url;
}
else
{
var tenantHost = new Uri(adminCtx.Url).Host;
var info = new CommunicationSiteCollectionCreationInformation { Title = newSiteTitle, Url = $"https://{tenantHost}/sites/{newSiteAlias}", Description = template.Settings?.Description ?? string.Empty };
using var siteCtx = await adminCtx.CreateSiteAsync(info);
siteCtx.Load(siteCtx.Web, w => w.Url);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
siteUrl = siteCtx.Web.Url;
}
var newCtx = new ClientContext(siteUrl) { Credentials = adminCtx.Credentials };
try
{
for (int i = 0; i < template.Libraries.Count; i++)
{
ct.ThrowIfCancellationRequested();
var lib = template.Libraries[i];
progress.Report(new OperationProgress(i + 1, template.Libraries.Count, $"Creating library: {lib.Name}"));
try
{
var listInfo = new ListCreationInformation { Title = lib.Name, TemplateType = lib.BaseTemplate };
var newList = newCtx.Web.Lists.Add(listInfo);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(newCtx, progress, ct);
if (lib.Folders.Count > 0) await CreateFoldersRecursiveAsync(newCtx, newList, lib.Folders, progress, ct);
}
catch (Exception ex) { Log.Warning("Failed to create library {Name}: {Error}", lib.Name, ex.Message); }
}
foreach (var group in template.PermissionGroups)
{
ct.ThrowIfCancellationRequested();
try
{
var groupInfo = new GroupCreationInformation { Title = group.Name, Description = group.Description };
var newGroup = newCtx.Web.SiteGroups.Add(groupInfo);
foreach (var roleName in group.RoleDefinitions)
{
try
{
var roleDef = newCtx.Web.RoleDefinitions.GetByName(roleName);
var bindings = new RoleDefinitionBindingCollection(newCtx) { roleDef };
newCtx.Web.RoleAssignments.Add(newGroup, bindings);
}
catch (Exception ex) { Log.Warning("Failed to assign role {Role}: {Error}", roleName, ex.Message); }
}
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(newCtx, progress, ct);
}
catch (Exception ex) { Log.Warning("Failed to create group {Name}: {Error}", group.Name, ex.Message); }
}
}
finally { newCtx.Dispose(); }
progress.Report(new OperationProgress(1, 1, $"Template applied. Site: {siteUrl}"));
await _audit.LogAsync("ApplyTemplate", adminCtx.Url, new[] { siteUrl },
$"Template '{template.Name}' applied to new site '{newSiteTitle}'");
return siteUrl;
}
private static async Task<List<TemplateFolderInfo>> EnumerateLibraryFoldersAsync(ClientContext ctx, List list, CancellationToken ct)
{
var rootUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
var folders = new List<(string Relative, string Parent)>();
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
ctx, list, rootUrl, recursive: true,
viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef", "FileDirRef" }, ct: ct))
{
if (item["FSObjType"]?.ToString() != "1") continue;
var name = item["FileLeafRef"]?.ToString() ?? string.Empty;
var fileRef = (item["FileRef"]?.ToString() ?? string.Empty).TrimEnd('/');
var dirRef = (item["FileDirRef"]?.ToString() ?? string.Empty).TrimEnd('/');
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(fileRef)) continue;
if (name.StartsWith("_") || name.Equals("Forms", StringComparison.OrdinalIgnoreCase)) continue;
var rel = fileRef.StartsWith(rootUrl, StringComparison.OrdinalIgnoreCase) ? fileRef[rootUrl.Length..].TrimStart('/') : name;
var parentRel = dirRef.StartsWith(rootUrl, StringComparison.OrdinalIgnoreCase) ? dirRef[rootUrl.Length..].TrimStart('/') : string.Empty;
folders.Add((rel, parentRel));
}
var nodes = folders.ToDictionary(f => f.Relative,
f => new TemplateFolderInfo { Name = Path.GetFileName(f.Relative), RelativePath = f.Relative, Children = new List<TemplateFolderInfo>() },
StringComparer.OrdinalIgnoreCase);
var roots = new List<TemplateFolderInfo>();
foreach (var (rel, parent) in folders)
{
if (!nodes.TryGetValue(rel, out var node)) continue;
if (!string.IsNullOrEmpty(parent) && nodes.TryGetValue(parent, out var p)) p.Children.Add(node);
else roots.Add(node);
}
return roots;
}
private static async Task CreateFoldersRecursiveAsync(ClientContext ctx, List list, List<TemplateFolderInfo> folders, IProgress<OperationProgress> progress, CancellationToken ct)
{
ctx.Load(list.RootFolder, f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
await CreateSubFoldersRecursiveAsync(ctx, list.RootFolder.ServerRelativeUrl.TrimEnd('/'), folders, progress, ct);
}
private static async Task CreateSubFoldersRecursiveAsync(ClientContext ctx, string parentUrl, List<TemplateFolderInfo> folders, IProgress<OperationProgress> progress, CancellationToken ct)
{
foreach (var folder in folders)
{
ct.ThrowIfCancellationRequested();
try
{
ctx.Web.Folders.Add($"{parentUrl}/{folder.Name}");
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
if (folder.Children.Count > 0)
await CreateSubFoldersRecursiveAsync(ctx, $"{parentUrl}/{folder.Name}", folder.Children, progress, ct);
}
catch (Exception ex) { Log.Warning("Failed to create folder {Path}: {Error}", folder.RelativePath, ex.Message); }
}
}
}
+119
View File
@@ -0,0 +1,119 @@
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public class UserAccessAuditService : IUserAccessAuditService
{
private readonly IPermissionsService _permissionsService;
private static readonly HashSet<string> HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase)
{
"Full Control", "Site Collection Administrator"
};
public UserAccessAuditService(IPermissionsService permissionsService)
{
_permissionsService = permissionsService;
}
public async Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
ISessionManager sessionManager,
TenantProfile currentProfile,
IReadOnlyList<string> targetUserLogins,
IReadOnlyList<SiteInfo> sites,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct,
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null)
{
var targets = targetUserLogins
.Select(l => l.Trim().ToLowerInvariant())
.Where(l => l.Length > 0).ToHashSet();
if (targets.Count == 0) return Array.Empty<UserAccessEntry>();
var allEntries = new List<UserAccessEntry>();
for (int i = 0; i < sites.Count; i++)
{
ct.ThrowIfCancellationRequested();
var site = sites[i];
progress.Report(new OperationProgress(i, sites.Count,
$"Scanning site {i + 1}/{sites.Count}: {site.Title}..."));
var profile = new TenantProfile
{
TenantUrl = site.Url,
TenantId = currentProfile.TenantId,
ClientId = currentProfile.ClientId,
Name = site.Title
};
var ctx = await sessionManager.GetOrCreateContextAsync(profile, ct);
IReadOnlyList<PermissionEntry> permEntries;
try
{
permEntries = await _permissionsService.ScanSiteAsync(ctx, options, progress, ct);
}
catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) when (onAccessDenied != null)
{
var elevated = await onAccessDenied(site.Url, ct);
if (!elevated) throw;
var retryCtx = await sessionManager.GetOrCreateContextAsync(profile, ct);
permEntries = await _permissionsService.ScanSiteAsync(retryCtx, options, progress, ct);
}
allEntries.AddRange(TransformEntries(permEntries, targets, site));
}
progress.Report(new OperationProgress(sites.Count, sites.Count,
$"Audit complete: {allEntries.Count} access entries found."));
return allEntries;
}
private static IEnumerable<UserAccessEntry> TransformEntries(
IReadOnlyList<PermissionEntry> permEntries, HashSet<string> targets, SiteInfo site)
{
foreach (var entry in permEntries)
{
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
var permLevels = entry.PermissionLevels.Split(';', StringSplitOptions.RemoveEmptyEntries);
for (int u = 0; u < logins.Length; u++)
{
var login = logins[u].Trim();
var loginLower = login.ToLowerInvariant();
var displayName = u < names.Length ? names[u].Trim() : login;
bool isTarget = targets.Any(t => loginLower.Contains(t) || t.Contains(loginLower));
if (!isTarget) continue;
var accessType = !entry.HasUniquePermissions ? AccessType.Inherited
: entry.GrantedThrough.StartsWith("SharePoint Group:", StringComparison.OrdinalIgnoreCase)
? AccessType.Group : AccessType.Direct;
foreach (var level in permLevels)
{
var trimmed = level.Trim();
if (string.IsNullOrEmpty(trimmed)) continue;
yield return new UserAccessEntry(
displayName, StripClaimsPrefix(login),
site.Url, site.Title,
entry.ObjectType, entry.Title, entry.Url,
trimmed, accessType, entry.GrantedThrough,
HighPrivilegeLevels.Contains(trimmed),
PermissionEntryHelper.IsExternalUser(login),
entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType);
}
}
}
}
private static string StripClaimsPrefix(string login)
{
int pipe = login.LastIndexOf('|');
return pipe >= 0 ? login[(pipe + 1)..] : login;
}
}
+106
View File
@@ -0,0 +1,106 @@
using Microsoft.Extensions.Logging;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Services.Audit;
namespace SharepointToolbox.Web.Services;
public class VersionCleanupService : IVersionCleanupService
{
private readonly ILogger<VersionCleanupService> _logger;
private readonly IAuditService _audit;
public VersionCleanupService(ILogger<VersionCleanupService> logger, IAuditService audit)
{
_logger = logger;
_audit = audit;
}
public async Task<IReadOnlyList<string>> ListLibraryTitlesAsync(ClientContext ctx, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
ctx.Load(ctx.Web, w => w.Lists.Include(l => l.Title, l => l.Hidden, l => l.BaseType));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
return ctx.Web.Lists.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.Select(l => l.Title).OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList();
}
public async Task<IReadOnlyList<VersionCleanupResult>> DeleteOldVersionsAsync(
ClientContext ctx, VersionCleanupOptions options,
IProgress<OperationProgress> progress, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (options.KeepLast < 0) throw new ArgumentOutOfRangeException(nameof(options), "KeepLast must be >= 0.");
ctx.Load(ctx.Web, w => w.Url, w => w.ServerRelativeUrl,
w => w.Lists.Include(l => l.Title, l => l.Hidden, l => l.BaseType, l => l.RootFolder.ServerRelativeUrl));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var allLibs = ctx.Web.Lists.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary).ToList();
var titleFilter = options.LibraryTitles?.Count > 0 ? new HashSet<string>(options.LibraryTitles, StringComparer.OrdinalIgnoreCase) : null;
var libs = titleFilter is null ? allLibs : allLibs.Where(l => titleFilter.Contains(l.Title)).ToList();
var results = new List<VersionCleanupResult>();
string siteUrl = ctx.Web.Url;
int libIdx = 0;
foreach (var lib in libs)
{
ct.ThrowIfCancellationRequested();
libIdx++;
progress.Report(new OperationProgress(libIdx, libs.Count, $"Scanning versions: {lib.Title} ({libIdx}/{libs.Count})"));
var files = new List<string>();
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(ctx, lib, lib.RootFolder.ServerRelativeUrl, recursive: true, viewFields: new[] { "FSObjType", "FileRef" }, ct: ct))
{
if (item["FSObjType"]?.ToString() != "0") continue;
var fileRef = item["FileRef"]?.ToString();
if (!string.IsNullOrEmpty(fileRef)) files.Add(fileRef);
}
int fileIdx = 0;
foreach (var fileRef in files)
{
ct.ThrowIfCancellationRequested();
fileIdx++;
if (fileIdx % 25 == 0 || fileIdx == files.Count)
progress.Report(new OperationProgress(fileIdx, files.Count, $"{lib.Title}: {fileIdx}/{files.Count} files"));
var result = await TrimFileVersionsAsync(ctx, siteUrl, lib.Title, fileRef, options, progress, ct);
if (result is not null) results.Add(result);
}
}
var totalDeleted = results.Sum(r => r.VersionsDeleted);
await _audit.LogAsync("VersionCleanup", siteUrl, new[] { siteUrl },
$"{totalDeleted} versions deleted across {results.Count} files");
return results;
}
private async Task<VersionCleanupResult?> TrimFileVersionsAsync(ClientContext ctx, string siteUrl, string libraryTitle, string fileServerRelativeUrl, VersionCleanupOptions options, IProgress<OperationProgress> progress, CancellationToken ct)
{
int before = 0;
try
{
var file = ctx.Web.GetFileByServerRelativeUrl(fileServerRelativeUrl);
ctx.Load(file, f => f.Name);
ctx.Load(file.Versions, vs => vs.Include(v => v.VersionLabel, v => v.Created, v => v.Size));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var versions = file.Versions.ToList();
before = versions.Count;
if (before == 0) return null;
var ordered = versions.OrderBy(v => v.Created).ToList();
var keep = new HashSet<int>();
int keepLast = Math.Min(options.KeepLast, ordered.Count);
for (int i = ordered.Count - keepLast; i < ordered.Count; i++) keep.Add(i);
if (options.KeepFirst && ordered.Count > 0) keep.Add(0);
long bytesFreed = 0; int deleted = 0;
for (int i = 0; i < ordered.Count; i++) { if (keep.Contains(i)) continue; bytesFreed += ordered[i].Size; ordered[i].DeleteObject(); deleted++; }
if (deleted == 0) return null;
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
return new VersionCleanupResult { SiteUrl = siteUrl, Library = libraryTitle, FileServerRelativeUrl = fileServerRelativeUrl, FileName = System.IO.Path.GetFileName(fileServerRelativeUrl), VersionsBefore = before, VersionsDeleted = deleted, VersionsRemaining = before - deleted, BytesFreed = bytesFreed };
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to trim versions for {File}", fileServerRelativeUrl);
return new VersionCleanupResult { SiteUrl = siteUrl, Library = libraryTitle, FileServerRelativeUrl = fileServerRelativeUrl, FileName = System.IO.Path.GetFileName(fileServerRelativeUrl), VersionsBefore = before, Error = ex.Message };
}
}
}