From d19092c84eb1ca4ea5a859d63f6521e9e7ada7b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20QUEROL?= <2+kawa@not.obvious> Date: Tue, 2 Jun 2026 10:51:14 +0200 Subject: [PATCH] Initial commit --- .dockerignore | 28 + .gitignore | 58 ++ Components/App.razor | 19 + Components/Layout/MainLayout.razor | 251 +++++ Components/Pages/Admin/AuditLogs.razor | 101 ++ Components/Pages/Admin/UserManagement.razor | 111 +++ Components/Pages/BulkMembers.razor | 116 +++ Components/Pages/BulkSites.razor | 115 +++ Components/Pages/Duplicates.razor | 111 +++ Components/Pages/FileTransfer.razor | 141 +++ Components/Pages/FolderStructure.razor | 91 ++ Components/Pages/Home.razor | 59 ++ Components/Pages/Permissions.razor | 135 +++ Components/Pages/Profiles.razor | 268 ++++++ Components/Pages/Search.razor | 124 +++ Components/Pages/Settings.razor | 59 ++ Components/Pages/Storage.razor | 118 +++ Components/Pages/Templates.razor | 150 +++ Components/Pages/UserAccessAudit.razor | 107 +++ Components/Pages/UserDirectory.razor | 74 ++ Components/Pages/VersionCleanup.razor | 141 +++ Components/Routes.razor | 22 + Components/Shared/AppInitializer.razor | 24 + Components/Shared/NoProfilePrompt.razor | 5 + Components/Shared/ProgressPanel.razor | 29 + .../Shared/SessionCredentialsModal.razor | 78 ++ Components/Shared/WriteGuard.razor | 28 + Components/_Imports.razor | 21 + Core/Config/ClientConnectOptions.cs | 7 + Core/Helpers/ExecuteQueryRetryHelper.cs | 41 + Core/Helpers/PermissionConsolidator.cs | 32 + Core/Helpers/PermissionEntryHelper.cs | 74 ++ Core/Helpers/PermissionLevelMapping.cs | 46 + Core/Helpers/SharePointPaginationHelper.cs | 88 ++ Core/Helpers/SharingLinkLabels.cs | 32 + Core/Helpers/StringExtensions.cs | 7 + Core/Helpers/SystemGroupKind.cs | 2 + Core/Models/AppConfiguration.cs | 7 + Core/Models/AppSettings.cs | 9 + Core/Models/AppUser.cs | 11 + Core/Models/AuditEntry.cs | 14 + Core/Models/BrandingSettings.cs | 6 + Core/Models/BulkMemberRow.cs | 11 + Core/Models/BulkOperationResult.cs | 29 + Core/Models/BulkSiteRow.cs | 13 + Core/Models/ConflictPolicy.cs | 3 + Core/Models/ConsolidatedPermissionEntry.cs | 18 + Core/Models/CsvValidationRow.cs | 22 + Core/Models/DuplicateGroup.cs | 8 + Core/Models/DuplicateItem.cs | 15 + Core/Models/DuplicateScanOptions.cs | 12 + Core/Models/FileTypeMetric.cs | 11 + Core/Models/FolderStructureRow.cs | 17 + Core/Models/GraphDirectoryUser.cs | 9 + Core/Models/LocationInfo.cs | 9 + Core/Models/LogoData.cs | 7 + Core/Models/OperationProgress.cs | 7 + Core/Models/PermissionEntry.cs | 17 + Core/Models/PermissionSummary.cs | 33 + Core/Models/ReportBranding.cs | 3 + Core/Models/ResolvedMember.cs | 3 + Core/Models/RiskLevel.cs | 9 + Core/Models/ScanOptions.cs | 8 + Core/Models/SearchOptions.cs | 15 + Core/Models/SearchResult.cs | 13 + Core/Models/SessionTokens.cs | 11 + Core/Models/SimplifiedPermissionEntry.cs | 35 + Core/Models/SiteInfo.cs | 38 + Core/Models/SiteTemplate.cs | 27 + Core/Models/SiteTemplateOptions.cs | 10 + Core/Models/StorageNode.cs | 17 + Core/Models/StorageNodeKind.cs | 11 + Core/Models/StorageScanOptions.cs | 11 + Core/Models/SystemGroupTarget.cs | 10 + Core/Models/TemplateFolderInfo.cs | 8 + Core/Models/TemplateLibraryInfo.cs | 9 + Core/Models/TemplatePermissionGroup.cs | 8 + Core/Models/TenantProfile.cs | 18 + Core/Models/TransferJob.cs | 16 + Core/Models/TransferMode.cs | 3 + Core/Models/UserAccessEntry.cs | 26 + Core/Models/UserRole.cs | 8 + Core/Models/VersionCleanupOptions.cs | 9 + Core/Models/VersionCleanupResult.cs | 14 + Dockerfile | 22 + Infrastructure/Auth/GraphClientFactory.cs | 32 + Infrastructure/Auth/SessionManager.cs | 160 ++++ Infrastructure/Auth/SessionTokenCredential.cs | 25 + Infrastructure/OAuth/OAuthEndpoints.cs | 328 +++++++ Infrastructure/Persistence/AuditRepository.cs | 50 + .../Persistence/ProfileRepository.cs | 53 ++ .../Persistence/SettingsRepository.cs | 43 + .../Persistence/TemplateRepository.cs | 78 ++ Infrastructure/Persistence/UserRepository.cs | 96 ++ Localization/Strings.Designer.cs | 231 +++++ Localization/Strings.fr.resx | 870 ++++++++++++++++++ Localization/Strings.resx | 870 ++++++++++++++++++ Localization/TranslationSource.cs | 40 + Program.cs | 275 ++++++ Properties/launchSettings.json | 13 + README.md | 2 + Resources/bulk_add_members.csv | 8 + Resources/bulk_create_sites.csv | 6 + Resources/folder_structure.csv | 20 + Services/Audit/AuditService.cs | 63 ++ Services/Audit/IAuditService.cs | 10 + Services/Auth/AppRegistrationService.cs | 141 +++ Services/Auth/IAppRegistrationService.cs | 16 + Services/Auth/ITokenRefreshService.cs | 17 + Services/Auth/IUserService.cs | 16 + Services/Auth/TokenRefreshService.cs | 42 + Services/Auth/UserService.cs | 62 ++ Services/BulkMemberService.cs | 107 +++ Services/BulkOperationRunner.cs | 61 ++ Services/BulkSiteService.cs | 85 ++ Services/CsvValidationService.cs | 107 +++ Services/DuplicatesService.cs | 139 +++ Services/Export/BrandingHtmlHelper.cs | 37 + Services/Export/BulkResultCsvExportService.cs | 61 ++ Services/Export/CsvExportService.cs | 180 ++++ Services/Export/CsvSanitizer.cs | 47 + Services/Export/DuplicatesCsvExportService.cs | 112 +++ .../Export/DuplicatesHtmlExportService.cs | 187 ++++ Services/Export/ExportFileWriter.cs | 64 ++ Services/Export/HtmlExportService.cs | 314 +++++++ Services/Export/PermissionHtmlFragments.cs | 378 ++++++++ Services/Export/ReportSplitHelper.cs | 199 ++++ Services/Export/ReportSplitMode.cs | 16 + Services/Export/SearchCsvExportService.cs | 53 ++ Services/Export/SearchHtmlExportService.cs | 165 ++++ Services/Export/StorageCsvExportService.cs | 227 +++++ Services/Export/StorageHtmlExportService.cs | 465 ++++++++++ Services/Export/UserAccessCsvExportService.cs | 257 ++++++ .../Export/UserAccessHtmlExportService.cs | 708 ++++++++++++++ .../Export/VersionCleanupHtmlExportService.cs | 180 ++++ Services/Export/WebExportService.cs | 28 + Services/FileTransferService.cs | 221 +++++ Services/FolderStructureService.cs | 56 ++ Services/GraphUserDirectoryService.cs | 53 ++ Services/IBulkMemberService.cs | 14 + Services/IBulkSiteService.cs | 11 + Services/ICsvValidationService.cs | 11 + Services/IDuplicatesService.cs | 11 + Services/IFileTransferService.cs | 11 + Services/IFolderStructureService.cs | 12 + Services/IGraphUserDirectoryService.cs | 12 + Services/IOwnershipElevationService.cs | 8 + Services/IPermissionsService.cs | 13 + Services/ISearchService.cs | 11 + Services/ISessionManager.cs | 14 + Services/ISharePointGroupResolver.cs | 13 + Services/IStorageService.cs | 21 + Services/ISystemGroupTargetResolver.cs | 13 + Services/ITemplateService.cs | 17 + Services/IUserAccessAuditService.cs | 16 + Services/IVersionCleanupService.cs | 14 + Services/OAuth/AppRegistrationResult.cs | 10 + Services/OAuth/IOAuthFlowCache.cs | 13 + Services/OAuth/OAuthFlowCache.cs | 44 + Services/OAuth/OAuthFlowState.cs | 16 + Services/OwnershipElevationService.cs | 27 + Services/PermissionsService.cs | 214 +++++ Services/SearchService.cs | 93 ++ Services/Session/ISessionCredentialStore.cs | 14 + Services/Session/IUserContextAccessor.cs | 16 + Services/Session/IUserSessionService.cs | 19 + Services/Session/SessionCredentialStore.cs | 42 + Services/Session/UserContextAccessor.cs | 21 + Services/Session/UserSessionService.cs | 52 ++ Services/SharePointGroupResolver.cs | 125 +++ Services/StorageService.cs | 320 +++++++ Services/SystemGroupTargetResolver.cs | 127 +++ Services/TemplateService.cs | 227 +++++ Services/UserAccessAuditService.cs | 119 +++ Services/VersionCleanupService.cs | 106 +++ SharepointToolbox.Web.csproj | 44 + appsettings.json | 22 + data/profiles.json | 12 + data/users.json | 12 + docker-compose.yml | 25 + wwwroot/app.css | 162 ++++ wwwroot/js/app.js | 16 + 182 files changed, 13757 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Components/App.razor create mode 100644 Components/Layout/MainLayout.razor create mode 100644 Components/Pages/Admin/AuditLogs.razor create mode 100644 Components/Pages/Admin/UserManagement.razor create mode 100644 Components/Pages/BulkMembers.razor create mode 100644 Components/Pages/BulkSites.razor create mode 100644 Components/Pages/Duplicates.razor create mode 100644 Components/Pages/FileTransfer.razor create mode 100644 Components/Pages/FolderStructure.razor create mode 100644 Components/Pages/Home.razor create mode 100644 Components/Pages/Permissions.razor create mode 100644 Components/Pages/Profiles.razor create mode 100644 Components/Pages/Search.razor create mode 100644 Components/Pages/Settings.razor create mode 100644 Components/Pages/Storage.razor create mode 100644 Components/Pages/Templates.razor create mode 100644 Components/Pages/UserAccessAudit.razor create mode 100644 Components/Pages/UserDirectory.razor create mode 100644 Components/Pages/VersionCleanup.razor create mode 100644 Components/Routes.razor create mode 100644 Components/Shared/AppInitializer.razor create mode 100644 Components/Shared/NoProfilePrompt.razor create mode 100644 Components/Shared/ProgressPanel.razor create mode 100644 Components/Shared/SessionCredentialsModal.razor create mode 100644 Components/Shared/WriteGuard.razor create mode 100644 Components/_Imports.razor create mode 100644 Core/Config/ClientConnectOptions.cs create mode 100644 Core/Helpers/ExecuteQueryRetryHelper.cs create mode 100644 Core/Helpers/PermissionConsolidator.cs create mode 100644 Core/Helpers/PermissionEntryHelper.cs create mode 100644 Core/Helpers/PermissionLevelMapping.cs create mode 100644 Core/Helpers/SharePointPaginationHelper.cs create mode 100644 Core/Helpers/SharingLinkLabels.cs create mode 100644 Core/Helpers/StringExtensions.cs create mode 100644 Core/Helpers/SystemGroupKind.cs create mode 100644 Core/Models/AppConfiguration.cs create mode 100644 Core/Models/AppSettings.cs create mode 100644 Core/Models/AppUser.cs create mode 100644 Core/Models/AuditEntry.cs create mode 100644 Core/Models/BrandingSettings.cs create mode 100644 Core/Models/BulkMemberRow.cs create mode 100644 Core/Models/BulkOperationResult.cs create mode 100644 Core/Models/BulkSiteRow.cs create mode 100644 Core/Models/ConflictPolicy.cs create mode 100644 Core/Models/ConsolidatedPermissionEntry.cs create mode 100644 Core/Models/CsvValidationRow.cs create mode 100644 Core/Models/DuplicateGroup.cs create mode 100644 Core/Models/DuplicateItem.cs create mode 100644 Core/Models/DuplicateScanOptions.cs create mode 100644 Core/Models/FileTypeMetric.cs create mode 100644 Core/Models/FolderStructureRow.cs create mode 100644 Core/Models/GraphDirectoryUser.cs create mode 100644 Core/Models/LocationInfo.cs create mode 100644 Core/Models/LogoData.cs create mode 100644 Core/Models/OperationProgress.cs create mode 100644 Core/Models/PermissionEntry.cs create mode 100644 Core/Models/PermissionSummary.cs create mode 100644 Core/Models/ReportBranding.cs create mode 100644 Core/Models/ResolvedMember.cs create mode 100644 Core/Models/RiskLevel.cs create mode 100644 Core/Models/ScanOptions.cs create mode 100644 Core/Models/SearchOptions.cs create mode 100644 Core/Models/SearchResult.cs create mode 100644 Core/Models/SessionTokens.cs create mode 100644 Core/Models/SimplifiedPermissionEntry.cs create mode 100644 Core/Models/SiteInfo.cs create mode 100644 Core/Models/SiteTemplate.cs create mode 100644 Core/Models/SiteTemplateOptions.cs create mode 100644 Core/Models/StorageNode.cs create mode 100644 Core/Models/StorageNodeKind.cs create mode 100644 Core/Models/StorageScanOptions.cs create mode 100644 Core/Models/SystemGroupTarget.cs create mode 100644 Core/Models/TemplateFolderInfo.cs create mode 100644 Core/Models/TemplateLibraryInfo.cs create mode 100644 Core/Models/TemplatePermissionGroup.cs create mode 100644 Core/Models/TenantProfile.cs create mode 100644 Core/Models/TransferJob.cs create mode 100644 Core/Models/TransferMode.cs create mode 100644 Core/Models/UserAccessEntry.cs create mode 100644 Core/Models/UserRole.cs create mode 100644 Core/Models/VersionCleanupOptions.cs create mode 100644 Core/Models/VersionCleanupResult.cs create mode 100644 Dockerfile create mode 100644 Infrastructure/Auth/GraphClientFactory.cs create mode 100644 Infrastructure/Auth/SessionManager.cs create mode 100644 Infrastructure/Auth/SessionTokenCredential.cs create mode 100644 Infrastructure/OAuth/OAuthEndpoints.cs create mode 100644 Infrastructure/Persistence/AuditRepository.cs create mode 100644 Infrastructure/Persistence/ProfileRepository.cs create mode 100644 Infrastructure/Persistence/SettingsRepository.cs create mode 100644 Infrastructure/Persistence/TemplateRepository.cs create mode 100644 Infrastructure/Persistence/UserRepository.cs create mode 100644 Localization/Strings.Designer.cs create mode 100644 Localization/Strings.fr.resx create mode 100644 Localization/Strings.resx create mode 100644 Localization/TranslationSource.cs create mode 100644 Program.cs create mode 100644 Properties/launchSettings.json create mode 100644 README.md create mode 100644 Resources/bulk_add_members.csv create mode 100644 Resources/bulk_create_sites.csv create mode 100644 Resources/folder_structure.csv create mode 100644 Services/Audit/AuditService.cs create mode 100644 Services/Audit/IAuditService.cs create mode 100644 Services/Auth/AppRegistrationService.cs create mode 100644 Services/Auth/IAppRegistrationService.cs create mode 100644 Services/Auth/ITokenRefreshService.cs create mode 100644 Services/Auth/IUserService.cs create mode 100644 Services/Auth/TokenRefreshService.cs create mode 100644 Services/Auth/UserService.cs create mode 100644 Services/BulkMemberService.cs create mode 100644 Services/BulkOperationRunner.cs create mode 100644 Services/BulkSiteService.cs create mode 100644 Services/CsvValidationService.cs create mode 100644 Services/DuplicatesService.cs create mode 100644 Services/Export/BrandingHtmlHelper.cs create mode 100644 Services/Export/BulkResultCsvExportService.cs create mode 100644 Services/Export/CsvExportService.cs create mode 100644 Services/Export/CsvSanitizer.cs create mode 100644 Services/Export/DuplicatesCsvExportService.cs create mode 100644 Services/Export/DuplicatesHtmlExportService.cs create mode 100644 Services/Export/ExportFileWriter.cs create mode 100644 Services/Export/HtmlExportService.cs create mode 100644 Services/Export/PermissionHtmlFragments.cs create mode 100644 Services/Export/ReportSplitHelper.cs create mode 100644 Services/Export/ReportSplitMode.cs create mode 100644 Services/Export/SearchCsvExportService.cs create mode 100644 Services/Export/SearchHtmlExportService.cs create mode 100644 Services/Export/StorageCsvExportService.cs create mode 100644 Services/Export/StorageHtmlExportService.cs create mode 100644 Services/Export/UserAccessCsvExportService.cs create mode 100644 Services/Export/UserAccessHtmlExportService.cs create mode 100644 Services/Export/VersionCleanupHtmlExportService.cs create mode 100644 Services/Export/WebExportService.cs create mode 100644 Services/FileTransferService.cs create mode 100644 Services/FolderStructureService.cs create mode 100644 Services/GraphUserDirectoryService.cs create mode 100644 Services/IBulkMemberService.cs create mode 100644 Services/IBulkSiteService.cs create mode 100644 Services/ICsvValidationService.cs create mode 100644 Services/IDuplicatesService.cs create mode 100644 Services/IFileTransferService.cs create mode 100644 Services/IFolderStructureService.cs create mode 100644 Services/IGraphUserDirectoryService.cs create mode 100644 Services/IOwnershipElevationService.cs create mode 100644 Services/IPermissionsService.cs create mode 100644 Services/ISearchService.cs create mode 100644 Services/ISessionManager.cs create mode 100644 Services/ISharePointGroupResolver.cs create mode 100644 Services/IStorageService.cs create mode 100644 Services/ISystemGroupTargetResolver.cs create mode 100644 Services/ITemplateService.cs create mode 100644 Services/IUserAccessAuditService.cs create mode 100644 Services/IVersionCleanupService.cs create mode 100644 Services/OAuth/AppRegistrationResult.cs create mode 100644 Services/OAuth/IOAuthFlowCache.cs create mode 100644 Services/OAuth/OAuthFlowCache.cs create mode 100644 Services/OAuth/OAuthFlowState.cs create mode 100644 Services/OwnershipElevationService.cs create mode 100644 Services/PermissionsService.cs create mode 100644 Services/SearchService.cs create mode 100644 Services/Session/ISessionCredentialStore.cs create mode 100644 Services/Session/IUserContextAccessor.cs create mode 100644 Services/Session/IUserSessionService.cs create mode 100644 Services/Session/SessionCredentialStore.cs create mode 100644 Services/Session/UserContextAccessor.cs create mode 100644 Services/Session/UserSessionService.cs create mode 100644 Services/SharePointGroupResolver.cs create mode 100644 Services/StorageService.cs create mode 100644 Services/SystemGroupTargetResolver.cs create mode 100644 Services/TemplateService.cs create mode 100644 Services/UserAccessAuditService.cs create mode 100644 Services/VersionCleanupService.cs create mode 100644 SharepointToolbox.Web.csproj create mode 100644 appsettings.json create mode 100644 data/profiles.json create mode 100644 data/users.json create mode 100644 docker-compose.yml create mode 100644 wwwroot/app.css create mode 100644 wwwroot/js/app.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aff72cb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README +!**/.gitignore +!.git/HEAD +data/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a4ab44 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +## .NET / C# +bin/ +obj/ +*.user +*.suo +*.userosscache +*.sln.docstates +.vs/ +*.rsuser +.idea/ + +## Build outputs +[Dd]ebug/ +[Rr]elease/ +[Pp]ublish/ +[Oo]ut/ +artifacts/ +*.nupkg +*.snupkg +*.zip + +## NuGet +*.nuget.props +*.nuget.targets +packages/ +!**/packages/build/ +project.lock.json +project.fragment.lock.json + +## ASP.NET Core +appsettings.Development.json +appsettings.*.json +!appsettings.json + +## Logs +logs/ +*.log + +## User secrets / sensitive config +secrets.json +*.pfx +*.p12 +*.key + +## OS +Thumbs.db +.DS_Store + +## Node (if any frontend assets) +node_modules/ +wwwroot/dist/ +wwwroot/lib/ + +## Test results +TestResults/ +*.trx +*.coveragexml +coverage/ diff --git a/Components/App.razor b/Components/App.razor new file mode 100644 index 0000000..4bd17f2 --- /dev/null +++ b/Components/App.razor @@ -0,0 +1,19 @@ + + + + + + + + SharePoint Toolbox + + + + + + + + + + + diff --git a/Components/Layout/MainLayout.razor b/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..add1d0e --- /dev/null +++ b/Components/Layout/MainLayout.razor @@ -0,0 +1,251 @@ +@inherits LayoutComponentBase +@inject IUserSessionService Session +@inject IUserContextAccessor UserContext +@inject ISessionCredentialStore CredStore +@inject ISessionManager SessionManager +@inject NavigationManager Nav +@inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.WebUtilities +@using SharepointToolbox.Web.Core.Models +@using SharepointToolbox.Web.Services.Session + + + +
+ + +
+ @if (UserContext.IsAuthenticated) + { + @Body + } + else + { +
Loading…
+ } +
+
+ + + +@code { + private bool _sidebarCollapsed; + private bool _hasCredentials; + private string _credUsername = string.Empty; + private SessionCredentialsModal? _credModal; + + protected override void OnInitialized() + { + Session.ProfileChanged += OnProfileChanged; + UserContext.Initialized += OnUserContextInitialized; + } + + private void OnUserContextInitialized() => InvokeAsync(StateHasChanged); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + + // Pick up token_key from OAuth callback redirect before checking credential state + await HandleOAuthCallbackAsync(); + await RefreshCredentialState(); + + // Check for connect_error query param + var uri = new Uri(Nav.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + if (query.TryGetValue("connect_error", out var err) && !string.IsNullOrEmpty(err)) + { + Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true); + if (_credModal is not null) + { + await _credModal.ShowAsync(); + } + } + + // If profile selected but no credentials → show modal + if (Session.HasProfile && !_hasCredentials && _credModal is not null) + await _credModal.ShowAsync(); + } + + private async Task HandleOAuthCallbackAsync() + { + var uri = new Uri(Nav.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + + if (!query.TryGetValue("token_key", out var tokenKey) || string.IsNullOrEmpty(tokenKey)) + return; + + var tokens = OAuthCache.GetAndRemoveTokens(tokenKey!); + if (tokens is not null) + { + await CredStore.SetAsync(tokens); + await SessionManager.ClearAllAsync(); + } + + // Strip token_key from URL bar + Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true); + } + + private async Task RefreshCredentialState() + { + var tokens = await CredStore.GetAsync(); + _hasCredentials = tokens is not null && !string.IsNullOrEmpty(tokens.RefreshToken); + _credUsername = tokens?.UserPrincipalName ?? string.Empty; + await InvokeAsync(StateHasChanged); + } + + private async Task ReconnectAsync() + { + await CredStore.ClearAsync(); + await SessionManager.ClearAllAsync(); + _hasCredentials = false; + _credUsername = string.Empty; + if (Session.HasProfile && _credModal is not null) + await _credModal.ShowAsync(); + } + + private async Task OnCredentialsConnected() + { + await RefreshCredentialState(); + } + + private void OnProfileChanged() + { + InvokeAsync(async () => + { + StateHasChanged(); + // New profile selected → prompt for credentials if none + if (Session.HasProfile && !_hasCredentials && _credModal is not null) + await _credModal.ShowAsync(); + }); + } + + private void ToggleSidebar() => _sidebarCollapsed = !_sidebarCollapsed; + + private static string RoleChipClass(UserRole role) => role switch + { + UserRole.Admin => "chip-red", + UserRole.TechN1 => "chip-green", + _ => "chip-blue" + }; + + public void Dispose() + { + Session.ProfileChanged -= OnProfileChanged; + UserContext.Initialized -= OnUserContextInitialized; + } +} diff --git a/Components/Pages/Admin/AuditLogs.razor b/Components/Pages/Admin/AuditLogs.razor new file mode 100644 index 0000000..aaccb3f --- /dev/null +++ b/Components/Pages/Admin/AuditLogs.razor @@ -0,0 +1,101 @@ +@page "/admin/audit" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@inject IAuditService AuditService +@inject IUserContextAccessor UserContext +@inject NavigationManager Nav +@rendermode InteractiveServer +@using SharepointToolbox.Web.Core.Models +@using SharepointToolbox.Web.Services.Audit +@using SharepointToolbox.Web.Services.Session + +

Audit Logs

+

All technician and admin actions within the application.

+ +@if (!UserContext.IsAuthenticated || UserContext.Role != UserRole.Admin) +{ +
Access denied. Admin role required.
+ return; +} + +
+ + + + Export CSV +
+ +@if (_loading) +{ +
Loading audit log...
+} +else if (_filtered.Count == 0) +{ +
No audit entries found.
+} +else +{ +
+ + + + + + + + + + + + + + @foreach (var e in _filtered) + { + + + + + + + + + + } + +
TimestampUserRoleActionClientSitesDetails
@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")@e.UserDisplay
@e.UserEmail
@e.UserRole@e.Action@e.ClientName@string.Join(", ", e.Sites)@e.Details
+
+

Showing @_filtered.Count of @_entries.Count entries

+} + +@code { + private List _entries = new(); + private List _filtered = new(); + private bool _loading = true; + private string _filterUser = string.Empty; + private string _filterClient = string.Empty; + private string _filterAction = string.Empty; + + protected override async Task OnInitializedAsync() + { + _entries = (await AuditService.GetAllAsync()) + .OrderByDescending(e => e.Timestamp) + .ToList(); + _loading = false; + ApplyFilters(); + } + + private void ApplyFilters() + { + _filtered = _entries.Where(e => + (string.IsNullOrEmpty(_filterUser) || e.UserEmail.Contains(_filterUser, StringComparison.OrdinalIgnoreCase) || e.UserDisplay.Contains(_filterUser, StringComparison.OrdinalIgnoreCase)) && + (string.IsNullOrEmpty(_filterClient) || e.ClientName.Contains(_filterClient, StringComparison.OrdinalIgnoreCase)) && + (string.IsNullOrEmpty(_filterAction) || e.Action.Contains(_filterAction, StringComparison.OrdinalIgnoreCase)) + ).ToList(); + } + + private static string RoleChipClass(UserRole role) => role switch + { + UserRole.Admin => "chip-red", + UserRole.TechN1 => "chip-green", + _ => "chip-blue" + }; +} diff --git a/Components/Pages/Admin/UserManagement.razor b/Components/Pages/Admin/UserManagement.razor new file mode 100644 index 0000000..c1364f4 --- /dev/null +++ b/Components/Pages/Admin/UserManagement.razor @@ -0,0 +1,111 @@ +@page "/admin/users" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@inject IUserService UserService +@inject IUserContextAccessor UserContext +@inject NavigationManager Nav +@rendermode InteractiveServer +@using SharepointToolbox.Web.Core.Models +@using SharepointToolbox.Web.Services.Auth +@using SharepointToolbox.Web.Services.Session + +

User Management

+

Manage technician accounts and roles. Auto-provisioned on first OIDC login.

+ +@if (!UserContext.IsAuthenticated || UserContext.Role != UserRole.Admin) +{ +
Access denied. Admin role required.
+ return; +} + +@if (!string.IsNullOrEmpty(_message)) +{ +
@_message
+} + +@if (_users.Count == 0) +{ +
No users provisioned yet.
+} +else +{ +
+ + + + + + + + + + + + @foreach (var user in _users) + { + + + + + + + + } + +
UserEmailRoleLast LoginActions
@user.DisplayName@user.Email + + @(user.LastLogin?.ToString("yyyy-MM-dd HH:mm") ?? "Never") + @if (user.Email != UserContext.Email) + { + + } + else + { + You + } +
+
+} + +@code { + private List _users = new(); + private string _message = string.Empty; + private bool _isError; + + protected override async Task OnInitializedAsync() + { + _users = (await UserService.GetAllAsync()).ToList(); + } + + private async Task OnRoleChange(AppUser user, ChangeEventArgs e) + { + if (!Enum.TryParse(e.Value?.ToString(), out var newRole)) return; + try + { + await UserService.UpdateRoleAsync(user.Id, newRole); + user.Role = newRole; + _message = $"Role updated for {user.DisplayName}."; + _isError = false; + } + catch (Exception ex) + { + _message = $"Error: {ex.Message}"; + _isError = true; + } + } + + private async Task DeleteUserAsync(AppUser user) + { + await UserService.DeleteAsync(user.Id); + _users.Remove(user); + _message = $"User {user.DisplayName} removed."; + _isError = false; + } +} diff --git a/Components/Pages/BulkMembers.razor b/Components/Pages/BulkMembers.razor new file mode 100644 index 0000000..932ed2f --- /dev/null +++ b/Components/Pages/BulkMembers.razor @@ -0,0 +1,116 @@ +@page "/bulk-members" +@attribute [Authorize] +@inject IUserSessionService Session +@inject IUserContextAccessor UserContext +@inject ISessionManager SessionMgr +@inject IBulkMemberService BulkSvc +@inject ICsvValidationService CsvValidation +@inject BulkResultCsvExportService ExportSvc +@inject WebExportService WebExport +@rendermode InteractiveServer + +

Bulk Members

+

Add users to SharePoint groups from a CSV file.

+ +@if (!Session.HasProfile) { return; } +@if (UserContext.Role < UserRole.TechN1) { return; } + +
+
+ + +
+
+ + +
+ + @if (_rows.Count > 0) + { +
+ @_rows.Count(r => r.IsValid) valid rows, @_rows.Count(r => !r.IsValid) errors. +
+
+ + + + @foreach (var row in _rows.Take(50)) + { + + + + + + + } + +
GroupEmailRoleStatus
@(row.Record?.GroupName ?? "—")@(row.Record?.Email ?? "—")@(row.Record?.Role ?? "—")@(row.IsValid ? "✓" : string.Join("; ", row.Errors))
+
+ } + +
+ + @if (_running) { } +
+ +
+ +@if (!string.IsNullOrEmpty(_error)) {
@_error
} + +@if (_summary != null) +{ +
+
+ Processed: @_summary.SuccessCount / @_summary.TotalCount. Failures: @_summary.FailedCount +
+ @if (_summary.HasFailures) + { + + } +
+} + +@code { + private string _siteUrl = string.Empty; + private List> _rows = new(); + private bool _running; private string _status = string.Empty, _error = string.Empty; + private int _current, _total; + private BulkOperationSummary? _summary; + private CancellationTokenSource? _cts; + + private async Task LoadFile(InputFileChangeEventArgs e) + { + _rows.Clear(); + var file = e.File; + using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); + _rows = CsvValidation.ParseAndValidateMembers(stream); + } + + private async Task RunBulk() + { + _error = string.Empty; _summary = null; _running = true; + _cts = new CancellationTokenSource(); + var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList(); + var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); + var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); + try + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token); + _summary = await BulkSvc.AddMembersAsync(ctx, Session.CurrentProfile!, validRows, progress, _cts.Token); + _status = $"Complete: {_summary.SuccessCount} added, {_summary.FailedCount} failed."; + } + catch (OperationCanceledException) { _status = "Cancelled."; } + catch (Exception ex) { _error = ex.Message; } + finally { _running = false; await InvokeAsync(StateHasChanged); } + } + + private void Cancel() => _cts?.Cancel(); + private async Task ExportErrors() + { + if (_summary == null) return; + var csv = ExportSvc.BuildFailedItemsCsv(_summary.Results.ToList()); + await WebExport.DownloadCsvAsync(csv, $"bulk_members_errors_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); + } +} diff --git a/Components/Pages/BulkSites.razor b/Components/Pages/BulkSites.razor new file mode 100644 index 0000000..5dfda53 --- /dev/null +++ b/Components/Pages/BulkSites.razor @@ -0,0 +1,115 @@ +@page "/bulk-sites" +@attribute [Authorize] +@inject IUserSessionService Session +@inject IUserContextAccessor UserContext +@inject ISessionManager SessionMgr +@inject IBulkSiteService BulkSvc +@inject ICsvValidationService CsvValidation +@inject BulkResultCsvExportService ExportSvc +@inject WebExportService WebExport +@rendermode InteractiveServer + +

Bulk Site Creation

+

Create multiple SharePoint sites from a CSV file.

+ +@if (!Session.HasProfile) { return; } +@if (UserContext.Role < UserRole.TechN1) { return; } + +
+
+ + +
+
+ + +
+ + @if (_rows.Count > 0) + { +
@_rows.Count(r => r.IsValid) valid, @_rows.Count(r => !r.IsValid) errors.
+
+ + + + @foreach (var row in _rows.Take(50)) + { + + + + + + + } + +
NameTypeAliasStatus
@(row.Record?.Name ?? "—")@(row.Record?.Type ?? "—")@(row.Record?.Alias ?? "—")@(row.IsValid ? "✓" : string.Join("; ", row.Errors))
+
+ } + +
+ + @if (_running) { } +
+ +
+ +@if (!string.IsNullOrEmpty(_error)) {
@_error
} + +@if (_summary != null) +{ +
+
+ Created: @_summary.SuccessCount / @_summary.TotalCount. Failures: @_summary.FailedCount +
+ @if (_summary.HasFailures) + { + + } +
+} + +@code { + private string _adminUrl = string.Empty; + private List> _rows = new(); + private bool _running; private string _status = string.Empty, _error = string.Empty; + private int _current, _total; + private BulkOperationSummary? _summary; + private CancellationTokenSource? _cts; + + private async Task LoadFile(InputFileChangeEventArgs e) + { + _rows.Clear(); + using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); + _rows = CsvValidation.ParseAndValidateSites(stream); + } + + private async Task RunBulk() + { + _error = string.Empty; _summary = null; _running = true; + _cts = new CancellationTokenSource(); + var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList(); + var adminUrl = string.IsNullOrWhiteSpace(_adminUrl) + ? Session.CurrentProfile!.TenantUrl.Replace(".sharepoint.com", "-admin.sharepoint.com") + : _adminUrl.Trim(); + var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); + try + { + var ctx = await SessionMgr.GetOrCreateContextAsync(adminUrl, Session.CurrentProfile!, _cts.Token); + _summary = await BulkSvc.CreateSitesAsync(ctx, validRows, progress, _cts.Token); + _status = $"Complete: {_summary.SuccessCount} created, {_summary.FailedCount} failed."; + } + catch (OperationCanceledException) { _status = "Cancelled."; } + catch (Exception ex) { _error = ex.Message; } + finally { _running = false; await InvokeAsync(StateHasChanged); } + } + + private void Cancel() => _cts?.Cancel(); + private async Task ExportErrors() + { + if (_summary == null) return; + var csv = ExportSvc.BuildFailedItemsCsv(_summary.Results.ToList()); + await WebExport.DownloadCsvAsync(csv, $"bulk_sites_errors_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); + } +} diff --git a/Components/Pages/Duplicates.razor b/Components/Pages/Duplicates.razor new file mode 100644 index 0000000..cfd1566 --- /dev/null +++ b/Components/Pages/Duplicates.razor @@ -0,0 +1,111 @@ +@page "/duplicates" +@attribute [Authorize] +@inject IUserSessionService Session +@inject ISessionManager SessionMgr +@inject IDuplicatesService DupSvc +@inject DuplicatesCsvExportService CsvExport +@inject DuplicatesHtmlExportService HtmlExport +@inject WebExportService WebExport +@rendermode InteractiveServer + +

Duplicate Detection

+ +@if (!Session.HasProfile) { return; } + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + @if (_mode == "Folders") + { + + + } +
+
+ + @if (_running) { } +
+ +
+ +@if (!string.IsNullOrEmpty(_error)) {
@_error
} + +@if (_results.Count > 0) +{ +
+
+
Duplicate Groups @_results.Count
+
+ + +
+ @foreach (var g in _results.Take(100)) + { +
+
+ @g.Name @g.Items.Count copies +
+ @foreach (var item in g.Items) + { +
+ @item.Library › @item.Path + @if (item.SizeBytes.HasValue) { (@((item.SizeBytes.Value/1024.0).ToString("F1")) KB) } +
+ } +
+ } + @if (_results.Count > 100) {
Showing first 100 groups. Export for all.
} +
+} + +@code { + private string _siteUrl = string.Empty, _library = string.Empty, _mode = "Files"; + private bool _matchSize = true, _matchCreated, _matchModified, _matchFolderCount, _matchFileCount; + private bool _running; private string _status = string.Empty, _error = string.Empty; + private int _current, _total; + private List _results = new(); + private CancellationTokenSource? _cts; + + private async Task RunScan() + { + _error = string.Empty; _results.Clear(); _running = true; + _cts = new CancellationTokenSource(); + var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); + var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); + try + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token); + var opts = new DuplicateScanOptions(_mode, _matchSize, _matchCreated, _matchModified, _matchFolderCount, _matchFileCount, Library: _library.TrimOrNull()); + _results = (await DupSvc.ScanDuplicatesAsync(ctx, opts, progress, _cts.Token)).ToList(); + _status = $"Found {_results.Count} duplicate groups."; + } + catch (OperationCanceledException) { _status = "Cancelled."; } + catch (Exception ex) { _error = ex.Message; } + finally { _running = false; await InvokeAsync(StateHasChanged); } + } + + private void Cancel() => _cts?.Cancel(); + private async Task ExportCsv() { await WebExport.DownloadCsvAsync(CsvExport.BuildCsv(_results), $"duplicates_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); } + private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"duplicates_{DateTime.Now:yyyyMMdd_HHmmss}.html"); } +} diff --git a/Components/Pages/FileTransfer.razor b/Components/Pages/FileTransfer.razor new file mode 100644 index 0000000..82c8b24 --- /dev/null +++ b/Components/Pages/FileTransfer.razor @@ -0,0 +1,141 @@ +@page "/transfer" +@attribute [Authorize] +@inject IUserSessionService Session +@inject IUserContextAccessor UserContext +@inject ISessionManager SessionMgr +@inject IFileTransferService TransferSvc +@rendermode InteractiveServer + +

File Transfer

+ +@if (!Session.HasProfile) { return; } +@if (UserContext.Role < UserRole.TechN1) { return; } + +
+
Source
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
Destination
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + @if (_running) { } +
+ +
+ +@if (!string.IsNullOrEmpty(_error)) {
@_error
} + +@if (_summary != null) +{ +
+
+ Transferred: @_summary.SuccessCount / @_summary.TotalCount files. + @if (_summary.HasFailures) { Failures: @_summary.FailedCount } +
+ @if (_summary.HasFailures) + { +
+ + + + @foreach (var f in _summary.FailedItems) + { + + } + +
FileError
@f.Item@f.ErrorMessage
+
+ } +
+} + +@code { + private string _srcSiteUrl = string.Empty, _srcLibrary = string.Empty, _srcFolder = string.Empty; + private string _dstSiteUrl = string.Empty, _dstLibrary = string.Empty, _dstFolder = string.Empty; + private string _mode = "Copy", _conflict = "Skip"; + private bool _includeSourceFolder; + private bool _running; private string _status = string.Empty, _error = string.Empty; + private int _current, _total; + private BulkOperationSummary? _summary; + private CancellationTokenSource? _cts; + + private async Task RunTransfer() + { + _error = string.Empty; _summary = null; _running = true; + _cts = new CancellationTokenSource(); + var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); + try + { + var srcUrl = string.IsNullOrWhiteSpace(_srcSiteUrl) ? Session.CurrentProfile!.TenantUrl : _srcSiteUrl.Trim(); + var dstUrl = string.IsNullOrWhiteSpace(_dstSiteUrl) ? Session.CurrentProfile!.TenantUrl : _dstSiteUrl.Trim(); + var srcCtx = await SessionMgr.GetOrCreateContextAsync(srcUrl, Session.CurrentProfile!, _cts.Token); + var dstCtx = await SessionMgr.GetOrCreateContextAsync(dstUrl, Session.CurrentProfile!, _cts.Token); + var job = new TransferJob + { + SourceSiteUrl = srcUrl, SourceLibrary = _srcLibrary, SourceFolderPath = _srcFolder, + DestinationSiteUrl = dstUrl, DestinationLibrary = _dstLibrary, DestinationFolderPath = _dstFolder, + Mode = _mode == "Move" ? TransferMode.Move : TransferMode.Copy, + ConflictPolicy = _conflict == "Overwrite" ? ConflictPolicy.Overwrite : _conflict == "Rename" ? ConflictPolicy.Rename : ConflictPolicy.Skip, + IncludeSourceFolder = _includeSourceFolder + }; + _summary = await TransferSvc.TransferAsync(srcCtx, dstCtx, job, progress, _cts.Token); + _status = $"Complete: {_summary.SuccessCount} transferred."; + } + catch (OperationCanceledException) { _status = "Cancelled."; } + catch (Exception ex) { _error = ex.Message; } + finally { _running = false; await InvokeAsync(StateHasChanged); } + } + + private void Cancel() => _cts?.Cancel(); +} diff --git a/Components/Pages/FolderStructure.razor b/Components/Pages/FolderStructure.razor new file mode 100644 index 0000000..e637d73 --- /dev/null +++ b/Components/Pages/FolderStructure.razor @@ -0,0 +1,91 @@ +@page "/folder-structure" +@attribute [Authorize] +@inject IUserSessionService Session +@inject IUserContextAccessor UserContext +@inject ISessionManager SessionMgr +@inject IFolderStructureService FolderSvc +@inject ICsvValidationService CsvValidation +@rendermode InteractiveServer + +

Folder Structure

+

Create folder hierarchies in a document library from a CSV template.

+ +@if (!Session.HasProfile) { return; } +@if (UserContext.Role < UserRole.TechN1) { return; } + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + @if (_rows.Count > 0) + { +
@_rows.Count(r => r.IsValid) valid rows, @_rows.Count(r => !r.IsValid) errors.
+ } + +
+ + @if (_running) { } +
+ +
+ +@if (!string.IsNullOrEmpty(_error)) {
@_error
} + +@if (_summary != null) +{ +
+
+ Created: @_summary.SuccessCount folders. Failures: @_summary.FailedCount +
+
+} + +@code { + private string _siteUrl = string.Empty, _libraryTitle = string.Empty; + private List> _rows = new(); + private bool _running; private string _status = string.Empty, _error = string.Empty; + private int _current, _total; + private BulkOperationSummary? _summary; + private CancellationTokenSource? _cts; + + private async Task LoadFile(InputFileChangeEventArgs e) + { + _rows.Clear(); + using var stream = e.File.OpenReadStream(maxAllowedSize: 5 * 1024 * 1024); + _rows = CsvValidation.ParseAndValidateFolders(stream); + } + + private async Task RunCreate() + { + _error = string.Empty; _summary = null; _running = true; + _cts = new CancellationTokenSource(); + var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList(); + var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); + var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); + try + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token); + _summary = await FolderSvc.CreateFoldersAsync(ctx, _libraryTitle, validRows, progress, _cts.Token); + _status = $"Complete: {_summary.SuccessCount} folders created."; + } + catch (OperationCanceledException) { _status = "Cancelled."; } + catch (Exception ex) { _error = ex.Message; } + finally { _running = false; await InvokeAsync(StateHasChanged); } + } + + private void Cancel() => _cts?.Cancel(); +} diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor new file mode 100644 index 0000000..2af34ac --- /dev/null +++ b/Components/Pages/Home.razor @@ -0,0 +1,59 @@ +@page "/" +@attribute [Authorize] +@inject IUserSessionService Session +@rendermode InteractiveServer + +

