From 9f891aa5122a08d050a5446c3f999bf542f28214 Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 7 Apr 2026 12:39:35 +0200 Subject: [PATCH] feat(07-06): implement UserAccessCsvExportService - BuildCsv per-user CSV with summary section (user, totals, sites, high-privilege, date) - WriteAsync groups entries by UserLogin, writes one file per user (audit_{email}_{date}.csv) - WriteSingleFileAsync combines all users in one file for SaveFileDialog export - RFC 4180 CSV escaping, UTF-8 with BOM for Excel compatibility - SanitizeFileName strips invalid path chars from email addresses --- .../Export/UserAccessCsvExportService.cs | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 SharepointToolbox/Services/Export/UserAccessCsvExportService.cs diff --git a/SharepointToolbox/Services/Export/UserAccessCsvExportService.cs b/SharepointToolbox/Services/Export/UserAccessCsvExportService.cs new file mode 100644 index 0000000..6a4577d --- /dev/null +++ b/SharepointToolbox/Services/Export/UserAccessCsvExportService.cs @@ -0,0 +1,145 @@ +using System.IO; +using System.Text; +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services.Export; + +/// +/// Exports user access audit results to CSV format. +/// Produces one CSV file per audited user with a summary section at the top. +/// +public class UserAccessCsvExportService +{ + private const string DataHeader = + "\"Site\",\"Object Type\",\"Object\",\"URL\",\"Permission Level\",\"Access Type\",\"Granted Through\""; + + /// + /// Builds a CSV string for a single user's access entries. + /// Includes a summary section at the top followed by data rows. + /// + public string BuildCsv(string userDisplayName, string userLogin, IReadOnlyList entries) + { + var sb = new StringBuilder(); + + // Summary section + var sitesCount = entries.Select(e => e.SiteUrl).Distinct().Count(); + var highPrivCount = entries.Count(e => e.IsHighPrivilege); + + sb.AppendLine($"\"User Access Audit Report\""); + sb.AppendLine($"\"User\",\"{Csv(userDisplayName)} ({Csv(userLogin)})\""); + sb.AppendLine($"\"Total Accesses\",\"{entries.Count}\""); + sb.AppendLine($"\"Sites\",\"{sitesCount}\""); + sb.AppendLine($"\"High Privilege\",\"{highPrivCount}\""); + sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\""); + sb.AppendLine(); // Blank line separating summary from data + + // Data rows + sb.AppendLine(DataHeader); + 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) + })); + } + + return sb.ToString(); + } + + /// + /// Writes one CSV file per user to the specified directory. + /// File names: audit_{email}_{date}.csv + /// + public async Task WriteAsync( + IReadOnlyList 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 File.WriteAllTextAsync(filePath, csv, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct); + } + } + + /// + /// Writes all entries to a single CSV file (alternative for single-file export). + /// Used when the ViewModel export command picks a single file path. + /// + public async Task WriteSingleFileAsync( + IReadOnlyList entries, + string filePath, + CancellationToken ct) + { + var sb = new StringBuilder(); + var fullHeader = "\"User\",\"User Login\"," + DataHeader; + + // Summary + var users = entries.Select(e => e.UserLogin).Distinct().ToList(); + sb.AppendLine($"\"User Access Audit Report\""); + sb.AppendLine($"\"Users Audited\",\"{users.Count}\""); + sb.AppendLine($"\"Total Accesses\",\"{entries.Count}\""); + sb.AppendLine($"\"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) + })); + } + + await File.WriteAllTextAsync(filePath, sb.ToString(), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct); + } + + /// RFC 4180 CSV field escaping: wrap in double quotes, double internal quotes. + private static string Csv(string value) + { + if (string.IsNullOrEmpty(value)) return "\"\""; + return $"\"{value.Replace("\"", "\"\"")}\""; + } + + 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(); + } +}