Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 <body> and <h1>.
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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\">▼</span><span class=\"{typeCss}\">{HtmlEncode(group.Key.ObjectType)}</span> <strong>{HtmlEncode(group.Key.Title)}</strong> <a href=\"{HtmlEncode(group.Key.Url)}\" target=\"_blank\">↗</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")
|
||||
};
|
||||
}
|
||||
@@ -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 <script> 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(" ▼</span>");
|
||||
|
||||
var parts = resolved.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>");
|
||||
var memberContent = string.Join(" • ", 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("→ <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("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\"", """)
|
||||
.Replace("'", "'");
|
||||
}
|
||||
}
|
||||
@@ -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 <iframe srcdoc> 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
|
||||
/// <iframe srcdoc="..."> 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("&", "&")
|
||||
.Replace("\"", """);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)} · {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})\">▶</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})\">▶</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"]} • {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" • <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} — {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 ? " ⚠" : "";
|
||||
|
||||
var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle)
|
||||
? "—"
|
||||
: 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} — {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 ? " ⚠" : "";
|
||||
var guestBadge = entry.IsExternalUser ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
|
||||
|
||||
var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle)
|
||||
? "—"
|
||||
: 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"]} • {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" • <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} — {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 ? " ⚠" : "";
|
||||
|
||||
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)} › {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> › {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 › 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("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\"", """)
|
||||
.Replace("'", "'");
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user