SharePoint Toolbox

+ +@if (!Session.HasProfile) +{ +
+
Welcome
+

Select a tenant profile to start using SharePoint Toolbox.

+ Manage Profiles +
+} +else +{ +
+
Connected: @Session.CurrentProfile!.Name
+

Tenant: @Session.CurrentProfile.TenantUrl

+ +
+ +
+ @foreach (var feature in _features) + { + +
+
@feature.Icon
+
@feature.Title
+
@feature.Description
+
+
+ } +
+} + +@code { + private readonly (string Href, string Icon, string Title, string Description)[] _features = new[] + { + ("/permissions", "🔐", "Permissions Audit", "Scan site permission assignments"), + ("/storage", "💾", "Storage Metrics", "Analyze library storage usage"), + ("/search", "🔍", "File Search", "KQL-based file search"), + ("/duplicates", "📋", "Duplicates", "Find duplicate files/folders"), + ("/versions", "🗂️", "Version Cleanup", "Delete old file versions"), + ("/transfer", "📦", "File Transfer", "Copy/move files between libraries"), + ("/bulk-members", "👥", "Bulk Members", "Add users to groups via CSV"), + ("/bulk-sites", "🌐", "Bulk Sites", "Create sites from CSV"), + ("/folder-structure", "📁", "Folder Structure", "Create folders from CSV template"), + ("/user-audit", "👤", "User Access Audit", "Audit user permissions cross-site"), + ("/user-directory", "📖", "User Directory", "Browse tenant users via Graph"), + ("/templates", "📐", "Templates", "Capture and apply site templates"), + }; +} diff --git a/Components/Pages/Permissions.razor b/Components/Pages/Permissions.razor new file mode 100644 index 0000000..ec0820d --- /dev/null +++ b/Components/Pages/Permissions.razor @@ -0,0 +1,135 @@ +@page "/permissions" +@attribute [Authorize] +@inject IUserSessionService Session +@inject ISessionManager SessionMgr +@inject IPermissionsService PermSvc +@inject CsvExportService CsvExport +@inject HtmlExportService HtmlExport +@inject WebExportService WebExport +@rendermode InteractiveServer + +

Permissions Audit

+ +@if (!Session.HasProfile) { return; } + +
+
Scan Options
+
+
+ + +
+
+
+
+ + +
+
+ + + +
+
+
+ + @if (_running) + { + + } +
+ +
+ +@if (!string.IsNullOrEmpty(_error)) +{ +
@_error
+} + +@if (_results.Count > 0) +{ +
+
+
Results @_results.Count
+
+ + +
+
+ + + + + + + + + + + + @foreach (var r in _results.Take(500)) + { + + + + + + + + } + +
TypeTitleUsersPermissionGranted Through
@r.ObjectType@r.Title@r.Users@r.PermissionLevels@r.GrantedThrough
+
+ @if (_results.Count > 500) + { +
Showing first 500 of @_results.Count rows. Export for full results.
+ } +
+} + +@code { + private string _siteUrl = string.Empty; + private bool _includeInherited, _includeSubsites; + private bool _scanFolders = true; + private int _folderDepth = 1; + private bool _running; + private string _status = string.Empty; + private string _error = string.Empty; + private int _current, _total; + private List _results = new(); + private CancellationTokenSource? _cts; + + private async Task RunScan() + { + _error = string.Empty; _results.Clear(); _running = true; + _cts = new CancellationTokenSource(); + var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); + var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); + try + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token); + var opts = new ScanOptions(_includeInherited, _scanFolders, _folderDepth, _includeSubsites); + _results = (await PermSvc.ScanSiteAsync(ctx, opts, progress, _cts.Token)).ToList(); + _status = $"Scan complete: {_results.Count} entries found."; + } + catch (OperationCanceledException) { _status = "Cancelled."; } + catch (Exception ex) { _error = ex.Message; } + finally { _running = false; await InvokeAsync(StateHasChanged); } + } + + private void Cancel() => _cts?.Cancel(); + + private async Task ExportCsv() + { + var csv = CsvExport.BuildCsv(_results); + await WebExport.DownloadCsvAsync(csv, $"permissions_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); + } + + private async Task ExportHtml() + { + var html = HtmlExport.BuildHtml(_results); + await WebExport.DownloadHtmlAsync(html, $"permissions_{DateTime.Now:yyyyMMdd_HHmmss}.html"); + } +} diff --git a/Components/Pages/Profiles.razor b/Components/Pages/Profiles.razor new file mode 100644 index 0000000..e4f6074 --- /dev/null +++ b/Components/Pages/Profiles.razor @@ -0,0 +1,268 @@ +@page "/profiles" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@inject IUserSessionService Session +@inject IUserContextAccessor UserContext +@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo +@inject ISessionCredentialStore CredStore +@inject NavigationManager Nav +@inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache +@rendermode InteractiveServer +@using Microsoft.AspNetCore.WebUtilities +@using SharepointToolbox.Web.Core.Models +@using SharepointToolbox.Web.Services.Session + +

Client Profiles

+

Manage SharePoint tenant connections. Credentials are entered per session — no secrets stored on disk.

+ +@if (UserContext.Role != UserRole.Admin) +{ + @* Non-admins can only select a profile, not create/edit/delete *@ +
Profile management is restricted to Admins. Select a profile below to work on a client.
+ + @foreach (var p in _profiles) + { +
+
+
+
@p.Name
+
@p.TenantUrl
+
+
+ @if (Session.CurrentProfile?.Id == p.Id) + { + Active + } + +
+
+ } + return; +} + +@* Admin view — full CRUD *@ + +@if (!string.IsNullOrEmpty(_pageError)) +{ +
@_pageError
+} + +
+ +
+ +@if (_profiles.Count == 0 && !_showForm) +{ +
No profiles configured. Create one to get started.
+} + +@foreach (var p in _profiles) +{ +
+
+
+
@p.Name
+
@p.TenantUrl
+
Tenant ID: @p.TenantId
+
Client ID: @p.ClientId
+
+
+ @if (Session.CurrentProfile?.Id == p.Id) + { + Active + } + + + +
+
+} + +@if (_showForm) +{ +
+
@(_editing?.Id == null ? "New Profile" : "Edit Profile")
+ @if (!string.IsNullOrEmpty(_formError)) + { +
@_formError
+ } +
+ + +
+
+ + +
+
+ + +
+ + @* App registration section *@ +
+ +
+ + +
+ + Click "Register in Entra" to auto-create the app registration in the client tenant — requires Global Admin credentials. + Or enter an existing public client App Registration ID manually. + +
+ +
+ + +
+
+} + +@code { + private List _profiles = new(); + private bool _showForm; + private bool _registering; + private TenantProfile? _editing; + private TenantProfile _form = new(); + private string _formError = string.Empty; + private string _pageError = string.Empty; + + private bool CanRegister => + !string.IsNullOrWhiteSpace(_form.Name) && + !string.IsNullOrWhiteSpace(_form.TenantUrl) && + !string.IsNullOrWhiteSpace(_form.TenantId); + + protected override async Task OnInitializedAsync() + { + _profiles = (await ProfileRepo.LoadAsync()).ToList(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + await HandleRegResultAsync(); + await HandleConnectErrorAsync(); + } + + private async Task HandleRegResultAsync() + { + var uri = new Uri(Nav.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + + if (!query.TryGetValue("reg_result_key", out var key) || string.IsNullOrEmpty(key)) + return; + + var result = OAuthCache.GetAndRemoveRegistrationResult(key!); + if (result is not null) + { + _form = new TenantProfile + { + Name = result.TenantName, + TenantUrl = result.TenantUrl, + TenantId = result.TenantId, + ClientId = result.ClientId, + }; + _showForm = true; + _formError = string.Empty; + await InvokeAsync(StateHasChanged); + } + + Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true); + } + + private async Task HandleConnectErrorAsync() + { + var uri = new Uri(Nav.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + + if (!query.TryGetValue("connect_error", out var err) || string.IsNullOrEmpty(err)) + return; + + _pageError = err!; + await InvokeAsync(StateHasChanged); + Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true); + } + + private void AddNew() + { + _editing = null; + _form = new TenantProfile(); + _showForm = true; + _formError = string.Empty; + _pageError = string.Empty; + } + + private void EditProfile(TenantProfile p) + { + _editing = p; + _form = new TenantProfile { Id = p.Id, Name = p.Name, TenantUrl = p.TenantUrl, TenantId = p.TenantId, ClientId = p.ClientId }; + _showForm = true; + _formError = _pageError = string.Empty; + } + + private void CancelForm() { _showForm = false; _editing = null; } + + private void SelectProfile(TenantProfile p) + { + Session.SetProfile(p); + StateHasChanged(); + } + + private async Task RegisterAppAsync() + { + if (!CanRegister) return; + _registering = true; + StateHasChanged(); + + var returnUrl = Nav.Uri.Contains('?') + ? Nav.Uri.Substring(0, Nav.Uri.IndexOf('?')) + : Nav.Uri; + + var url = $"/connect/register-initiate" + + $"?tenantId={Uri.EscapeDataString(_form.TenantId)}" + + $"&tenantName={Uri.EscapeDataString(_form.Name)}" + + $"&tenantUrl={Uri.EscapeDataString(_form.TenantUrl)}" + + $"&returnUrl={Uri.EscapeDataString(returnUrl)}"; + + Nav.NavigateTo(url, forceLoad: true); + } + + private async Task SaveProfile() + { + _formError = string.Empty; + if (string.IsNullOrWhiteSpace(_form.Name)) { _formError = "Name is required."; return; } + if (string.IsNullOrWhiteSpace(_form.TenantUrl)) { _formError = "Tenant URL is required."; return; } + if (string.IsNullOrWhiteSpace(_form.ClientId)) { _formError = "Client ID is required."; return; } + if (string.IsNullOrWhiteSpace(_form.TenantId)) { _formError = "Tenant ID is required."; return; } + + if (_editing == null) + { + _form.Id = Guid.NewGuid().ToString(); + _profiles.Add(_form); + } + else + { + var idx = _profiles.FindIndex(p => p.Id == _editing.Id); + if (idx >= 0) _profiles[idx] = _form; + } + await ProfileRepo.SaveAsync(_profiles); + _showForm = false; _editing = null; + } + + private async Task DeleteProfile(TenantProfile p) + { + _profiles.RemoveAll(x => x.Id == p.Id); + await ProfileRepo.SaveAsync(_profiles); + if (Session.CurrentProfile?.Id == p.Id) await Session.ClearSessionAsync(); + } +} diff --git a/Components/Pages/Search.razor b/Components/Pages/Search.razor new file mode 100644 index 0000000..f80ebaa --- /dev/null +++ b/Components/Pages/Search.razor @@ -0,0 +1,124 @@ +@page "/search" +@attribute [Authorize] +@inject IUserSessionService Session +@inject ISessionManager SessionMgr +@inject ISearchService SearchSvc +@inject SearchCsvExportService CsvExport +@inject SearchHtmlExportService HtmlExport +@inject WebExportService WebExport +@rendermode InteractiveServer + +

File Search

+ +@if (!Session.HasProfile) { return; } + +
+
Search Options
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + @if (_running) { } +
+ +
+ +@if (!string.IsNullOrEmpty(_error)) {
@_error
} + +@if (_results.Count > 0) +{ +
+
+
Results @_results.Count
+
+ + +
+
+ + + + @foreach (var r in _results.Take(500)) + { + + + + + + + + + } + +
NameExtPathCreatedModifiedSize (KB)
@System.IO.Path.GetFileName(r.Path)@r.FileExtension@r.Path@(r.Created?.ToString("yyyy-MM-dd") ?? "")@(r.LastModified?.ToString("yyyy-MM-dd") ?? "")@((r.SizeBytes / 1024.0).ToString("F1"))
+
+ @if (_results.Count > 500) {
Showing first 500 of @_results.Count. Export for full results.
} +
+} + +@code { + private string _siteUrl = string.Empty, _extensions = string.Empty, _regex = string.Empty; + private string _createdBy = string.Empty, _modifiedBy = string.Empty, _library = string.Empty; + private int _maxResults = 5000; + private bool _running; private string _status = string.Empty, _error = string.Empty; + private int _current, _total; + private List _results = new(); + private CancellationTokenSource? _cts; + + private async Task RunSearch() + { + _error = string.Empty; _results.Clear(); _running = true; + _cts = new CancellationTokenSource(); + var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); + var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); + try + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token); + var exts = _extensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var opts = new SearchOptions(exts, _regex, null, null, null, null, _createdBy.TrimOrNull(), _modifiedBy.TrimOrNull(), _library.TrimOrNull(), _maxResults, siteUrl); + _results = (await SearchSvc.SearchFilesAsync(ctx, opts, progress, _cts.Token)).ToList(); + _status = $"Found {_results.Count} files."; + } + catch (OperationCanceledException) { _status = "Cancelled."; } + catch (Exception ex) { _error = ex.Message; } + finally { _running = false; await InvokeAsync(StateHasChanged); } + } + + private void Cancel() => _cts?.Cancel(); + private async Task ExportCsv() { await WebExport.DownloadCsvAsync(CsvExport.BuildCsv(_results), $"search_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); } + private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"search_{DateTime.Now:yyyyMMdd_HHmmss}.html"); } +} diff --git a/Components/Pages/Settings.razor b/Components/Pages/Settings.razor new file mode 100644 index 0000000..c68d9c3 --- /dev/null +++ b/Components/Pages/Settings.razor @@ -0,0 +1,59 @@ +@page "/settings" +@attribute [Authorize] +@inject IUserSessionService Session +@rendermode InteractiveServer + +

Settings

+ +
+
Display
+
+ + +
+
+ + +
+
+ +
+
Behavior
+
+ +
+
+ +@if (_saved) {
Settings saved.
} + +@code { + private string _lang = "en", _theme = "System"; + private bool _autoTakeOwnership, _saved; + + protected override void OnInitialized() + { + var s = Session.Settings; + _lang = s.Lang; + _theme = s.Theme; + _autoTakeOwnership = s.AutoTakeOwnership; + } + + private void Save() + { + Session.UpdateSettings(new AppSettings { Lang = _lang, Theme = _theme, AutoTakeOwnership = _autoTakeOwnership }); + SharepointToolbox.Web.Localization.TranslationSource.Instance.SetCulture(_lang); + _saved = true; + StateHasChanged(); + _ = Task.Delay(2000).ContinueWith(_ => { _saved = false; InvokeAsync(StateHasChanged); }); + } +} diff --git a/Components/Pages/Storage.razor b/Components/Pages/Storage.razor new file mode 100644 index 0000000..b7f272f --- /dev/null +++ b/Components/Pages/Storage.razor @@ -0,0 +1,118 @@ +@page "/storage" +@attribute [Authorize] +@inject IUserSessionService Session +@inject ISessionManager SessionMgr +@inject IStorageService StorageSvc +@inject StorageCsvExportService CsvExport +@inject StorageHtmlExportService HtmlExport +@inject WebExportService WebExport +@rendermode InteractiveServer + +

Storage Metrics

+ +@if (!Session.HasProfile) { return; } + +
+
Scan Options
+
+
+ + +
+
+
+
+ + + +
+
+
+ + @if (_running) { } +
+ +
+ +@if (!string.IsNullOrEmpty(_error)) {
@_error
} + +@if (_results.Count > 0) +{ +
+
+
Storage Report @_results.Count libraries
+
+ + +
+
+ + + + + + + + + + + + + @foreach (var n in _results) + { + + + + + + + + + } + +
LibrarySiteFilesTotal (MB)Versions (MB)Last Modified
@n.Name@n.SiteTitle@n.TotalFileCount.ToString("N0")@((n.TotalSizeBytes / 1048576.0).ToString("F2"))@((n.VersionSizeBytes / 1048576.0).ToString("F2"))@(n.LastModified?.ToString("yyyy-MM-dd") ?? "")
+
+
+} + +@code { + private string _siteUrl = string.Empty; + private bool _includeSubsites, _includeHidden = true, _includeRecycleBin = true; + private bool _running; private string _status = string.Empty, _error = string.Empty; + private int _current, _total; + private List _results = new(); + private CancellationTokenSource? _cts; + + private async Task RunScan() + { + _error = string.Empty; _results.Clear(); _running = true; + _cts = new CancellationTokenSource(); + var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); + var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); + try + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token); + var opts = new StorageScanOptions(IncludeSubsites: _includeSubsites, IncludeHiddenLibraries: _includeHidden, IncludeRecycleBin: _includeRecycleBin); + _results = (await StorageSvc.CollectStorageAsync(ctx, opts, progress, _cts.Token)).ToList(); + _status = $"Complete: {_results.Count} nodes."; + } + catch (OperationCanceledException) { _status = "Cancelled."; } + catch (Exception ex) { _error = ex.Message; } + finally { _running = false; await InvokeAsync(StateHasChanged); } + } + + private void Cancel() => _cts?.Cancel(); + + private async Task ExportCsv() + { + var csv = CsvExport.BuildCsv(_results); + await WebExport.DownloadCsvAsync(csv, $"storage_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); + } + private async Task ExportHtml() + { + var html = HtmlExport.BuildHtml(_results); + await WebExport.DownloadHtmlAsync(html, $"storage_{DateTime.Now:yyyyMMdd_HHmmss}.html"); + } +} diff --git a/Components/Pages/Templates.razor b/Components/Pages/Templates.razor new file mode 100644 index 0000000..0c488d8 --- /dev/null +++ b/Components/Pages/Templates.razor @@ -0,0 +1,150 @@ +@page "/templates" +@attribute [Authorize] +@inject IUserSessionService Session +@inject IUserContextAccessor UserContext +@inject ISessionManager SessionMgr +@inject ITemplateService TemplateSvc +@inject SharepointToolbox.Web.Infrastructure.Persistence.TemplateRepository TemplateRepo +@rendermode InteractiveServer + +

Site Templates

+

Capture site structure and apply to new sites.

+ +@if (!Session.HasProfile) { return; } +@if (UserContext.Role < UserRole.TechN1) { return; } + +
+
+
Capture Template
+
+ + +
+
+ + +
+
+ + + +
+ + +
+ +
+
Apply Template
+ @if (_selectedTemplate == null) + { +
Select a template from the list below.
+ } + else + { +
Template: @_selectedTemplate.Name
+
+ + +
+
+ + +
+
+ + +
+ + } +
+
+ +@if (!string.IsNullOrEmpty(_error)) {
@_error
} +@if (!string.IsNullOrEmpty(_successMsg)) {
@_successMsg
} + +
+
Saved Templates
+ @if (_templates.Count == 0) + { +
No templates saved.
+ } + @foreach (var t in _templates) + { +
+
+
@t.Name
+
@t.SiteType · @t.CapturedAt.ToString("yyyy-MM-dd") · @t.Libraries.Count libraries
+
+
+ + +
+ } +
+ +@code { + private string _captureUrl = string.Empty, _captureName = string.Empty; + private bool _capLibraries = true, _capFolders = true, _capGroups = true; + private SiteTemplate? _selectedTemplate; + private string _newTitle = string.Empty, _newAlias = string.Empty, _adminUrl = string.Empty; + private bool _running; private string _status = string.Empty, _error = string.Empty, _successMsg = string.Empty; + private List _templates = new(); + private CancellationTokenSource? _cts; + + protected override async Task OnInitializedAsync() + { + _templates = (await TemplateRepo.GetAllAsync()).ToList(); + } + + private async Task CaptureTemplate() + { + _error = string.Empty; _successMsg = string.Empty; _running = true; + _cts = new CancellationTokenSource(); + var siteUrl = string.IsNullOrWhiteSpace(_captureUrl) ? Session.CurrentProfile!.TenantUrl : _captureUrl.Trim(); + var progress = new Progress(p => { _status = p.Message; InvokeAsync(StateHasChanged); }); + try + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token); + var opts = new SiteTemplateOptions { CaptureLibraries = _capLibraries, CaptureFolders = _capFolders, CapturePermissionGroups = _capGroups }; + var template = await TemplateSvc.CaptureTemplateAsync(ctx, opts, progress, _cts.Token); + template.Name = string.IsNullOrWhiteSpace(_captureName) ? $"Template-{DateTime.Now:yyyyMMdd}" : _captureName; + await TemplateRepo.SaveAsync(template); + _templates = (await TemplateRepo.GetAllAsync()).ToList(); + _successMsg = $"Template '{template.Name}' saved."; + } + catch (OperationCanceledException) { _status = "Cancelled."; } + catch (Exception ex) { _error = ex.Message; } + finally { _running = false; await InvokeAsync(StateHasChanged); } + } + + private async Task ApplyTemplate() + { + if (_selectedTemplate == null) return; + _error = string.Empty; _successMsg = string.Empty; _running = true; + _cts = new CancellationTokenSource(); + var adminUrl = string.IsNullOrWhiteSpace(_adminUrl) + ? Session.CurrentProfile!.TenantUrl.Replace(".sharepoint.com", "-admin.sharepoint.com") + : _adminUrl.Trim(); + var progress = new Progress(p => { _status = p.Message; InvokeAsync(StateHasChanged); }); + try + { + var ctx = await SessionMgr.GetOrCreateContextAsync(adminUrl, Session.CurrentProfile!, _cts.Token); + var url = await TemplateSvc.ApplyTemplateAsync(ctx, _selectedTemplate, _newTitle, _newAlias, progress, _cts.Token); + _successMsg = $"Site created: {url}"; + } + catch (OperationCanceledException) { _status = "Cancelled."; } + catch (Exception ex) { _error = ex.Message; } + finally { _running = false; await InvokeAsync(StateHasChanged); } + } + + private async Task DeleteTemplate(SiteTemplate t) + { + await TemplateRepo.DeleteAsync(t.Id); + _templates.RemoveAll(x => x.Id == t.Id); + if (_selectedTemplate?.Id == t.Id) _selectedTemplate = null; + } +} diff --git a/Components/Pages/UserAccessAudit.razor b/Components/Pages/UserAccessAudit.razor new file mode 100644 index 0000000..da55a3e --- /dev/null +++ b/Components/Pages/UserAccessAudit.razor @@ -0,0 +1,107 @@ +@page "/user-audit" +@attribute [Authorize] +@inject IUserSessionService Session +@inject ISessionManager SessionMgr +@inject IUserAccessAuditService AuditSvc +@inject UserAccessCsvExportService CsvExport +@inject UserAccessHtmlExportService HtmlExport +@inject WebExportService WebExport +@rendermode InteractiveServer + +

User Access Audit

+

Find all permissions for one or more users across multiple sites.

+ +@if (!Session.HasProfile) { return; } + +
+
+
+ + +
+
+ + +
+
+
+
+ + + +
+
+
+ + @if (_running) { } +
+ +
+ +@if (!string.IsNullOrEmpty(_error)) {
@_error
} + +@if (_results.Count > 0) +{ +
+
+
Audit Results @_results.Count
+
+ + +
+
+ + + + @foreach (var r in _results.Take(500)) + { + + + + + + + + + } + +
UserSiteObjectPermissionAccess TypeGranted Through
@r.UserDisplayName@r.SiteTitle@r.ObjectTitle (@r.ObjectType)@r.PermissionLevel @if (r.IsHighPrivilege) { High }@r.AccessType@r.GrantedThrough
+
+ @if (_results.Count > 500) {
Showing first 500. Export for full results.
} +
+} + +@code { + private string _users = string.Empty, _sites = string.Empty; + private bool _includeInherited, _includeSubsites, _scanFolders = true; + private bool _running; private string _status = string.Empty, _error = string.Empty; + private int _current, _total; + private List _results = new(); + private CancellationTokenSource? _cts; + + private async Task RunAudit() + { + _error = string.Empty; _results.Clear(); _running = true; + _cts = new CancellationTokenSource(); + var userList = _users.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + var siteList = _sites.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(u => new SiteInfo(u, u.TrimEnd('/').Split('/').Last())).ToList(); + if (!siteList.Any()) siteList.Add(new SiteInfo(Session.CurrentProfile!.TenantUrl, Session.CurrentProfile.Name)); + var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); + try + { + var opts = new ScanOptions(_includeInherited, _scanFolders, 1, _includeSubsites); + _results = (await AuditSvc.AuditUsersAsync(SessionMgr, Session.CurrentProfile!, userList, siteList, opts, progress, _cts.Token)).ToList(); + _status = $"Found {_results.Count} access entries."; + } + catch (OperationCanceledException) { _status = "Cancelled."; } + catch (Exception ex) { _error = ex.Message; } + finally { _running = false; await InvokeAsync(StateHasChanged); } + } + + private void Cancel() => _cts?.Cancel(); + private async Task ExportCsv() { await WebExport.DownloadCsvAsync(CsvExport.BuildCsv(_results.FirstOrDefault()?.UserDisplayName ?? "Users", _results.FirstOrDefault()?.UserLogin ?? "", _results), $"user_audit_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); } + private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"user_audit_{DateTime.Now:yyyyMMdd_HHmmss}.html"); } +} diff --git a/Components/Pages/UserDirectory.razor b/Components/Pages/UserDirectory.razor new file mode 100644 index 0000000..f1a2eb5 --- /dev/null +++ b/Components/Pages/UserDirectory.razor @@ -0,0 +1,74 @@ +@page "/user-directory" +@attribute [Authorize] +@inject IUserSessionService Session +@inject IGraphUserDirectoryService GraphSvc +@rendermode InteractiveServer + +

User Directory

+

Browse all tenant users via Microsoft Graph.

+ +@if (!Session.HasProfile) { return; } + +
+
+ + +
+ +
+ +@if (!string.IsNullOrEmpty(_error)) {
@_error
} + +@if (_users.Count > 0) +{ +
+
+
Users @_users.Count
+ +
+
+ + + + @foreach (var u in FilteredUsers.Take(500)) + { + + + + + + + + } + +
NameUPNDepartmentJob TitleType
@u.DisplayName@u.UserPrincipalName@u.Department@u.JobTitle@(u.UserType ?? "Member")
+
+ @if (FilteredUsers.Count() > 500) {
Showing first 500 of @FilteredUsers.Count() filtered.
} +
+} + +@code { + private bool _includeGuests, _running; + private string _status = string.Empty, _error = string.Empty, _filter = string.Empty; + private int _loadCount; + private List _users = new(); + + private IEnumerable FilteredUsers => string.IsNullOrWhiteSpace(_filter) + ? _users + : _users.Where(u => u.DisplayName.Contains(_filter, StringComparison.OrdinalIgnoreCase) || u.UserPrincipalName.Contains(_filter, StringComparison.OrdinalIgnoreCase)); + + private async Task LoadUsers() + { + _error = string.Empty; _users.Clear(); _running = true; _loadCount = 0; + var progress = new Progress(count => { _loadCount = count; InvokeAsync(StateHasChanged); }); + try + { + _users = (await GraphSvc.GetUsersAsync(Session.CurrentProfile!, _includeGuests, progress)).ToList(); + _status = $"Loaded {_users.Count} users."; + } + catch (Exception ex) { _error = ex.Message; } + finally { _running = false; await InvokeAsync(StateHasChanged); } + } +} diff --git a/Components/Pages/VersionCleanup.razor b/Components/Pages/VersionCleanup.razor new file mode 100644 index 0000000..3f6b126 --- /dev/null +++ b/Components/Pages/VersionCleanup.razor @@ -0,0 +1,141 @@ +@page "/versions" +@attribute [Authorize] +@inject IUserSessionService Session +@inject IUserContextAccessor UserContext +@inject ISessionManager SessionMgr +@inject IVersionCleanupService VersionSvc +@inject VersionCleanupHtmlExportService HtmlExport +@inject WebExportService WebExport +@rendermode InteractiveServer + +

Version Cleanup

+ +@if (!Session.HasProfile) { return; } +@if (UserContext.Role < UserRole.TechN1) { return; } + +
+
+
+ + +
+
+
+ +
+ @if (_libraries.Count > 0) + { +
+ +
+ @foreach (var lib in _libraries) + { + + } +
+
+ } +
+
+ + +
+
+ +
+
+
+ + @if (_running) { } +
+ +
+ +@if (!string.IsNullOrEmpty(_error)) {
@_error
} + +@if (_results.Count > 0) +{ +
+
+
Results @_results.Count files
+
+ +
+
+ Versions deleted: @_results.Sum(r => r.VersionsDeleted) | + Freed: @((_results.Sum(r => r.BytesFreed) / 1048576.0).ToString("F2")) MB | + Errors: @_results.Count(r => r.Error != null) +
+
+ + + + @foreach (var r in _results.Take(500)) + { + + + + + + + + + } + +
LibraryFileBeforeDeletedFreed (KB)Error
@r.Library@r.FileName@r.VersionsBefore@r.VersionsDeleted@((r.BytesFreed / 1024.0).ToString("F1"))@r.Error
+
+
+} + +@code { + private string _siteUrl = string.Empty; + private int _keepLast = 5; private bool _keepFirst; + private List _libraries = new(), _selectedLibs = new(); + private bool _running, _loading; private string _status = string.Empty, _error = string.Empty; + private int _current, _total; + private List _results = new(); + private CancellationTokenSource? _cts; + + private async Task LoadLibraries() + { + _loading = true; _error = string.Empty; + try + { + var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, CancellationToken.None); + _libraries = (await VersionSvc.ListLibraryTitlesAsync(ctx, CancellationToken.None)).ToList(); + } + catch (Exception ex) { _error = ex.Message; } + finally { _loading = false; } + } + + private void ToggleLib(string lib, bool selected) { if (selected) _selectedLibs.Add(lib); else _selectedLibs.Remove(lib); } + + private async Task RunCleanup() + { + _error = string.Empty; _results.Clear(); _running = true; + _cts = new CancellationTokenSource(); + var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); + var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); + try + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token); + var opts = new VersionCleanupOptions(_selectedLibs, _keepLast, _keepFirst); + _results = (await VersionSvc.DeleteOldVersionsAsync(ctx, opts, progress, _cts.Token)).ToList(); + _status = $"Complete: {_results.Sum(r => r.VersionsDeleted)} versions deleted."; + } + catch (OperationCanceledException) { _status = "Cancelled."; } + catch (Exception ex) { _error = ex.Message; } + finally { _running = false; await InvokeAsync(StateHasChanged); } + } + + private void Cancel() => _cts?.Cancel(); + private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"versions_{DateTime.Now:yyyyMMdd_HHmmss}.html"); } +} diff --git a/Components/Routes.razor b/Components/Routes.razor new file mode 100644 index 0000000..66cae57 --- /dev/null +++ b/Components/Routes.razor @@ -0,0 +1,22 @@ + + + + + + @if (!context.User.Identity?.IsAuthenticated ?? true) + { + + } + else + { +
+

Access denied

+

You do not have permission to view this page.

+
+ } +
+
+ +
+
+
diff --git a/Components/Shared/AppInitializer.razor b/Components/Shared/AppInitializer.razor new file mode 100644 index 0000000..2cd1b23 --- /dev/null +++ b/Components/Shared/AppInitializer.razor @@ -0,0 +1,24 @@ +@inject AuthenticationStateProvider AuthProvider +@inject IUserService UserService +@inject IUserContextAccessor UserContext +@using Microsoft.AspNetCore.Components.Authorization +@using SharepointToolbox.Web.Services.Auth +@using SharepointToolbox.Web.Services.Session + +@* Invisible component. Run once per circuit to seed IUserContextAccessor. *@ + +@code { + protected override async Task OnInitializedAsync() + { + var state = await AuthProvider.GetAuthenticationStateAsync(); + var principal = state.User; + if (principal.Identity?.IsAuthenticated != true) return; + + var email = principal.FindFirst("preferred_username")?.Value + ?? principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value; + if (string.IsNullOrEmpty(email)) return; + + var user = await UserService.GetByEmailAsync(email); + if (user is not null) UserContext.Initialize(user); + } +} diff --git a/Components/Shared/NoProfilePrompt.razor b/Components/Shared/NoProfilePrompt.razor new file mode 100644 index 0000000..a463050 --- /dev/null +++ b/Components/Shared/NoProfilePrompt.razor @@ -0,0 +1,5 @@ +
+

No profile selected

+

Select or create a tenant profile to get started.

