using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.SharePoint.Client; using SharepointToolbox.Web.Core.Helpers; using SharepointToolbox.Web.Core.Models; using SharepointToolbox.Web.Infrastructure.Auth; using SharepointToolbox.Web.Infrastructure.Persistence; using SharepointToolbox.Web.Services.Export; using AppConfiguration = SharepointToolbox.Web.Core.Models.AppConfiguration; namespace SharepointToolbox.Web.Services.Reports; /// /// Drives one scheduled report end-to-end under app-only auth: resolve the client /// profile, discover/select sites, scan each site with the matching report service, /// merge the per-site results into a single artifact, write it to the client's /// exports subfolder, and index + audit the run. /// /// Report services take a plain , so they are reused /// verbatim; the only difference from the interactive pages is that the context is /// produced by instead of the delegated session. /// public class ScheduledReportRunner : IScheduledReportRunner { private readonly ProfileRepository _profiles; private readonly SettingsRepository _settings; private readonly GeneratedReportRepository _index; private readonly AuditRepository _audit; private readonly IAppOnlyContextFactory _appOnly; private readonly IReportMailService _mail; private readonly AppConfiguration _cfg; private readonly ILogger _log; private readonly IPermissionsService _perm; private readonly ISharePointGroupResolver _groupResolver; private readonly IStorageService _storage; private readonly IDuplicatesService _dup; private readonly ISearchService _search; private readonly IVersionCleanupService _version; private readonly CsvExportService _permCsv; private readonly HtmlExportService _permHtml; private readonly StorageCsvExportService _storageCsv; private readonly StorageHtmlExportService _storageHtml; private readonly DuplicatesCsvExportService _dupCsv; private readonly DuplicatesHtmlExportService _dupHtml; private readonly SearchCsvExportService _searchCsv; private readonly SearchHtmlExportService _searchHtml; private readonly UserAccessCsvExportService _uaCsv; private readonly UserAccessHtmlExportService _uaHtml; private readonly VersionCleanupHtmlExportService _versionHtml; public ScheduledReportRunner( ProfileRepository profiles, SettingsRepository settings, GeneratedReportRepository index, AuditRepository audit, IAppOnlyContextFactory appOnly, IReportMailService mail, IOptions cfg, ILogger log, IPermissionsService perm, ISharePointGroupResolver groupResolver, IStorageService storage, IDuplicatesService dup, ISearchService search, IVersionCleanupService version, CsvExportService permCsv, HtmlExportService permHtml, StorageCsvExportService storageCsv, StorageHtmlExportService storageHtml, DuplicatesCsvExportService dupCsv, DuplicatesHtmlExportService dupHtml, SearchCsvExportService searchCsv, SearchHtmlExportService searchHtml, UserAccessCsvExportService uaCsv, UserAccessHtmlExportService uaHtml, VersionCleanupHtmlExportService versionHtml) { _profiles = profiles; _settings = settings; _index = index; _audit = audit; _appOnly = appOnly; _mail = mail; _cfg = cfg.Value; _log = log; _perm = perm; _groupResolver = groupResolver; _storage = storage; _dup = dup; _search = search; _version = version; _permCsv = permCsv; _permHtml = permHtml; _storageCsv = storageCsv; _storageHtml = storageHtml; _dupCsv = dupCsv; _dupHtml = dupHtml; _searchCsv = searchCsv; _searchHtml = searchHtml; _uaCsv = uaCsv; _uaHtml = uaHtml; _versionHtml = versionHtml; } public async Task RunAsync(ScheduledReport schedule, CancellationToken ct = default) { var profile = (await _profiles.LoadAsync()).FirstOrDefault(p => p.Id == schedule.ProfileId); if (profile is null) return await FailAsync(schedule, profileName: "(unknown)", $"Client profile '{schedule.ProfileId}' not found."); if (!profile.AppOnlyEnabled) return await FailAsync(schedule, profile.Name, $"App-only reports are not enabled for client '{profile.Name}'."); try { var sites = await ResolveSitesAsync(profile, schedule, ct); if (sites.Count == 0) return await FailAsync(schedule, profile.Name, "No sites resolved for this schedule."); var settings = await _settings.LoadAsync(); var branding = new ReportBranding(settings.MspLogo, profile.ClientLogo); var ts = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); var output = await GenerateAsync(profile, schedule, sites, branding, ts, ct); var dir = Path.Combine(_cfg.ExportsFolder, profile.Id); Directory.CreateDirectory(dir); var path = Path.Combine(dir, output.FileName); await System.IO.File.WriteAllBytesAsync(path, output.Bytes, ct); var report = new GeneratedReport { ProfileId = profile.Id, ScheduledReportId = schedule.Id, Type = schedule.Type, Name = schedule.Name, FileName = output.FileName, Mime = output.Mime, SizeBytes = output.Bytes.LongLength, Status = ReportRunStatus.Success }; // Optional email delivery. A delivery failure does NOT fail the report — // the file is already on disk and indexed; we record the error on the entry. string mailNote = ""; if (schedule.Email.Enabled) { try { await _mail.SendAsync(profile, schedule, schedule.Email, output.FileName, output.Mime, output.Bytes, ct); report.Emailed = true; mailNote = $", emailed to {schedule.Email.To.Count + schedule.Email.Cc.Count} recipient(s)"; } catch (OperationCanceledException) { throw; } catch (Exception mex) { report.EmailError = mex.Message; mailNote = $", email FAILED: {mex.Message}"; _log.LogError(mex, "Emailing report '{Name}' for '{Client}' failed", schedule.Name, profile.Name); } } await _index.AddAsync(report); await AuditAsync(profile.Name, schedule, ReportRunStatus.Success, $"{schedule.Type} report '{output.FileName}' ({sites.Count} site(s), {output.Bytes.LongLength / 1024.0:F1} KB){mailNote}"); _log.LogInformation("Scheduled report '{Name}' for '{Client}' produced {File}", schedule.Name, profile.Name, output.FileName); return report; } catch (OperationCanceledException) { throw; } catch (Exception ex) { _log.LogError(ex, "Scheduled report '{Name}' for '{Client}' failed", schedule.Name, profile.Name); return await FailAsync(schedule, profile.Name, ex.Message); } } private async Task> ResolveSitesAsync( TenantProfile profile, ScheduledReport schedule, CancellationToken ct) { if (!schedule.AllSites) return schedule.SiteUrls .Where(u => !string.IsNullOrWhiteSpace(u)) .Select(u => new SiteInfo(u, ReportSplitHelper.DeriveSiteLabel(u))) .ToList(); using var adminCtx = await _appOnly.CreateContextAsync( profile, TenantSiteEnumerator.BuildAdminUrl(profile.TenantUrl), ct); return await TenantSiteEnumerator.EnumerateAsync(adminCtx, ct); } private async Task GenerateAsync( TenantProfile profile, ScheduledReport schedule, IReadOnlyList sites, ReportBranding branding, string ts, CancellationToken ct) { var o = schedule.Options; var progress = new Progress(); var mode = schedule.MergeMode; var fmt = schedule.Format; // Runs the same per-site scan loop for every report type. ClientContext is // app-only and disposed per site. async Task Results)>> ScanSites( Func>> scan) { var bySite = new List<(string, IReadOnlyList)>(); foreach (var site in sites) { ct.ThrowIfCancellationRequested(); using var ctx = await _appOnly.CreateContextAsync(profile, site.Url, ct); bySite.Add((site.Title, await scan(ctx, site))); } return bySite; } switch (schedule.Type) { case ReportType.Permissions: { var opts = new ScanOptions(o.IncludeInherited, o.ScanFolders, o.FolderDepth, o.IncludeSubsites); var bySite = await ScanSites((ctx, _) => _perm.ScanSiteAsync(ctx, opts, progress, ct)); return ReportMergeHelper.Build(bySite, mode, "permissions", ts, fmt, fmt == ReportFormat.Csv ? rs => _permCsv.BuildCsv(rs) : rs => _permHtml.BuildHtml(rs, branding)); } case ReportType.Storage: { var opts = new StorageScanOptions(o.PerLibrary, o.IncludeSubsites, o.FolderDepth, o.IncludeHiddenLibraries, o.IncludePreservationHold, o.IncludeListAttachments, o.IncludeRecycleBin); var bySite = await ScanSites((ctx, _) => _storage.CollectStorageAsync(ctx, opts, progress, ct)); return ReportMergeHelper.Build(bySite, mode, "storage", ts, fmt, fmt == ReportFormat.Csv ? rs => _storageCsv.BuildCsv(rs) : rs => _storageHtml.BuildHtml(rs, branding)); } case ReportType.Duplicates: { var opts = new DuplicateScanOptions(o.DuplicateMode, o.MatchSize, o.MatchCreated, o.MatchModified, o.MatchSubfolderCount, o.MatchFileCount, o.IncludeSubsites, o.Library); var bySite = await ScanSites((ctx, _) => _dup.ScanDuplicatesAsync(ctx, opts, progress, ct)); return ReportMergeHelper.Build(bySite, mode, "duplicates", ts, fmt, fmt == ReportFormat.Csv ? rs => _dupCsv.BuildCsv(rs) : rs => _dupHtml.BuildHtml(rs, branding)); } case ReportType.Search: { var bySite = await ScanSites((ctx, site) => { var opts = new SearchOptions(o.Extensions.ToArray(), o.Regex, null, null, null, null, null, null, o.Library, o.MaxResults, site.Url); return _search.SearchFilesAsync(ctx, opts, progress, ct); }); return ReportMergeHelper.Build(bySite, mode, "search", ts, fmt, fmt == ReportFormat.Csv ? rs => _searchCsv.BuildCsv(rs) : rs => _searchHtml.BuildHtml(rs, branding)); } case ReportType.UserAccess: { var opts = new ScanOptions(o.IncludeInherited, o.ScanFolders, o.FolderDepth, o.IncludeSubsites); var targets = o.TargetUserLogins .Select(l => l.Trim().ToLowerInvariant()) .Where(l => l.Length > 0).ToHashSet(); var bySite = await ScanSites(async (ctx, site) => { var permEntries = await _perm.ScanSiteAsync(ctx, opts, progress, ct); // Expand SharePoint group membership so group-granted access is attributed to // the target user (otherwise the scan only sees the group principal, not the user). var groupMembers = await UserAccessAuditService.ResolveGroupMembersAsync( _groupResolver, ctx, profile, permEntries, ct); return UserAccessAuditService.TransformEntries(permEntries, targets, site, groupMembers).ToList(); }); return ReportMergeHelper.Build(bySite, mode, "user_audit", ts, fmt, fmt == ReportFormat.Csv ? rs => _uaCsv.BuildCsv(rs.FirstOrDefault()?.UserDisplayName ?? "Users", rs.FirstOrDefault()?.UserLogin ?? "", rs) : rs => _uaHtml.BuildHtml(rs, mergePermissions: false, branding: branding)); } case ReportType.VersionCleanup: { // Destructive: this DELETES old file versions. No CSV exporter exists, so the // output is always the HTML summary of what was removed. var opts = new VersionCleanupOptions(o.LibraryTitles, o.KeepLast, o.KeepFirst); var bySite = await ScanSites((ctx, _) => _version.DeleteOldVersionsAsync(ctx, opts, progress, ct)); return ReportMergeHelper.Build(bySite, mode, "versions", ts, ReportFormat.Html, rs => _versionHtml.BuildHtml(rs, branding)); } default: throw new NotSupportedException($"Report type {schedule.Type} is not supported."); } } private async Task FailAsync(ScheduledReport schedule, string profileName, string error) { var report = new GeneratedReport { ProfileId = schedule.ProfileId, ScheduledReportId = schedule.Id, Type = schedule.Type, Name = schedule.Name, Status = ReportRunStatus.Failed, Error = error }; await _index.AddAsync(report); await AuditAsync(profileName, schedule, ReportRunStatus.Failed, error); return report; } private Task AuditAsync(string profileName, ScheduledReport schedule, ReportRunStatus status, string details) => _audit.AppendAsync(new AuditEntry { Action = "ScheduledReport", ClientName = profileName, Sites = new List(), Details = $"{status}: {schedule.Name} — {details}", UserEmail = "system", UserDisplay = "Scheduler", UserRole = UserRole.Admin }); }