+ Go to Profiles +
diff --git a/Components/Shared/ProgressPanel.razor b/Components/Shared/ProgressPanel.razor new file mode 100644 index 0000000..c419bc0 --- /dev/null +++ b/Components/Shared/ProgressPanel.razor @@ -0,0 +1,29 @@ +@if (IsRunning || !string.IsNullOrEmpty(StatusMessage)) +{ +
+ @if (!string.IsNullOrEmpty(StatusMessage)) + { +
@StatusMessage
+ } + @if (IsRunning) + { +
+ @if (Total > 0) + { +
+ } + else + { +
+ } +
+ } +
+} + +@code { + [Parameter] public bool IsRunning { get; set; } + [Parameter] public string StatusMessage { get; set; } = string.Empty; + [Parameter] public int Current { get; set; } + [Parameter] public int Total { get; set; } +} diff --git a/Components/Shared/SessionCredentialsModal.razor b/Components/Shared/SessionCredentialsModal.razor new file mode 100644 index 0000000..aa748a3 --- /dev/null +++ b/Components/Shared/SessionCredentialsModal.razor @@ -0,0 +1,78 @@ +@inject ISessionCredentialStore CredStore +@inject IUserSessionService Session +@inject ISessionManager SessionManager +@inject NavigationManager Nav +@using SharepointToolbox.Web.Core.Models +@using SharepointToolbox.Web.Services.Session + +@if (_visible) +{ + +} + +@code { + [Parameter] public EventCallback OnConnected { get; set; } + + private bool _visible; + private bool _connecting; + private string _error = string.Empty; + + public async Task ShowAsync() + { + _error = string.Empty; + _connecting = false; + _visible = true; + await InvokeAsync(StateHasChanged); + } + + private async Task ConnectAsync() + { + var profile = Session.CurrentProfile; + if (profile is null) { _error = "No client profile selected."; return; } + + _connecting = true; + _error = string.Empty; + + // Clear any stale CSOM contexts + await SessionManager.ClearAllAsync(); + + var currentUrl = Nav.Uri; + var connectUrl = $"/connect/initiate?profileId={Uri.EscapeDataString(profile.Id)}" + + $"&returnUrl={Uri.EscapeDataString(currentUrl)}"; + + // Force full HTTP navigation to break out of the Blazor SignalR circuit + Nav.NavigateTo(connectUrl, forceLoad: true); + } + + private void Cancel() + { + _visible = false; + _connecting = false; + } +} diff --git a/Components/Shared/WriteGuard.razor b/Components/Shared/WriteGuard.razor new file mode 100644 index 0000000..64d9a62 --- /dev/null +++ b/Components/Shared/WriteGuard.razor @@ -0,0 +1,28 @@ +@inject IUserContextAccessor UserContext +@using SharepointToolbox.Web.Core.Models +@using SharepointToolbox.Web.Services.Session + +@* Wrap write-only UI. TechN0 sees the ReadOnlyContent fallback. *@ + +@if (UserContext.Role >= UserRole.TechN1) +{ + @ChildContent +} +else +{ + @if (ReadOnlyContent is not null) + { + @ReadOnlyContent + } + else + { +
+ You have read-only access (Tech-N0). Contact an Admin to request write access. +
+ } +} + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public RenderFragment? ReadOnlyContent { get; set; } +} diff --git a/Components/_Imports.razor b/Components/_Imports.razor new file mode 100644 index 0000000..b526846 --- /dev/null +++ b/Components/_Imports.razor @@ -0,0 +1,21 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using SharepointToolbox.Web.Services.Audit +@using SharepointToolbox.Web.Services.Auth +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Microsoft.Extensions.Options +@using SharepointToolbox.Web +@using SharepointToolbox.Web.Core.Models +@using SharepointToolbox.Web.Services +@using SharepointToolbox.Web.Services.Session +@using SharepointToolbox.Web.Services.Export +@using SharepointToolbox.Web.Components +@using SharepointToolbox.Web.Components.Shared +@using SharepointToolbox.Web.Core.Helpers diff --git a/Core/Config/ClientConnectOptions.cs b/Core/Config/ClientConnectOptions.cs new file mode 100644 index 0000000..a90c1ab --- /dev/null +++ b/Core/Config/ClientConnectOptions.cs @@ -0,0 +1,7 @@ +namespace SharepointToolbox.Web.Core.Config; + +public class ClientConnectOptions +{ + /// Must match the redirect URI registered in each client's app registration. + public string RedirectUri { get; set; } = string.Empty; +} diff --git a/Core/Helpers/ExecuteQueryRetryHelper.cs b/Core/Helpers/ExecuteQueryRetryHelper.cs new file mode 100644 index 0000000..f9ec257 --- /dev/null +++ b/Core/Helpers/ExecuteQueryRetryHelper.cs @@ -0,0 +1,41 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Core.Helpers; + +public static class ExecuteQueryRetryHelper +{ + private const int MaxRetries = 5; + + public static async Task ExecuteQueryRetryAsync( + ClientContext ctx, + IProgress? progress = null, + CancellationToken ct = default) + { + int attempt = 0; + while (true) + { + ct.ThrowIfCancellationRequested(); + try + { + await ctx.ExecuteQueryAsync(); + return; + } + catch (Exception ex) when (IsThrottleException(ex) && attempt < MaxRetries) + { + attempt++; + int delaySeconds = (int)Math.Pow(2, attempt) * 5; + progress?.Report(OperationProgress.Indeterminate( + $"Throttled by SharePoint — retrying in {delaySeconds}s (attempt {attempt}/{MaxRetries})…")); + await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct); + } + } + } + + internal static bool IsThrottleException(Exception ex) + { + var msg = ex.Message; + return msg.Contains("429") || msg.Contains("503") || + msg.Contains("throttl", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Core/Helpers/PermissionConsolidator.cs b/Core/Helpers/PermissionConsolidator.cs new file mode 100644 index 0000000..55cf9d0 --- /dev/null +++ b/Core/Helpers/PermissionConsolidator.cs @@ -0,0 +1,32 @@ +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Core.Helpers; + +public static class PermissionConsolidator +{ + internal static string MakeKey(UserAccessEntry entry) + => string.Join("|", + entry.UserLogin.ToLowerInvariant(), + entry.PermissionLevel.ToLowerInvariant(), + entry.AccessType.ToString(), + entry.GrantedThrough.ToLowerInvariant()); + + public static IReadOnlyList Consolidate(IReadOnlyList entries) + { + if (entries.Count == 0) return Array.Empty(); + return entries + .GroupBy(e => MakeKey(e)) + .Select(g => + { + var first = g.First(); + var locations = g.Select(e => new LocationInfo( + e.SiteUrl, e.SiteTitle, e.ObjectTitle, e.ObjectUrl, e.ObjectType)).ToList(); + return new ConsolidatedPermissionEntry( + first.UserDisplayName, first.UserLogin, first.PermissionLevel, + first.AccessType, first.GrantedThrough, first.IsHighPrivilege, + first.IsExternalUser, locations, + first.TargetUrl, first.TargetLabel, first.SharingLinkType); + }) + .OrderBy(c => c.UserLogin).ThenBy(c => c.PermissionLevel).ToList(); + } +} diff --git a/Core/Helpers/PermissionEntryHelper.cs b/Core/Helpers/PermissionEntryHelper.cs new file mode 100644 index 0000000..4a8eb91 --- /dev/null +++ b/Core/Helpers/PermissionEntryHelper.cs @@ -0,0 +1,74 @@ +using System.Text.RegularExpressions; + +namespace SharepointToolbox.Web.Core.Helpers; + +public static class PermissionEntryHelper +{ + private static readonly Regex LimitedAccessWebRegex = new( + @"^Limited Access System Group For Web\s+(?[0-9a-fA-F-]{36})\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex LimitedAccessListRegex = new( + @"^Limited Access System Group For List\s+(?[0-9a-fA-F-]{36})\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex SharingLinkRegex = new( + @"^SharingLinks\.(?[0-9a-fA-F-]{36})\.(?[^.]+)\.(?[0-9a-fA-F-]{36})\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public static bool IsExternalUser(string loginName) => + loginName.Contains("#EXT#", StringComparison.OrdinalIgnoreCase); + + public static IReadOnlyList FilterPermissionLevels(IEnumerable levels) => + levels.Where(l => !string.Equals(l, "Limited Access", StringComparison.OrdinalIgnoreCase)).ToList(); + + public static bool IsBareLimitedAccessSystemGroup(string name) => + name.Equals("Limited Access System Group", StringComparison.OrdinalIgnoreCase); + + public static SystemGroupClassification Classify(string groupTitle) + { + if (string.IsNullOrWhiteSpace(groupTitle)) + return new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null); + + var trimmed = groupTitle.Trim(); + + if (IsBareLimitedAccessSystemGroup(trimmed)) + return new SystemGroupClassification(SystemGroupKind.LimitedAccessBare, null, null, null, null, null); + + var mWeb = LimitedAccessWebRegex.Match(trimmed); + if (mWeb.Success && Guid.TryParse(mWeb.Groups["id"].Value, out var webId)) + return new SystemGroupClassification(SystemGroupKind.LimitedAccessWeb, webId, null, null, null, null); + + var mList = LimitedAccessListRegex.Match(trimmed); + if (mList.Success && Guid.TryParse(mList.Groups["id"].Value, out var listId)) + return new SystemGroupClassification(SystemGroupKind.LimitedAccessList, null, listId, null, null, null); + + var mShare = SharingLinkRegex.Match(trimmed); + if (mShare.Success + && Guid.TryParse(mShare.Groups["item"].Value, out var itemId) + && Guid.TryParse(mShare.Groups["share"].Value, out var shareId)) + { + return new SystemGroupClassification( + SystemGroupKind.SharingLink, null, null, itemId, mShare.Groups["type"].Value, shareId); + } + + return new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null); + } +} + +public enum SystemGroupKind +{ + None, + LimitedAccessBare, + LimitedAccessWeb, + LimitedAccessList, + SharingLink +} + +public readonly record struct SystemGroupClassification( + SystemGroupKind Kind, + Guid? WebId, + Guid? ListId, + Guid? ItemUniqueId, + string? LinkType, + Guid? ShareId); diff --git a/Core/Helpers/PermissionLevelMapping.cs b/Core/Helpers/PermissionLevelMapping.cs new file mode 100644 index 0000000..99288ef --- /dev/null +++ b/Core/Helpers/PermissionLevelMapping.cs @@ -0,0 +1,46 @@ +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Core.Helpers; + +public static class PermissionLevelMapping +{ + public record MappingResult(string Label, RiskLevel RiskLevel); + + private static readonly Dictionary Mappings = new(StringComparer.OrdinalIgnoreCase) + { + ["Full Control"] = new("Full control (can manage everything)", RiskLevel.High), + ["Site Collection Administrator"] = new("Site collection admin (full control)", RiskLevel.High), + ["Contribute"] = new("Can edit files and list items", RiskLevel.Medium), + ["Edit"] = new("Can edit files, lists, and pages", RiskLevel.Medium), + ["Design"] = new("Can edit pages and use design tools", RiskLevel.Medium), + ["Approve"] = new("Can approve content and list items", RiskLevel.Medium), + ["Manage Hierarchy"] = new("Can create sites and manage pages", RiskLevel.Medium), + ["Read"] = new("Can view files and pages", RiskLevel.Low), + ["Restricted Read"] = new("Can view pages only (no download)", RiskLevel.Low), + ["View Only"] = new("Can view files in browser only", RiskLevel.ReadOnly), + ["Restricted View"] = new("Restricted view access", RiskLevel.ReadOnly), + }; + + public static MappingResult GetMapping(string roleName) + { + if (string.IsNullOrWhiteSpace(roleName)) return new(roleName, RiskLevel.Low); + return Mappings.TryGetValue(roleName.Trim(), out var result) ? result : new(roleName.Trim(), RiskLevel.Medium); + } + + public static IReadOnlyList GetMappings(string permissionLevels) + { + if (string.IsNullOrWhiteSpace(permissionLevels)) return Array.Empty(); + return permissionLevels.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(GetMapping).ToList(); + } + + public static RiskLevel GetHighestRisk(string permissionLevels) + { + var mappings = GetMappings(permissionLevels); + if (mappings.Count == 0) return RiskLevel.Low; + return mappings.Min(m => m.RiskLevel); + } + + public static string GetSimplifiedLabels(string permissionLevels) + => string.Join("; ", GetMappings(permissionLevels).Select(m => m.Label)); +} diff --git a/Core/Helpers/SharePointPaginationHelper.cs b/Core/Helpers/SharePointPaginationHelper.cs new file mode 100644 index 0000000..79ca40f --- /dev/null +++ b/Core/Helpers/SharePointPaginationHelper.cs @@ -0,0 +1,88 @@ +using System.Runtime.CompilerServices; +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Core.Helpers; + +public static class SharePointPaginationHelper +{ + private const int DefaultRowLimit = 5000; + + public static async IAsyncEnumerable GetAllItemsAsync( + ClientContext ctx, + List list, + CamlQuery? baseQuery = null, + [EnumeratorCancellation] CancellationToken ct = default) + { + var query = baseQuery ?? CamlQuery.CreateAllItemsQuery(); + query.ViewXml = BuildPagedViewXml(query.ViewXml, DefaultRowLimit); + query.ListItemCollectionPosition = null; + + do + { + ct.ThrowIfCancellationRequested(); + var items = list.GetItems(query); + ctx.Load(items); + await ctx.ExecuteQueryAsync(); + foreach (var item in items) yield return item; + query.ListItemCollectionPosition = items.ListItemCollectionPosition; + } + while (query.ListItemCollectionPosition != null); + } + + public static async IAsyncEnumerable GetItemsInFolderAsync( + ClientContext ctx, + List list, + string folderServerRelativeUrl, + bool recursive, + string[]? viewFields = null, + [EnumeratorCancellation] CancellationToken ct = default) + { + var fields = viewFields ?? new[] + { + "FSObjType", "FileRef", "FileLeafRef", "FileDirRef", "File_x0020_Size" + }; + var viewFieldsXml = string.Join(string.Empty, fields.Select(f => $"")); + var scope = recursive ? " Scope='RecursiveAll'" : string.Empty; + var viewXml = + $"" + + $"{viewFieldsXml}" + + $"{DefaultRowLimit}"; + + var query = new CamlQuery + { + ViewXml = viewXml, + FolderServerRelativeUrl = folderServerRelativeUrl, + ListItemCollectionPosition = null + }; + + do + { + ct.ThrowIfCancellationRequested(); + var items = list.GetItems(query); + ctx.Load(items); + await ctx.ExecuteQueryAsync(); + foreach (var item in items) yield return item; + query.ListItemCollectionPosition = items.ListItemCollectionPosition; + } + while (query.ListItemCollectionPosition != null); + } + + internal static string BuildPagedViewXml(string? existingXml, int rowLimit) + { + if (string.IsNullOrWhiteSpace(existingXml)) + return $"{rowLimit}"; + if (System.Text.RegularExpressions.Regex.IsMatch( + existingXml, @"]*>\d+", + System.Text.RegularExpressions.RegexOptions.IgnoreCase)) + { + return System.Text.RegularExpressions.Regex.Replace( + existingXml, @"]*>\d+", + $"{rowLimit}", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + } + return existingXml.Replace("", + $"{rowLimit}", + StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Core/Helpers/SharingLinkLabels.cs b/Core/Helpers/SharingLinkLabels.cs new file mode 100644 index 0000000..3e14736 --- /dev/null +++ b/Core/Helpers/SharingLinkLabels.cs @@ -0,0 +1,32 @@ +namespace SharepointToolbox.Web.Core.Helpers; + +public enum SharingLinkRisk { Low, Medium, High, Unknown } + +public static class SharingLinkLabels +{ + public static (string Label, SharingLinkRisk Risk) Describe(string? rawLinkType) + { + if (string.IsNullOrWhiteSpace(rawLinkType)) return (string.Empty, SharingLinkRisk.Unknown); + return rawLinkType.Trim() switch + { + "OrganizationView" => ("Org link · View", SharingLinkRisk.Low), + "OrganizationEdit" => ("Org link · Edit", SharingLinkRisk.Medium), + "AnonymousView" => ("Anyone · View", SharingLinkRisk.High), + "AnonymousEdit" => ("Anyone · Edit", SharingLinkRisk.High), + "Flexible" => ("Custom link", SharingLinkRisk.Medium), + "Direct" => ("Specific people", SharingLinkRisk.Low), + "Existing" => ("Existing access", SharingLinkRisk.Low), + "Review" => ("Review only", SharingLinkRisk.Low), + "Embed" => ("Embedded link", SharingLinkRisk.Medium), + _ => (rawLinkType, SharingLinkRisk.Unknown) + }; + } + + public static (string Background, string Foreground) Colors(SharingLinkRisk risk) => risk switch + { + SharingLinkRisk.Low => ("#D1FAE5", "#065F46"), + SharingLinkRisk.Medium => ("#FEF3C7", "#92400E"), + SharingLinkRisk.High => ("#FEE2E2", "#991B1B"), + _ => ("#F3F4F6", "#374151"), + }; +} diff --git a/Core/Helpers/StringExtensions.cs b/Core/Helpers/StringExtensions.cs new file mode 100644 index 0000000..d703415 --- /dev/null +++ b/Core/Helpers/StringExtensions.cs @@ -0,0 +1,7 @@ +namespace SharepointToolbox.Web.Core.Helpers; + +public static class StringExtensions +{ + public static string? TrimOrNull(this string? s) + => string.IsNullOrWhiteSpace(s) ? null : s.Trim(); +} diff --git a/Core/Helpers/SystemGroupKind.cs b/Core/Helpers/SystemGroupKind.cs new file mode 100644 index 0000000..51d56e2 --- /dev/null +++ b/Core/Helpers/SystemGroupKind.cs @@ -0,0 +1,2 @@ +// SystemGroupKind, SystemGroupClassification, and PermissionEntryHelper are defined in PermissionEntryHelper.cs + diff --git a/Core/Models/AppConfiguration.cs b/Core/Models/AppConfiguration.cs new file mode 100644 index 0000000..8ed7219 --- /dev/null +++ b/Core/Models/AppConfiguration.cs @@ -0,0 +1,7 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class AppConfiguration +{ + public string DataFolder { get; set; } = "/data"; + public string ExportsFolder { get; set; } = "/data/exports"; +} diff --git a/Core/Models/AppSettings.cs b/Core/Models/AppSettings.cs new file mode 100644 index 0000000..a6d141e --- /dev/null +++ b/Core/Models/AppSettings.cs @@ -0,0 +1,9 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class AppSettings +{ + public string DataFolder { get; set; } = string.Empty; + public string Lang { get; set; } = "en"; + public bool AutoTakeOwnership { get; set; } = false; + public string Theme { get; set; } = "System"; +} diff --git a/Core/Models/AppUser.cs b/Core/Models/AppUser.cs new file mode 100644 index 0000000..71bd629 --- /dev/null +++ b/Core/Models/AppUser.cs @@ -0,0 +1,11 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class AppUser +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Email { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public UserRole Role { get; set; } = UserRole.TechN0; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastLogin { get; set; } +} diff --git a/Core/Models/AuditEntry.cs b/Core/Models/AuditEntry.cs new file mode 100644 index 0000000..936c55b --- /dev/null +++ b/Core/Models/AuditEntry.cs @@ -0,0 +1,14 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class AuditEntry +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; + public string UserEmail { get; set; } = string.Empty; + public string UserDisplay { get; set; } = string.Empty; + public UserRole UserRole { get; set; } + public string Action { get; set; } = string.Empty; + public string ClientName { get; set; } = string.Empty; + public List Sites { get; set; } = new(); + public string Details { get; set; } = string.Empty; +} diff --git a/Core/Models/BrandingSettings.cs b/Core/Models/BrandingSettings.cs new file mode 100644 index 0000000..6f395f8 --- /dev/null +++ b/Core/Models/BrandingSettings.cs @@ -0,0 +1,6 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class BrandingSettings +{ + public LogoData? MspLogo { get; set; } +} diff --git a/Core/Models/BulkMemberRow.cs b/Core/Models/BulkMemberRow.cs new file mode 100644 index 0000000..97043a7 --- /dev/null +++ b/Core/Models/BulkMemberRow.cs @@ -0,0 +1,11 @@ +using CsvHelper.Configuration.Attributes; + +namespace SharepointToolbox.Web.Core.Models; + +public class BulkMemberRow +{ + [Name("GroupName")] public string GroupName { get; set; } = string.Empty; + [Name("GroupUrl")] public string GroupUrl { get; set; } = string.Empty; + [Name("Email")] public string Email { get; set; } = string.Empty; + [Name("Role")] public string Role { get; set; } = string.Empty; +} diff --git a/Core/Models/BulkOperationResult.cs b/Core/Models/BulkOperationResult.cs new file mode 100644 index 0000000..70024f0 --- /dev/null +++ b/Core/Models/BulkOperationResult.cs @@ -0,0 +1,29 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class BulkItemResult +{ + public T Item { get; } + public bool IsSuccess { get; } + public string? ErrorMessage { get; } + public DateTime Timestamp { get; } + + private BulkItemResult(T item, bool success, string? error) + { + Item = item; IsSuccess = success; ErrorMessage = error; Timestamp = DateTime.UtcNow; + } + + public static BulkItemResult Success(T item) => new(item, true, null); + public static BulkItemResult Failed(T item, string error) => new(item, false, error); +} + +public class BulkOperationSummary +{ + public IReadOnlyList> Results { get; } + public int TotalCount => Results.Count; + public int SuccessCount => Results.Count(r => r.IsSuccess); + public int FailedCount => Results.Count(r => !r.IsSuccess); + public bool HasFailures => FailedCount > 0; + public IReadOnlyList> FailedItems => Results.Where(r => !r.IsSuccess).ToList(); + + public BulkOperationSummary(IReadOnlyList> results) { Results = results; } +} diff --git a/Core/Models/BulkSiteRow.cs b/Core/Models/BulkSiteRow.cs new file mode 100644 index 0000000..94b611b --- /dev/null +++ b/Core/Models/BulkSiteRow.cs @@ -0,0 +1,13 @@ +using CsvHelper.Configuration.Attributes; + +namespace SharepointToolbox.Web.Core.Models; + +public class BulkSiteRow +{ + [Name("Name")] public string Name { get; set; } = string.Empty; + [Name("Alias")] public string Alias { get; set; } = string.Empty; + [Name("Type")] public string Type { get; set; } = string.Empty; + [Name("Template")] public string Template { get; set; } = string.Empty; + [Name("Owners")] public string Owners { get; set; } = string.Empty; + [Name("Members")] public string Members { get; set; } = string.Empty; +} diff --git a/Core/Models/ConflictPolicy.cs b/Core/Models/ConflictPolicy.cs new file mode 100644 index 0000000..b392c74 --- /dev/null +++ b/Core/Models/ConflictPolicy.cs @@ -0,0 +1,3 @@ +namespace SharepointToolbox.Web.Core.Models; + +public enum ConflictPolicy { Skip, Overwrite, Rename } diff --git a/Core/Models/ConsolidatedPermissionEntry.cs b/Core/Models/ConsolidatedPermissionEntry.cs new file mode 100644 index 0000000..1fd9a6a --- /dev/null +++ b/Core/Models/ConsolidatedPermissionEntry.cs @@ -0,0 +1,18 @@ +namespace SharepointToolbox.Web.Core.Models; + +public record ConsolidatedPermissionEntry( + string UserDisplayName, + string UserLogin, + string PermissionLevel, + AccessType AccessType, + string GrantedThrough, + bool IsHighPrivilege, + bool IsExternalUser, + IReadOnlyList Locations, + string? TargetUrl = null, + string? TargetLabel = null, + string? SharingLinkType = null +) +{ + public int LocationCount => Locations.Count; +} diff --git a/Core/Models/CsvValidationRow.cs b/Core/Models/CsvValidationRow.cs new file mode 100644 index 0000000..914d5e9 --- /dev/null +++ b/Core/Models/CsvValidationRow.cs @@ -0,0 +1,22 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class CsvValidationRow +{ + public T? Record { get; } + public bool IsValid => Errors.Count == 0; + public List Errors { get; } + public string? RawRecord { get; } + + public CsvValidationRow(T record, List errors) + { + Record = record; Errors = errors; + } + + private CsvValidationRow(string rawRecord, string parseError) + { + Record = default; RawRecord = rawRecord; Errors = new List { parseError }; + } + + public static CsvValidationRow ParseError(string? rawRecord, string error) + => new(rawRecord ?? string.Empty, error); +} diff --git a/Core/Models/DuplicateGroup.cs b/Core/Models/DuplicateGroup.cs new file mode 100644 index 0000000..5c1936b --- /dev/null +++ b/Core/Models/DuplicateGroup.cs @@ -0,0 +1,8 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class DuplicateGroup +{ + public string GroupKey { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public List Items { get; set; } = new(); +} diff --git a/Core/Models/DuplicateItem.cs b/Core/Models/DuplicateItem.cs new file mode 100644 index 0000000..1bfd36a --- /dev/null +++ b/Core/Models/DuplicateItem.cs @@ -0,0 +1,15 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class DuplicateItem +{ + public string Name { get; set; } = string.Empty; + public string Path { get; set; } = string.Empty; + public string Library { get; set; } = string.Empty; + public long? SizeBytes { get; set; } + public DateTime? Created { get; set; } + public DateTime? Modified { get; set; } + public int? FolderCount { get; set; } + public int? FileCount { get; set; } + public string SiteUrl { get; set; } = string.Empty; + public string SiteTitle { get; set; } = string.Empty; +} diff --git a/Core/Models/DuplicateScanOptions.cs b/Core/Models/DuplicateScanOptions.cs new file mode 100644 index 0000000..a5a8aa7 --- /dev/null +++ b/Core/Models/DuplicateScanOptions.cs @@ -0,0 +1,12 @@ +namespace SharepointToolbox.Web.Core.Models; + +public record DuplicateScanOptions( + string Mode = "Files", + bool MatchSize = true, + bool MatchCreated = false, + bool MatchModified = false, + bool MatchSubfolderCount = false, + bool MatchFileCount = false, + bool IncludeSubsites = false, + string? Library = null +); diff --git a/Core/Models/FileTypeMetric.cs b/Core/Models/FileTypeMetric.cs new file mode 100644 index 0000000..3ae13c9 --- /dev/null +++ b/Core/Models/FileTypeMetric.cs @@ -0,0 +1,11 @@ +namespace SharepointToolbox.Web.Core.Models; + +public record FileTypeMetric( + string Extension, + long TotalSizeBytes, + int FileCount) +{ + public string DisplayLabel => string.IsNullOrEmpty(Extension) + ? "No Extension" + : Extension.TrimStart('.').ToUpperInvariant(); +} diff --git a/Core/Models/FolderStructureRow.cs b/Core/Models/FolderStructureRow.cs new file mode 100644 index 0000000..a5752c6 --- /dev/null +++ b/Core/Models/FolderStructureRow.cs @@ -0,0 +1,17 @@ +using CsvHelper.Configuration.Attributes; + +namespace SharepointToolbox.Web.Core.Models; + +public class FolderStructureRow +{ + [Name("Level1")] public string Level1 { get; set; } = string.Empty; + [Name("Level2")] public string Level2 { get; set; } = string.Empty; + [Name("Level3")] public string Level3 { get; set; } = string.Empty; + [Name("Level4")] public string Level4 { get; set; } = string.Empty; + + public string BuildPath() + { + var parts = new[] { Level1, Level2, Level3, Level4 }.Where(s => !string.IsNullOrWhiteSpace(s)); + return string.Join("/", parts); + } +} diff --git a/Core/Models/GraphDirectoryUser.cs b/Core/Models/GraphDirectoryUser.cs new file mode 100644 index 0000000..9a803f3 --- /dev/null +++ b/Core/Models/GraphDirectoryUser.cs @@ -0,0 +1,9 @@ +namespace SharepointToolbox.Web.Core.Models; + +public record GraphDirectoryUser( + string DisplayName, + string UserPrincipalName, + string? Mail, + string? Department, + string? JobTitle, + string? UserType); diff --git a/Core/Models/LocationInfo.cs b/Core/Models/LocationInfo.cs new file mode 100644 index 0000000..627e30a --- /dev/null +++ b/Core/Models/LocationInfo.cs @@ -0,0 +1,9 @@ +namespace SharepointToolbox.Web.Core.Models; + +public record LocationInfo( + string SiteUrl, + string SiteTitle, + string ObjectTitle, + string ObjectUrl, + string ObjectType +); diff --git a/Core/Models/LogoData.cs b/Core/Models/LogoData.cs new file mode 100644 index 0000000..0096754 --- /dev/null +++ b/Core/Models/LogoData.cs @@ -0,0 +1,7 @@ +namespace SharepointToolbox.Web.Core.Models; + +public record LogoData +{ + public string Base64 { get; init; } = string.Empty; + public string MimeType { get; init; } = string.Empty; +} diff --git a/Core/Models/OperationProgress.cs b/Core/Models/OperationProgress.cs new file mode 100644 index 0000000..5fecb98 --- /dev/null +++ b/Core/Models/OperationProgress.cs @@ -0,0 +1,7 @@ +namespace SharepointToolbox.Web.Core.Models; + +public record OperationProgress(int Current, int Total, string Message, bool IsIndeterminate = false) +{ + public static OperationProgress Indeterminate(string message) => + new(0, 0, message, IsIndeterminate: true); +} diff --git a/Core/Models/PermissionEntry.cs b/Core/Models/PermissionEntry.cs new file mode 100644 index 0000000..d14b530 --- /dev/null +++ b/Core/Models/PermissionEntry.cs @@ -0,0 +1,17 @@ +namespace SharepointToolbox.Web.Core.Models; + +public record PermissionEntry( + string ObjectType, + string Title, + string Url, + bool HasUniquePermissions, + string Users, + string UserLogins, + string PermissionLevels, + string GrantedThrough, + string PrincipalType, + bool WasAutoElevated = false, + string? TargetUrl = null, + string? TargetLabel = null, + string? SharingLinkType = null +); diff --git a/Core/Models/PermissionSummary.cs b/Core/Models/PermissionSummary.cs new file mode 100644 index 0000000..6fd7587 --- /dev/null +++ b/Core/Models/PermissionSummary.cs @@ -0,0 +1,33 @@ +namespace SharepointToolbox.Web.Core.Models; + +public record PermissionSummary( + string Label, + RiskLevel RiskLevel, + int Count, + int DistinctUsers +); + +public static class PermissionSummaryBuilder +{ + private static readonly Dictionary Labels = new() + { + [RiskLevel.High] = "High Risk", + [RiskLevel.Medium] = "Medium Risk", + [RiskLevel.Low] = "Low Risk", + [RiskLevel.ReadOnly] = "Read Only", + }; + + public static IReadOnlyList Build(IEnumerable entries) + { + var grouped = entries.GroupBy(e => e.RiskLevel).ToDictionary(g => g.Key, g => g.ToList()); + return Enum.GetValues().Select(level => + { + var items = grouped.GetValueOrDefault(level, new List()); + var distinctUsers = items + .SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries)) + .Select(u => u.Trim()).Where(u => u.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase).Count(); + return new PermissionSummary(Labels[level], level, items.Count, distinctUsers); + }).ToList(); + } +} diff --git a/Core/Models/ReportBranding.cs b/Core/Models/ReportBranding.cs new file mode 100644 index 0000000..707e9b8 --- /dev/null +++ b/Core/Models/ReportBranding.cs @@ -0,0 +1,3 @@ +namespace SharepointToolbox.Web.Core.Models; + +public record ReportBranding(LogoData? MspLogo, LogoData? ClientLogo); diff --git a/Core/Models/ResolvedMember.cs b/Core/Models/ResolvedMember.cs new file mode 100644 index 0000000..b3395ea --- /dev/null +++ b/Core/Models/ResolvedMember.cs @@ -0,0 +1,3 @@ +namespace SharepointToolbox.Web.Core.Models; + +public record ResolvedMember(string DisplayName, string Login); diff --git a/Core/Models/RiskLevel.cs b/Core/Models/RiskLevel.cs new file mode 100644 index 0000000..1b816d5 --- /dev/null +++ b/Core/Models/RiskLevel.cs @@ -0,0 +1,9 @@ +namespace SharepointToolbox.Web.Core.Models; + +public enum RiskLevel +{ + High, + Medium, + Low, + ReadOnly +} diff --git a/Core/Models/ScanOptions.cs b/Core/Models/ScanOptions.cs new file mode 100644 index 0000000..cfdef54 --- /dev/null +++ b/Core/Models/ScanOptions.cs @@ -0,0 +1,8 @@ +namespace SharepointToolbox.Web.Core.Models; + +public record ScanOptions( + bool IncludeInherited = false, + bool ScanFolders = true, + int FolderDepth = 1, + bool IncludeSubsites = false +); diff --git a/Core/Models/SearchOptions.cs b/Core/Models/SearchOptions.cs new file mode 100644 index 0000000..f2b347d --- /dev/null +++ b/Core/Models/SearchOptions.cs @@ -0,0 +1,15 @@ +namespace SharepointToolbox.Web.Core.Models; + +public record SearchOptions( + string[] Extensions, + string? Regex, + DateTime? CreatedAfter, + DateTime? CreatedBefore, + DateTime? ModifiedAfter, + DateTime? ModifiedBefore, + string? CreatedBy, + string? ModifiedBy, + string? Library, + int MaxResults, + string SiteUrl +); diff --git a/Core/Models/SearchResult.cs b/Core/Models/SearchResult.cs new file mode 100644 index 0000000..38da6b5 --- /dev/null +++ b/Core/Models/SearchResult.cs @@ -0,0 +1,13 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class SearchResult +{ + public string Title { get; set; } = string.Empty; + public string Path { get; set; } = string.Empty; + public string FileExtension { get; set; } = string.Empty; + public DateTime? Created { get; set; } + public DateTime? LastModified { get; set; } + public string Author { get; set; } = string.Empty; + public string ModifiedBy { get; set; } = string.Empty; + public long SizeBytes { get; set; } +} diff --git a/Core/Models/SessionTokens.cs b/Core/Models/SessionTokens.cs new file mode 100644 index 0000000..c8b813c --- /dev/null +++ b/Core/Models/SessionTokens.cs @@ -0,0 +1,11 @@ +namespace SharepointToolbox.Web.Core.Models; + +/// Held in ProtectedSessionStorage — never persisted to disk. +public class SessionTokens +{ + public string RefreshToken { 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 UserPrincipalName { get; set; } = string.Empty; +} diff --git a/Core/Models/SimplifiedPermissionEntry.cs b/Core/Models/SimplifiedPermissionEntry.cs new file mode 100644 index 0000000..59c1d75 --- /dev/null +++ b/Core/Models/SimplifiedPermissionEntry.cs @@ -0,0 +1,35 @@ +using SharepointToolbox.Web.Core.Helpers; + +namespace SharepointToolbox.Web.Core.Models; + +public class SimplifiedPermissionEntry +{ + public PermissionEntry Inner { get; } + public string SimplifiedLabels { get; } + public RiskLevel RiskLevel { get; } + public IReadOnlyList Mappings { get; } + + public string ObjectType => Inner.ObjectType; + public string Title => Inner.Title; + public string Url => Inner.Url; + public bool HasUniquePermissions => Inner.HasUniquePermissions; + public string Users => Inner.Users; + public string UserLogins => Inner.UserLogins; + public string PermissionLevels => Inner.PermissionLevels; + public string GrantedThrough => Inner.GrantedThrough; + public string PrincipalType => Inner.PrincipalType; + public string? TargetUrl => Inner.TargetUrl; + public string? TargetLabel => Inner.TargetLabel; + public string? SharingLinkType => Inner.SharingLinkType; + + public SimplifiedPermissionEntry(PermissionEntry entry) + { + Inner = entry; + Mappings = PermissionLevelMapping.GetMappings(entry.PermissionLevels); + SimplifiedLabels = PermissionLevelMapping.GetSimplifiedLabels(entry.PermissionLevels); + RiskLevel = PermissionLevelMapping.GetHighestRisk(entry.PermissionLevels); + } + + public static IReadOnlyList WrapAll(IEnumerable entries) + => entries.Select(e => new SimplifiedPermissionEntry(e)).ToList(); +} diff --git a/Core/Models/SiteInfo.cs b/Core/Models/SiteInfo.cs new file mode 100644 index 0000000..5d81c4c --- /dev/null +++ b/Core/Models/SiteInfo.cs @@ -0,0 +1,38 @@ +namespace SharepointToolbox.Web.Core.Models; + +public record SiteInfo(string Url, string Title) +{ + public long StorageUsedMb { get; init; } + public long StorageQuotaMb { get; init; } + public string Template { get; init; } = string.Empty; + + public SiteKind Kind => SiteKindHelper.FromTemplate(Template); +} + +public enum SiteKind +{ + Unknown, + TeamSite, + CommunicationSite, + Classic +} + +public static class SiteKindHelper +{ + public static SiteKind FromTemplate(string template) + { + if (string.IsNullOrEmpty(template)) return SiteKind.Unknown; + if (template.StartsWith("GROUP#", StringComparison.OrdinalIgnoreCase)) return SiteKind.TeamSite; + if (template.StartsWith("SITEPAGEPUBLISHING#", StringComparison.OrdinalIgnoreCase)) return SiteKind.CommunicationSite; + if (template.StartsWith("STS#", StringComparison.OrdinalIgnoreCase)) return SiteKind.Classic; + return SiteKind.Unknown; + } + + public static string DisplayName(SiteKind kind) => kind switch + { + SiteKind.TeamSite => "Team site", + SiteKind.CommunicationSite => "Communication site", + SiteKind.Classic => "Classic site", + _ => "Other" + }; +} diff --git a/Core/Models/SiteTemplate.cs b/Core/Models/SiteTemplate.cs new file mode 100644 index 0000000..01d04e6 --- /dev/null +++ b/Core/Models/SiteTemplate.cs @@ -0,0 +1,27 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class SiteTemplate +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Name { get; set; } = string.Empty; + public string SourceUrl { get; set; } = string.Empty; + public DateTime CapturedAt { get; set; } + public string SiteType { get; set; } = string.Empty; + public SiteTemplateOptions Options { get; set; } = new(); + public TemplateSettings? Settings { get; set; } + public TemplateLogo? Logo { get; set; } + public List Libraries { get; set; } = new(); + public List PermissionGroups { get; set; } = new(); +} + +public class TemplateSettings +{ + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public int Language { get; set; } +} + +public class TemplateLogo +{ + public string LogoUrl { get; set; } = string.Empty; +} diff --git a/Core/Models/SiteTemplateOptions.cs b/Core/Models/SiteTemplateOptions.cs new file mode 100644 index 0000000..0e22639 --- /dev/null +++ b/Core/Models/SiteTemplateOptions.cs @@ -0,0 +1,10 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class SiteTemplateOptions +{ + public bool CaptureLibraries { get; set; } = true; + public bool CaptureFolders { get; set; } = true; + public bool CapturePermissionGroups { get; set; } = true; + public bool CaptureLogo { get; set; } = true; + public bool CaptureSettings { get; set; } = true; +} diff --git a/Core/Models/StorageNode.cs b/Core/Models/StorageNode.cs new file mode 100644 index 0000000..adf03ba --- /dev/null +++ b/Core/Models/StorageNode.cs @@ -0,0 +1,17 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class StorageNode +{ + public string Name { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + public string SiteTitle { get; set; } = string.Empty; + public string Library { get; set; } = string.Empty; + public long TotalSizeBytes { get; set; } + public long FileStreamSizeBytes { get; set; } + public long VersionSizeBytes => Math.Max(0L, TotalSizeBytes - FileStreamSizeBytes); + public long TotalFileCount { get; set; } + public DateTime? LastModified { get; set; } + public int IndentLevel { get; set; } + public StorageNodeKind Kind { get; set; } = StorageNodeKind.Library; + public List Children { get; set; } = new(); +} diff --git a/Core/Models/StorageNodeKind.cs b/Core/Models/StorageNodeKind.cs new file mode 100644 index 0000000..8c20c7e --- /dev/null +++ b/Core/Models/StorageNodeKind.cs @@ -0,0 +1,11 @@ +namespace SharepointToolbox.Web.Core.Models; + +public enum StorageNodeKind +{ + Library, + HiddenLibrary, + PreservationHold, + ListAttachments, + RecycleBin, + Subsite +} diff --git a/Core/Models/StorageScanOptions.cs b/Core/Models/StorageScanOptions.cs new file mode 100644 index 0000000..f35d58c --- /dev/null +++ b/Core/Models/StorageScanOptions.cs @@ -0,0 +1,11 @@ +namespace SharepointToolbox.Web.Core.Models; + +public record StorageScanOptions( + bool PerLibrary = true, + bool IncludeSubsites = false, + int FolderDepth = 0, + bool IncludeHiddenLibraries = true, + bool IncludePreservationHold = true, + bool IncludeListAttachments = true, + bool IncludeRecycleBin = true +); diff --git a/Core/Models/SystemGroupTarget.cs b/Core/Models/SystemGroupTarget.cs new file mode 100644 index 0000000..c09fdfa --- /dev/null +++ b/Core/Models/SystemGroupTarget.cs @@ -0,0 +1,10 @@ +using SharepointToolbox.Web.Core.Helpers; + +namespace SharepointToolbox.Web.Core.Models; + +public record SystemGroupTarget( + SystemGroupKind Kind, + string Label, + string Url, + string? LinkType = null +); diff --git a/Core/Models/TemplateFolderInfo.cs b/Core/Models/TemplateFolderInfo.cs new file mode 100644 index 0000000..8c23e02 --- /dev/null +++ b/Core/Models/TemplateFolderInfo.cs @@ -0,0 +1,8 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class TemplateFolderInfo +{ + public string Name { get; set; } = string.Empty; + public string RelativePath { get; set; } = string.Empty; + public List Children { get; set; } = new(); +} diff --git a/Core/Models/TemplateLibraryInfo.cs b/Core/Models/TemplateLibraryInfo.cs new file mode 100644 index 0000000..93cc658 --- /dev/null +++ b/Core/Models/TemplateLibraryInfo.cs @@ -0,0 +1,9 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class TemplateLibraryInfo +{ + public string Name { get; set; } = string.Empty; + public string BaseType { get; set; } = string.Empty; + public int BaseTemplate { get; set; } + public List Folders { get; set; } = new(); +} diff --git a/Core/Models/TemplatePermissionGroup.cs b/Core/Models/TemplatePermissionGroup.cs new file mode 100644 index 0000000..d64df44 --- /dev/null +++ b/Core/Models/TemplatePermissionGroup.cs @@ -0,0 +1,8 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class TemplatePermissionGroup +{ + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public List RoleDefinitions { get; set; } = new(); +} diff --git a/Core/Models/TenantProfile.cs b/Core/Models/TenantProfile.cs new file mode 100644 index 0000000..29dcfb5 --- /dev/null +++ b/Core/Models/TenantProfile.cs @@ -0,0 +1,18 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class TenantProfile +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Name { get; set; } = string.Empty; + + /// https://contoso.sharepoint.com + public string TenantUrl { get; set; } = string.Empty; + + /// Azure AD tenant GUID or domain (e.g. contoso.onmicrosoft.com). Required for app-only Graph calls. + public string TenantId { get; set; } = string.Empty; + + /// Azure AD app registration client (application) ID. + public string ClientId { get; set; } = string.Empty; + + public LogoData? ClientLogo { get; set; } +} diff --git a/Core/Models/TransferJob.cs b/Core/Models/TransferJob.cs new file mode 100644 index 0000000..98e70f9 --- /dev/null +++ b/Core/Models/TransferJob.cs @@ -0,0 +1,16 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class TransferJob +{ + public string SourceSiteUrl { get; set; } = string.Empty; + public string SourceLibrary { get; set; } = string.Empty; + public string SourceFolderPath { get; set; } = string.Empty; + public string DestinationSiteUrl { get; set; } = string.Empty; + public string DestinationLibrary { get; set; } = string.Empty; + public string DestinationFolderPath { get; set; } = string.Empty; + public TransferMode Mode { get; set; } = TransferMode.Copy; + public ConflictPolicy ConflictPolicy { get; set; } = ConflictPolicy.Skip; + public IReadOnlyList SelectedFilePaths { get; set; } = Array.Empty(); + public bool IncludeSourceFolder { get; set; } + public bool CopyFolderContents { get; set; } = true; +} diff --git a/Core/Models/TransferMode.cs b/Core/Models/TransferMode.cs new file mode 100644 index 0000000..71dc612 --- /dev/null +++ b/Core/Models/TransferMode.cs @@ -0,0 +1,3 @@ +namespace SharepointToolbox.Web.Core.Models; + +public enum TransferMode { Copy, Move } diff --git a/Core/Models/UserAccessEntry.cs b/Core/Models/UserAccessEntry.cs new file mode 100644 index 0000000..9eb91bb --- /dev/null +++ b/Core/Models/UserAccessEntry.cs @@ -0,0 +1,26 @@ +namespace SharepointToolbox.Web.Core.Models; + +public enum AccessType +{ + Direct, + Group, + Inherited +} + +public record UserAccessEntry( + string UserDisplayName, + string UserLogin, + string SiteUrl, + string SiteTitle, + string ObjectType, + string ObjectTitle, + string ObjectUrl, + string PermissionLevel, + AccessType AccessType, + string GrantedThrough, + bool IsHighPrivilege, + bool IsExternalUser, + string? TargetUrl = null, + string? TargetLabel = null, + string? SharingLinkType = null +); diff --git a/Core/Models/UserRole.cs b/Core/Models/UserRole.cs new file mode 100644 index 0000000..8d2c408 --- /dev/null +++ b/Core/Models/UserRole.cs @@ -0,0 +1,8 @@ +namespace SharepointToolbox.Web.Core.Models; + +public enum UserRole +{ + TechN0 = 0, // Read-only + TechN1 = 1, // Read/Write + Admin = 2 // Read/Write + account management + client profiles +} diff --git a/Core/Models/VersionCleanupOptions.cs b/Core/Models/VersionCleanupOptions.cs new file mode 100644 index 0000000..093fc8f --- /dev/null +++ b/Core/Models/VersionCleanupOptions.cs @@ -0,0 +1,9 @@ +namespace SharepointToolbox.Web.Core.Models; + +public record VersionCleanupOptions( + IReadOnlyList LibraryTitles, + int KeepLast, + bool KeepFirst) +{ + public static VersionCleanupOptions Default => new(Array.Empty(), 5, false); +} diff --git a/Core/Models/VersionCleanupResult.cs b/Core/Models/VersionCleanupResult.cs new file mode 100644 index 0000000..e2bfd50 --- /dev/null +++ b/Core/Models/VersionCleanupResult.cs @@ -0,0 +1,14 @@ +namespace SharepointToolbox.Web.Core.Models; + +public class VersionCleanupResult +{ + public string SiteUrl { get; init; } = string.Empty; + public string Library { get; init; } = string.Empty; + public string FileServerRelativeUrl { get; init; } = string.Empty; + public string FileName { get; init; } = string.Empty; + public int VersionsBefore { get; init; } + public int VersionsDeleted { get; init; } + public int VersionsRemaining { get; init; } + public long BytesFreed { get; init; } + public string? Error { get; init; } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f58ae1c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app +EXPOSE 8080 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY ["SharepointToolbox.Web.csproj", "."] +RUN dotnet restore +COPY . . +RUN dotnet publish -c Release -o /app/publish --no-restore + +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . + +# Volume for persistent data (profiles, settings, templates, logs, exports) +VOLUME ["/data"] + +ENV ASPNETCORE_URLS=http://+:8080 +ENV DataFolder=/data + +ENTRYPOINT ["dotnet", "SharepointToolbox.Web.dll"] diff --git a/Infrastructure/Auth/GraphClientFactory.cs b/Infrastructure/Auth/GraphClientFactory.cs new file mode 100644 index 0000000..7ae674a --- /dev/null +++ b/Infrastructure/Auth/GraphClientFactory.cs @@ -0,0 +1,32 @@ +using Microsoft.Graph; +using SharepointToolbox.Web.Core.Models; +using SharepointToolbox.Web.Services; +using SharepointToolbox.Web.Services.Session; + +namespace SharepointToolbox.Web.Infrastructure.Auth; + +/// Delegated Graph client using OAuth2 refresh-token flow via ISessionManager. +public class GraphClientFactory +{ + private readonly ISessionCredentialStore _credentialStore; + private readonly ISessionManager _sessionManager; + + public GraphClientFactory(ISessionCredentialStore credentialStore, ISessionManager sessionManager) + { + _credentialStore = credentialStore; + _sessionManager = sessionManager; + } + + public async Task CreateClientAsync(TenantProfile profile) + { + ArgumentException.ThrowIfNullOrEmpty(profile.TenantId); + + var hasTokens = await _credentialStore.HasCredentialsAsync(); + if (!hasTokens) + throw new InvalidOperationException( + "No session tokens found. Please authenticate via Microsoft first."); + + var credential = new SessionTokenCredential(_sessionManager); + return new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]); + } +} diff --git a/Infrastructure/Auth/SessionManager.cs b/Infrastructure/Auth/SessionManager.cs new file mode 100644 index 0000000..2d45aea --- /dev/null +++ b/Infrastructure/Auth/SessionManager.cs @@ -0,0 +1,160 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Models; +using SharepointToolbox.Web.Services; +using SharepointToolbox.Web.Services.Auth; +using SharepointToolbox.Web.Services.Session; + +namespace SharepointToolbox.Web.Infrastructure.Auth; + +/// +/// Delegated session manager using OAuth2 refresh tokens. +/// Tokens come from ISessionCredentialStore (ProtectedSessionStorage — browser-side only). +/// Caches access tokens in-memory per scope for the duration of the Blazor circuit. +/// Scoped per Blazor circuit. +/// +public class SessionManager : ISessionManager +{ + private readonly ISessionCredentialStore _credentialStore; + private readonly ITokenRefreshService _tokenRefresh; + private readonly Dictionary _contexts = new(); + private readonly Dictionary _accessTokenCache = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + public SessionManager(ISessionCredentialStore credentialStore, ITokenRefreshService tokenRefresh) + { + _credentialStore = credentialStore; + _tokenRefresh = tokenRefresh; + } + + public bool IsAuthenticated(string tenantUrl) => _contexts.ContainsKey(NormalizeUrl(tenantUrl)); + + public async Task<(string Token, DateTimeOffset ExpiresAt)> GetAccessTokenWithExpiryAsync( + string scope, CancellationToken ct = default) + { + await _lock.WaitAsync(ct); + try + { + if (_accessTokenCache.TryGetValue(scope, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow) + return cached; + + var tokens = await _credentialStore.GetAsync() + ?? throw new InvalidOperationException( + "No session tokens found. Please authenticate via Microsoft first."); + + var result = await _tokenRefresh.RefreshAsync(tokens.RefreshToken, tokens.TenantId, tokens.ClientId, scope); + + // Persist rotated refresh token back to browser storage + if (result.RefreshToken != tokens.RefreshToken) + await _credentialStore.UpdateRefreshTokenAsync(result.RefreshToken); + + var entry = (result.AccessToken, result.ExpiresAt); + _accessTokenCache[scope] = entry; + return entry; + } + finally { _lock.Release(); } + } + + public async Task GetOrCreateContextAsync(TenantProfile profile, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(profile.TenantUrl); + ArgumentException.ThrowIfNullOrEmpty(profile.TenantId); + + var key = NormalizeUrl(profile.TenantUrl); + var spScope = NormalizeScopeUrl(profile.TenantUrl) + "/.default"; + + await _lock.WaitAsync(ct); + try + { + if (_contexts.TryGetValue(key, out var existing)) + return existing; + + // Validate tokens are present before creating context + var tokens = await _credentialStore.GetAsync() + ?? throw new InvalidOperationException( + "No session tokens found. Please authenticate via Microsoft first."); + + _ = tokens; // validated; actual token acquired per-request below + + var ctx = new ClientContext(profile.TenantUrl); + ctx.ExecutingWebRequest += async (_, e) => + { + var (token, _) = await GetAccessTokenWithExpiryAsyncInternal(spScope, tokens.TenantId); + e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + token; + }; + + _contexts[key] = ctx; + return ctx; + } + finally { _lock.Release(); } + } + + public async Task GetOrCreateContextAsync(string siteUrl, TenantProfile profile, CancellationToken ct = default) + { + var profileForSite = new TenantProfile + { + Id = profile.Id, + Name = profile.Name, + TenantUrl = siteUrl, + TenantId = profile.TenantId, + ClientId = profile.ClientId, + ClientLogo = profile.ClientLogo, + }; + return await GetOrCreateContextAsync(profileForSite, ct); + } + + public async Task ClearSessionAsync(string tenantUrl) + { + var key = NormalizeUrl(tenantUrl); + await _lock.WaitAsync(); + try + { + if (_contexts.TryGetValue(key, out var ctx)) + { + ctx.Dispose(); + _contexts.Remove(key); + } + } + finally { _lock.Release(); } + } + + public async Task ClearAllAsync() + { + await _lock.WaitAsync(); + try + { + foreach (var ctx in _contexts.Values) ctx.Dispose(); + _contexts.Clear(); + _accessTokenCache.Clear(); + } + finally { _lock.Release(); } + } + + // Internal version that bypasses the outer lock (called from ExecutingWebRequest which may run concurrently) + private async Task<(string Token, DateTimeOffset ExpiresAt)> GetAccessTokenWithExpiryAsyncInternal( + string scope, string tenantId) + { + if (_accessTokenCache.TryGetValue(scope, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow) + return cached; + + var tokens = await _credentialStore.GetAsync() + ?? throw new InvalidOperationException("No session tokens in store."); + + var result = await _tokenRefresh.RefreshAsync(tokens.RefreshToken, tenantId, tokens.ClientId, scope); + + if (result.RefreshToken != tokens.RefreshToken) + await _credentialStore.UpdateRefreshTokenAsync(result.RefreshToken); + + var entry = (result.AccessToken, result.ExpiresAt); + _accessTokenCache[scope] = entry; + return entry; + } + + private static string NormalizeUrl(string url) => url.TrimEnd('/').ToLowerInvariant(); + + private static string NormalizeScopeUrl(string siteUrl) + { + if (Uri.TryCreate(siteUrl, UriKind.Absolute, out var uri)) + return $"{uri.Scheme}://{uri.Host}"; + return siteUrl.TrimEnd('/'); + } +} diff --git a/Infrastructure/Auth/SessionTokenCredential.cs b/Infrastructure/Auth/SessionTokenCredential.cs new file mode 100644 index 0000000..8bc730e --- /dev/null +++ b/Infrastructure/Auth/SessionTokenCredential.cs @@ -0,0 +1,25 @@ +using Azure.Core; +using SharepointToolbox.Web.Services; + +namespace SharepointToolbox.Web.Infrastructure.Auth; + +/// +/// TokenCredential backed by the circuit's ISessionManager. +/// Lets GraphServiceClient call GetTokenAsync transparently using our refresh-token flow. +/// +public class SessionTokenCredential : TokenCredential +{ + private readonly ISessionManager _sessionManager; + + public SessionTokenCredential(ISessionManager sessionManager) { _sessionManager = sessionManager; } + + public override async ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken ct) + { + var scope = requestContext.Scopes.FirstOrDefault() ?? "https://graph.microsoft.com/.default"; + var (token, expiresAt) = await _sessionManager.GetAccessTokenWithExpiryAsync(scope, ct); + return new AccessToken(token, expiresAt); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken ct) + => GetTokenAsync(requestContext, ct).AsTask().GetAwaiter().GetResult(); +} diff --git a/Infrastructure/OAuth/OAuthEndpoints.cs b/Infrastructure/OAuth/OAuthEndpoints.cs new file mode 100644 index 0000000..b8ce9f4 --- /dev/null +++ b/Infrastructure/OAuth/OAuthEndpoints.cs @@ -0,0 +1,328 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; +using SharepointToolbox.Web.Core.Config; +using SharepointToolbox.Web.Core.Models; +using SharepointToolbox.Web.Infrastructure.Persistence; +using SharepointToolbox.Web.Services.Auth; +using SharepointToolbox.Web.Services.OAuth; + +namespace SharepointToolbox.Web.Infrastructure.OAuth; + +public static class OAuthEndpoints +{ + public static IEndpointRouteBuilder MapOAuthEndpoints(this IEndpointRouteBuilder app) + { + // ── Connect: initiate PKCE flow for client tenant access ────────────────── + app.MapGet("/connect/initiate", async ( + HttpContext ctx, + string profileId, + string? returnUrl, + ProfileRepository profiles, + IOAuthFlowCache flowCache, + IOptions opts) => + { + if (!ctx.User.Identity?.IsAuthenticated ?? true) + return Results.Unauthorized(); + + var allProfiles = await profiles.LoadAsync(); + var profile = allProfiles.FirstOrDefault(p => p.Id == profileId); + if (profile is null) + return Results.NotFound($"Profile '{profileId}' not found."); + + if (string.IsNullOrEmpty(profile.ClientId)) + return Results.Problem($"Profile '{profile.Name}' has no ClientId configured."); + + var o = opts.Value; + if (string.IsNullOrEmpty(o.RedirectUri)) + return Results.Problem("ClientConnect:RedirectUri is not configured on this server."); + + var (state, authUrl) = BuildAuthUrl( + tenantId: profile.TenantId, + clientId: profile.ClientId, + redirectUri: o.RedirectUri, + scope: "openid offline_access", + flowCache: flowCache, + flowState: new OAuthFlowState + { + ProfileId = profileId, + TenantId = profile.TenantId, + ClientId = profile.ClientId, + SpHost = ExtractHost(profile.TenantUrl), + ReturnUrl = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl, + IsRegistration = false, + }); + + return Results.Redirect(authUrl); + }); + + // ── Register: initiate admin auth to create app registration in client tenant + app.MapGet("/connect/register-initiate", ( + HttpContext ctx, + string tenantId, + string tenantName, + string tenantUrl, + string? returnUrl, + IOAuthFlowCache flowCache, + IOptions opts, + IConfiguration config) => + { + if (!ctx.User.Identity?.IsAuthenticated ?? true) + return Results.Unauthorized(); + + var o = opts.Value; + if (string.IsNullOrEmpty(o.RedirectUri)) + return Results.Problem("ClientConnect:RedirectUri is not configured on this server."); + + // Use our OIDC app (confidential client) to authenticate against the client tenant + var oidcClientId = config["Oidc:ClientId"]; + if (string.IsNullOrEmpty(oidcClientId)) + return Results.Problem("Oidc:ClientId is not configured."); + + // Need admin consent for Application.ReadWrite.All + DelegatedPermissionGrant.ReadWrite.All + var (_, authUrl) = BuildAuthUrl( + tenantId: tenantId, + clientId: oidcClientId, + redirectUri: o.RedirectUri, + scope: "https://graph.microsoft.com/Application.ReadWrite.All " + + "https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " + + "openid offline_access", + flowCache: flowCache, + flowState: new OAuthFlowState + { + TenantId = tenantId, + TenantName = tenantName, + TenantUrl = tenantUrl, + ReturnUrl = string.IsNullOrEmpty(returnUrl) ? "/profiles" : returnUrl, + IsRegistration = true, + }, + promptConsent: true); + + return Results.Redirect(authUrl); + }); + + // ── Shared callback for both connect and register flows ──────────────────── + app.MapGet("/connect/callback", async ( + string? code, + string? state, + string? error, + string? error_description, + IOAuthFlowCache flowCache, + IOptions opts, + IConfiguration config, + IAppRegistrationService appRegService, + IHttpClientFactory httpClientFactory) => + { + if (!string.IsNullOrEmpty(error)) + { + var errMsg = Uri.EscapeDataString(error_description ?? error); + return Results.Redirect($"/?connect_error={errMsg}"); + } + + if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state)) + return Results.BadRequest("Missing code or state."); + + var flowState = flowCache.GetAndRemoveFlowState(state); + if (flowState is null) + return Results.BadRequest("Invalid or expired state. Please try connecting again."); + + var o = opts.Value; + var http = httpClientFactory.CreateClient("oauth"); + + if (flowState.IsRegistration) + { + // ── Registration flow: confidential client exchange (OIDC app + secret) ── + var oidcClientId = config["Oidc:ClientId"]!; + var oidcClientSecret = config["Oidc:ClientSecret"]!; + + var body = new Dictionary + { + ["grant_type"] = "authorization_code", + ["client_id"] = oidcClientId, + ["client_secret"] = oidcClientSecret, + ["code"] = code, + ["redirect_uri"] = o.RedirectUri, + ["code_verifier"] = flowState.CodeVerifier, + ["scope"] = "https://graph.microsoft.com/Application.ReadWrite.All " + + "https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " + + "openid offline_access", + }; + + var tokenUrl = $"https://login.microsoftonline.com/{flowState.TenantId}/oauth2/v2.0/token"; + var resp = await http.PostAsync(tokenUrl, new FormUrlEncodedContent(body)); + var json = await resp.Content.ReadAsStringAsync(); + + if (!resp.IsSuccessStatusCode) + { + var msg = Uri.EscapeDataString($"Admin token exchange failed: {json}"); + return Results.Redirect($"/profiles?connect_error={msg}"); + } + + using var doc = JsonDocument.Parse(json); + var accessToken = doc.RootElement.GetProperty("access_token").GetString()!; + + string clientId; + try + { + clientId = await appRegService.CreateAsync( + adminAccessToken: accessToken, + tenantName: flowState.TenantName, + redirectUri: o.RedirectUri); + } + catch (Exception ex) + { + var msg = Uri.EscapeDataString($"App registration failed: {ex.Message}"); + return Results.Redirect($"/profiles?connect_error={msg}"); + } + + var regKey = Guid.NewGuid().ToString("N"); + flowCache.StoreRegistrationResult(regKey, new AppRegistrationResult + { + ClientId = clientId, + TenantId = flowState.TenantId, + TenantUrl = flowState.TenantUrl, + TenantName = flowState.TenantName, + DisplayName = $"SP Toolbox — {flowState.TenantName}", + }); + + var returnTo = QueryHelpers.AddQueryString(flowState.ReturnUrl, "reg_result_key", regKey); + return Results.Redirect(returnTo); + } + else + { + // ── Connect flow: public client exchange (profile ClientId, no secret) ── + var body = new Dictionary + { + ["grant_type"] = "authorization_code", + ["client_id"] = flowState.ClientId, + ["code"] = code, + ["redirect_uri"] = o.RedirectUri, + ["code_verifier"] = flowState.CodeVerifier, + ["scope"] = "openid offline_access", + }; + + var tokenUrl = $"https://login.microsoftonline.com/{flowState.TenantId}/oauth2/v2.0/token"; + var resp = await http.PostAsync(tokenUrl, new FormUrlEncodedContent(body)); + var json = await resp.Content.ReadAsStringAsync(); + + if (!resp.IsSuccessStatusCode) + { + var msg = Uri.EscapeDataString($"Token exchange failed: {json}"); + return Results.Redirect($"/?connect_error={msg}"); + } + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var upn = ExtractUpnFromIdToken(root); + var refreshToken = root.GetProperty("refresh_token").GetString()!; + + var tokens = new SessionTokens + { + RefreshToken = refreshToken, + TenantId = flowState.TenantId, + ClientId = flowState.ClientId, + SpHost = flowState.SpHost, + UserPrincipalName = upn, + }; + + var tokenKey = Guid.NewGuid().ToString("N"); + flowCache.StoreTokens(tokenKey, tokens); + + var returnTo = QueryHelpers.AddQueryString(flowState.ReturnUrl, "token_key", tokenKey); + return Results.Redirect(returnTo); + } + }); + + return app; + } + + // ── Helpers ─────────────────────────────────────────────────────────────────── + + private static (string State, string AuthUrl) BuildAuthUrl( + string tenantId, + string clientId, + string redirectUri, + string scope, + IOAuthFlowCache flowCache, + OAuthFlowState flowState, + bool promptConsent = false) + { + var codeVerifier = GenerateCodeVerifier(); + var codeChallenge = GenerateCodeChallenge(codeVerifier); + var state = Guid.NewGuid().ToString("N"); + + flowState.CodeVerifier = codeVerifier; + flowCache.StoreFlowState(state, flowState); + + var @params = new Dictionary + { + ["client_id"] = clientId, + ["response_type"] = "code", + ["redirect_uri"] = redirectUri, + ["scope"] = scope, + ["state"] = state, + ["code_challenge"] = codeChallenge, + ["code_challenge_method"] = "S256", + ["prompt"] = promptConsent ? "consent" : "select_account", + }; + + var authUrl = QueryHelpers.AddQueryString( + $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/authorize", + @params); + + return (state, authUrl); + } + + private static string GenerateCodeVerifier() + { + var bytes = new byte[32]; + RandomNumberGenerator.Fill(bytes); + return Base64UrlEncode(bytes); + } + + private static string GenerateCodeChallenge(string codeVerifier) + { + var bytes = SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier)); + return Base64UrlEncode(bytes); + } + + private static string Base64UrlEncode(byte[] bytes) => + Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + private static string ExtractHost(string url) + { + if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) + return uri.Host; + return url.TrimEnd('/'); + } + + private static string ExtractUpnFromIdToken(JsonElement tokenResponse) + { + try + { + if (!tokenResponse.TryGetProperty("id_token", out var idTokenEl)) + return string.Empty; + + var parts = idTokenEl.GetString()!.Split('.'); + if (parts.Length < 2) return string.Empty; + + var payload = parts[1]; + var padded = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '=') + .Replace('-', '+').Replace('_', '/'); + var bytes = Convert.FromBase64String(padded); + using var doc = JsonDocument.Parse(bytes); + var root = doc.RootElement; + + foreach (var claim in new[] { "preferred_username", "upn", "email", "unique_name" }) + if (root.TryGetProperty(claim, out var val) && val.ValueKind == JsonValueKind.String) + return val.GetString()!; + } + catch { /* best-effort */ } + return string.Empty; + } +} diff --git a/Infrastructure/Persistence/AuditRepository.cs b/Infrastructure/Persistence/AuditRepository.cs new file mode 100644 index 0000000..628a8f1 --- /dev/null +++ b/Infrastructure/Persistence/AuditRepository.cs @@ -0,0 +1,50 @@ +using System.Text; +using System.Text.Json; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Infrastructure.Persistence; + +/// Append-only JSONL audit log. Each line is one AuditEntry JSON object. +public class AuditRepository +{ + private readonly string _filePath; + private readonly SemaphoreSlim _writeLock = new(1, 1); + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + public AuditRepository(string filePath) { _filePath = filePath; } + + public async Task AppendAsync(AuditEntry entry) + { + await _writeLock.WaitAsync(); + try + { + var dir = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + var line = JsonSerializer.Serialize(entry, _opts) + "\n"; + await File.AppendAllTextAsync(_filePath, line, Encoding.UTF8); + } + finally { _writeLock.Release(); } + } + + public async Task> LoadAllAsync() + { + if (!File.Exists(_filePath)) return Array.Empty(); + var lines = await File.ReadAllLinesAsync(_filePath, Encoding.UTF8); + var result = new List(lines.Length); + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) continue; + try + { + var entry = JsonSerializer.Deserialize(line, _opts); + if (entry != null) result.Add(entry); + } + catch { /* skip corrupt lines */ } + } + return result; + } +} diff --git a/Infrastructure/Persistence/ProfileRepository.cs b/Infrastructure/Persistence/ProfileRepository.cs new file mode 100644 index 0000000..316ca17 --- /dev/null +++ b/Infrastructure/Persistence/ProfileRepository.cs @@ -0,0 +1,53 @@ +using System.Text; +using System.Text.Json; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Infrastructure.Persistence; + +public class ProfileRepository +{ + private readonly string _filePath; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + public ProfileRepository(string filePath) { _filePath = filePath; } + + public async Task> LoadAsync() + { + if (!File.Exists(_filePath)) return Array.Empty(); + string json; + try { json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8); } + catch (IOException ex) { throw new InvalidDataException($"Failed to read profiles: {_filePath}", ex); } + + ProfilesRoot? root; + try + { + root = JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (JsonException ex) { throw new InvalidDataException($"Invalid JSON in profiles: {_filePath}", ex); } + + return (IReadOnlyList?)root?.Profiles ?? Array.Empty(); + } + + public async Task SaveAsync(IReadOnlyList profiles) + { + await _writeLock.WaitAsync(); + try + { + var root = new ProfilesRoot { Profiles = profiles.ToList() }; + var json = JsonSerializer.Serialize(root, new JsonSerializerOptions + { + WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + var tmpPath = _filePath + ".tmp"; + var dir = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8); + JsonDocument.Parse(await File.ReadAllTextAsync(tmpPath, Encoding.UTF8)).Dispose(); + File.Move(tmpPath, _filePath, overwrite: true); + } + finally { _writeLock.Release(); } + } + + private sealed class ProfilesRoot { public List Profiles { get; set; } = new(); } +} diff --git a/Infrastructure/Persistence/SettingsRepository.cs b/Infrastructure/Persistence/SettingsRepository.cs new file mode 100644 index 0000000..e8384c9 --- /dev/null +++ b/Infrastructure/Persistence/SettingsRepository.cs @@ -0,0 +1,43 @@ +using System.Text; +using System.Text.Json; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Infrastructure.Persistence; + +public class SettingsRepository +{ + private readonly string _filePath; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + public SettingsRepository(string filePath) { _filePath = filePath; } + + public async Task LoadAsync() + { + if (!File.Exists(_filePath)) return new AppSettings(); + try + { + var json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8); + return JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new AppSettings(); + } + catch { return new AppSettings(); } + } + + public async Task SaveAsync(AppSettings settings) + { + await _writeLock.WaitAsync(); + try + { + var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions + { + WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + var dir = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + var tmp = _filePath + ".tmp"; + await File.WriteAllTextAsync(tmp, json, Encoding.UTF8); + File.Move(tmp, _filePath, overwrite: true); + } + finally { _writeLock.Release(); } + } +} diff --git a/Infrastructure/Persistence/TemplateRepository.cs b/Infrastructure/Persistence/TemplateRepository.cs new file mode 100644 index 0000000..1400236 --- /dev/null +++ b/Infrastructure/Persistence/TemplateRepository.cs @@ -0,0 +1,78 @@ +using System.Text; +using System.Text.Json; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Infrastructure.Persistence; + +public class TemplateRepository +{ + private readonly string _directory; + private readonly SemaphoreSlim _lock = new(1, 1); + + public TemplateRepository(string directory) { _directory = directory; } + + public async Task> GetAllAsync() + { + if (!Directory.Exists(_directory)) return Array.Empty(); + var files = Directory.GetFiles(_directory, "*.json"); + var templates = new List(); + foreach (var file in files) + { + try + { + var json = await File.ReadAllTextAsync(file, Encoding.UTF8); + var t = JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (t is not null) templates.Add(t); + } + catch { /* skip corrupt files */ } + } + return templates.OrderByDescending(t => t.CapturedAt).ToList(); + } + + public async Task GetByIdAsync(string id) + { + var file = Path.Combine(_directory, $"{id}.json"); + if (!File.Exists(file)) return null; + try + { + var json = await File.ReadAllTextAsync(file, Encoding.UTF8); + return JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch { return null; } + } + + public async Task SaveAsync(SiteTemplate template) + { + await _lock.WaitAsync(); + try + { + Directory.CreateDirectory(_directory); + var json = JsonSerializer.Serialize(template, new JsonSerializerOptions + { + WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + var path = Path.Combine(_directory, $"{template.Id}.json"); + var tmp = path + ".tmp"; + await File.WriteAllTextAsync(tmp, json, Encoding.UTF8); + File.Move(tmp, path, overwrite: true); + } + finally { _lock.Release(); } + } + + public Task DeleteAsync(string id) + { + var file = Path.Combine(_directory, $"{id}.json"); + if (File.Exists(file)) File.Delete(file); + return Task.CompletedTask; + } + + public async Task RenameAsync(string id, string newName) + { + var t = await GetByIdAsync(id); + if (t is null) return; + t.Name = newName; + await SaveAsync(t); + } +} diff --git a/Infrastructure/Persistence/UserRepository.cs b/Infrastructure/Persistence/UserRepository.cs new file mode 100644 index 0000000..292c822 --- /dev/null +++ b/Infrastructure/Persistence/UserRepository.cs @@ -0,0 +1,96 @@ +using System.Text; +using System.Text.Json; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Infrastructure.Persistence; + +public class UserRepository +{ + private readonly string _filePath; + private readonly SemaphoreSlim _writeLock = new(1, 1); + private static readonly JsonSerializerOptions _opts = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + public UserRepository(string filePath) { _filePath = filePath; } + + public async Task> LoadAsync() + { + if (!File.Exists(_filePath)) return Array.Empty(); + var json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8); + var root = JsonSerializer.Deserialize(json, _opts); + return (IReadOnlyList?)root?.Users ?? Array.Empty(); + } + + public async Task FindByEmailAsync(string email) + { + var users = await LoadAsync(); + return users.FirstOrDefault(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase)); + } + + public async Task SaveAsync(IReadOnlyList users) + { + await _writeLock.WaitAsync(); + try + { + var root = new UsersRoot { Users = users.ToList() }; + var json = JsonSerializer.Serialize(root, _opts); + var tmpPath = _filePath + ".tmp"; + var dir = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8); + File.Move(tmpPath, _filePath, overwrite: true); + } + finally { _writeLock.Release(); } + } + + public async Task UpsertAsync(AppUser user) + { + await _writeLock.WaitAsync(); + try + { + var users = (await LoadInternal()).ToList(); + var idx = users.FindIndex(u => u.Id == user.Id); + if (idx >= 0) users[idx] = user; + else users.Add(user); + await SaveInternal(users); + } + finally { _writeLock.Release(); } + } + + public async Task DeleteAsync(string userId) + { + await _writeLock.WaitAsync(); + try + { + var users = (await LoadInternal()).ToList(); + users.RemoveAll(u => u.Id == userId); + await SaveInternal(users); + } + finally { _writeLock.Release(); } + } + + private async Task> LoadInternal() + { + if (!File.Exists(_filePath)) return Array.Empty(); + var json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8); + var root = JsonSerializer.Deserialize(json, _opts); + return (IReadOnlyList?)root?.Users ?? Array.Empty(); + } + + private async Task SaveInternal(List users) + { + var root = new UsersRoot { Users = users }; + var json = JsonSerializer.Serialize(root, _opts); + var tmpPath = _filePath + ".tmp"; + var dir = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8); + File.Move(tmpPath, _filePath, overwrite: true); + } + + private sealed class UsersRoot { public List Users { get; set; } = new(); } +} diff --git a/Localization/Strings.Designer.cs b/Localization/Strings.Designer.cs new file mode 100644 index 0000000..6ac8e22 --- /dev/null +++ b/Localization/Strings.Designer.cs @@ -0,0 +1,231 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace SharepointToolbox.Web.Localization { + using System; + using System.Reflection; + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// Auto-generated designer file for Strings.resx — do not edit manually. + /// + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Strings { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Strings() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("SharepointToolbox.Localization.Strings", typeof(Strings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + public static string grp_scan_opts => ResourceManager.GetString("grp.scan.opts", resourceCulture) ?? string.Empty; + public static string chk_scan_folders => ResourceManager.GetString("chk.scan.folders", resourceCulture) ?? string.Empty; + public static string chk_recursive => ResourceManager.GetString("chk.recursive", resourceCulture) ?? string.Empty; + public static string lbl_folder_depth => ResourceManager.GetString("lbl.folder.depth", resourceCulture) ?? string.Empty; + public static string chk_max_depth => ResourceManager.GetString("chk.max.depth", resourceCulture) ?? string.Empty; + public static string chk_inherited_perms => ResourceManager.GetString("chk.inherited.perms", resourceCulture) ?? string.Empty; + public static string grp_export_fmt => ResourceManager.GetString("grp.export.fmt", resourceCulture) ?? string.Empty; + public static string rad_csv_perms => ResourceManager.GetString("rad.csv.perms", resourceCulture) ?? string.Empty; + public static string rad_html_perms => ResourceManager.GetString("rad.html.perms", resourceCulture) ?? string.Empty; + public static string btn_gen_perms => ResourceManager.GetString("btn.gen.perms", resourceCulture) ?? string.Empty; + public static string btn_open_perms => ResourceManager.GetString("btn.open.perms", resourceCulture) ?? string.Empty; + public static string btn_view_sites => ResourceManager.GetString("btn.view.sites", resourceCulture) ?? string.Empty; + public static string perm_site_url => ResourceManager.GetString("perm.site.url", resourceCulture) ?? string.Empty; + public static string perm_or_select => ResourceManager.GetString("perm.or.select", resourceCulture) ?? string.Empty; + public static string perm_sites_selected => ResourceManager.GetString("perm.sites.selected", resourceCulture) ?? string.Empty; + + // Phase 3: Storage Tab + public static string chk_per_lib => ResourceManager.GetString("chk.per.lib", resourceCulture) ?? string.Empty; + public static string chk_subsites => ResourceManager.GetString("chk.subsites", resourceCulture) ?? string.Empty; + public static string stor_note => ResourceManager.GetString("stor.note", resourceCulture) ?? string.Empty; + public static string btn_gen_storage => ResourceManager.GetString("btn.gen.storage", resourceCulture) ?? string.Empty; + public static string btn_open_storage => ResourceManager.GetString("btn.open.storage", resourceCulture) ?? string.Empty; + public static string stor_col_library => ResourceManager.GetString("stor.col.library", resourceCulture) ?? string.Empty; + public static string stor_col_site => ResourceManager.GetString("stor.col.site", resourceCulture) ?? string.Empty; + public static string stor_col_files => ResourceManager.GetString("stor.col.files", resourceCulture) ?? string.Empty; + public static string stor_col_size => ResourceManager.GetString("stor.col.size", resourceCulture) ?? string.Empty; + public static string stor_col_versions => ResourceManager.GetString("stor.col.versions", resourceCulture) ?? string.Empty; + public static string stor_col_lastmod => ResourceManager.GetString("stor.col.lastmod", resourceCulture) ?? string.Empty; + public static string stor_col_share => ResourceManager.GetString("stor.col.share", resourceCulture) ?? string.Empty; + public static string stor_rad_csv => ResourceManager.GetString("stor.rad.csv", resourceCulture) ?? string.Empty; + public static string stor_rad_html => ResourceManager.GetString("stor.rad.html", resourceCulture) ?? string.Empty; + + // Phase 3: File Search Tab + public static string grp_search_filters => ResourceManager.GetString("grp.search.filters", resourceCulture) ?? string.Empty; + public static string lbl_extensions => ResourceManager.GetString("lbl.extensions", resourceCulture) ?? string.Empty; + public static string ph_extensions => ResourceManager.GetString("ph.extensions", resourceCulture) ?? string.Empty; + public static string lbl_regex => ResourceManager.GetString("lbl.regex", resourceCulture) ?? string.Empty; + public static string ph_regex => ResourceManager.GetString("ph.regex", resourceCulture) ?? string.Empty; + public static string chk_created_after => ResourceManager.GetString("chk.created.after", resourceCulture) ?? string.Empty; + public static string chk_created_before => ResourceManager.GetString("chk.created.before", resourceCulture) ?? string.Empty; + public static string chk_modified_after => ResourceManager.GetString("chk.modified.after", resourceCulture) ?? string.Empty; + public static string chk_modified_before => ResourceManager.GetString("chk.modified.before", resourceCulture) ?? string.Empty; + public static string lbl_created_by => ResourceManager.GetString("lbl.created.by", resourceCulture) ?? string.Empty; + public static string ph_created_by => ResourceManager.GetString("ph.created.by", resourceCulture) ?? string.Empty; + public static string lbl_modified_by => ResourceManager.GetString("lbl.modified.by", resourceCulture) ?? string.Empty; + public static string ph_modified_by => ResourceManager.GetString("ph.modified.by", resourceCulture) ?? string.Empty; + public static string lbl_library => ResourceManager.GetString("lbl.library", resourceCulture) ?? string.Empty; + public static string ph_library => ResourceManager.GetString("ph.library", resourceCulture) ?? string.Empty; + public static string lbl_max_results => ResourceManager.GetString("lbl.max.results", resourceCulture) ?? string.Empty; + public static string lbl_site_url => ResourceManager.GetString("lbl.site.url", resourceCulture) ?? string.Empty; + public static string ph_site_url => ResourceManager.GetString("ph.site.url", resourceCulture) ?? string.Empty; + public static string btn_run_search => ResourceManager.GetString("btn.run.search", resourceCulture) ?? string.Empty; + public static string btn_open_search => ResourceManager.GetString("btn.open.search", resourceCulture) ?? string.Empty; + public static string srch_col_name => ResourceManager.GetString("srch.col.name", resourceCulture) ?? string.Empty; + public static string srch_col_ext => ResourceManager.GetString("srch.col.ext", resourceCulture) ?? string.Empty; + public static string srch_col_created => ResourceManager.GetString("srch.col.created", resourceCulture) ?? string.Empty; + public static string srch_col_modified => ResourceManager.GetString("srch.col.modified", resourceCulture) ?? string.Empty; + public static string srch_col_author => ResourceManager.GetString("srch.col.author", resourceCulture) ?? string.Empty; + public static string srch_col_modby => ResourceManager.GetString("srch.col.modby", resourceCulture) ?? string.Empty; + public static string srch_col_size => ResourceManager.GetString("srch.col.size", resourceCulture) ?? string.Empty; + public static string srch_col_path => ResourceManager.GetString("srch.col.path", resourceCulture) ?? string.Empty; + public static string srch_rad_csv => ResourceManager.GetString("srch.rad.csv", resourceCulture) ?? string.Empty; + public static string srch_rad_html => ResourceManager.GetString("srch.rad.html", resourceCulture) ?? string.Empty; + + // Phase 3: Duplicates Tab + public static string grp_dup_type => ResourceManager.GetString("grp.dup.type", resourceCulture) ?? string.Empty; + public static string rad_dup_files => ResourceManager.GetString("rad.dup.files", resourceCulture) ?? string.Empty; + public static string rad_dup_folders => ResourceManager.GetString("rad.dup.folders", resourceCulture) ?? string.Empty; + public static string grp_dup_criteria => ResourceManager.GetString("grp.dup.criteria", resourceCulture) ?? string.Empty; + public static string lbl_dup_note => ResourceManager.GetString("lbl.dup.note", resourceCulture) ?? string.Empty; + public static string chk_dup_size => ResourceManager.GetString("chk.dup.size", resourceCulture) ?? string.Empty; + public static string chk_dup_created => ResourceManager.GetString("chk.dup.created", resourceCulture) ?? string.Empty; + public static string chk_dup_modified => ResourceManager.GetString("chk.dup.modified", resourceCulture) ?? string.Empty; + public static string chk_dup_subfolders => ResourceManager.GetString("chk.dup.subfolders", resourceCulture) ?? string.Empty; + public static string chk_dup_filecount => ResourceManager.GetString("chk.dup.filecount", resourceCulture) ?? string.Empty; + public static string chk_include_subsites => ResourceManager.GetString("chk.include.subsites", resourceCulture) ?? string.Empty; + public static string ph_dup_lib => ResourceManager.GetString("ph.dup.lib", resourceCulture) ?? string.Empty; + public static string btn_run_scan => ResourceManager.GetString("btn.run.scan", resourceCulture) ?? string.Empty; + public static string btn_open_results => ResourceManager.GetString("btn.open.results", resourceCulture) ?? string.Empty; + + // Phase 4: Tab headers + public static string tab_transfer => ResourceManager.GetString("tab.transfer", resourceCulture) ?? string.Empty; + public static string tab_bulkMembers => ResourceManager.GetString("tab.bulkMembers", resourceCulture) ?? string.Empty; + public static string tab_bulkSites => ResourceManager.GetString("tab.bulkSites", resourceCulture) ?? string.Empty; + public static string tab_folderStructure => ResourceManager.GetString("tab.folderStructure", resourceCulture) ?? string.Empty; + + // Phase 4: Transfer tab + public static string transfer_sourcesite => ResourceManager.GetString("transfer.sourcesite", resourceCulture) ?? string.Empty; + public static string transfer_destsite => ResourceManager.GetString("transfer.destsite", resourceCulture) ?? string.Empty; + public static string transfer_sourcelibrary => ResourceManager.GetString("transfer.sourcelibrary", resourceCulture) ?? string.Empty; + public static string transfer_destlibrary => ResourceManager.GetString("transfer.destlibrary", resourceCulture) ?? string.Empty; + public static string transfer_sourcefolder => ResourceManager.GetString("transfer.sourcefolder", resourceCulture) ?? string.Empty; + public static string transfer_destfolder => ResourceManager.GetString("transfer.destfolder", resourceCulture) ?? string.Empty; + public static string transfer_mode => ResourceManager.GetString("transfer.mode", resourceCulture) ?? string.Empty; + public static string transfer_mode_copy => ResourceManager.GetString("transfer.mode.copy", resourceCulture) ?? string.Empty; + public static string transfer_mode_move => ResourceManager.GetString("transfer.mode.move", resourceCulture) ?? string.Empty; + public static string transfer_conflict => ResourceManager.GetString("transfer.conflict", resourceCulture) ?? string.Empty; + public static string transfer_conflict_skip => ResourceManager.GetString("transfer.conflict.skip", resourceCulture) ?? string.Empty; + public static string transfer_conflict_overwrite => ResourceManager.GetString("transfer.conflict.overwrite", resourceCulture) ?? string.Empty; + public static string transfer_conflict_rename => ResourceManager.GetString("transfer.conflict.rename", resourceCulture) ?? string.Empty; + public static string transfer_browse => ResourceManager.GetString("transfer.browse", resourceCulture) ?? string.Empty; + public static string transfer_start => ResourceManager.GetString("transfer.start", resourceCulture) ?? string.Empty; + public static string transfer_nofiles => ResourceManager.GetString("transfer.nofiles", resourceCulture) ?? string.Empty; + + // Phase 4: Bulk Members tab + public static string bulkmembers_import => ResourceManager.GetString("bulkmembers.import", resourceCulture) ?? string.Empty; + public static string bulkmembers_example => ResourceManager.GetString("bulkmembers.example", resourceCulture) ?? string.Empty; + public static string bulkmembers_execute => ResourceManager.GetString("bulkmembers.execute", resourceCulture) ?? string.Empty; + public static string bulkmembers_preview => ResourceManager.GetString("bulkmembers.preview", resourceCulture) ?? string.Empty; + public static string bulkmembers_groupname => ResourceManager.GetString("bulkmembers.groupname", resourceCulture) ?? string.Empty; + public static string bulkmembers_groupurl => ResourceManager.GetString("bulkmembers.groupurl", resourceCulture) ?? string.Empty; + public static string bulkmembers_email => ResourceManager.GetString("bulkmembers.email", resourceCulture) ?? string.Empty; + public static string bulkmembers_role => ResourceManager.GetString("bulkmembers.role", resourceCulture) ?? string.Empty; + + // Phase 4: Bulk Sites tab + public static string bulksites_import => ResourceManager.GetString("bulksites.import", resourceCulture) ?? string.Empty; + public static string bulksites_example => ResourceManager.GetString("bulksites.example", resourceCulture) ?? string.Empty; + public static string bulksites_execute => ResourceManager.GetString("bulksites.execute", resourceCulture) ?? string.Empty; + public static string bulksites_preview => ResourceManager.GetString("bulksites.preview", resourceCulture) ?? string.Empty; + public static string bulksites_name => ResourceManager.GetString("bulksites.name", resourceCulture) ?? string.Empty; + public static string bulksites_alias => ResourceManager.GetString("bulksites.alias", resourceCulture) ?? string.Empty; + public static string bulksites_type => ResourceManager.GetString("bulksites.type", resourceCulture) ?? string.Empty; + public static string bulksites_owners => ResourceManager.GetString("bulksites.owners", resourceCulture) ?? string.Empty; + public static string bulksites_members => ResourceManager.GetString("bulksites.members", resourceCulture) ?? string.Empty; + + // Phase 4: Folder Structure tab + public static string folderstruct_import => ResourceManager.GetString("folderstruct.import", resourceCulture) ?? string.Empty; + public static string folderstruct_example => ResourceManager.GetString("folderstruct.example", resourceCulture) ?? string.Empty; + public static string folderstruct_execute => ResourceManager.GetString("folderstruct.execute", resourceCulture) ?? string.Empty; + public static string folderstruct_preview => ResourceManager.GetString("folderstruct.preview", resourceCulture) ?? string.Empty; + public static string folderstruct_library => ResourceManager.GetString("folderstruct.library", resourceCulture) ?? string.Empty; + public static string folderstruct_siteurl => ResourceManager.GetString("folderstruct.siteurl", resourceCulture) ?? string.Empty; + + // Phase 4: Templates tab + public static string templates_list => ResourceManager.GetString("templates.list", resourceCulture) ?? string.Empty; + public static string templates_capture => ResourceManager.GetString("templates.capture", resourceCulture) ?? string.Empty; + public static string templates_apply => ResourceManager.GetString("templates.apply", resourceCulture) ?? string.Empty; + public static string templates_rename => ResourceManager.GetString("templates.rename", resourceCulture) ?? string.Empty; + public static string templates_delete => ResourceManager.GetString("templates.delete", resourceCulture) ?? string.Empty; + public static string templates_siteurl => ResourceManager.GetString("templates.siteurl", resourceCulture) ?? string.Empty; + public static string templates_name => ResourceManager.GetString("templates.name", resourceCulture) ?? string.Empty; + public static string templates_newtitle => ResourceManager.GetString("templates.newtitle", resourceCulture) ?? string.Empty; + public static string templates_newalias => ResourceManager.GetString("templates.newalias", resourceCulture) ?? string.Empty; + public static string templates_options => ResourceManager.GetString("templates.options", resourceCulture) ?? string.Empty; + public static string templates_opt_libraries => ResourceManager.GetString("templates.opt.libraries", resourceCulture) ?? string.Empty; + public static string templates_opt_folders => ResourceManager.GetString("templates.opt.folders", resourceCulture) ?? string.Empty; + public static string templates_opt_permissions => ResourceManager.GetString("templates.opt.permissions", resourceCulture) ?? string.Empty; + public static string templates_opt_logo => ResourceManager.GetString("templates.opt.logo", resourceCulture) ?? string.Empty; + public static string templates_opt_settings => ResourceManager.GetString("templates.opt.settings", resourceCulture) ?? string.Empty; + public static string templates_empty => ResourceManager.GetString("templates.empty", resourceCulture) ?? string.Empty; + + // Phase 4: Shared bulk operation strings + public static string bulk_confirm_title => ResourceManager.GetString("bulk.confirm.title", resourceCulture) ?? string.Empty; + public static string bulk_confirm_proceed => ResourceManager.GetString("bulk.confirm.proceed", resourceCulture) ?? string.Empty; + public static string bulk_confirm_cancel => ResourceManager.GetString("bulk.confirm.cancel", resourceCulture) ?? string.Empty; + public static string bulk_confirm_message => ResourceManager.GetString("bulk.confirm.message", resourceCulture) ?? string.Empty; + public static string bulk_result_success => ResourceManager.GetString("bulk.result.success", resourceCulture) ?? string.Empty; + public static string bulk_result_allfailed => ResourceManager.GetString("bulk.result.allfailed", resourceCulture) ?? string.Empty; + public static string bulk_result_allsuccess => ResourceManager.GetString("bulk.result.allsuccess", resourceCulture) ?? string.Empty; + public static string bulk_exportfailed => ResourceManager.GetString("bulk.exportfailed", resourceCulture) ?? string.Empty; + public static string bulk_retryfailed => ResourceManager.GetString("bulk.retryfailed", resourceCulture) ?? string.Empty; + public static string bulk_validation_invalid => ResourceManager.GetString("bulk.validation.invalid", resourceCulture) ?? string.Empty; + public static string bulk_csvimport_title => ResourceManager.GetString("bulk.csvimport.title", resourceCulture) ?? string.Empty; + public static string bulk_csvimport_filter => ResourceManager.GetString("bulk.csvimport.filter", resourceCulture) ?? string.Empty; + + // Phase 4: Folder browser dialog + public static string folderbrowser_title => ResourceManager.GetString("folderbrowser.title", resourceCulture) ?? string.Empty; + public static string folderbrowser_loading => ResourceManager.GetString("folderbrowser.loading", resourceCulture) ?? string.Empty; + public static string folderbrowser_select => ResourceManager.GetString("folderbrowser.select", resourceCulture) ?? string.Empty; + public static string folderbrowser_cancel => ResourceManager.GetString("folderbrowser.cancel", resourceCulture) ?? string.Empty; + } +} diff --git a/Localization/Strings.fr.resx b/Localization/Strings.fr.resx new file mode 100644 index 0000000..0a6f348 --- /dev/null +++ b/Localization/Strings.fr.resx @@ -0,0 +1,870 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + SharePoint Toolbox + + + Connexion + + + Gérer les profils... + + + Déconnecter + + + Permissions + + + Stockage + + + Recherche de fichiers + + + Doublons + + + Versions + + + Nettoyage des versions + + + Bibliothèques + + + Politique de conservation + + + Choisir des bibliothèques… + + + Réinitialiser (toutes les bibliothèques) + + + Supprimer les anciennes versions + + + Conserver les dernières : + + + Conserver aussi la toute première version + + + Demander confirmation avant l'exécution + + + Seules les versions historiques sont supprimées. La version courante publiée est toujours conservée. L'action est irréversible. + + + Toutes les bibliothèques (aucun filtre) + + + {0} bibliothèque(s) sélectionnée(s) + + + Supprimer les versions historiques en gardant les {0} dernières {1} ? +Cette action est irréversible. + + + (plus la première version) + + + « Conserver les dernières » doit être supérieur ou égal à 0. + + + Fichiers nettoyés : + + + Versions supprimées : + + + Octets libérés : + + + Bibliothèque + + + Fichier + + + Avant + + + Supprimées + + + Restantes + + + Libérés + + + Chemin + + + Erreur + + + Sélectionner les bibliothèques + + + Chargement des bibliothèques… + + + {0} bibliothèques chargées. + + + Tout sélectionner + + + Tout désélectionner + + + Modèles + + + Opérations en masse + + + Structure de dossiers + + + Paramètres + + + Bientôt disponible + + + Annuler + + + Langue + + + Anglais + + + Français + + + Thème + + + Utiliser le paramètre système + + + Clair + + + Sombre + + + Dossier de sortie des données + + + Parcourir... + + + Nom du profil + + + URL du tenant + + + ID client + + + Optionnel — laissez vide pour enregistrer l'application automatiquement + + + Ajouter + + + Enregistrer + + + Supprimer + + + Créer un nouveau profil à partir des valeurs ci-dessus. + + + Enregistrer les modifications du profil sélectionné. + + + Supprimer le profil sélectionné. + + + L'enregistrement de l'application peut nécessiter jusqu'à {0} connexions. Continuer ? + + + Prêt + + + Terminé + + + Opération annulée + + + Échec de l'authentification. Vérifiez l'URL du tenant et l'ID client. + + + Une erreur s'est produite. Consultez le journal pour plus de détails. + + Options d'analyse + Analyser les dossiers + Récursif (sous-sites) + Profondeur des dossiers : + Maximum (tous les niveaux) + Inclure les permissions héritées + Mode simplifié + Options d'affichage + Format d'export + CSV + Détaillé (toutes les lignes) + Simple (résumé uniquement) + HTML + Générer le rapport + Ouvrir le rapport + Voir les sites + URL du site : + ou sélectionnez plusieurs sites : + {0} site(s) sélectionné(s) + + Détail par bibliothèque + Inclure les sous-sites + Remarque : les analyses de dossiers profondes sur les grands sites peuvent prendre plusieurs minutes. + Générer les métriques + Ouvrir le rapport + Bibliothèque + Site + Fichiers + Taille totale + Taille des versions + Dernière modification + Part du total + CSV + HTML + Type + Bibliothèque + Bibliothèque masquée + Conservation + Pièces jointes + Corbeille + Sous-site + Sources analysées + Afficher dans le rapport + Bibliothèques masquées + Conservation + Pièces jointes + Corbeille + Bibliothèques + Bibliothèques masquées + Conservation + Pièces jointes + Corbeille + Sous-sites + Combiner les corbeilles (afficher le total) + Total rapporté par SPO : + Corbeille : + + Filtres de recherche + Niveau de détail : + Extension(s) : + docx pdf xlsx + Nom / Regex : + Ex : rapport.* ou \.bak$ + Créé après : + Créé avant : + Modifié après : + Modifié avant : + Créé par : + Prénom Nom ou courriel + Modifié par : + Prénom Nom ou courriel + Bibliothèque : + Chemin relatif optionnel, ex. Documents partagés + Max résultats : + URL du site : + utilisateur(s) + https://tenant.sharepoint.com/sites/MonSite + Lancer la recherche + Ouvrir les résultats + Nom du fichier + Extension + Créé + Modifié + Créé par + Modifié par + Taille + Chemin + CSV + HTML + + Type de doublon + Fichiers en doublon + Dossiers en doublon + Critères de comparaison + Le nom est toujours le critère principal. Cochez des critères supplémentaires : + Même taille + Même date de création + Même date de modification + Même nombre de sous-dossiers + Même nombre de fichiers + Inclure les sous-sites + Tous (laisser vide) + Lancer l'analyse + Ouvrir les résultats + + Transfert + Ajout en masse + Sites en masse + Structure de dossiers + + Site source + Site destination + Bibliothèque source + Bibliothèque destination + Dossier source + Dossier destination + Mode de transfert + Copier + Déplacer + Politique de conflit + Ignorer + Écraser + Renommer (ajouter suffixe) + Parcourir... + Démarrer le transfert + Aucun fichier à transférer. + + Importer CSV + Charger l'exemple + Ajouter les membres + Aperçu ({0} lignes, {1} valides, {2} invalides) + Nom du groupe + URL du groupe + Courriel + Role + + Importer CSV + Charger l'exemple + Créer les sites + Aperçu ({0} lignes, {1} valides, {2} invalides) + Nom + Alias + Type + Propriétaires + Membres + + Importer CSV + Charger l'exemple + Créer les dossiers + Aperçu ({0} dossiers à créer) + Bibliothèque cible + URL du site + + Modèles enregistrés + Capturer un modèle + Appliquer le modèle + Renommer + Supprimer + URL du site source + Nom du modèle + Titre du nouveau site + Alias du nouveau site + Options de capture + Bibliothèques + Dossiers + Groupes de permissions + Logo du site + Paramètres du site + Aucun modèle enregistré. + + Confirmer l'opération + Continuer + Annuler + {0} — Continuer ? + Terminé : {0} réussis, {1} échoués + Les {0} éléments ont échoué. + Les {0} éléments ont été traités avec succès. + Exporter les éléments échoués + Réessayer les échecs + {0} lignes contiennent des erreurs. Corrigez et réimportez. + Sélectionner un fichier CSV + Fichiers CSV (*.csv)|*.csv + + Sélectionner un dossier + Chargement de l'arborescence... + Sélectionner + Annuler + + + Choisir les sites + + + Choisir les sites cibles pour tous les onglets + + + Connectez-vous d'abord + + + {0} site(s) selectionne(s) + + + Aucun site selectionne + + + + Audit des accès utilisateur + + + Sélectionner les utilisateurs + + + Sites cibles + + + Options d'analyse + + + Rechercher par nom ou email... + + + {0} utilisateur(s) sélectionné(s) + + + Lancer l'audit + + + Exporter CSV + + + Exporter HTML + + + Découper + + + Fichier unique + + + Par site + + + Par utilisateur + + + Mise en page HTML + + + Fichiers séparés + + + Fichier unique à onglets + + + Total des accès + + + Sites + + + Privilèges élevés + + + Par utilisateur + + + Par site + + + Filtrer les résultats... + + + Sélectionnez au moins un utilisateur. + + + Sélectionnez au moins un site. + + + Stockage par type de fichier + Graphique en anneau + Graphique en barres + Type de graphique : + Exécutez une analyse pour voir la répartition par type de fichier. + + Logo MSP + Importer + Effacer + Aucun logo configuré + Logo client + Importer + Effacer + Importer depuis Entra + Aucun logo configuré + + Recherche + Parcourir l'annuaire + Annuaire utilisateurs + Charger l'annuaire + Annuler + Filtrer les utilisateurs... + Inclure les invités + utilisateurs + Double-cliquez sur un utilisateur pour l'ajouter à l'audit + Nom + Courriel + Département + Poste + Type + + Options d'exportation + Fusionner les permissions en double + Masquer les noms bruts (SharingLinks, Limited Access) + Exclure les liens de partage + Exclure les groupes système (Limited Access) + + Enregistrer l'app + Supprimer l'app + Vérification des permissions... + Enregistrement de l'application... + Application enregistrée avec succès + L'enregistrement a échoué + Permissions insuffisantes pour l'enregistrement automatique + Suppression de l'application... + Application supprimée avec succès + Enregistrement manuel requis + 1. Allez dans le portail Azure > Inscriptions d'applications > Nouvelle inscription + 2. Nom : 'SharePoint Toolbox - {0}', Types de comptes : Locataire unique + 3. URI de redirection : Client public, https://login.microsoftonline.com/common/oauth2/nativeclient + 4. Sous Permissions API, ajouter : Microsoft Graph (User.Read, User.Read.All, Group.Read.All, Directory.Read.All) et SharePoint (AllSites.FullControl) + 5. Accorder le consentement administrateur pour toutes les permissions + 6. Copier l'ID d'application (client) et le coller dans le champ ID Client ci-dessus + + Propriété du site + Prendre automatiquement la propriété d'administrateur de collection de sites en cas de refus d'accès + Lorsqu'activé, l'application prendra automatiquement les droits d'administrateur de collection de sites lorsqu'un scan rencontre une erreur de refus d'accès. Nécessite les permissions d'administrateur de tenant. + Ce site a été élevé automatiquement — la propriété a été prise pour compléter le scan + + Rapport d'audit des accès utilisateurs + Rapport d'audit des accès utilisateurs (consolidé) + Rapport des permissions SharePoint + Rapport des permissions SharePoint (simplifié) + Métriques de stockage SharePoint + Rapport de détection de doublons SharePoint + Rapport de détection de doublons + Résultats de recherche de fichiers SharePoint + Résultats de recherche de fichiers + Rapport de nettoyage des versions SharePoint + Rapport de nettoyage des versions + Accès totaux + Utilisateurs audités + Sites analysés + Privilège élevé + Utilisateurs externes + Entrées totales + Ensembles de permissions uniques + Utilisateurs/Groupes distincts + Bibliothèques + Fichiers + Taille totale + Taille des versions + Invité + Direct + Groupe + Hérité + Unique + Par utilisateur + Par site + Filtrer les résultats... + Filtrer les permissions... + Filtrer les lignes… + Filtre : + Site + Sites + Type d'objet + Objet + Niveau de permission + Type d'accès + Accordé via + Utilisateur + Titre + URL + Utilisateurs/Groupes + Simplifié + Risque + Bibliothèque / Dossier + Dernière modification + Nom + Bibliothèque + Chemin + Taille + Créé + Modifié + Créé par + Modifié par + Nom de fichier + Extension + Type de fichier + Nombre de fichiers + Erreur + Horodatage + # + Groupe + Taille totale (Mo) + Taille des versions (Mo) + Taille (Mo) + Taille (octets) + accès + accès + site(s) + permission(s) + copies + groupe(s) de doublons trouvé(s). + résultat(s) + sur + affiché(s) + Généré + Généré : + membres indisponibles + Groupe vide + Lien + (sans ext.) + (sans extension) + priv. élevé + Stockage par type de fichier + Détails des bibliothèques + + Sélectionner les sites + Filtre : + Type : + Tous + Sites d'équipe (MS Teams) + Communication + Classique + Autre + Taille (Mo) : + min + max + Titre + URL + Type + Taille + Charger les sites + Tout sélectionner + Tout désélectionner + OK + Annuler + Chargement des sites... + {0} sites chargés. + {0} / {1} sites affichés. + Erreur : {0} + Site d'équipe + Communication + Classique + Autre + + Valide + Erreurs + Fermer + + Nouveau dossier + Invité + + Saisie + + Gérer les profils + Profils + + Groupe + Copies + + Niveau 1 + Niveau 2 + Niveau 3 + Niveau 4 + + Perm. uniques + Niveaux d'autorisation + Type de principal + + Taille totale : + Taille des versions : + Fichiers : + + Source + Capturé + + fichier(s) sélectionné(s) + Inclure le dossier source dans la destination + Si activé, recrée le dossier source sous la destination. Sinon, dépose le contenu directement dans le dossier de destination. + Copier le contenu du dossier + Si activé (par défaut), transfère les fichiers du dossier. Sinon, seul le dossier est créé à la destination. + + Aucun tenant connecté. + Aucun tenant sélectionné. Connectez-vous à un tenant d'abord. + Aucun profil de tenant sélectionné. Connectez-vous d'abord. + Sélectionnez au moins un site dans la barre d'outils. + Ajoutez au moins un utilisateur à auditer. + Aucune ligne valide à traiter. Importez un CSV d'abord. + Le nom du modèle est requis. + Le titre du nouveau site est requis. + L'alias du nouveau site est requis. + Le site source et la bibliothèque doivent être sélectionnés. + Le site de destination et la bibliothèque doivent être sélectionnés. + Le titre de la bibliothèque est requis. + + Capture du modèle... + Modèle capturé avec succès. + Échec de la capture : {0} + Application du modèle... + Modèle appliqué. Site créé à : {0} + Échec de l'application : {0} + + Recherche en cours... + + utilisateur(s) + fichiers + sites + entrées + + Mode simplifié + Regroupe les permissions brutes SharePoint en libellés lisibles (Propriétaire, Éditeur, Contributeur, Lecteur, Lecture seule) et colore les lignes par niveau de risque. Utile pour un aperçu rapide de la sécurité sans jargon technique. + Fusionner les permissions + Lorsqu'activé, les entrées de permission multiples pour le même utilisateur ou groupe sont regroupées en une seule ligne dans l'export, réduisant la taille du rapport. Désactivez pour voir chaque permission individuellement. + Masquer les groupes système + Supprime les groupes système créés automatiquement par SharePoint (ex. « Excel Services Viewers », groupes « SharingLinks.* »). Ces groupes sont gérés en interne par SharePoint et ne sont généralement pas pertinents pour les audits d'accès. + Exclure les liens de partage + Supprime les entrées de lien de partage des résultats et des exports (ex. « Tout le monde avec le lien », liens à l'échelle de l'organisation). Utile pour ne conserver que les permissions directes des utilisateurs et groupes. + Exclure les groupes système (Limited Access) + Supprime les entrées « Limited Access System Group For Web/List » des résultats et des exports. SharePoint crée ces groupes automatiquement lorsqu'un utilisateur a accès à un élément spécifique ; ils sont rarement pertinents pour les audits d'accès. + Inclure les permissions héritées + Par défaut, seuls les objets avec des permissions uniques (rompues) sont affichés. Activez pour inclure les objets qui héritent les permissions d'un parent et obtenir une vue complète des accès. + Mode de fractionnement de l'export + Fichier unique : tous les résultats dans un seul fichier CSV ou HTML. + +Fractionner par site : crée un fichier séparé pour chaque collection de sites. Utile pour les grandes tenances multi-sites. + Recherche de fichiers KQL + Recherche des fichiers dans vos sites SharePoint via KQL (Keyword Query Language). Le champ mot-clé est optionnel — laissez-le vide pour retourner tous les fichiers correspondant aux filtres actifs. Combinez les filtres de date, auteur et bibliothèque pour affiner les résultats. + Filtre regex sur le nom de fichier + Filtre les résultats côté client avec une expression régulière .NET appliquée aux noms de fichiers. Exemple : \.pdf$ correspond uniquement aux PDF. Laissez vide pour ignorer ce filtre. L'expression est insensible à la casse. + Politique de nettoyage des versions + Supprime définitivement les anciennes versions de documents des bibliothèques SharePoint. Seules les N versions les plus récentes sont conservées — les versions plus anciennes sont supprimées de façon permanente et ne peuvent pas être récupérées. Effectuez d'abord une analyse pour prévisualiser les suppressions. + Conserver la première version + Conserve toujours la version 1.0 (originale) de chaque document, indépendamment du paramètre « Conserver les N dernières ». Utile pour maintenir une trace de l'état initial du document. + Confirmer avant suppression + Lorsqu'activé, une boîte de dialogue de confirmation apparaît pour chaque fichier avant la suppression des versions. Décochez pour un traitement en lot sans intervention. + Critères de détection des doublons + Deux éléments sont identifiés comme doublons quand leurs noms correspondent ET que tous les critères supplémentaires cochés correspondent également. Plus de critères cochés = moins de groupes, mais plus précis. Nom uniquement : trouve les fichiers avec le même nom, quel que soit leur contenu. + Inclure le dossier source + Lorsqu'activé, le dossier source lui-même est recréé à la destination (ex. transférer « Rapports » crée un dossier « Rapports/ » à la cible). Lorsque désactivé, seul le contenu du dossier est transféré — utile pour fusionner du contenu dans un dossier existant. + Copier uniquement le contenu + Lorsqu'activé, seuls les fichiers et sous-dossiers à l'intérieur du dossier sélectionné sont transférés — le dossier lui-même n'est pas recréé à la destination. + Politique de conflit de fichiers + Définit ce qui se passe quand un fichier du même nom existe déjà à la destination : + +• Ignorer — laisser le fichier destination inchangé. +• Écraser — remplacer le fichier destination par le fichier source. +• Renommer — conserver les deux en ajoutant un suffixe numérique au fichier transféré. + Ajout de membres en masse — Format CSV + Le fichier CSV doit contenir ces colonnes (en-têtes obligatoires, ordre libre) : +• GroupName — le nom exact du groupe SharePoint +• Email — l'adresse e-mail de l'utilisateur +• Role — Member, Owner ou Visitor + +Cliquez sur « Charger l'exemple » pour ouvrir un fichier d'exemple pré-rempli. + Création de sites en masse — Format CSV + Le fichier CSV doit contenir ces colonnes : +• Name — le nom d'affichage du nouveau site +• Alias — alias d'URL (sans espaces ; fait partie de l'URL du site) +• Type — TeamSite ou CommunicationSite +• Owners — liste d'adresses e-mail des propriétaires séparées par des virgules + +Cliquez sur « Charger l'exemple » pour ouvrir un fichier d'exemple pré-rempli. + Créer une structure de dossiers — Format CSV + Crée une hiérarchie de dossiers dans une bibliothèque SharePoint à partir d'un fichier CSV. Chaque ligne définit un chemin avec jusqu'à 4 niveaux (Level1–Level4). Laissez les colonnes des niveaux inférieurs vides pour des chemins plus courts. + +Exemple : Contrats | 2024 | T1 | (vide) +Crée : Bibliothèque / Contrats / 2024 / T1 + Capturer un modèle de site + Enregistre la structure du site sélectionné (bibliothèques, dossiers, permissions, paramètres et logo) comme modèle réutilisable stocké localement. Le site source n'est pas modifié. + +Sélectionnez les éléments à capturer avec les cases à cocher ci-dessus. + Appliquer le modèle à un nouveau site + Crée un nouveau site SharePoint et reproduit la structure du modèle sélectionné — bibliothèques, dossiers, permissions, paramètres et logo. Le modèle source et le site d'origine ne sont pas affectés. + +Fournissez un nom d'affichage et un alias d'URL avant de cliquer sur Appliquer. + Mode Recherche vs Mode Navigation + Mode Recherche : tapez un nom ou e-mail pour trouver un utilisateur via Azure AD. Les résultats apparaissent dans une liste — cliquez pour sélectionner. + +Mode Navigation : charge tous les utilisateurs du répertoire de la tenant. Utilisez le filtre pour trouver un utilisateur, puis double-cliquez pour l'ajouter à l'audit. + Audit d'accès vs Audit des permissions + L'onglet Permissions analyse les objets (bibliothèques, dossiers, éléments) pour montrer qui y a accès. + +Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et il trouve chaque objet auquel ils peuvent accéder — y compris via des groupes SharePoint ou Active Directory. + Bibliothèques masquées + Analyse les bibliothèques SharePoint cachées dans la navigation normale du site (ex. Site Assets, Style Library, Form Templates). Elles peuvent consommer beaucoup d'espace et sont souvent oubliées dans les audits de routine. + Bibliothèque de conservation + Bibliothèque SharePoint cachée qui stocke les versions de documents modifiés ou supprimés pendant qu'une politique de rétention Microsoft Purview / Microsoft 365 Compliance est active. Elle peut croître considérablement sans être visible pour les utilisateurs du site. + diff --git a/Localization/Strings.resx b/Localization/Strings.resx new file mode 100644 index 0000000..403a60e --- /dev/null +++ b/Localization/Strings.resx @@ -0,0 +1,870 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + SharePoint Toolbox + + + Connect + + + Manage Profiles... + + + Clear Session + + + Permissions + + + Storage + + + File Search + + + Duplicates + + + Versions + + + Version cleanup + + + Libraries + + + Retention policy + + + Select libraries... + + + Reset (all libraries) + + + Delete old versions + + + Keep last: + + + Also keep the very first version + + + Ask for confirmation before running + + + Only historical versions are removed. The current published version is always kept. The action cannot be undone. + + + All libraries (no filter) + + + {0} library/libraries selected + + + Delete historical file versions, keeping the last {0} {1}? +This cannot be undone. + + + (plus the first version) + + + "Keep last" must be 0 or greater. + + + Files trimmed: + + + Versions deleted: + + + Bytes freed: + + + Library + + + File + + + Before + + + Deleted + + + Remaining + + + Freed + + + Path + + + Error + + + Select libraries + + + Loading libraries... + + + {0} libraries loaded. + + + Select all + + + Select none + + + Templates + + + Bulk Operations + + + Folder Structure + + + Settings + + + Coming soon + + + Cancel + + + Language + + + English + + + French + + + Theme + + + Use system setting + + + Light + + + Dark + + + Data output folder + + + Browse... + + + Profile name + + + Tenant URL + + + Client ID + + + Optional — leave blank to register the app automatically + + + Add + + + Save + + + Delete + + + Create a new profile from the values entered above. + + + Save changes to the selected profile. + + + Delete the selected profile. + + + Registering an app may prompt you to sign in up to {0} times. Continue? + + + Ready + + + Complete + + + Operation cancelled + + + Authentication failed. Check tenant URL and Client ID. + + + An error occurred. See log for details. + + Scan Options + Scan Folders + Recursive (subsites) + Folder depth: + Maximum (all levels) + Include Inherited Permissions + Simplified mode + Display Options + Export Format + CSV + Detailed (all rows) + Simple (summary only) + HTML + Generate Report + Open Report + View Sites + Site URL: + or select multiple sites: + {0} site(s) selected + + Per-Library Breakdown + Include Subsites + Note: deeper folder scans on large sites may take several minutes. + Generate Metrics + Open Report + Library + Site + Files + Total Size + Version Size + Last Modified + Share of Total + CSV + HTML + Kind + Library + Hidden Library + Preservation Hold + List Attachments + Recycle Bin + Subsite + Scan Sources + Show in Report + Hidden Libraries + Preservation Hold + List Attachments + Recycle Bin + Libraries + Hidden Libraries + Preservation Hold + List Attachments + Recycle Bin + Subsites + Combine Recycle Bin Stages (show total) + SPO reported total: + Recycle Bin: + + Search Filters + Detail level: + Extension(s): + docx pdf xlsx + Name / Regex: + Ex: report.* or \.bak$ + Created after: + Created before: + Modified after: + Modified before: + Created by: + First Last or email + Modified by: + First Last or email + Library: + Optional relative path e.g. Shared Documents + Max results: + Site URL: + user(s) + https://tenant.sharepoint.com/sites/MySite + Run Search + Open Results + File Name + Extension + Created + Modified + Created By + Modified By + Size + Path + CSV + HTML + + Duplicate Type + Duplicate files + Duplicate folders + Comparison Criteria + Name is always the primary criterion. Check additional criteria: + Same size + Same creation date + Same modification date + Same subfolder count + Same file count + Include subsites + All (leave empty) + Run Scan + Open Results + + Transfer + Bulk Members + Bulk Sites + Folder Structure + + Source Site + Destination Site + Source Library + Destination Library + Source Folder + Destination Folder + Transfer Mode + Copy + Move + Conflict Policy + Skip + Overwrite + Rename (append suffix) + Browse... + Start Transfer + No files found to transfer. + + Import CSV + Load Example + Add Members + Preview ({0} rows, {1} valid, {2} invalid) + Group Name + Group URL + Email + Role + + Import CSV + Load Example + Create Sites + Preview ({0} rows, {1} valid, {2} invalid) + Name + Alias + Type + Owners + Members + + Import CSV + Load Example + Create Folders + Preview ({0} folders to create) + Target Library + Site URL + + Saved Templates + Capture Template + Apply Template + Rename + Delete + Source Site URL + Template Name + New Site Title + New Site Alias + Capture Options + Libraries + Folders + Permission Groups + Site Logo + Site Settings + No templates saved yet. + + Confirm Operation + Proceed + Cancel + {0} — Proceed? + Completed: {0} succeeded, {1} failed + All {0} items failed. + All {0} items completed successfully. + Export Failed Items + Retry Failed + {0} rows have validation errors. Fix and re-import. + Select CSV File + CSV Files (*.csv)|*.csv + + Select Folder + Loading folder tree... + Select + Cancel + + + Select Sites + + + Select target sites for all tabs + + + Connect to a tenant first + + + {0} site(s) selected + + + No sites selected + + + + User Access Audit + + + Select Users + + + Target Sites + + + Scan Options + + + Search users by name or email... + + + {0} user(s) selected + + + Run Audit + + + Export CSV + + + Export HTML + + + Split + + + Single file + + + By site + + + By user + + + HTML layout + + + Separate files + + + Single tabbed file + + + Total Accesses + + + Sites + + + High Privilege + + + By User + + + By Site + + + Filter results... + + + Select at least one user to audit. + + + Select at least one site to scan. + + + Storage by File Type + Donut Chart + Bar Chart + Chart View: + Run a storage scan to see file type breakdown. + + MSP Logo + Import + Clear + No logo configured + Client Logo + Import + Clear + Pull from Entra + No logo configured + + Search + Browse Directory + User Directory + Load Directory + Cancel + Filter users... + Include guests + users + Double-click a user to add to audit + Name + Email + Department + Job Title + Type + + Export Options + Merge duplicate permissions + Hide raw system group names (SharingLinks, Limited Access) + Exclude sharing links + Exclude system groups (Limited Access) + + Register App + Remove App + Checking permissions... + Registering application... + Application registered successfully + Registration failed + Insufficient permissions for automatic registration + Removing application... + Application removed successfully + Manual Registration Required + 1. Go to Azure Portal > App registrations > New registration + 2. Name: 'SharePoint Toolbox - {0}', Supported account types: Single tenant + 3. Redirect URI: Public client, https://login.microsoftonline.com/common/oauth2/nativeclient + 4. Under API permissions, add: Microsoft Graph (User.Read, User.Read.All, Group.Read.All, Directory.Read.All) and SharePoint (AllSites.FullControl) + 5. Grant admin consent for all permissions + 6. Copy the Application (client) ID and paste it in the Client ID field above + + Site Ownership + Automatically take site collection admin ownership on access denied + When enabled, the app will automatically elevate to site collection admin when a scan encounters an access denied error. Requires Tenant Admin permissions. + This site was automatically elevated — ownership was taken to complete the scan + + User Access Audit Report + User Access Audit Report (Consolidated) + SharePoint Permissions Report + SharePoint Permissions Report (Simplified) + SharePoint Storage Metrics + SharePoint Duplicate Detection Report + Duplicate Detection Report + SharePoint File Search Results + File Search Results + SharePoint Version Cleanup Report + Version Cleanup Report + Total Accesses + Users Audited + Sites Scanned + High Privilege + External Users + Total Entries + Unique Permission Sets + Distinct Users/Groups + Libraries + Files + Total Size + Version Size + Guest + Direct + Group + Inherited + Unique + By User + By Site + Filter results... + Filter permissions... + Filter rows… + Filter: + Site + Sites + Object Type + Object + Permission Level + Access Type + Granted Through + User + Title + URL + Users/Groups + Simplified + Risk + Library / Folder + Last Modified + Name + Library + Path + Size + Created + Modified + Created By + Modified By + File Name + Extension + File Type + File Count + Error + Timestamp + # + Group + Total Size (MB) + Version Size (MB) + Size (MB) + Size (bytes) + accesses + access(es) + site(s) + permission(s) + copies + duplicate group(s) found. + result(s) + of + shown + Generated + Generated: + members unavailable + Empty group + Link + (no ext) + (no extension) + high-priv + Storage by File Type + Library Details + + Select Sites + Filter: + Type: + All + Team sites (MS Teams) + Communication + Classic + Other + Size (MB): + min + max + Title + URL + Type + Size + Load Sites + Select All + Deselect All + OK + Cancel + Loading sites... + {0} sites loaded. + {0} / {1} sites shown. + Error: {0} + Team site + Communication + Classic + Other + + Valid + Errors + Close + + New Folder + Guest + + Input + + Manage Profiles + Profiles + + Group + Copies + + Level 1 + Level 2 + Level 3 + Level 4 + + Unique Perms + Permission Levels + Principal Type + + Total Size: + Version Size: + Files: + + Source + Captured + + file(s) selected + Include source folder at destination + When on, recreate the source folder under the destination. When off, drop contents directly into the destination folder. + Copy folder contents + When on (default), transfer files inside the folder. When off, only the folder is created at the destination. + + No tenant connected. + No tenant selected. Please connect to a tenant first. + No tenant profile selected. Please connect first. + Select at least one site from the toolbar. + Add at least one user to audit. + No valid rows to process. Import a CSV first. + Template name is required. + New site title is required. + New site alias is required. + Source site and library must be selected. + Destination site and library must be selected. + Library title is required. + + Capturing template... + Template captured successfully. + Capture failed: {0} + Applying template... + Template applied. Site created at: {0} + Apply failed: {0} + + Searching... + + user(s) + files + sites + entries + + Simplified Permissions Mode + Groups raw SharePoint permissions into readable labels (Owner, Editor, Contributor, Reader, View-Only) and color-codes rows by risk level. Useful for a quick security overview without permission-level jargon. + Merge Permissions + When enabled, multiple permission entries for the same user or group are consolidated into a single row in the export, reducing report size. Disable to see every individual permission assignment separately. + Hide System Groups + Removes automatically-created SharePoint system groups from results (e.g. "Excel Services Viewers", "SharingLinks.*" groups). These groups are managed internally by SharePoint and are typically not relevant for user access audits. + Exclude Sharing Links + Removes sharing link entries from results and exports (e.g. "Anyone with the link", organisation-wide links). Useful when you only care about direct user and group permissions. + Exclude System Groups (Limited Access) + Removes "Limited Access System Group For Web/List" entries from results and exports. SharePoint creates these automatically when a user has item-level access; they are rarely relevant for user access audits. + Include Inherited Permissions + By default only objects with unique (broken) permissions are reported. Enable this to also include objects that inherit permissions from a parent, giving a complete picture of who can access every item. + Export Split Mode + Single File: all results are saved in one CSV or HTML file. + +Split by Site: creates a separate file for each site collection. Useful when auditing large multi-site tenants to keep individual files manageable. + KQL File Search + Searches files across your SharePoint sites using KQL (Keyword Query Language). The keyword field is optional — leave it empty to return all files matching only the active filters. Combine date range, author, and library filters to narrow results. + Filename Regex Filter + Post-filters results client-side using a .NET regular expression matched against file names. Example: \.pdf$ matches only PDF files. Leave blank to skip this filter. The expression is case-insensitive. + Version Cleanup Policy + Permanently deletes old document versions from SharePoint libraries. Only the N most recent versions are kept — older ones are removed permanently and cannot be recovered. Run a preview scan first to see what will be deleted. + Keep First Version + Always preserves version 1.0 (the original) of each document, regardless of the "Keep Last N" setting. Useful to maintain an audit trail of a document's initial state. + Confirm Before Delete + When enabled, a confirmation dialog appears for each file before its versions are deleted. Uncheck for unattended batch processing. + Duplicate Matching Criteria + Two items are flagged as duplicates when their names match AND all checked additional criteria also match. More criteria checked = fewer groups, but more precise matches. Using name only finds files with the same filename anywhere in the site, regardless of content. + Include Source Folder + When enabled, the source folder itself is recreated at the destination (e.g. transferring "Reports" creates a "Reports/" folder at the target). When disabled, only the contents inside the folder are transferred — useful when merging into an existing destination folder. + Copy Folder Contents Only + When enabled, only the files and subfolders inside the selected folder are transferred — the selected folder itself is not recreated at the destination. + File Conflict Policy + Defines what happens when a file with the same name already exists at the destination: + +• Skip — leave the existing destination file unchanged. +• Overwrite — replace the destination file with the source file. +• Rename — keep both by appending a number suffix to the transferred file's name. + Bulk Add Members — CSV Format + The CSV file must contain these columns (headers required, order is flexible): +• GroupName — the exact SharePoint group name +• Email — the user's email address +• Role — Member, Owner, or Visitor + +Click "Load Example" to open a pre-filled sample file. + Bulk Create Sites — CSV Format + The CSV file must contain these columns: +• Name — the display name for the new site +• Alias — URL alias (no spaces; becomes part of the site URL) +• Type — TeamSite or CommunicationSite +• Owners — comma-separated list of owner email addresses + +Click "Load Example" to open a pre-filled sample file. + Create Folder Structure — CSV Format + Creates a folder hierarchy inside a SharePoint library from a CSV file. Each row defines one folder path using up to 4 levels (Level1–Level4). Leave deeper level columns empty for shallower paths. + +Example row: Contracts | 2024 | Q1 | (empty) +Creates: Library / Contracts / 2024 / Q1 + Capture Site Template + Saves the currently selected site's structure (libraries, folder hierarchy, permissions, settings, and logo) as a reusable template stored locally on your machine. The source site is not modified in any way. + +Select which elements to include using the checkboxes above. + Apply Template to New Site + Creates a brand-new SharePoint site and reproduces the structure captured in the selected template — including libraries, folders, permissions, settings, and logo. The source template and original site are not affected. + +Provide a display name and URL alias for the new site before clicking Apply. + Search vs Browse Mode + Search Mode: type a name or email to find a specific user via Azure AD. Matching users appear in a list — click to select them for the audit. + +Browse Mode: loads all users in your tenant directory. Use the filter box to narrow the list, then double-click a row to add the user to the audit. + User Access Audit vs Permissions Audit + The Permissions tab scans objects (libraries, folders, items) and shows who has access to each one. + +This tab does the reverse: you select one or more users and it finds every object they can access — including access granted via SharePoint groups or Active Directory groups. + Hidden Libraries + Scans SharePoint libraries hidden from the site's normal navigation (e.g. Site Assets, Style Library, Form Templates). These can consume significant storage and are often overlooked in routine audits. + Preservation Hold Library + A hidden SharePoint library that stores versions of documents modified or deleted while a Microsoft Purview / Microsoft 365 Compliance retention policy is active. It can grow very large over time without being visible to normal site users. + diff --git a/Localization/TranslationSource.cs b/Localization/TranslationSource.cs new file mode 100644 index 0000000..692063d --- /dev/null +++ b/Localization/TranslationSource.cs @@ -0,0 +1,40 @@ +using System.Globalization; +using System.Resources; + +namespace SharepointToolbox.Web.Localization; + +/// +/// Singleton string lookup backed by Strings.resx / Strings.fr.resx. +/// Web version: no INotifyPropertyChanged — culture switching is per-request. +/// +public class TranslationSource +{ + public static readonly TranslationSource Instance = new(); + + private ResourceManager _resourceManager = Strings.ResourceManager; + private CultureInfo _currentCulture = CultureInfo.CurrentUICulture; + + private TranslationSource() { } + + public string this[string key] => + _resourceManager.GetString(key, _currentCulture) ?? $"[{key}]"; + + public CultureInfo CurrentCulture + { + get => _currentCulture; + set + { + if (Equals(_currentCulture, value)) return; + _currentCulture = value; + } + } + + public void SetCulture(string lang) + { + CurrentCulture = lang switch + { + "fr" => new CultureInfo("fr"), + _ => CultureInfo.InvariantCulture + }; + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..610a43f --- /dev/null +++ b/Program.cs @@ -0,0 +1,275 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Serilog; +using SharepointToolbox.Web.Core.Config; +using SharepointToolbox.Web.Core.Models; +using SharepointToolbox.Web.Infrastructure.Auth; +using SharepointToolbox.Web.Infrastructure.OAuth; +using SharepointToolbox.Web.Infrastructure.Persistence; +using SharepointToolbox.Web.Services; +using SharepointToolbox.Web.Services.Audit; +using SharepointToolbox.Web.Services.Auth; +using SharepointToolbox.Web.Services.Export; +using SharepointToolbox.Web.Services.OAuth; +using SharepointToolbox.Web.Services.Session; + +var builder = WebApplication.CreateBuilder(args); + +// ── Serilog ─────────────────────────────────────────────────────────────────── +var dataFolder = builder.Configuration["DataFolder"] ?? "/data"; +Directory.CreateDirectory(dataFolder); + +Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .WriteTo.Console() + .WriteTo.File( + Path.Combine(dataFolder, "logs", "app-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 30) + .CreateLogger(); + +builder.Host.UseSerilog(); + +// ── Blazor / Razor Components ───────────────────────────────────────────────── +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); +builder.Services.AddHttpContextAccessor(); + +// ── Authentication ──────────────────────────────────────────────────────────── +if (builder.Environment.IsDevelopment()) +{ + // Dev: cookie-only, no OIDC. /account/login auto-signs in a hardcoded Admin. + builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = "/account/login"; + options.LogoutPath = "/account/logout"; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.ExpireTimeSpan = TimeSpan.FromHours(8); + options.SlidingExpiration = true; + }); +} +else +{ + builder.Services.AddAuthentication(options => + { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; + }) + .AddCookie(options => + { + options.LoginPath = "/account/login"; + options.LogoutPath = "/account/logout"; + // Auth state lives entirely in the browser cookie (Data Protection encrypted) + options.SessionStore = null; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.ExpireTimeSpan = TimeSpan.FromHours(8); + options.SlidingExpiration = true; + }) + .AddOpenIdConnect(options => + { + var oidc = builder.Configuration.GetSection("Oidc"); + options.Authority = $"https://login.microsoftonline.com/{oidc["TenantId"]}/v2.0"; + options.ClientId = oidc["ClientId"]; + options.ClientSecret = oidc["ClientSecret"]; + options.ResponseType = OpenIdConnectResponseType.Code; + options.SaveTokens = true; + options.Scope.Add("openid"); + options.Scope.Add("profile"); + options.Scope.Add("email"); + options.GetClaimsFromUserInfoEndpoint = true; + options.MapInboundClaims = false; + options.TokenValidationParameters.NameClaimType = "preferred_username"; + + options.Events.OnTokenValidated = async ctx => + { + var userService = ctx.HttpContext.RequestServices.GetRequiredService(); + await userService.ProvisionAsync(ctx.Principal!); + }; + }); +} + +builder.Services.AddAuthorization(); + +// ── Memory cache (used by OAuth flow cache) ─────────────────────────────────── +builder.Services.AddMemoryCache(); +builder.Services.AddHttpClient("oauth"); + +// ── ClientConnect options ───────────────────────────────────────────────────── +builder.Services.Configure(builder.Configuration.GetSection("ClientConnect")); + +// ── App config ──────────────────────────────────────────────────────────────── +builder.Services.Configure(opt => +{ + opt.DataFolder = dataFolder; + opt.ExportsFolder = Path.Combine(dataFolder, "exports"); + Directory.CreateDirectory(opt.ExportsFolder); +}); + +// ── Persistence (Singleton — files on disk) ─────────────────────────────────── +builder.Services.AddSingleton(new ProfileRepository(Path.Combine(dataFolder, "profiles.json"))); +builder.Services.AddSingleton(new SettingsRepository(Path.Combine(dataFolder, "settings.json"))); +builder.Services.AddSingleton(new TemplateRepository(Path.Combine(dataFolder, "templates"))); +builder.Services.AddSingleton(new UserRepository(Path.Combine(dataFolder, "users.json"))); +builder.Services.AddSingleton(new AuditRepository(Path.Combine(dataFolder, "audit.jsonl"))); + +// ── Auth infrastructure ─────────────────────────────────────────────────────── +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); + +// ── User session (Scoped = one per Blazor circuit = one per browser tab) ───── +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// ── Audit (Scoped — reads user context from circuit) ───────────────────────── +builder.Services.AddScoped(); + +// ── Business services (Scoped — each user circuit gets its own instances) ───── +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// ── Export services (Scoped) ────────────────────────────────────────────────── +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.UseStaticFiles(); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseAntiforgery(); + +// ── Login / Logout endpoints ────────────────────────────────────────────────── +if (app.Environment.IsDevelopment()) +{ + app.MapGet("/account/login", async (HttpContext ctx, string? returnUrl, IUserService userService) => + { + const string devEmail = "dev@local.test"; + const string devName = "Dev Admin"; + + // Provision the dev user in users.json (first run = Admin) + var provisionPrincipal = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim("preferred_username", devEmail), new Claim("name", devName) }, + CookieAuthenticationDefaults.AuthenticationScheme)); + var user = await userService.ProvisionAsync(provisionPrincipal); + + // Sign in with full claims including app_role for HTTP endpoints + var principal = new ClaimsPrincipal(new ClaimsIdentity( + new Claim[] { + new("preferred_username", devEmail), + new("name", devName), + new("app_role", user.Role.ToString()), + }, + CookieAuthenticationDefaults.AuthenticationScheme)); + + await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); + ctx.Response.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl); + }); + + app.MapGet("/account/logout", async (HttpContext ctx) => + { + await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + ctx.Response.Redirect("/"); + }); +} +else +{ + app.MapGet("/account/login", async (HttpContext ctx, string? returnUrl) => + { + var props = new AuthenticationProperties + { + RedirectUri = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl + }; + await ctx.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, props); + }); + + app.MapGet("/account/logout", async (HttpContext ctx) => + { + await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + await ctx.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, + new AuthenticationProperties { RedirectUri = "/" }); + }); +} + +// ── OAuth2 connect endpoints ────────────────────────────────────────────────── +app.MapOAuthEndpoints(); + +// ── File download endpoint ──────────────────────────────────────────────────── +app.MapGet("/export/download/{fileName}", async (string fileName, IOptions opts, HttpContext ctx) => +{ + if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized(); + var path = Path.Combine(opts.Value.ExportsFolder, Path.GetFileName(fileName)); + if (!File.Exists(path)) return Results.NotFound(); + var bytes = await File.ReadAllBytesAsync(path); + var ct = fileName.EndsWith(".csv") ? "text/csv" : "text/html"; + return Results.File(bytes, ct, fileName); +}); + +// ── Audit CSV download ──────────────────────────────────────────────────────── +app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) => +{ + if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized(); + // Role check via the app-role claim set during OIDC provisioning + var rolesClaim = ctx.User.FindFirst("app_role")?.Value; + if (rolesClaim != nameof(UserRole.Admin)) return Results.Forbid(); + var entries = await auditRepo.LoadAllAsync(); + var sb = new System.Text.StringBuilder(); + sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details"); + foreach (var e in entries.OrderByDescending(x => x.Timestamp)) + { + string Esc(string v) => v.Contains(',') || v.Contains('"') || v.Contains('\n') + ? $"\"{v.Replace("\"", "\"\"")}\"" : v; + sb.AppendLine(string.Join(",", + Esc(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")), + Esc(e.UserEmail), Esc(e.UserDisplay), Esc(e.UserRole.ToString()), + Esc(e.Action), Esc(e.ClientName), + Esc(string.Join("; ", e.Sites)), Esc(e.Details))); + } + return Results.File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", "audit-log.csv"); +}); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..df85fca --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..7abdb9e --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# SharepointToolbox-Web + diff --git a/Resources/bulk_add_members.csv b/Resources/bulk_add_members.csv new file mode 100644 index 0000000..6dcca84 --- /dev/null +++ b/Resources/bulk_add_members.csv @@ -0,0 +1,8 @@ +GroupName,GroupUrl,Email,Role +Marketing Team,https://contoso.sharepoint.com/sites/Marketing,user1@contoso.com,Member +Marketing Team,https://contoso.sharepoint.com/sites/Marketing,manager@contoso.com,Owner +HR Team,https://contoso.sharepoint.com/sites/HR,hr-admin@contoso.com,Owner +HR Team,https://contoso.sharepoint.com/sites/HR,recruiter@contoso.com,Member +HR Team,https://contoso.sharepoint.com/sites/HR,analyst@contoso.com,Member +IT Support,https://contoso.sharepoint.com/sites/IT,sysadmin@contoso.com,Owner +IT Support,https://contoso.sharepoint.com/sites/IT,helpdesk@contoso.com,Member diff --git a/Resources/bulk_create_sites.csv b/Resources/bulk_create_sites.csv new file mode 100644 index 0000000..c2e9b0a --- /dev/null +++ b/Resources/bulk_create_sites.csv @@ -0,0 +1,6 @@ +Name;Alias;Type;Template;Owners;Members +Projet Alpha;projet-alpha;Team;;admin@contoso.com;user1@contoso.com, user2@contoso.com +Projet Beta;projet-beta;Team;;admin@contoso.com;user3@contoso.com, user4@contoso.com +Communication RH;comm-rh;Communication;;rh-admin@contoso.com;manager1@contoso.com, manager2@contoso.com +Equipe Marketing;equipe-marketing;Team;;marketing-lead@contoso.com;designer@contoso.com, redacteur@contoso.com +Portail Intranet;portail-intranet;Communication;;it-admin@contoso.com; diff --git a/Resources/folder_structure.csv b/Resources/folder_structure.csv new file mode 100644 index 0000000..0d09f19 --- /dev/null +++ b/Resources/folder_structure.csv @@ -0,0 +1,20 @@ +Level1;Level2;Level3;Level4 +Administration;;; +Administration;Comptabilite;; +Administration;Comptabilite;Factures; +Administration;Comptabilite;Bilans; +Administration;Ressources Humaines;; +Administration;Ressources Humaines;Contrats; +Administration;Ressources Humaines;Fiches de paie; +Projets;;; +Projets;Projet Alpha;; +Projets;Projet Alpha;Documents; +Projets;Projet Alpha;Livrables; +Projets;Projet Beta;; +Projets;Projet Beta;Documents; +Communication;;; +Communication;Interne;; +Communication;Interne;Notes de service; +Communication;Externe;; +Communication;Externe;Communiques de presse; +Communication;Externe;Newsletter; diff --git a/Services/Audit/AuditService.cs b/Services/Audit/AuditService.cs new file mode 100644 index 0000000..1d84198 --- /dev/null +++ b/Services/Audit/AuditService.cs @@ -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 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> GetAllAsync() => _repo.LoadAllAsync(); + + public async Task 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; + } +} diff --git a/Services/Audit/IAuditService.cs b/Services/Audit/IAuditService.cs new file mode 100644 index 0000000..7f534ed --- /dev/null +++ b/Services/Audit/IAuditService.cs @@ -0,0 +1,10 @@ +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services.Audit; + +public interface IAuditService +{ + Task LogAsync(string action, string clientName, IEnumerable sites, string details = ""); + Task> GetAllAsync(); + Task ExportCsvAsync(); +} diff --git a/Services/Auth/AppRegistrationService.cs b/Services/Auth/AppRegistrationService.cs new file mode 100644 index 0000000..cb69e45 --- /dev/null +++ b/Services/Auth/AppRegistrationService.cs @@ -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 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(); + 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 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(); + } +} diff --git a/Services/Auth/IAppRegistrationService.cs b/Services/Auth/IAppRegistrationService.cs new file mode 100644 index 0000000..8f4cce6 --- /dev/null +++ b/Services/Auth/IAppRegistrationService.cs @@ -0,0 +1,16 @@ +namespace SharepointToolbox.Web.Services.Auth; + +public interface IAppRegistrationService +{ + /// + /// 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). + /// + Task CreateAsync( + string adminAccessToken, + string tenantName, + string redirectUri, + CancellationToken ct = default); +} diff --git a/Services/Auth/ITokenRefreshService.cs b/Services/Auth/ITokenRefreshService.cs new file mode 100644 index 0000000..08eb359 --- /dev/null +++ b/Services/Auth/ITokenRefreshService.cs @@ -0,0 +1,17 @@ +namespace SharepointToolbox.Web.Services.Auth; + +public interface ITokenRefreshService +{ + /// + /// 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. + /// + Task 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; } +} diff --git a/Services/Auth/IUserService.cs b/Services/Auth/IUserService.cs new file mode 100644 index 0000000..a48d288 --- /dev/null +++ b/Services/Auth/IUserService.cs @@ -0,0 +1,16 @@ +using System.Security.Claims; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services.Auth; + +public interface IUserService +{ + /// Auto-provision on first OIDC login; update LastLogin on subsequent logins. + /// First user ever becomes Admin automatically. + Task ProvisionAsync(ClaimsPrincipal principal); + + Task GetByEmailAsync(string email); + Task> GetAllAsync(); + Task UpdateRoleAsync(string userId, UserRole role); + Task DeleteAsync(string userId); +} diff --git a/Services/Auth/TokenRefreshService.cs b/Services/Auth/TokenRefreshService.cs new file mode 100644 index 0000000..9ceb670 --- /dev/null +++ b/Services/Auth/TokenRefreshService.cs @@ -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 RefreshAsync( + string refreshToken, string tenantId, string clientId, string scope) + { + var body = new Dictionary + { + ["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), + }; + } +} diff --git a/Services/Auth/UserService.cs b/Services/Auth/UserService.cs new file mode 100644 index 0000000..c5b2a11 --- /dev/null +++ b/Services/Auth/UserService.cs @@ -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 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 GetByEmailAsync(string email) => _repo.FindByEmailAsync(email); + + public Task> 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); +} diff --git a/Services/BulkMemberService.cs b/Services/BulkMemberService.cs new file mode 100644 index 0000000..d2acc0d --- /dev/null +++ b/Services/BulkMemberService.cs @@ -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> AddMembersAsync( + ClientContext ctx, TenantProfile profile, + IReadOnlyList rows, + IProgress 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 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 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 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); + } +} diff --git a/Services/BulkOperationRunner.cs b/Services/BulkOperationRunner.cs new file mode 100644 index 0000000..c08b05c --- /dev/null +++ b/Services/BulkOperationRunner.cs @@ -0,0 +1,61 @@ +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +public static class BulkOperationRunner +{ + public static async Task> RunAsync( + IReadOnlyList items, + Func processItem, + IProgress progress, + CancellationToken ct, + int maxConcurrency = 1) + { + if (items.Count == 0) + { + progress.Report(new OperationProgress(0, 0, "Nothing to do.")); + return new BulkOperationSummary(Array.Empty>()); + } + + progress.Report(new OperationProgress(0, items.Count, $"Processing 1/{items.Count}...")); + var results = new BulkItemResult[items.Count]; + int completed = 0; + + async Task RunOne(int i, CancellationToken token) + { + try + { + await processItem(items[i], i, token); + results[i] = BulkItemResult.Success(items[i]); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + results[i] = BulkItemResult.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(results); + } +} diff --git a/Services/BulkSiteService.cs b/Services/BulkSiteService.cs new file mode 100644 index 0000000..946bb6e --- /dev/null +++ b/Services/BulkSiteService.cs @@ -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> CreateSitesAsync( + ClientContext adminCtx, IReadOnlyList rows, + IProgress progress, CancellationToken ct) + { + var createdUrls = new System.Collections.Concurrent.ConcurrentBag(); + 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 CreateSingleSiteAsync(ClientContext adminCtx, BulkSiteRow row, IProgress 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 CreateTeamSiteAsync(ClientContext adminCtx, BulkSiteRow row, IProgress 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 CreateCommunicationSiteAsync(ClientContext adminCtx, BulkSiteRow row, IProgress 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 ParseEmails(string commaSeparated) => + string.IsNullOrWhiteSpace(commaSeparated) ? new List() : + 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(); +} diff --git a/Services/CsvValidationService.cs b/Services/CsvValidationService.cs new file mode 100644 index 0000000..9d478ce --- /dev/null +++ b/Services/CsvValidationService.cs @@ -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> ParseAndValidate(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>(); + csv.Read(); csv.ReadHeader(); + while (csv.Read()) + { + try + { + var record = csv.GetRecord(); + if (record == null) + { + rows.Add(CsvValidationRow.ParseError(csv.Context.Parser?.RawRecord, "Failed to parse row")); + continue; + } + rows.Add(new CsvValidationRow(record, new List())); + } + catch (Exception ex) + { + rows.Add(CsvValidationRow.ParseError(csv.Context.Parser?.RawRecord, ex.Message)); + } + } + return rows; + } + + public List> ParseAndValidateMembers(Stream csvStream) + { + var rows = ParseAndValidate(csvStream); + foreach (var row in rows.Where(r => r.IsValid && r.Record != null)) + row.Errors.AddRange(ValidateMemberRow(row.Record!)); + return rows; + } + + public List> ParseAndValidateSites(Stream csvStream) + { + var rows = ParseAndValidate(csvStream); + foreach (var row in rows.Where(r => r.IsValid && r.Record != null)) + row.Errors.AddRange(ValidateSiteRow(row.Record!)); + return rows; + } + + public List> ParseAndValidateFolders(Stream csvStream) + { + var rows = ParseAndValidate(csvStream); + foreach (var row in rows.Where(r => r.IsValid && r.Record != null)) + row.Errors.AddRange(ValidateFolderRow(row.Record!)); + return rows; + } + + private static List ValidateMemberRow(BulkMemberRow row) + { + var e = new List(); + 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 ValidateSiteRow(BulkSiteRow row) + { + var e = new List(); + 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 ValidateFolderRow(FolderStructureRow row) + { + var e = new List(); + if (string.IsNullOrWhiteSpace(row.Level1)) e.Add("Level1 is required"); + return e; + } +} diff --git a/Services/DuplicatesService.cs b/Services/DuplicatesService.cs new file mode 100644 index 0000000..2d70f2a --- /dev/null +++ b/Services/DuplicatesService.cs @@ -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> ScanDuplicatesAsync( + ClientContext ctx, DuplicateScanOptions options, + IProgress progress, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + List 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> CollectFileItemsAsync(ClientContext ctx, DuplicateScanOptions options, IProgress progress, CancellationToken ct) + { + var (siteUrl, siteTitle) = await LoadSiteIdentityAsync(ctx, progress, ct); + var kqlParts = new List { "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(); + 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 dict; + if (rawRow is IDictionary generic) dict = generic; + else if (rawRow is System.Collections.IDictionary legacy) { dict = new Dictionary(); 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> CollectFolderItemsAsync(ClientContext ctx, DuplicateScanOptions options, IProgress 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 = "5000" }; + var allItems = new List(); + 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 { 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 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 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); + } +} diff --git a/Services/Export/BrandingHtmlHelper.cs b/Services/Export/BrandingHtmlHelper.cs new file mode 100644 index 0000000..219ef1d --- /dev/null +++ b/Services/Export/BrandingHtmlHelper.cs @@ -0,0 +1,37 @@ +using System.Text; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services.Export; + +/// +/// 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). +/// +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("
"); + + if (msp is not null) + sb.AppendLine($" \"\""); + + if (msp is not null && client is not null) + sb.AppendLine("
"); + + if (client is not null) + sb.AppendLine($" \"\""); + + sb.AppendLine("
"); + return sb.ToString(); + } +} diff --git a/Services/Export/BulkResultCsvExportService.cs b/Services/Export/BulkResultCsvExportService.cs new file mode 100644 index 0000000..9833b6a --- /dev/null +++ b/Services/Export/BulkResultCsvExportService.cs @@ -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; + +/// +/// Exports the failed subset of a run +/// to CSV. CsvHelper is used so the payload's +/// properties become columns automatically, plus one error-message and one +/// timestamp column appended at the end. +/// +public class BulkResultCsvExportService +{ + private static readonly CsvConfiguration CsvConfig = new(CultureInfo.InvariantCulture) + { + // Prevent CSV formula injection: prefix =, +, -, @, tab, CR with single quote + InjectionOptions = InjectionOptions.Escape, + }; + + /// + /// Builds a CSV containing only items whose + /// is false. Columns: every public property of + /// followed by Error and Timestamp (ISO-8601). + /// + public string BuildFailedItemsCsv(IReadOnlyList> failedItems) + { + var TL = TranslationSource.Instance; + using var writer = new StringWriter(); + using var csv = new CsvWriter(writer, CsvConfig); + + csv.WriteHeader(); + 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(); + } + + /// Writes the failed-items CSV to with UTF-8 BOM. + public async Task WriteFailedItemsCsvAsync( + IReadOnlyList> failedItems, + string filePath, + CancellationToken ct) + { + var content = BuildFailedItemsCsv(failedItems); + await ExportFileWriter.WriteCsvAsync(filePath, content, ct); + } +} diff --git a/Services/Export/CsvExportService.cs b/Services/Export/CsvExportService.cs new file mode 100644 index 0000000..4407e8e --- /dev/null +++ b/Services/Export/CsvExportService.cs @@ -0,0 +1,180 @@ +using System.IO; +using System.Text; +using SharepointToolbox.Web.Core.Models; +using SharepointToolbox.Web.Localization; + +namespace SharepointToolbox.Web.Services.Export; + +/// +/// Exports permission entries to CSV format. +/// Ports PowerShell Merge-PermissionRows + Export-Csv functionality. +/// +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\""; + } + + /// + /// Builds a CSV string from the supplied permission entries. + /// Merges rows with identical (Users, PermissionLevels, GrantedThrough) by pipe-joining URLs and Titles. + /// + public string BuildCsv(IReadOnlyList 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(); + } + + /// + /// Writes the CSV to the specified file path using UTF-8 with BOM (for Excel compatibility). + /// + public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct) + { + var csv = BuildCsv(entries); + await ExportFileWriter.WriteCsvAsync(filePath, csv, ct); + } + + /// + /// Header for simplified CSV export — includes "SimplifiedLabels" and "RiskLevel" columns. + /// + 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\""; + } + + /// + /// 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. + /// + public string BuildCsv(IReadOnlyList 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(); + } + + /// + /// Writes simplified CSV to the specified file path. + /// + public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct) + { + var csv = BuildCsv(entries); + await ExportFileWriter.WriteCsvAsync(filePath, csv, ct); + } + + /// + /// Writes permission entries with optional per-site partitioning. + /// Single → writes one file at . + /// BySite → one file per site-collection URL, suffixed on the base path. + /// + public Task WriteAsync( + IReadOnlyList entries, + string basePath, + ReportSplitMode splitMode, + CancellationToken ct) + => ReportSplitHelper.WritePartitionedAsync( + entries, basePath, splitMode, + PartitionBySite, + (part, path, c) => WriteAsync(part, path, c), + ct); + + /// Simplified-entry split variant. + public Task WriteAsync( + IReadOnlyList 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 Partition)> PartitionBySite( + IReadOnlyList entries) + { + return entries + .GroupBy(e => ReportSplitHelper.DeriveSiteCollectionUrl(e.Url), StringComparer.OrdinalIgnoreCase) + .Select(g => ( + Label: ReportSplitHelper.DeriveSiteLabel(g.Key), + Partition: (IReadOnlyList)g.ToList())); + } + + internal static IEnumerable<(string Label, IReadOnlyList Partition)> PartitionBySite( + IReadOnlyList entries) + { + return entries + .GroupBy(e => ReportSplitHelper.DeriveSiteCollectionUrl(e.Url), StringComparer.OrdinalIgnoreCase) + .Select(g => ( + Label: ReportSplitHelper.DeriveSiteLabel(g.Key), + Partition: (IReadOnlyList)g.ToList())); + } + + /// RFC 4180 CSV field escaping with formula-injection guard. + private static string Csv(string value) => CsvSanitizer.Escape(value); +} diff --git a/Services/Export/CsvSanitizer.cs b/Services/Export/CsvSanitizer.cs new file mode 100644 index 0000000..1e38d36 --- /dev/null +++ b/Services/Export/CsvSanitizer.cs @@ -0,0 +1,47 @@ +namespace SharepointToolbox.Web.Services.Export; + +/// +/// 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. +/// +internal static class CsvSanitizer +{ + /// + /// 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. + /// + public static string Escape(string? value) + { + if (string.IsNullOrEmpty(value)) return "\"\""; + var safe = NeutralizeFormulaPrefix(value).Replace("\"", "\"\""); + return $"\"{safe}\""; + } + + /// + /// Minimal quoting variant: only wraps in quotes when the value contains + /// a delimiter, quote, or newline. Still guards against formula injection. + /// + 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; + } +} diff --git a/Services/Export/DuplicatesCsvExportService.cs b/Services/Export/DuplicatesCsvExportService.cs new file mode 100644 index 0000000..e03675d --- /dev/null +++ b/Services/Export/DuplicatesCsvExportService.cs @@ -0,0 +1,112 @@ +using System.IO; +using System.Text; +using SharepointToolbox.Web.Core.Models; +using SharepointToolbox.Web.Localization; + +namespace SharepointToolbox.Web.Services.Export; + +/// +/// 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. +/// +public class DuplicatesCsvExportService +{ + /// Writes the CSV to with UTF-8 BOM (Excel-compatible). + public async Task WriteAsync( + IReadOnlyList groups, + string filePath, + CancellationToken ct) + { + var csv = BuildCsv(groups); + await ExportFileWriter.WriteCsvAsync(filePath, csv, ct); + } + + /// + /// Writes one or more CSVs depending on . + /// Single → as-is. BySite → one file per site, + /// filenames derived from with a site suffix. + /// + public Task WriteAsync( + IReadOnlyList 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 Partition)> PartitionBySite( + IReadOnlyList 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)g.ToList())); + } + + /// + /// 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 . + /// + public string BuildCsv(IReadOnlyList 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); +} diff --git a/Services/Export/DuplicatesHtmlExportService.cs b/Services/Export/DuplicatesHtmlExportService.cs new file mode 100644 index 0000000..a057c20 --- /dev/null +++ b/Services/Export/DuplicatesHtmlExportService.cs @@ -0,0 +1,187 @@ +using SharepointToolbox.Web.Core.Models; +using SharepointToolbox.Web.Localization; +using System.Text; + +namespace SharepointToolbox.Web.Services.Export; + +/// +/// 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. +/// +public class DuplicatesHtmlExportService +{ + /// + /// Builds a self-contained HTML string rendering one collapsible card per + /// . The document ships with inline CSS and a + /// tiny JS toggle so no external assets are needed. + /// + public string BuildHtml(IReadOnlyList groups, ReportBranding? branding = null) + { + var T = TranslationSource.Instance; + var sb = new StringBuilder(); + + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"{T["report.title.duplicates"]}"); + sb.AppendLine(""" + + + + + """); + sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); + sb.AppendLine($"

{T["report.title.duplicates_short"]}

"); + + sb.AppendLine($"

{groups.Count:N0} {T["report.text.duplicate_groups_found"]}

"); + + for (int i = 0; i < groups.Count; i++) + { + var g = groups[i]; + int count = g.Items.Count; + string badgeClass = "badge-dup"; + + sb.AppendLine($""" +
+
+ {H(g.Name)} + {count} {T["report.text.copies"]} +
+
+ + + + + + + + + + + + + + """); + + 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($""" + + + + + + + + + + """); + } + + sb.AppendLine(""" + +
{T["report.col.number"]}{T["report.col.name"]}{T["report.col.library"]}{T["report.col.path"]}{T["report.col.size"]}{T["report.col.created"]}{T["report.col.modified"]}
{j + 1}{H(item.Name)}{H(item.Library)}{H(item.Path)}{size}{created}{modified}
+
+
+ """); + } + + sb.AppendLine($"

{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}

"); + sb.AppendLine(""); + + return sb.ToString(); + } + + /// Writes the HTML report to the specified file path using UTF-8. + public async Task WriteAsync(IReadOnlyList groups, string filePath, CancellationToken ct, ReportBranding? branding = null) + { + var html = BuildHtml(groups, branding); + await System.IO.File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct); + } + + /// + /// Writes one or more HTML reports depending on and + /// . Single → one file. BySite + SeparateFiles → one + /// file per site. BySite + SingleTabbed → one file with per-site iframe tabs. + /// + public async Task WriteAsync( + IReadOnlyList 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"; + } +} diff --git a/Services/Export/ExportFileWriter.cs b/Services/Export/ExportFileWriter.cs new file mode 100644 index 0000000..9270a55 --- /dev/null +++ b/Services/Export/ExportFileWriter.cs @@ -0,0 +1,64 @@ +using System.IO; +using System.Text; + +namespace SharepointToolbox.Web.Services.Export; + +/// +/// 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 srcdoc paths render the BOM as a visible character). +/// Export services should call these helpers rather than constructing +/// inline. +/// +internal static class ExportFileWriter +{ + private static readonly UTF8Encoding Utf8WithBom = new(encoderShouldEmitUTF8Identifier: true); + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + + /// Writes to as UTF-8 with BOM. + public static Task WriteCsvAsync(string filePath, string csv, CancellationToken ct) + => File.WriteAllTextAsync(filePath, csv, Utf8WithBom, ct); + + /// Writes to as UTF-8 without BOM. + public static Task WriteHtmlAsync(string filePath, string html, CancellationToken ct) + => File.WriteAllTextAsync(filePath, html, Utf8NoBom, ct); + + /// + /// Streams a directly to disk as UTF-8 with + /// BOM, chunk by chunk. Avoids the full-document ToString() copy + /// and the separate UTF-8 byte buffer that + /// would otherwise allocate — meaningful for large CSV exports. + /// + public static Task WriteCsvChunksAsync(string filePath, StringBuilder builder, CancellationToken ct) + => WriteChunksAsync(filePath, builder, Utf8WithBom, ct); + + /// + /// Streams a directly to disk as UTF-8 without + /// BOM. Same rationale as — for large + /// HTML reports it halves peak memory by skipping the intermediate string. + /// + 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); + } +} diff --git a/Services/Export/HtmlExportService.cs b/Services/Export/HtmlExportService.cs new file mode 100644 index 0000000..514196d --- /dev/null +++ b/Services/Export/HtmlExportService.cs @@ -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; + +/// +/// Exports permission entries to a self-contained interactive HTML report. +/// Ports PowerShell Export-PermissionsToHTML 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 ; this class only +/// owns the table column sets and the simplified risk summary. +/// +public class HtmlExportService +{ + /// + /// Builds a self-contained HTML string from the supplied permission + /// entries. Standard report: columns are Object / Title / URL / Unique / + /// Users / Permission / Granted Through. When + /// is provided, SharePoint group pills + /// become expandable rows listing resolved members. + /// + public string BuildHtml( + IReadOnlyList entries, + ReportBranding? branding = null, + IReadOnlyDictionary>? 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(""); + sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); + sb.AppendLine($"

{T["report.title.permissions"]}

"); + AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers); + AppendFilterInput(sb); + AppendTableOpen(sb); + sb.AppendLine(""); + sb.AppendLine($" {T["report.col.object"]}{T["report.col.title"]}{T["report.col.url"]}{T["report.badge.unique"]}{T["report.col.users_groups"]}{T["report.col.permission_level"]}{T["report.col.granted_through"]}"); + sb.AppendLine(""); + sb.AppendLine(""); + + 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(""); + sb.AppendLine($" {HtmlEncode(entry.ObjectType)}"); + sb.AppendLine($" {HtmlEncode(entry.Title)}"); + sb.AppendLine($" {T["report.text.link"]}"); + sb.AppendLine($" {uniqueLbl}"); + sb.AppendLine($" {pills}"); + sb.AppendLine($" {HtmlEncode(entry.PermissionLevels)}"); + sb.AppendLine($" {BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}"); + sb.AppendLine(""); + if (subRows.Length > 0) sb.Append(subRows); + } + + AppendTableClose(sb); + AppendInlineJs(sb); + sb.AppendLine(""); + sb.AppendLine(""); + return sb.ToString(); + } + + /// + /// Builds a self-contained HTML string from simplified permission entries. + /// Adds a risk-level summary card strip plus two columns (Simplified, + /// Risk) relative to . + /// Color-coded risk badges use . + /// + public string BuildHtml( + IReadOnlyList entries, + ReportBranding? branding = null, + IReadOnlyDictionary>? 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(""); + sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); + sb.AppendLine($"

{T["report.title.permissions_simplified"]}

"); + AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers); + + sb.AppendLine("
"); + foreach (var s in summaries) + { + var (bg, text, border) = RiskLevelColors(s.RiskLevel); + sb.AppendLine($"
"); + sb.AppendLine($"
{s.Count}
"); + sb.AppendLine($"
{HtmlEncode(s.Label)}
"); + sb.AppendLine($"
{s.DistinctUsers} {T["report.text.users_parens"]}
"); + sb.AppendLine("
"); + } + sb.AppendLine("
"); + + AppendFilterInput(sb); + AppendTableOpen(sb); + sb.AppendLine(""); + sb.AppendLine($" {T["report.col.users_groups"]}{T["report.col.permission_level"]}{T["report.col.simplified"]}{T["report.col.risk"]}{T["report.col.granted_through"]}"); + sb.AppendLine(""); + sb.AppendLine(""); + + 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($""); + sb.AppendLine($" {HtmlEncode(group.Key.ObjectType)} {HtmlEncode(group.Key.Title)} {uniqueLbl}{count} {T["report.text.entries_unit"]}"); + sb.AppendLine(""); + + 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($""); + sb.AppendLine($" {pills}"); + sb.AppendLine($" {HtmlEncode(entry.PermissionLevels)}"); + sb.AppendLine($" {HtmlEncode(entry.SimplifiedLabels)}"); + sb.AppendLine($" {HtmlEncode(entry.RiskLevel.ToString())}"); + sb.AppendLine($" {BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}"); + sb.AppendLine(""); + if (subRows.Length > 0) sb.Append(subRows); + } + } + + AppendTableClose(sb); + AppendInlineJs(sb); + sb.AppendLine(""); + sb.AppendLine(""); + return sb.ToString(); + } + + /// Writes the HTML report to the specified file path using UTF-8 without BOM. + public async Task WriteAsync( + IReadOnlyList entries, + string filePath, + CancellationToken ct, + ReportBranding? branding = null, + IReadOnlyDictionary>? groupMembers = null, + bool hideSystemGroupRaw = false) + { + var html = BuildHtml(entries, branding, groupMembers, hideSystemGroupRaw); + await ExportFileWriter.WriteHtmlAsync(filePath, html, ct); + } + + /// Writes the simplified HTML report to the specified file path using UTF-8 without BOM. + public async Task WriteAsync( + IReadOnlyList entries, + string filePath, + CancellationToken ct, + ReportBranding? branding = null, + IReadOnlyDictionary>? groupMembers = null, + bool hideSystemGroupRaw = false) + { + var html = BuildHtml(entries, branding, groupMembers, hideSystemGroupRaw); + await ExportFileWriter.WriteHtmlAsync(filePath, html, ct); + } + + /// + /// Split-aware write for permission entries. + /// Single → one file. BySite + SeparateFiles → one file per site. + /// BySite + SingleTabbed → one file with per-site iframe tabs. + /// + public async Task WriteAsync( + IReadOnlyList entries, + string basePath, + ReportSplitMode splitMode, + HtmlSplitLayout layout, + CancellationToken ct, + ReportBranding? branding = null, + IReadOnlyDictionary>? 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); + } + } + + /// Simplified-entry split variant. + public async Task WriteAsync( + IReadOnlyList entries, + string basePath, + ReportSplitMode splitMode, + HtmlSplitLayout layout, + CancellationToken ct, + ReportBranding? branding = null, + IReadOnlyDictionary>? 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 permissionLevels, + IEnumerable 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("
"); + sb.AppendLine(""); + } + + private static void AppendTableClose(StringBuilder sb) + { + sb.AppendLine(""); + sb.AppendLine("
"); + sb.AppendLine("
"); + } + + /// Returns inline CSS background, text, and border colors for a risk level. + 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") + }; +} diff --git a/Services/Export/PermissionHtmlFragments.cs b/Services/Export/PermissionHtmlFragments.cs new file mode 100644 index 0000000..ee4389c --- /dev/null +++ b/Services/Export/PermissionHtmlFragments.cs @@ -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; + +/// +/// Shared HTML-rendering fragments for the permission exports (standard and +/// simplified). Extracted so the two 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. +/// +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'; + } + }); +});"; + + /// + /// Appends the shared HTML head (doctype, meta, inline CSS, title) to + /// . Pass when the + /// caller renders risk cards/badges (simplified report only). + /// + internal static void AppendHead(StringBuilder sb, string title, bool includeRiskCss) + { + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"{title}"); + sb.AppendLine(""); + sb.AppendLine(""); + } + + /// + /// Appends the three stat cards (total entries, unique permission sets, + /// distinct users/groups) inside a single .stats row. + /// + internal static void AppendStatsCards(StringBuilder sb, int totalEntries, int uniquePermSets, int distinctUsers) + { + var T = TranslationSource.Instance; + sb.AppendLine("
"); + sb.AppendLine($"
{totalEntries}
{T["report.stat.total_entries"]}
"); + sb.AppendLine($"
{uniquePermSets}
{T["report.stat.unique_permission_sets"]}
"); + sb.AppendLine($"
{distinctUsers}
{T["report.stat.distinct_users_groups"]}
"); + sb.AppendLine("
"); + } + + /// Appends the live-filter input bound to #permTable. + internal static void AppendFilterInput(StringBuilder sb) + { + var T = TranslationSource.Instance; + sb.AppendLine("
"); + sb.AppendLine($" "); + sb.AppendLine("
"); + } + + /// Appends the inline <script> that powers filter and group toggle. + internal static void AppendInlineJs(StringBuilder sb) + { + sb.AppendLine(""); + } + + /// + /// 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; must be mutated + /// across rows so sub-row IDs stay unique. + /// + internal static (string Pills, string MemberSubRows) BuildUserPillsCell( + string userLogins, + string userNames, + string? principalType, + IReadOnlyDictionary>? 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($""); + if (isResolvedSystemGroup) + { + if (!string.IsNullOrEmpty(sharingLinkType)) + pills.Append(BuildSharingLinkBadge(sharingLinkType!)); + pills.Append(HtmlEncode(targetLabel!)); + } + else + { + pills.Append(HtmlEncode(name)); + } + pills.Append(""); + } + else + { + var grpId = $"grpmem{grpMemIdx}"; + pills.Append(""); + if (isResolvedSystemGroup) + { + if (!string.IsNullOrEmpty(sharingLinkType)) + pills.Append(BuildSharingLinkBadge(sharingLinkType!)); + pills.Append(HtmlEncode(targetLabel!)); + } + else + { + pills.Append(HtmlEncode(name)); + } + pills.Append(" ▼"); + + 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($"{memberContent}"); + grpMemIdx++; + } + } + else if (isResolvedSystemGroup) + { + pills.Append(""); + if (!string.IsNullOrEmpty(sharingLinkType)) + pills.Append(BuildSharingLinkBadge(sharingLinkType!)); + pills.Append(HtmlEncode(targetLabel!)); + pills.Append(""); + } + else + { + var cls = isExt ? "user-pill external-user" : "user-pill"; + pills.Append($"{HtmlEncode(name)}"); + } + } + + return (pills.ToString(), subRows.ToString()); + } + + /// + /// 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 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. + /// + 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(""); + sb.Append(HtmlEncode(targetLabel!)); + sb.Append(""); + } + return sb.ToString(); + } + + sb.Append("
"); + if (hasLinkType) + sb.Append(BuildSharingLinkBadge(sharingLinkType!)); + if (hasTarget) + { + sb.Append("→ "); + sb.Append(HtmlEncode(targetLabel!)); + sb.Append(""); + } + sb.Append("
"); + return sb.ToString(); + } + + /// + /// Builds the colored badge for a SharePoint sharing-link type. Translates the + /// raw linkType code (e.g. OrganizationEdit) into a human label + /// (e.g. Org link · Edit) and tints by risk tier; raw code surfaces as a + /// title tooltip so operators can still trace it back to the source. + /// + internal static string BuildSharingLinkBadge(string rawLinkType) + { + var (label, risk) = SharingLinkLabels.Describe(rawLinkType); + var (bg, fg) = SharingLinkLabels.Colors(risk); + return $"{HtmlEncode(label)}"; + } + + /// Returns the CSS class for the object-type badge. + internal static string ObjectTypeCss(string t) => t switch + { + "Site Collection" => "badge site-coll", + "Site" => "badge site", + "List" => "badge list", + "Folder" => "badge folder", + _ => "badge" + }; + + /// Minimal HTML encoding for text content and attribute values. + internal static string HtmlEncode(string value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + return value + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """) + .Replace("'", "'"); + } +} diff --git a/Services/Export/ReportSplitHelper.cs b/Services/Export/ReportSplitHelper.cs new file mode 100644 index 0000000..673768b --- /dev/null +++ b/Services/Export/ReportSplitHelper.cs @@ -0,0 +1,199 @@ +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Text; + +namespace SharepointToolbox.Web.Services.Export; + +/// +/// Shared helpers for split report exports: filename partitioning, site label +/// derivation, and bundling per-partition HTML into a single tabbed document. +/// +public static class ReportSplitHelper +{ + /// + /// Returns a file-safe variant of . Invalid filename + /// characters are replaced with underscores; whitespace runs are collapsed. + /// + 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; + } + + /// + /// Given a user-selected (e.g. "C:\reports\duplicates.csv"), + /// returns a partitioned path like "C:\reports\duplicates_{label}.csv". + /// + 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); + } + + /// + /// 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. + /// + 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; + } + + /// + /// Derives a short, human-friendly site label from a SharePoint site URL. + /// Falls back to the raw URL (sanitized) when parsing fails. + /// + 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; + } + } + + /// + /// Generic dispatcher for split-aware export: if + /// is not BySite, writes a single file via + /// ; otherwise partitions via + /// and writes one file per partition, + /// each at a filename derived from plus the + /// partition label. + /// + public static async Task WritePartitionedAsync( + IReadOnlyList items, + string basePath, + ReportSplitMode splitMode, + Func, IEnumerable<(string Label, IReadOnlyList Partition)>> partitioner, + Func, 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); + } + } + + /// + /// 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. + /// + public static string BuildTabbedHtml( + IReadOnlyList<(string Label, string Html)> parts, + string title) + { + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"{WebUtility.HtmlEncode(title)}"); + sb.AppendLine(""" + + + + """); + sb.Append("
"); + for (int i = 0; i < parts.Count; i++) + { + var cls = i == 0 ? "tab active" : "tab"; + sb.Append($"
{WebUtility.HtmlEncode(parts[i].Label)}
"); + } + sb.AppendLine("
"); + + for (int i = 0; i < parts.Count; i++) + { + var cls = i == 0 ? "active" : string.Empty; + var escaped = EscapeForSrcdoc(parts[i].Html); + sb.AppendLine($""); + } + sb.AppendLine(""" + + + """); + return sb.ToString(); + } + + /// + /// 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. + /// + private static string EscapeForSrcdoc(string html) + { + if (string.IsNullOrEmpty(html)) return string.Empty; + return html + .Replace("&", "&") + .Replace("\"", """); + } +} diff --git a/Services/Export/ReportSplitMode.cs b/Services/Export/ReportSplitMode.cs new file mode 100644 index 0000000..93adaa4 --- /dev/null +++ b/Services/Export/ReportSplitMode.cs @@ -0,0 +1,16 @@ +namespace SharepointToolbox.Web.Services.Export; + +/// How a report export is partitioned. +public enum ReportSplitMode +{ + Single, + BySite, + ByUser +} + +/// When a report is split, how HTML output is laid out. +public enum HtmlSplitLayout +{ + SeparateFiles, + SingleTabbed +} diff --git a/Services/Export/SearchCsvExportService.cs b/Services/Export/SearchCsvExportService.cs new file mode 100644 index 0000000..f4d2acd --- /dev/null +++ b/Services/Export/SearchCsvExportService.cs @@ -0,0 +1,53 @@ +using System.IO; +using System.Text; +using SharepointToolbox.Web.Core.Models; +using SharepointToolbox.Web.Localization; + +namespace SharepointToolbox.Web.Services.Export; + +/// +/// Exports SearchResult list to a UTF-8 BOM CSV file. +/// Header matches the column order in SearchHtmlExportService for consistency. +/// +public class SearchCsvExportService +{ + /// + /// Builds the CSV payload. Column order mirrors + /// . + /// + public string BuildCsv(IReadOnlyList 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(); + } + + /// Writes the CSV to with UTF-8 BOM. + public async Task WriteAsync(IReadOnlyList 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!; +} diff --git a/Services/Export/SearchHtmlExportService.cs b/Services/Export/SearchHtmlExportService.cs new file mode 100644 index 0000000..0b75cca --- /dev/null +++ b/Services/Export/SearchHtmlExportService.cs @@ -0,0 +1,165 @@ +using System.IO; +using System.Text; +using SharepointToolbox.Web.Core.Models; +using SharepointToolbox.Web.Localization; + +namespace SharepointToolbox.Web.Services.Export; + +/// +/// 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. +/// +public class SearchHtmlExportService +{ + /// + /// Builds a self-contained HTML table with inline sort/filter scripts. + /// Each becomes one row; the document has no + /// external dependencies. + /// + public string BuildHtml(IReadOnlyList results, ReportBranding? branding = null) + { + var T = TranslationSource.Instance; + var sb = new StringBuilder(); + + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"{T["report.title.search"]}"); + sb.AppendLine(""" + + + + """); + sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); + sb.AppendLine($""" +

{T["report.title.search_short"]}

+
+ + + +
+ """); + + sb.AppendLine($""" + + + + + + + + + + + + + + + """); + + foreach (var r in results) + { + string fileName = System.IO.Path.GetFileName(r.Path); + if (string.IsNullOrEmpty(fileName)) fileName = r.Title; + + sb.AppendLine($""" + + + + + + + + + + + """); + } + + sb.AppendLine(" \n
{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"]}
{H(fileName)}{H(r.FileExtension)}{H(r.Path)}{(r.Created.HasValue ? r.Created.Value.ToString("yyyy-MM-dd") : string.Empty)}{H(r.Author)}{(r.LastModified.HasValue ? r.LastModified.Value.ToString("yyyy-MM-dd") : string.Empty)}{H(r.ModifiedBy)}{FormatSize(r.SizeBytes)}
"); + + int count = results.Count; + sb.AppendLine($"

{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} {T["report.text.results_parens"]}

"); + + sb.AppendLine($$""" + + + """); + + return sb.ToString(); + } + + /// Writes the HTML report to . + public async Task WriteAsync(IReadOnlyList 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"; + } +} diff --git a/Services/Export/StorageCsvExportService.cs b/Services/Export/StorageCsvExportService.cs new file mode 100644 index 0000000..b31d436 --- /dev/null +++ b/Services/Export/StorageCsvExportService.cs @@ -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; + +/// +/// Exports a flat list of StorageNode objects to a UTF-8 BOM CSV. +/// Compatible with Microsoft Excel (BOM signals UTF-8 encoding). +/// +public class StorageCsvExportService +{ + /// + /// Builds a single-section CSV: header row plus one row per + /// with library, site, file count, total size + /// (MB), version size (MB), and last-modified date. + /// + public string BuildCsv(IReadOnlyList 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 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(); + } + } + + /// Writes the library-level CSV to with UTF-8 BOM. + public async Task WriteAsync(IReadOnlyList 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); + } + + /// + /// Builds a CSV with library details followed by a file-type breakdown section. + /// + public string BuildCsv(IReadOnlyList nodes, IReadOnlyList 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 nodes, IReadOnlyList 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(); + } + } + } + + /// Writes the two-section CSV (libraries + file-type breakdown) with UTF-8 BOM. + public async Task WriteAsync(IReadOnlyList nodes, IReadOnlyList 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); + } + + /// + /// 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. + /// + public Task WriteAsync( + IReadOnlyList nodes, + IReadOnlyList fileTypeMetrics, + string basePath, + ReportSplitMode splitMode, + CancellationToken ct) + => ReportSplitHelper.WritePartitionedAsync( + nodes, basePath, splitMode, + PartitionBySite, + (part, path, c) => WriteAsync(part, fileTypeMetrics, path, c), + ct); + + /// + /// 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. + /// + internal static IEnumerable<(string Label, IReadOnlyList Partition)> PartitionBySite( + IReadOnlyList nodes) + { + var buckets = new Dictionary>(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(); + buckets[currentSite] = list; + } + list.Add(node); + } + return buckets.Select(kv => (kv.Key, (IReadOnlyList)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)); + + /// + /// Pre-resolves localized labels for every + /// once per export, indexed by the enum's int value. Avoids a + /// ResourceManager.GetString call per row in hot CSV loops. + /// + 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() + }; + } +} diff --git a/Services/Export/StorageHtmlExportService.cs b/Services/Export/StorageHtmlExportService.cs new file mode 100644 index 0000000..f0dd9eb --- /dev/null +++ b/Services/Export/StorageHtmlExportService.cs @@ -0,0 +1,465 @@ +using SharepointToolbox.Web.Core.Models; +using SharepointToolbox.Web.Localization; +using System.IO; +using System.Text; + +namespace SharepointToolbox.Web.Services.Export; + +/// +/// 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}". +/// +public class StorageHtmlExportService +{ + private int _togIdx; + private string[] _kindLabels = Array.Empty(); + private string[] _kindLabelsHtml = Array.Empty(); + + /// + /// Builds a self-contained HTML report with one collapsible row per + /// library and indented child folders. Library-only variant — use the + /// overload that accepts s when a file-type + /// breakdown section is desired. + /// + public string BuildHtml(IReadOnlyList 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 nodes, ReportBranding? branding) + { + var T = TranslationSource.Instance; + _togIdx = 0; + _kindLabels = BuildKindLabelCache(); + _kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels); + + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"{T["report.title.storage"]}"); + sb.AppendLine(""" + + + + + """); + sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); + sb.AppendLine($"

{T["report.title.storage"]}

"); + + // Single-pass root aggregation: replaces 4 separate enumerations + // (.Where().ToList() + 3× .Sum() + a final .Where() during render). + var rootNodes0 = new List(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($""" +
+
{FormatSize(siteTotal0)}
{T["report.stat.total_size"]}
+
{FormatSize(versionTotal0)}
{T["report.stat.version_size"]}
+
{fileTotal0:N0}
{T["report.stat.files"]}
+
+ """); + + sb.AppendLine($""" + + + + + + + + + + + + + + """); + + // 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(""" + +
{T["report.col.library_folder"]}{T["stor.col.kind"]}{T["report.col.site"]}{T["report.stat.files"]}{T["report.stat.total_size"]}{T["report.stat.version_size"]}{T["report.col.last_modified"]}
+ """); + + sb.AppendLine($"

{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}

"); + sb.AppendLine(""); + } + + /// + /// Builds an HTML report including a file-type breakdown chart section. + /// + public string BuildHtml(IReadOnlyList nodes, IReadOnlyList 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 nodes, IReadOnlyList fileTypeMetrics, ReportBranding? branding) + { + var T = TranslationSource.Instance; + _togIdx = 0; + _kindLabels = BuildKindLabelCache(); + _kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels); + + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"{T["report.title.storage"]}"); + sb.AppendLine(""" + + + + + """); + sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); + sb.AppendLine($"

{T["report.title.storage"]}

"); + + // ── Summary cards (single-pass aggregation) ── + var rootNodes = new List(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("
"); + sb.AppendLine($"
{FormatSize(siteTotal)}
{T["report.stat.total_size"]}
"); + sb.AppendLine($"
{FormatSize(versionTotal)}
{T["report.stat.version_size"]}
"); + sb.AppendLine($"
{fileTotal:N0}
{T["report.stat.files"]}
"); + sb.AppendLine($"
{rootNodes.Count}
{T["report.stat.libraries"]}
"); + sb.AppendLine("
"); + + // ── 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("
"); + sb.AppendLine($"

{T["report.section.storage_by_file_type"]} ({totalFiles:N0} {T["report.text.files_unit"]}, {FormatSize(totalSize)})

"); + + 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($""" +
+ {HtmlEncode(label)} +
+ {FormatSize(m.TotalSizeBytes)} · {m.FileCount:N0} {T["report.text.files_unit"]} +
+ """); + idx++; + } + + sb.AppendLine("
"); + } + + // ── Storage table ── + sb.AppendLine($"

{T["report.section.library_details"]}

"); + sb.AppendLine($""" + + + + + + + + + + + + + + """); + + // 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(""" + +
{T["report.col.library_folder"]}{T["stor.col.kind"]}{T["report.col.site"]}{T["report.stat.files"]}{T["report.stat.total_size"]}{T["report.stat.version_size"]}{T["report.col.last_modified"]}
+ """); + + sb.AppendLine($"

{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}

"); + sb.AppendLine(""); + } + + /// Writes the library-only HTML report to . + public async Task WriteAsync(IReadOnlyList 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); + } + + /// Writes the HTML report including the file-type breakdown chart. + public async Task WriteAsync(IReadOnlyList nodes, IReadOnlyList 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); + } + + /// + /// 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. + /// + public async Task WriteAsync( + IReadOnlyList nodes, + IReadOnlyList 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 + ? $"{HtmlEncode(node.Name)}" + : $"{HtmlEncode(node.Name)}"; + + AppendRow(sb, node, nameCell); + + if (hasChildren) + { + sb.AppendLine($""); + sb.AppendLine(""); + foreach (var child in node.Children) + { + RenderChildNode(sb, child); + } + sb.AppendLine("
"); + sb.AppendLine(""); + } + } + + 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 + ? $"{HtmlEncode(node.Name)}" + : $"{HtmlEncode(node.Name)}"; + + AppendRow(sb, node, nameCell); + + if (hasChildren) + { + sb.AppendLine($""); + sb.AppendLine(""); + foreach (var child in node.Children) + { + RenderChildNode(sb, child); + } + sb.AppendLine("
"); + sb.AppendLine(""); + } + } + + /// + /// Appends one data row given the pre-rendered name cell. Hot path: + /// pulls localized kind labels from instead + /// of going through ResourceManager.GetString + HtmlEncode + /// per row. + /// + 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($""" + + {nameCell} + {kindLabel} + {HtmlEncode(node.SiteTitle)} + {node.TotalFileCount:N0} + {FormatSize(node.TotalSizeBytes)} + {FormatSize(node.VersionSizeBytes)} + {lastMod} + + """); + } + + 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() + }; + } + + /// + /// Pre-resolves localized labels for every + /// once per export. Cached array index lookup avoids + /// ResourceManager.GetString per row in hot rendering loops. + /// + 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; + } + + /// HTML-encodes each entry of once. + 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; + } +} diff --git a/Services/Export/UserAccessCsvExportService.cs b/Services/Export/UserAccessCsvExportService.cs new file mode 100644 index 0000000..1b9fd24 --- /dev/null +++ b/Services/Export/UserAccessCsvExportService.cs @@ -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; + +/// +/// 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 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\""; + } + + /// + /// 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 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(); + } + + /// + /// 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 ExportFileWriter.WriteCsvAsync(filePath, csv, ct); + } + } + + /// + /// Writes all entries split per site. File naming: "{base}_{siteLabel}.csv". + /// + public async Task WriteBySiteAsync( + IReadOnlyList 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); + } + } + + /// + /// Split-aware export dispatcher. + /// Single → one file at . + /// BySite → one file per site. ByUser → one file per user. + /// + public async Task WriteAsync( + IReadOnlyList 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; + } + } + + /// + /// Writes one CSV per user using as a filename template. + /// + public async Task WriteByUserAsync( + IReadOnlyList 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); + } + } + + /// + /// 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 is true, entries are consolidated using + /// and written in a compact multi-location format. + /// + public async Task WriteSingleFileAsync( + IReadOnlyList 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); + } + } + + /// RFC 4180 CSV field escaping with formula-injection guard. + 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(); + } +} diff --git a/Services/Export/UserAccessHtmlExportService.cs b/Services/Export/UserAccessHtmlExportService.cs new file mode 100644 index 0000000..d4f6d89 --- /dev/null +++ b/Services/Export/UserAccessHtmlExportService.cs @@ -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; + +/// +/// 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. +/// +public class UserAccessHtmlExportService +{ + /// + /// Builds a self-contained HTML string from the supplied user access entries. + /// When is true, renders a consolidated by-user + /// report with an expandable Sites column instead of the dual by-user/by-site view. + /// + /// + /// 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. + /// + public async Task WriteAsync( + IReadOnlyList 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 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)g.ToList())); + } + else // ByUser + { + partitions = entries + .GroupBy(e => e.UserLogin) + .Select(g => ( + Label: ReportSplitHelper.SanitizeFileName(g.Key), + Entries: (IReadOnlyList)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); + } + } + + /// + /// Builds the user-access HTML report. Default layout is a per-entry + /// grouped-by-user table; when is true + /// entries are consolidated via into + /// a single-row-per-user format with a Locations column. + /// + public string BuildHtml(IReadOnlyList 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(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"{T["report.title.user_access"]}"); + sb.AppendLine(""); + sb.AppendLine(""); + + // ── BODY ─────────────────────────────────────────────────────────────── + sb.AppendLine(""); + sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); + sb.AppendLine($"

{T["report.title.user_access"]}

"); + + // Stats cards + sb.AppendLine("
"); + sb.AppendLine($"
{totalAccesses}
{T["report.stat.total_accesses"]}
"); + sb.AppendLine($"
{usersAudited}
{T["report.stat.users_audited"]}
"); + sb.AppendLine($"
{sitesScanned}
{T["report.stat.sites_scanned"]}
"); + sb.AppendLine($"
{highPrivCount}
{T["report.stat.high_privilege"]}
"); + sb.AppendLine($"
{externalCount}
{T["report.stat.external_users"]}
"); + sb.AppendLine("
"); + + // Per-user summary cards + sb.AppendLine("
"); + 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($"
"); + sb.AppendLine($"
{uName}{(uIsExt ? $" {T["report.badge.guest"]}" : "")}
"); + sb.AppendLine($"
{uLogin}
"); + sb.AppendLine($"
{uTotal} {T["report.text.accesses"]} • {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" • {uHighPriv} {T["report.text.high_priv"]}" : "")}
"); + sb.AppendLine("
"); + } + sb.AppendLine("
"); + + // View toggle buttons + sb.AppendLine("
"); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine("
"); + + // Filter input + sb.AppendLine("
"); + sb.AppendLine($" "); + sb.AppendLine("
"); + + // ── BY-USER VIEW ─────────────────────────────────────────────────────── + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine(""); + sb.AppendLine(""); + + 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 ? $" {T["report.badge.guest"]}" : ""; + + sb.AppendLine($""); + sb.AppendLine($" "); + sb.AppendLine(""); + + 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($""); + sb.AppendLine($" {HtmlEncode(entry.SiteTitle)}"); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine($" {HtmlEncode(entry.PermissionLevel)}{highIcon}"); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine(""); + } + } + + sb.AppendLine(""); + sb.AppendLine("
{T["report.col.site"]}{T["report.col.object_type"]}{T["report.col.object"]}{T["report.col.permission_level"]}{T["report.col.access_type"]}{T["report.col.granted_through"]}
{uName}{guestBadge} — {uCount} {T["report.text.access_es"]}
{HtmlEncode(entry.ObjectType)}{objectCell}{accessBadge}{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}
"); + sb.AppendLine("
"); + + // ── BY-SITE VIEW ─────────────────────────────────────────────────────── + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine(""); + sb.AppendLine(""); + + 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($""); + sb.AppendLine($" "); + sb.AppendLine(""); + + 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 ? $" {T["report.badge.guest"]}" : ""; + + var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle) + ? "—" + : HtmlEncode(entry.ObjectTitle); + + sb.AppendLine($""); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine($" {HtmlEncode(entry.PermissionLevel)}{highIcon}"); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine(""); + } + } + + sb.AppendLine(""); + sb.AppendLine("
{T["report.col.user"]}{T["report.col.object_type"]}{T["report.col.object"]}{T["report.col.permission_level"]}{T["report.col.access_type"]}{T["report.col.granted_through"]}
{siteTitle} — {sCount} {T["report.text.access_es"]}
{HtmlEncode(entry.UserDisplayName)}{guestBadge}{HtmlEncode(entry.ObjectType)}{objectCell}{accessBadge}{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}
"); + sb.AppendLine("
"); + + // ── INLINE JS ───────────────────────────────────────────────────────── + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + + return sb.ToString(); + } + + /// + /// Writes the HTML report to the specified file path using UTF-8 without BOM. + /// + public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct, bool mergePermissions = false, ReportBranding? branding = null) + { + var html = BuildHtml(entries, mergePermissions, branding); + await ExportFileWriter.WriteHtmlAsync(filePath, html, ct); + } + + /// + /// 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. + /// + private string BuildConsolidatedHtml( + IReadOnlyList consolidated, + IReadOnlyList 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(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"{T["report.title.user_access_consolidated"]}"); + sb.AppendLine(""); + sb.AppendLine(""); + + // ── BODY ─────────────────────────────────────────────────────────────── + sb.AppendLine(""); + sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); + sb.AppendLine($"

{T["report.title.user_access_consolidated"]}

"); + + // Stats cards + sb.AppendLine("
"); + sb.AppendLine($"
{totalAccesses}
{T["report.stat.total_accesses"]}
"); + sb.AppendLine($"
{usersAudited}
{T["report.stat.users_audited"]}
"); + sb.AppendLine($"
{sitesScanned}
{T["report.stat.sites_scanned"]}
"); + sb.AppendLine($"
{highPrivCount}
{T["report.stat.high_privilege"]}
"); + sb.AppendLine($"
{externalCount}
{T["report.stat.external_users"]}
"); + sb.AppendLine("
"); + + // Per-user summary cards (from original flat entries) + sb.AppendLine("
"); + 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($"
"); + sb.AppendLine($"
{uName}{(uIsExt ? $" {T["report.badge.guest"]}" : "")}
"); + sb.AppendLine($"
{uLogin}
"); + sb.AppendLine($"
{uTotal} {T["report.text.accesses"]} • {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" • {uHighPriv} {T["report.text.high_priv"]}" : "")}
"); + sb.AppendLine("
"); + } + sb.AppendLine("
"); + + // View toggle — only By User (By Site is suppressed for consolidated view) + sb.AppendLine("
"); + sb.AppendLine($" "); + sb.AppendLine("
"); + + // Filter input + sb.AppendLine("
"); + sb.AppendLine($" "); + sb.AppendLine("
"); + + // ── CONSOLIDATED BY-USER TABLE ──────────────────────────────────────── + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine(""); + sb.AppendLine(""); + + // 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 ? $" {T["report.badge.guest"]}" : ""; + + sb.AppendLine($""); + sb.AppendLine($" "); + sb.AppendLine(""); + + foreach (var entry in cug) + { + var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : ""; + var accessBadge = AccessTypeBadge(entry.AccessType); + var highIcon = entry.IsHighPrivilege ? " ⚠" : ""; + + sb.AppendLine($""); + sb.AppendLine($" {HtmlEncode(entry.UserDisplayName)}{guestBadge}"); + sb.AppendLine($" {HtmlEncode(entry.PermissionLevel)}{highIcon}"); + sb.AppendLine($" "); + sb.AppendLine($" "); + + 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($" "); + sb.AppendLine(""); + } + else + { + // Multiple locations — expandable badge + var currentLocId = $"loc{locIdx++}"; + sb.AppendLine($" "); + sb.AppendLine(""); + + // Hidden sub-rows — one per location + foreach (var loc in entry.Locations) + { + var subLabel = IsRedundantObjectTitle(loc.SiteTitle, loc.ObjectTitle) + ? $"{HtmlEncode(loc.SiteTitle)}" + : $"{HtmlEncode(loc.SiteTitle)} › {HtmlEncode(loc.ObjectTitle)}"; + sb.AppendLine($""); + sb.AppendLine($" "); + sb.AppendLine(""); + } + } + } + } + + sb.AppendLine(""); + sb.AppendLine("
{T["report.col.user"]}{T["report.col.permission_level"]}{T["report.col.access_type"]}{T["report.col.granted_through"]}{T["report.col.sites"]}
{cuName}{guestBadge} — {cuCount} {T["report.text.permissions_parens"]}
{accessBadge}{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}{locLabel}
{entry.LocationCount} {TranslationSource.Instance["report.text.sites_unit"]}
"); + sb.AppendLine($" {subLabel}"); + sb.AppendLine("
"); + sb.AppendLine("
"); + + // ── INLINE JS ───────────────────────────────────────────────────────── + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + + return sb.ToString(); + } + + /// Returns a colored badge span for the given access type. + private static string AccessTypeBadge(AccessType accessType) + { + var T = TranslationSource.Instance; + return accessType switch + { + AccessType.Direct => $"{T["report.badge.direct"]}", + AccessType.Group => $"{T["report.badge.group"]}", + AccessType.Inherited => $"{T["report.badge.inherited"]}", + _ => $"{HtmlEncode(accessType.ToString())}" + }; + } + + /// + /// 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". + /// + 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); + } + + /// Minimal HTML encoding for text content and attribute values. + private static string HtmlEncode(string value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + return value + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """) + .Replace("'", "'"); + } +} diff --git a/Services/Export/VersionCleanupHtmlExportService.cs b/Services/Export/VersionCleanupHtmlExportService.cs new file mode 100644 index 0000000..ce67d8d --- /dev/null +++ b/Services/Export/VersionCleanupHtmlExportService.cs @@ -0,0 +1,180 @@ +using System.Text; +using SharepointToolbox.Web.Core.Models; +using SharepointToolbox.Web.Localization; + +namespace SharepointToolbox.Web.Services.Export; + +/// +/// 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. +/// +public class VersionCleanupHtmlExportService +{ + public string BuildHtml(IReadOnlyList 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(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"{T["report.title.versions"]}"); + sb.AppendLine(""" + + + + """); + sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); + sb.AppendLine($"

{T["report.title.versions_short"]}

"); + + sb.AppendLine($""" +
+
{T["versions.summary.files"]}{totalFiles:N0}
+
{T["versions.summary.deleted"]}{totalDeleted:N0}
+
{T["versions.summary.freed"]}{FormatSize(totalBytes)}
+
+
+ + + +
+ """); + + sb.AppendLine($""" + + + + + + + + + + + + + + + + """); + + foreach (var r in results) + { + string rowClass = string.IsNullOrEmpty(r.Error) ? string.Empty : " class=\"err\""; + string errCell = string.IsNullOrEmpty(r.Error) + ? string.Empty + : $"{H(r.Error)}"; + + sb.AppendLine($""" + + + + + + + + + + + + """); + } + + sb.AppendLine(" \n
{T["report.col.site"]}{T["versions.col.library"]}{T["versions.col.file"]}{T["versions.col.path"]}{T["versions.col.before"]}{T["versions.col.deleted"]}{T["versions.col.remaining"]}{T["versions.col.freed"]}{T["versions.col.error"]}
{H(r.SiteUrl)}{H(r.Library)}{H(r.FileName)}{H(r.FileServerRelativeUrl)}{r.VersionsBefore:N0}{r.VersionsDeleted:N0}{r.VersionsRemaining:N0}{FormatSize(r.BytesFreed)}{errCell}
"); + + int count = results.Count; + sb.AppendLine($"

{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} {T["report.text.results_parens"]}

"); + + sb.AppendLine($$""" + + + """); + + return sb.ToString(); + } + + /// Writes the HTML report to as UTF-8. + public async Task WriteAsync(IReadOnlyList 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"; + } +} diff --git a/Services/Export/WebExportService.cs b/Services/Export/WebExportService.cs new file mode 100644 index 0000000..3996161 --- /dev/null +++ b/Services/Export/WebExportService.cs @@ -0,0 +1,28 @@ +using System.Text; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace SharepointToolbox.Web.Services.Export; + +/// +/// Triggers browser file downloads from Blazor Server components. +/// Converts string export outputs to bytes and invokes JS download. +/// +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)); + } +} diff --git a/Services/FileTransferService.cs b/Services/FileTransferService.cs new file mode 100644 index 0000000..1dd691d --- /dev/null +++ b/Services/FileTransferService.cs @@ -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> TransferAsync( + ClientContext sourceCtx, ClientContext destCtx, + TransferJob job, IProgress 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 files = job.CopyFolderContents + ? await EnumerateFilesAsync(sourceCtx, job, srcItemCount, progress, ct) + : Array.Empty(); + + if (files.Count == 0 && !job.IncludeSourceFolder) + { + progress.Report(new OperationProgress(0, 0, "No files found to transfer.")); + return new BulkOperationSummary(new List>()); + } + + 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(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 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 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 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 ResolveDestinationOnConflictAsync(ClientContext destCtx, string dstFileUrl, TransferJob job, IProgress 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 FileExistsAsync(ClientContext ctx, string fileServerRelativeUrl, IProgress 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> EnumerateFilesAsync(ClientContext ctx, TransferJob job, int sourceItemCount, IProgress 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(); + 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 TryGetListItemCountAsync(ClientContext ctx, string libraryTitle, IProgress 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 cache, IProgress 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 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 ResolveLibraryPathAsync(ClientContext ctx, string libraryTitle, string relativeFolderPath, IProgress 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; + } +} diff --git a/Services/FolderStructureService.cs b/Services/FolderStructureService.cs new file mode 100644 index 0000000..ee42395 --- /dev/null +++ b/Services/FolderStructureService.cs @@ -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> CreateFoldersAsync( + ClientContext ctx, string libraryTitle, + IReadOnlyList rows, + IProgress 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 BuildUniquePaths(IReadOnlyList rows) + { + var paths = new HashSet(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(); + } +} diff --git a/Services/GraphUserDirectoryService.cs b/Services/GraphUserDirectoryService.cs new file mode 100644 index 0000000..d5231b8 --- /dev/null +++ b/Services/GraphUserDirectoryService.cs @@ -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> GetUsersAsync( + TenantProfile profile, + bool includeGuests = false, + IProgress? 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(); + + var results = new List(); + var iter = PageIterator.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; + } +} diff --git a/Services/IBulkMemberService.cs b/Services/IBulkMemberService.cs new file mode 100644 index 0000000..b27e83b --- /dev/null +++ b/Services/IBulkMemberService.cs @@ -0,0 +1,14 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +public interface IBulkMemberService +{ + Task> AddMembersAsync( + ClientContext ctx, + TenantProfile profile, + IReadOnlyList rows, + IProgress progress, + CancellationToken ct); +} diff --git a/Services/IBulkSiteService.cs b/Services/IBulkSiteService.cs new file mode 100644 index 0000000..0983899 --- /dev/null +++ b/Services/IBulkSiteService.cs @@ -0,0 +1,11 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +public interface IBulkSiteService +{ + Task> CreateSitesAsync( + ClientContext adminCtx, IReadOnlyList rows, + IProgress progress, CancellationToken ct); +} diff --git a/Services/ICsvValidationService.cs b/Services/ICsvValidationService.cs new file mode 100644 index 0000000..f20dbd9 --- /dev/null +++ b/Services/ICsvValidationService.cs @@ -0,0 +1,11 @@ +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +public interface ICsvValidationService +{ + List> ParseAndValidate(Stream csvStream) where T : class; + List> ParseAndValidateMembers(Stream csvStream); + List> ParseAndValidateSites(Stream csvStream); + List> ParseAndValidateFolders(Stream csvStream); +} diff --git a/Services/IDuplicatesService.cs b/Services/IDuplicatesService.cs new file mode 100644 index 0000000..53b93d2 --- /dev/null +++ b/Services/IDuplicatesService.cs @@ -0,0 +1,11 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +public interface IDuplicatesService +{ + Task> ScanDuplicatesAsync( + ClientContext ctx, DuplicateScanOptions options, + IProgress progress, CancellationToken ct); +} diff --git a/Services/IFileTransferService.cs b/Services/IFileTransferService.cs new file mode 100644 index 0000000..74b54d3 --- /dev/null +++ b/Services/IFileTransferService.cs @@ -0,0 +1,11 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +public interface IFileTransferService +{ + Task> TransferAsync( + ClientContext sourceCtx, ClientContext destCtx, + TransferJob job, IProgress progress, CancellationToken ct); +} diff --git a/Services/IFolderStructureService.cs b/Services/IFolderStructureService.cs new file mode 100644 index 0000000..e12d3cf --- /dev/null +++ b/Services/IFolderStructureService.cs @@ -0,0 +1,12 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +public interface IFolderStructureService +{ + Task> CreateFoldersAsync( + ClientContext ctx, string libraryTitle, + IReadOnlyList rows, + IProgress progress, CancellationToken ct); +} diff --git a/Services/IGraphUserDirectoryService.cs b/Services/IGraphUserDirectoryService.cs new file mode 100644 index 0000000..bd2d716 --- /dev/null +++ b/Services/IGraphUserDirectoryService.cs @@ -0,0 +1,12 @@ +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +public interface IGraphUserDirectoryService +{ + Task> GetUsersAsync( + TenantProfile profile, + bool includeGuests = false, + IProgress? progress = null, + CancellationToken ct = default); +} diff --git a/Services/IOwnershipElevationService.cs b/Services/IOwnershipElevationService.cs new file mode 100644 index 0000000..e735378 --- /dev/null +++ b/Services/IOwnershipElevationService.cs @@ -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); +} diff --git a/Services/IPermissionsService.cs b/Services/IPermissionsService.cs new file mode 100644 index 0000000..67f3c22 --- /dev/null +++ b/Services/IPermissionsService.cs @@ -0,0 +1,13 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +public interface IPermissionsService +{ + Task> ScanSiteAsync( + ClientContext ctx, + ScanOptions options, + IProgress progress, + CancellationToken ct); +} diff --git a/Services/ISearchService.cs b/Services/ISearchService.cs new file mode 100644 index 0000000..f47d5af --- /dev/null +++ b/Services/ISearchService.cs @@ -0,0 +1,11 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +public interface ISearchService +{ + Task> SearchFilesAsync( + ClientContext ctx, SearchOptions options, + IProgress progress, CancellationToken ct); +} diff --git a/Services/ISessionManager.cs b/Services/ISessionManager.cs new file mode 100644 index 0000000..8cdce8d --- /dev/null +++ b/Services/ISessionManager.cs @@ -0,0 +1,14 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +public interface ISessionManager +{ + Task GetOrCreateContextAsync(TenantProfile profile, CancellationToken ct = default); + Task 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); +} diff --git a/Services/ISharePointGroupResolver.cs b/Services/ISharePointGroupResolver.cs new file mode 100644 index 0000000..35bd403 --- /dev/null +++ b/Services/ISharePointGroupResolver.cs @@ -0,0 +1,13 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +public interface ISharePointGroupResolver +{ + Task>> ResolveGroupsAsync( + ClientContext ctx, + TenantProfile profile, + IReadOnlyList groupNames, + CancellationToken ct); +} diff --git a/Services/IStorageService.cs b/Services/IStorageService.cs new file mode 100644 index 0000000..44e4dc1 --- /dev/null +++ b/Services/IStorageService.cs @@ -0,0 +1,21 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +public interface IStorageService +{ + Task> CollectStorageAsync( + ClientContext ctx, StorageScanOptions options, + IProgress progress, CancellationToken ct); + + Task> CollectFileTypeMetricsAsync( + ClientContext ctx, IProgress progress, CancellationToken ct); + + Task BackfillZeroNodesAsync( + ClientContext ctx, IReadOnlyList nodes, + IProgress progress, CancellationToken ct); + + Task GetSiteUsageStorageBytesAsync( + ClientContext ctx, IProgress progress, CancellationToken ct); +} diff --git a/Services/ISystemGroupTargetResolver.cs b/Services/ISystemGroupTargetResolver.cs new file mode 100644 index 0000000..418f7a3 --- /dev/null +++ b/Services/ISystemGroupTargetResolver.cs @@ -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 ResolveAsync( + ClientContext ctx, + SystemGroupClassification classification, + CancellationToken ct); +} diff --git a/Services/ITemplateService.cs b/Services/ITemplateService.cs new file mode 100644 index 0000000..5271f93 --- /dev/null +++ b/Services/ITemplateService.cs @@ -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 CaptureTemplateAsync( + ClientContext ctx, SiteTemplateOptions options, + IProgress progress, CancellationToken ct); + + Task ApplyTemplateAsync( + ClientContext adminCtx, ModelSiteTemplate template, + string newSiteTitle, string newSiteAlias, + IProgress progress, CancellationToken ct); +} diff --git a/Services/IUserAccessAuditService.cs b/Services/IUserAccessAuditService.cs new file mode 100644 index 0000000..8475412 --- /dev/null +++ b/Services/IUserAccessAuditService.cs @@ -0,0 +1,16 @@ +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +public interface IUserAccessAuditService +{ + Task> AuditUsersAsync( + ISessionManager sessionManager, + TenantProfile currentProfile, + IReadOnlyList targetUserLogins, + IReadOnlyList sites, + ScanOptions options, + IProgress progress, + CancellationToken ct, + Func>? onAccessDenied = null); +} diff --git a/Services/IVersionCleanupService.cs b/Services/IVersionCleanupService.cs new file mode 100644 index 0000000..67afafd --- /dev/null +++ b/Services/IVersionCleanupService.cs @@ -0,0 +1,14 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +public interface IVersionCleanupService +{ + Task> DeleteOldVersionsAsync( + ClientContext ctx, VersionCleanupOptions options, + IProgress progress, CancellationToken ct); + + Task> ListLibraryTitlesAsync( + ClientContext ctx, CancellationToken ct); +} diff --git a/Services/OAuth/AppRegistrationResult.cs b/Services/OAuth/AppRegistrationResult.cs new file mode 100644 index 0000000..767b447 --- /dev/null +++ b/Services/OAuth/AppRegistrationResult.cs @@ -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; +} diff --git a/Services/OAuth/IOAuthFlowCache.cs b/Services/OAuth/IOAuthFlowCache.cs new file mode 100644 index 0000000..218b9ea --- /dev/null +++ b/Services/OAuth/IOAuthFlowCache.cs @@ -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); +} diff --git a/Services/OAuth/OAuthFlowCache.cs b/Services/OAuth/OAuthFlowCache.cs new file mode 100644 index 0000000..a3f7d97 --- /dev/null +++ b/Services/OAuth/OAuthFlowCache.cs @@ -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(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(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(cacheKey); + if (value is not null) _cache.Remove(cacheKey); + return value; + } +} diff --git a/Services/OAuth/OAuthFlowState.cs b/Services/OAuth/OAuthFlowState.cs new file mode 100644 index 0000000..39b5624 --- /dev/null +++ b/Services/OAuth/OAuthFlowState.cs @@ -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; +} diff --git a/Services/OwnershipElevationService.cs b/Services/OwnershipElevationService.cs new file mode 100644 index 0000000..46c0bd2 --- /dev/null +++ b/Services/OwnershipElevationService.cs @@ -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}"); + } +} diff --git a/Services/PermissionsService.cs b/Services/PermissionsService.cs new file mode 100644 index 0000000..44219ba --- /dev/null +++ b/Services/PermissionsService.cs @@ -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 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> ScanSiteAsync( + ClientContext ctx, ScanOptions options, + IProgress progress, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var results = new List(); + + 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> GetSiteCollectionAdminsAsync( + ClientContext ctx, IProgress 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(); } + + var admins = ctx.Web.SiteUsers.Where(u => u.IsSiteAdmin).ToList(); + if (admins.Count == 0) return Enumerable.Empty(); + 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> GetWebPermissionsAsync( + ClientContext ctx, SpWeb web, ScanOptions options, + IProgress progress, CancellationToken ct) => + ExtractPermissionsAsync(ctx, web, "Site", web.Title, web.Url, options, progress, ct); + + private async Task> GetListPermissionsAsync( + ClientContext ctx, List list, ScanOptions options, + IProgress 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> GetFolderPermissionsAsync( + ClientContext ctx, List list, ScanOptions options, + IProgress progress, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var results = new List(); + var camlQuery = new CamlQuery { ViewXml = @"500" }; + + 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> ExtractPermissionsAsync( + ClientContext ctx, SecurableObject obj, string objectType, string title, string url, + ScanOptions options, IProgress 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(); + } + + if (!options.IncludeInherited && !obj.HasUniqueRoleAssignments) + return Enumerable.Empty(); + + var entries = new List(); + 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; + } +} diff --git a/Services/SearchService.cs b/Services/SearchService.cs new file mode 100644 index 0000000..e5df996 --- /dev/null +++ b/Services/SearchService.cs @@ -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> SearchFilesAsync( + ClientContext ctx, SearchOptions options, + IProgress 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(); + 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 dict; + if (rawRow is IDictionary generic) dict = generic; + else if (rawRow is System.Collections.IDictionary legacy) { dict = new Dictionary(); 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 { "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 row) + { + static string S(IDictionary r, string k) => r.TryGetValue(k, out var v) ? v?.ToString() ?? string.Empty : string.Empty; + static DateTime? D(IDictionary r, string k) { var s = S(r, k); return DateTime.TryParse(s, out var dt) ? dt : (DateTime?)null; } + static long L(IDictionary 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 r, string key) => r.TryGetValue(key, out var v) ? v?.ToString() ?? string.Empty : string.Empty; +} diff --git a/Services/Session/ISessionCredentialStore.cs b/Services/Session/ISessionCredentialStore.cs new file mode 100644 index 0000000..d985676 --- /dev/null +++ b/Services/Session/ISessionCredentialStore.cs @@ -0,0 +1,14 @@ +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services.Session; + +/// Stores OAuth tokens in ProtectedSessionStorage (browser-side, encrypted). +/// Nothing written to server disk. +public interface ISessionCredentialStore +{ + Task GetAsync(); + Task SetAsync(SessionTokens tokens); + Task UpdateRefreshTokenAsync(string newRefreshToken); + Task ClearAsync(); + Task HasCredentialsAsync(); +} diff --git a/Services/Session/IUserContextAccessor.cs b/Services/Session/IUserContextAccessor.cs new file mode 100644 index 0000000..6889346 --- /dev/null +++ b/Services/Session/IUserContextAccessor.cs @@ -0,0 +1,16 @@ +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services.Session; + +/// Scoped per Blazor circuit. Set once on circuit init from auth state. +public interface IUserContextAccessor +{ + string Email { get; } + string DisplayName { get; } + UserRole Role { get; } + bool IsAuthenticated { get; } + + event Action? Initialized; + + void Initialize(AppUser user); +} diff --git a/Services/Session/IUserSessionService.cs b/Services/Session/IUserSessionService.cs new file mode 100644 index 0000000..31b2dcb --- /dev/null +++ b/Services/Session/IUserSessionService.cs @@ -0,0 +1,19 @@ +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services.Session; + +/// +/// 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. +/// +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; +} diff --git a/Services/Session/SessionCredentialStore.cs b/Services/Session/SessionCredentialStore.cs new file mode 100644 index 0000000..2b292fc --- /dev/null +++ b/Services/Session/SessionCredentialStore.cs @@ -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 GetAsync() + { + try + { + var result = await _storage.GetAsync(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 HasCredentialsAsync() + { + var tokens = await GetAsync(); + return tokens is not null && !string.IsNullOrEmpty(tokens.RefreshToken); + } +} diff --git a/Services/Session/UserContextAccessor.cs b/Services/Session/UserContextAccessor.cs new file mode 100644 index 0000000..b863a8b --- /dev/null +++ b/Services/Session/UserContextAccessor.cs @@ -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(); + } +} diff --git a/Services/Session/UserSessionService.cs b/Services/Session/UserSessionService.cs new file mode 100644 index 0000000..8587a74 --- /dev/null +++ b/Services/Session/UserSessionService.cs @@ -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 */ } + } +} diff --git a/Services/SharePointGroupResolver.cs b/Services/SharePointGroupResolver.cs new file mode 100644 index 0000000..af4a52b --- /dev/null +++ b/Services/SharePointGroupResolver.cs @@ -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>> ResolveGroupsAsync( + ClientContext ctx, + TenantProfile profile, + IReadOnlyList groupNames, + CancellationToken ct) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (groupNames.Count == 0) return result; + + GraphServiceClient? graphClient = null; + + var groupTitles = new HashSet(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(); + 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(); + 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(); + } + } + 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> 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(); + + var members = new List(); + var iter = PageIterator.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(); + } + } +} diff --git a/Services/StorageService.cs b/Services/StorageService.cs new file mode 100644 index 0000000..a8826e5 --- /dev/null +++ b/Services/StorageService.cs @@ -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> CollectStorageAsync( + ClientContext ctx, StorageScanOptions options, + IProgress progress, CancellationToken ct) + { + var result = new List(); + await CollectForWebAsync(ctx, ctx.Web, options, result, progress, ct); + return result; + } + + private async Task CollectForWebAsync(ClientContext ctx, SpWeb web, StorageScanOptions options, + List result, IProgress 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(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(); + 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 TryLoadAttachmentsNodeAsync(ClientContext ctx, List list, string siteTitle, IProgress 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 Nodes, Dictionary PerDir)> LoadRecycleBinNodesAsync(ClientContext ctx, SpWeb web, string siteTitle, IProgress progress, CancellationToken ct) + { + var nodes = new List(); + var perDir = new Dictionary(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> CollectFileTypeMetricsAsync(ClientContext ctx, IProgress 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(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 = "500" }; + 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 progress, CancellationToken ct) + { + progress.Report(OperationProgress.Indeterminate($"Counting files: {libNode.Name}...")); + string libRootSrl = NormalizeServerRelative(lib.RootFolder.ServerRelativeUrl); + var folderLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + BuildFolderLookup(libNode, libRootSrl, folderLookup); + + var query = new CamlQuery { ViewXml = "500" }; + 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 nodes, IProgress progress, CancellationToken ct) => Task.CompletedTask; + + public async Task GetSiteUsageStorageBytesAsync(ClientContext ctx, IProgress 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 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 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 LoadFolderNodeAsync(ClientContext ctx, string serverRelativeUrl, string name, string siteTitle, string library, int indentLevel, StorageNodeKind kind, IProgress 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() }; + } + + private static async Task CollectSubfoldersAsync(ClientContext ctx, List list, string parentServerRelativeUrl, StorageNode parentNode, int currentDepth, int maxDepth, string siteTitle, string library, StorageNodeKind kind, IProgress 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); + } + } +} diff --git a/Services/SystemGroupTargetResolver.cs b/Services/SystemGroupTargetResolver.cs new file mode 100644 index 0000000..3e6f6f6 --- /dev/null +++ b/Services/SystemGroupTargetResolver.cs @@ -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 _cache = new(StringComparer.OrdinalIgnoreCase); + + public async Task 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 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 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 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 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 ToDict(object rawRow) + { + if (rawRow is IDictionary generic) return generic; + var dict = new Dictionary(); + 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}"; + } +} diff --git a/Services/TemplateService.cs b/Services/TemplateService.cs new file mode 100644 index 0000000..eb47219 --- /dev/null +++ b/Services/TemplateService.cs @@ -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 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 CaptureTemplateAsync( + ClientContext ctx, SiteTemplateOptions options, + IProgress 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 ApplyTemplateAsync( + ClientContext adminCtx, ModelSiteTemplate template, + string newSiteTitle, string newSiteAlias, + IProgress 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> 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() }, + StringComparer.OrdinalIgnoreCase); + var roots = new List(); + 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 folders, IProgress 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 folders, IProgress 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); } + } + } +} diff --git a/Services/UserAccessAuditService.cs b/Services/UserAccessAuditService.cs new file mode 100644 index 0000000..46ec3c3 --- /dev/null +++ b/Services/UserAccessAuditService.cs @@ -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 HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase) + { + "Full Control", "Site Collection Administrator" + }; + + public UserAccessAuditService(IPermissionsService permissionsService) + { + _permissionsService = permissionsService; + } + + public async Task> AuditUsersAsync( + ISessionManager sessionManager, + TenantProfile currentProfile, + IReadOnlyList targetUserLogins, + IReadOnlyList sites, + ScanOptions options, + IProgress progress, + CancellationToken ct, + Func>? onAccessDenied = null) + { + var targets = targetUserLogins + .Select(l => l.Trim().ToLowerInvariant()) + .Where(l => l.Length > 0).ToHashSet(); + + if (targets.Count == 0) return Array.Empty(); + + var allEntries = new List(); + + 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 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 TransformEntries( + IReadOnlyList permEntries, HashSet 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; + } +} diff --git a/Services/VersionCleanupService.cs b/Services/VersionCleanupService.cs new file mode 100644 index 0000000..0b64017 --- /dev/null +++ b/Services/VersionCleanupService.cs @@ -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 _logger; + private readonly IAuditService _audit; + + public VersionCleanupService(ILogger logger, IAuditService audit) + { + _logger = logger; + _audit = audit; + } + + public async Task> 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> DeleteOldVersionsAsync( + ClientContext ctx, VersionCleanupOptions options, + IProgress 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(options.LibraryTitles, StringComparer.OrdinalIgnoreCase) : null; + var libs = titleFilter is null ? allLibs : allLibs.Where(l => titleFilter.Contains(l.Title)).ToList(); + var results = new List(); + 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(); + 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 TrimFileVersionsAsync(ClientContext ctx, string siteUrl, string libraryTitle, string fileServerRelativeUrl, VersionCleanupOptions options, IProgress 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 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 }; + } + } +} diff --git a/SharepointToolbox.Web.csproj b/SharepointToolbox.Web.csproj new file mode 100644 index 0000000..74cffba --- /dev/null +++ b/SharepointToolbox.Web.csproj @@ -0,0 +1,44 @@ + + + + net10.0 + enable + enable + SharepointToolbox.Web + $(NoWarn);NU1701;CS0618 + + + + + ResXFileCodeGenerator + Strings.Designer.cs + + + Strings.resx + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..5655436 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,22 @@ +{ + "DataFolder": "/data", + "Oidc": { + "TenantId": "YOUR_ENTRA_TENANT_ID", + "ClientId": "YOUR_SPTB_APP_CLIENT_ID", + "ClientSecret": "YOUR_SPTB_APP_CLIENT_SECRET" + }, + "ClientConnect": { + "RedirectUri": "http://localhost:5000/connect/callback" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning", + "System": "Warning" + } + } + }, + "AllowedHosts": "*" +} diff --git a/data/profiles.json b/data/profiles.json new file mode 100644 index 0000000..9f4223b --- /dev/null +++ b/data/profiles.json @@ -0,0 +1,12 @@ +{ + "profiles": [ + { + "id": "013fd12a-3282-4780-babc-d0e94f80a68f", + "name": "AB Cube", + "tenantUrl": "https://abcube.sharepoint.com", + "tenantId": "66ce65de-d1de-4d74-8a59-f8f2b196b11a", + "clientId": "60fc19ea-13ca-452a-9db9-7e3b89d37247", + "clientLogo": null + } + ] +} \ No newline at end of file diff --git a/data/users.json b/data/users.json new file mode 100644 index 0000000..5f05a3c --- /dev/null +++ b/data/users.json @@ -0,0 +1,12 @@ +{ + "users": [ + { + "id": "21e0cb63-eb81-4503-8201-8e268985a056", + "email": "dev@local.test", + "displayName": "Dev Admin", + "role": 2, + "createdAt": "2026-05-29T14:17:03.9853276+00:00", + "lastLogin": "2026-06-01T09:22:36.0655945+00:00" + } + ] +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1fe0b40 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + sptb-web: + build: + context: . + dockerfile: Dockerfile + image: sptb-web:latest + container_name: sptb-web + ports: + - "8080:8080" + volumes: + - sptb-data:/data + environment: + - ASPNETCORE_ENVIRONMENT=Production + - DataFolder=/data + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + +volumes: + sptb-data: + driver: local diff --git a/wwwroot/app.css b/wwwroot/app.css new file mode 100644 index 0000000..a54e426 --- /dev/null +++ b/wwwroot/app.css @@ -0,0 +1,162 @@ +:root { + --sidebar-width: 220px; + --sidebar-collapsed-width: 54px; + --bg: #f5f5f5; + --sidebar-bg: #1a1a2e; + --sidebar-text: #e0e0e0; + --sidebar-hover: #2d2d4e; + --sidebar-active: #0078d4; + --card-bg: #fff; + --border: #e0e0e0; + --accent: #0078d4; + --accent-dark: #005a9e; + --danger: #d13438; + --success: #107c10; + --warn: #797673; + --text: #323130; + --text-muted: #605e5c; + --font: 'Segoe UI', system-ui, sans-serif; +} + +*, *::before, *::after { box-sizing: border-box; } + +body { + margin: 0; padding: 0; font-family: var(--font); font-size: 14px; + background: var(--bg); color: var(--text); +} + +/* ── Layout ── */ +.app-layout { display: flex; height: 100vh; overflow: hidden; } + +.sidebar { + width: var(--sidebar-width); min-width: var(--sidebar-width); + background: var(--sidebar-bg); color: var(--sidebar-text); + display: flex; flex-direction: column; + transition: width 0.2s, min-width 0.2s; + overflow: hidden; +} +.sidebar.collapsed { width: var(--sidebar-collapsed-width); min-width: var(--sidebar-collapsed-width); } +.sidebar.collapsed .nav-label, +.sidebar.collapsed .profile-name, +.sidebar.collapsed .logo-text, +.sidebar.collapsed .nav-divider { display: none; } + +.sidebar-header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 12px; border-bottom: 1px solid rgba(255,255,255,.1); + flex-shrink: 0; +} +.logo-text { font-weight: 700; font-size: 15px; color: #fff; white-space: nowrap; } +.toggle-btn { background: none; border: none; color: var(--sidebar-text); cursor: pointer; font-size: 18px; padding: 2px 4px; } + +.profile-badge { + display: flex; align-items: center; gap: 8px; + padding: 8px 12px; background: rgba(255,255,255,.07); + border-bottom: 1px solid rgba(255,255,255,.08); + font-size: 12px; +} +.profile-icon { font-size: 16px; } +.profile-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.nav-menu { flex: 1; overflow-y: auto; padding: 8px 0; } +.nav-divider { padding: 12px 12px 4px; font-size: 10px; text-transform: uppercase; letter-spacing: .8px; color: rgba(255,255,255,.4); } + +.nav-item { + display: flex; align-items: center; gap: 10px; + padding: 9px 14px; color: var(--sidebar-text); text-decoration: none; + font-size: 13.5px; border-left: 3px solid transparent; + transition: background 0.15s; + white-space: nowrap; +} +.nav-item:hover { background: var(--sidebar-hover); } +.nav-item.active { background: rgba(0,120,212,.2); border-left-color: var(--accent); color: #fff; } +.nav-icon { font-size: 16px; min-width: 22px; text-align: center; } + +.content { flex: 1; overflow-y: auto; padding: 24px 28px; } + +/* ── Cards ── */ +.card { background: var(--card-bg); border-radius: 6px; border: 1px solid var(--border); padding: 20px; margin-bottom: 16px; } +.card-title { font-size: 16px; font-weight: 600; margin: 0 0 12px 0; color: var(--text); } + +/* ── Forms ── */ +.form-group { margin-bottom: 14px; } +.form-label { display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .4px; } +.form-input, .form-select, .form-textarea { + width: 100%; padding: 8px 10px; border: 1px solid #ccc; border-radius: 4px; + font-size: 14px; font-family: var(--font); background: #fff; + transition: border-color 0.15s; +} +.form-input:focus, .form-select:focus, .form-textarea:focus { outline: none; border-color: var(--accent); } +.form-textarea { min-height: 80px; resize: vertical; } +.form-row { display: flex; gap: 12px; flex-wrap: wrap; } +.form-row .form-group { flex: 1; min-width: 180px; } + +/* ── Buttons ── */ +.btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 8px 16px; border-radius: 4px; cursor: pointer; + font-size: 14px; font-family: var(--font); font-weight: 500; + border: 1px solid transparent; transition: background 0.15s, opacity 0.15s; + white-space: nowrap; +} +.btn:disabled { opacity: .5; cursor: not-allowed; } +.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent-dark); } +.btn-primary:hover:not(:disabled) { background: var(--accent-dark); } +.btn-secondary { background: #fff; color: var(--text); border-color: #ccc; } +.btn-secondary:hover:not(:disabled) { background: #f0f0f0; } +.btn-danger { background: var(--danger); color: #fff; border-color: #a4262c; } +.btn-danger:hover:not(:disabled) { background: #a4262c; } +.btn-sm { padding: 5px 10px; font-size: 12px; } + +/* ── Progress ── */ +.progress-bar { height: 6px; background: #e0e0e0; border-radius: 3px; overflow: hidden; margin: 8px 0; } +.progress-fill { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.3s; } +.progress-msg { font-size: 12px; color: var(--text-muted); margin-bottom: 4px; } + +/* ── Tables ── */ +.data-table-wrap { overflow-x: auto; } +.data-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.data-table th { background: #f0f0f0; padding: 8px 12px; text-align: left; font-weight: 600; border-bottom: 2px solid var(--border); white-space: nowrap; } +.data-table td { padding: 6px 12px; border-bottom: 1px solid var(--border); word-break: break-word; } +.data-table tr:hover td { background: #f7f9fd; } +.data-table .num { text-align: right; font-variant-numeric: tabular-nums; } + +/* ── Alerts ── */ +.alert { padding: 10px 14px; border-radius: 4px; margin: 8px 0; font-size: 13px; } +.alert-error { background: #fde7e9; border: 1px solid #f4abab; color: #831111; } +.alert-success { background: #dff6dd; border: 1px solid #92c47a; color: #215732; } +.alert-info { background: #e8f4fd; border: 1px solid #84bae3; color: #1b4b72; } +.alert-warn { background: #fff4ce; border: 1px solid #ffd966; color: #523a00; } + +/* ── Tags / Chips ── */ +.chip { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; } +.chip-blue { background: #dbeafe; color: #1e40af; } +.chip-green { background: #d1fae5; color: #065f46; } +.chip-red { background: #fee2e2; color: #991b1b; } +.chip-yellow { background: #fef3c7; color: #92400e; } +.chip-gray { background: #f3f4f6; color: #374151; } + +/* ── Misc ── */ +.page-title { font-size: 20px; font-weight: 700; margin: 0 0 20px 0; color: var(--text); } +.page-subtitle { font-size: 13px; color: var(--text-muted); margin: -14px 0 20px 0; } +.flex-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.spacer { flex: 1; } +.mt-8 { margin-top: 8px; } +.mt-16 { margin-top: 16px; } +.text-muted { color: var(--text-muted); font-size: 12px; } +.count-badge { background: var(--accent); color: #fff; border-radius: 12px; padding: 1px 8px; font-size: 11px; font-weight: 700; } + +/* ── Site picker ── */ +.site-list { max-height: 300px; overflow-y: auto; border: 1px solid var(--border); border-radius: 4px; } +.site-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; cursor: pointer; border-bottom: 1px solid var(--border); font-size: 13px; } +.site-item:hover { background: #f5f5f5; } +.site-item.selected { background: #e8f1fb; } + +/* ── CSV validation table ── */ +.val-valid td { background: #f0fff4; } +.val-error td { background: #fff0f0; } +.val-error-msg { color: var(--danger); font-size: 11px; } + +/* ── No-profile state ── */ +.no-profile { text-align: center; padding: 60px 20px; color: var(--text-muted); } +.no-profile h2 { color: var(--text); } diff --git a/wwwroot/js/app.js b/wwwroot/js/app.js new file mode 100644 index 0000000..0b519e1 --- /dev/null +++ b/wwwroot/js/app.js @@ -0,0 +1,16 @@ +window.sptb = { + downloadFile: function(fileName, contentType, base64) { + var link = document.createElement('a'); + link.download = fileName; + link.href = 'data:' + contentType + ';base64,' + base64; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, + setTheme: function(theme) { + document.documentElement.setAttribute('data-theme', theme); + }, + scrollToBottom: function(el) { + if (el) el.scrollTop = el.scrollHeight; + } +};