using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using AyaNova.Api.ControllerHelpers; using AyaNova.Models; using AyaNova.Util; using EnumsNET; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; using PuppeteerSharp; using StackExchange.Profiling.Internal; namespace AyaNova.Biz { internal class ReportBiz : BizObject, IJobObject, ISearchAbleObject { internal ReportBiz( AyContext dbcontext, long currentUserId, long userTranslationId, AuthorizationRoles UserRoles ) { ct = dbcontext; UserId = currentUserId; UserTranslationId = userTranslationId; CurrentUserRoles = UserRoles; BizType = AyaType.Report; } internal static ReportBiz GetBiz( AyContext ct, Microsoft.AspNetCore.Http.HttpContext httpContext = null ) { if (httpContext != null) return new ReportBiz( ct, UserIdFromContext.Id(httpContext.Items), UserTranslationIdFromContext.Id(httpContext.Items), UserRolesFromContext.Roles(httpContext.Items) ); else return new ReportBiz( ct, 1, ServerBootConfig.AYANOVA_DEFAULT_TRANSLATION_ID, AuthorizationRoles.BizAdmin ); } //////////////////////////////////////////////////////////////////////////////////////////////// //EXISTS internal async Task ExistsAsync(long id) { return await ct.Report.AnyAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE // internal async Task CreateAsync(Report newObject) { await ValidateAsync(newObject, null); if (HasErrors) return null; else { await ct.Report.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync( new Event(UserId, newObject.Id, BizType, AyaEvent.Created), ct ); await SearchIndexAsync(newObject, true); return newObject; } } //////////////////////////////////////////////////////////////////////////////////////////////// //IMPORT // internal async Task ImportAsync(JObject o, bool skipIfAlreadyPresent = false) { //Report newObject = new Report(); var newObject = o.ToObject(); var proposedName = (string)o["Name"]; string newUniqueName = proposedName; bool NotUnique = true; long l = 1; do { NotUnique = await ct.Report.AnyAsync(z => z.Name == newUniqueName); if (NotUnique) { if (!skipIfAlreadyPresent) newUniqueName = Util.StringUtil.UniqueNameBuilder(proposedName, l++, 255); else { return true; } } } while (NotUnique); newObject.Name = newUniqueName; await ValidateAsync(newObject, null); if (HasErrors) return false; await ct.Report.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync( new Event(UserId, newObject.Id, BizType, AyaEvent.Created), ct ); if (skipIfAlreadyPresent) { ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); log.LogInformation($"Stock report '{proposedName}' imported"); } return true; } //////////////////////////////////////////////////////////////////////////////////////////////// //GET // internal async Task GetAsync(long id, bool logTheGetEvent = true) { var ret = await ct.Report.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync( new Event(UserId, id, BizType, AyaEvent.Retrieved), ct ); return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //UPDATE // internal async Task PutAsync(Report putObject) { var dbObject = await GetAsync(putObject.Id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return null; } if (dbObject.Concurrency != putObject.Concurrency) { AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await ValidateAsync(putObject, dbObject); if (HasErrors) return null; ct.Replace(dbObject, putObject); try { await ct.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { if (!await ExistsAsync(putObject.Id)) AddError(ApiErrorCode.NOT_FOUND); else AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await EventLogProcessor.LogEventToDatabaseAsync( new Event(UserId, dbObject.Id, BizType, AyaEvent.Modified), ct ); await SearchIndexAsync(putObject, false); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task DeleteAsync(long id) { using (var transaction = await ct.Database.BeginTransactionAsync()) { var dbObject = await GetAsync(id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND); return false; } await ValidateCanDelete(dbObject); if (HasErrors) return false; { var IDList = await ct .Review.AsNoTracking() .Where(x => x.AType == AyaType.Report && x.ObjectId == id) .Select(x => x.Id) .ToListAsync(); if (IDList.Count() > 0) { ReviewBiz b = new ReviewBiz( ct, UserId, UserTranslationId, CurrentUserRoles ); foreach (long ItemId in IDList) if (!await b.DeleteAsync(ItemId, transaction)) { AddError( ApiErrorCode.CHILD_OBJECT_ERROR, null, $"Review [{ItemId}]: {b.GetErrorsAsString()}" ); return false; } } } ct.Report.Remove(dbObject); await ct.SaveChangesAsync(); await EventLogProcessor.DeleteObjectLogAsync( UserId, BizType, dbObject.Id, dbObject.Name, ct ); await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, BizType, ct); await FileUtil.DeleteAttachmentsForObjectAsync(BizType, dbObject.Id, ct); await transaction.CommitAsync(); return true; } } //////////////////////////////////////////////////////////////////////////////////////////////// //GET LIST // internal async Task> GetReportListAsync(AyaType aType) { var rpts = await ct .Report.AsNoTracking() .Where(z => z.AType == aType && z.Active == true) .Select(z => new { id = z.Id, name = z.Name, roles = z.Roles, }) .OrderBy(z => z.name) .ToListAsync(); var ret = new List(); foreach (var item in rpts) { if (CurrentUserRoles.HasAnyFlags(item.roles)) { ret.Add(new NameIdItem() { Name = item.name, Id = item.id }); } } return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //SEARCH // private async Task SearchIndexAsync(Report obj, bool isNew) { var SearchParams = new Search.SearchIndexProcessObjectParameters( UserTranslationId, obj.Id, BizType ); DigestSearchText(obj, SearchParams); if (isNew) await Search.ProcessNewObjectKeywordsAsync(SearchParams); else await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); } public async Task GetSearchResultSummary( long id, AyaType specificType ) { var obj = await GetAsync(id, false); var SearchParams = new Search.SearchIndexProcessObjectParameters(); DigestSearchText(obj, SearchParams); return SearchParams; } public void DigestSearchText( Report obj, Search.SearchIndexProcessObjectParameters searchParams ) { if (obj != null) searchParams .AddText(obj.Notes) .AddText(obj.Name) .AddText(obj.Template) .AddText(obj.Style) .AddText(obj.JsPrerender) .AddText(obj.JsHelpers) .AddText(obj.HeaderTemplate) .AddText(obj.FooterTemplate); } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task ValidateAsync(Report proposedObj, Report currentObj) { bool isNew = currentObj == null; //Name required if (string.IsNullOrWhiteSpace(proposedObj.Name)) AddError(ApiErrorCode.VALIDATION_REQUIRED, "Name"); //If name is otherwise OK, check that name is unique if (!PropertyHasErrors("Name")) { //Use Any command is efficient way to check existance, it doesn't return the record, just a true or false //NOTE: unlike other objects reports can have the same name as long as the type differs if ( await ct.Report.AnyAsync(z => z.Name == proposedObj.Name && z.AType == proposedObj.AType && z.Id != proposedObj.Id ) ) { AddError(ApiErrorCode.VALIDATION_NOT_UNIQUE, "Name"); } } } private async Task ValidateCanDelete(Report inObj) { //Referential integrity error if (await ct.NotifySubscription.AnyAsync(z => z.LinkReportId == inObj.Id) == true) { //Note: errorbox will ensure it appears in the general errror box and not field specific //the translation key is to indicate what the linked object is that is causing the error AddError( ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("NotifySubscription") ); } if ( await ct.GlobalBizSettings.AnyAsync(z => z.CustomerDefaultWorkOrderReportId == inObj.Id ) == true ) AddError( ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("GlobalSettings") ); } //////////////////////////////////////////////////////////////////////////////////////////////// //REPORT DATA //Data fetched to return to report render or for designer for Client report design usage public async Task GetReportDataForReportDesigner( DataListSelectedRequest selectedRequest ) { var log = AyaNova.Util.ApplicationLogging.CreateLogger( "ReportBiz::GetReportDataForReportDesigner" ); AuthorizationRoles effectiveRoles = CurrentUserRoles; if (selectedRequest.AType == AyaType.NoType) { AddError(ApiErrorCode.VALIDATION_REQUIRED, null, $"AType is required"); return null; } //Do we need to rehydrate the ID List from a DataList? if (selectedRequest.SelectedRowIds.Length == 0) selectedRequest.SelectedRowIds = await DataListSelectedProcessingOptions.RehydrateIdList( selectedRequest, ct, effectiveRoles, log, UserId, UserTranslationId ); //case 4568 get translation id ultimately without failing so as to not affect any existing callers that this might not work with long RequestorUserTranslationId = UserTranslationId; try { JToken clientMeta = ((DataListReportRequest)selectedRequest).ClientMeta; if (clientMeta["UserId"] != null) { RequestorUserTranslationId = await ct .User.AsNoTracking() .Where(a => a.Id == clientMeta["UserId"].ToObject()) .Select(m => m.UserOptions.TranslationId) .FirstAsync(); //m => new { roles = m.Roles, name = m.Name, m.UserType, id = m.Id, translationId = m.UserOptions.TranslationId, currentAuthToken = m.CurrentAuthToken }).FirstAsync(); } } catch (Exception exx) { log.LogDebug(exx, $"Failed to get RequestionUserTranslationId from ClientMeta"); } ; log.LogDebug($"Instantiating biz object handler for {selectedRequest.AType}"); var biz = BizObjectFactory.GetBizObject( selectedRequest.AType, ct, UserId, CurrentUserRoles, RequestorUserTranslationId ); log.LogDebug( $"Fetching data for {selectedRequest.SelectedRowIds.Length} {selectedRequest.AType} items" ); return await ((IReportAbleObject)biz).GetReportData(selectedRequest, Guid.Empty); //Guid.empty signifies it's not a job calling it } public async Task GetReportData( DataListSelectedRequest selectedRequest, Guid jobId, bool requestIsCustomerWorkOrderReport = false ) { var log = AyaNova.Util.ApplicationLogging.CreateLogger("ReportBiz::GetReportData"); AuthorizationRoles effectiveRoles = CurrentUserRoles; if (selectedRequest.AType == AyaType.NoType) { AddError(ApiErrorCode.VALIDATION_REQUIRED, null, $"AType is required"); return null; } if ( !requestIsCustomerWorkOrderReport && !AyaNova.Api.ControllerHelpers.Authorized.HasReadFullRole( effectiveRoles, selectedRequest.AType ) ) { AddError( ApiErrorCode.NOT_AUTHORIZED, null, $"User not authorized for {selectedRequest.AType} type object" ); return null; } //Do we need to rehydrate the ID List from a DataList? if (selectedRequest.SelectedRowIds.Length == 0) selectedRequest.SelectedRowIds = await DataListSelectedProcessingOptions.RehydrateIdList( selectedRequest, ct, effectiveRoles, log, UserId, UserTranslationId ); if (!ReportRenderManager.KeepGoing(jobId)) return null; //case 4568 get translation id ultimately without failing so as to not affect any existing callers that this might not work with long RequestorUserTranslationId = UserTranslationId; try { JToken clientMeta = ((DataListReportRequest)selectedRequest).ClientMeta; if (clientMeta["UserId"] != null) { RequestorUserTranslationId = await ct .User.AsNoTracking() .Where(a => a.Id == clientMeta["UserId"].ToObject()) .Select(m => m.UserOptions.TranslationId) .FirstAsync(); //m => new { roles = m.Roles, name = m.Name, m.UserType, id = m.Id, translationId = m.UserOptions.TranslationId, currentAuthToken = m.CurrentAuthToken }).FirstAsync(); } } catch (Exception exx) { log.LogDebug(exx, $"Failed to get RequestionUserTranslationId from ClientMeta"); } ; log.LogDebug($"Instantiating biz object handler for {selectedRequest.AType}"); var biz = BizObjectFactory.GetBizObject( selectedRequest.AType, ct, UserId, CurrentUserRoles, RequestorUserTranslationId ); log.LogDebug( $"Fetching data for {selectedRequest.SelectedRowIds.Length} {selectedRequest.AType} items" ); return await ((IReportAbleObject)biz).GetReportData(selectedRequest, jobId); } //////////////////////////////////////////////////////////////////////////////////////////////// //RENDER // public async Task RequestRenderReport( DataListReportRequest reportRequest, DateTime renderTimeOutExpiry, string apiUrl, string userName ) { var log = AyaNova.Util.ApplicationLogging.CreateLogger( "ReportBiz::RequestRenderReport" ); log.LogDebug( $"report id {reportRequest.ReportId}, timeout @ {renderTimeOutExpiry.ToString()}" ); //Is reporting api url overridden in CORS issue scenario (case 4398) if (!string.IsNullOrWhiteSpace(ServerBootConfig.AYANOVA_REPORT_RENDER_API_URL_OVERRIDE)) { //case 4632, override breaks special api url when processing customer notification //in order to minimize side effects will go by special user name used here if ( userName != ServerBootConfig.CUSTOMER_NOTIFICATION_ATTACHED_REPORT_RENDER_USERNAME ) apiUrl = $"{ServerBootConfig.AYANOVA_REPORT_RENDER_API_URL_OVERRIDE.TrimEnd().TrimEnd('/')}/api/{AyaNovaVersion.CurrentApiVersion}/"; } //Customer User Report? bool RequestIsCustomerWorkOrderReport = false; if (reportRequest.ReportId == -100) { log.LogDebug("customer user report requested"); //get the user and workorder data and set the actual report id or return null if not found var woTags = await ct .WorkOrder.AsNoTracking() .Where(x => x.Id == reportRequest.SelectedRowIds[0]) .Select(x => x.Tags) .FirstOrDefaultAsync(); if (woTags == null) return null; var cr = await UserBiz.CustomerUserEffectiveRightsAsync(UserId, woTags); if (cr.ThisWOEffectiveWOReportId == null) return null; reportRequest.ReportId = (long)cr.ThisWOEffectiveWOReportId; RequestIsCustomerWorkOrderReport = true; } //get report, vet security, see what we need before init in case of issue log.LogDebug($"get report from db"); var report = await ct.Report.FirstOrDefaultAsync(z => z.Id == reportRequest.ReportId); if (report == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return null; } //If we get here via the /viewreport url in the client then there is no object type set so we need to set it here from the report if (reportRequest.AType == AyaType.NoType) { reportRequest.AType = report.AType; } AuthorizationRoles effectiveRoles = CurrentUserRoles; if ( !RequestIsCustomerWorkOrderReport && !AyaNova.Api.ControllerHelpers.Authorized.HasReadFullRole( effectiveRoles, report.AType ) ) { log.LogDebug($"bail: user unauthorized"); AddError( ApiErrorCode.NOT_AUTHORIZED, null, $"User not authorized for {report.AType} type object" ); return null; } //Client meta data is required if (reportRequest.ClientMeta == null) { log.LogDebug($"bail: ClientMeta parameter is missing"); AddError( ApiErrorCode.VALIDATION_MISSING_PROPERTY, null, "ClientMeta parameter is missing and required to render report" ); return null; } //includeWoItemDescendants? reportRequest.IncludeWoItemDescendants = report.IncludeWoItemDescendants; //Do we need to rehydrate the ID List from a DataList? if (reportRequest.SelectedRowIds.Length == 0) reportRequest.SelectedRowIds = await DataListSelectedProcessingOptions.RehydrateIdList( reportRequest, ct, effectiveRoles, log, UserId, UserTranslationId ); var JobName = $"LT:Report id: \"{reportRequest.ReportId}\" LT:{reportRequest.AType} ({reportRequest.SelectedRowIds.LongLength}) LT:User {userName}"; JObject o = JObject.FromObject( new { reportRequest = reportRequest, requestIsCustomerWorkOrderReport = RequestIsCustomerWorkOrderReport, apiUrl = apiUrl, userName = userName, } ); OpsJob j = new OpsJob(); j.Name = JobName; j.AType = AyaType.Report; j.JobType = JobType.RenderReport; j.SubType = JobSubType.NotSet; j.Exclusive = false; j.JobInfo = o.ToString(); await JobsBiz.AddJobAsync(j); await EventLogProcessor.LogEventToDatabaseAsync( new Event(UserId, 0, AyaType.ServerJob, AyaEvent.Created, JobName), ct ); return j.GId; /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// } public async Task DoRenderJob(OpsJob job) { var log = AyaNova.Util.ApplicationLogging.CreateLogger("ReportBiz::DoRenderJob"); await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Running); ReportRenderManager.AddJob(job.GId, log); //rehydrate job objects log.LogDebug($"Start; rehydrate job {job.Name}"); JObject jobData = JObject.Parse(job.JobInfo); var reportRequest = jobData["reportRequest"].ToObject(); var RequestIsCustomerWorkOrderReport = jobData["requestIsCustomerWorkOrderReport"] .ToObject(); var apiUrl = jobData["apiUrl"].ToObject(); var userName = jobData["userName"].ToObject(); var report = await ct.Report.FirstOrDefaultAsync(z => z.Id == reportRequest.ReportId); if (report == null) { await JobsBiz.LogJobAsync( job.GId, $"rendererror:error,\"LT:ErrorAPI2010 LT:Report({reportRequest.ReportId})\"" ); await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Failed); return; } if (!ReportRenderManager.KeepGoing(job.GId)) return; //Get data log.LogDebug("Getting report data now"); // var watch = new Stopwatch(); // watch.Start(); var ReportData = await GetReportData( reportRequest, job.GId, RequestIsCustomerWorkOrderReport ); // watch.Stop(); // log.LogInformation($"GetReportData took {watch.ElapsedMilliseconds}ms to execute"); //THIS is here to catch scenario where report data returned null because it expired, not because of an issue if (!ReportRenderManager.KeepGoing(job.GId)) return; //if GetReportData errored then will return null so need to return that as well here if (ReportData == null) { log.LogDebug($"bail: ReportData == null"); await JobsBiz.LogJobAsync( job.GId, $"rendererror:error,\"{this.GetErrorsAsString()}\"" ); //?? circle back on this one await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Failed); } if (!ReportRenderManager.KeepGoing(job.GId)) return; //initialization log.LogDebug("Initializing report rendering system"); bool AutoDownloadBrowser = true; if (string.IsNullOrWhiteSpace(ServerBootConfig.AYANOVA_REPORT_RENDER_BROWSER_PATH)) { log.LogDebug($"Using default browser (downloaded)"); } else { log.LogDebug( $"Using user specified browser at {ServerBootConfig.AYANOVA_REPORT_RENDER_BROWSER_PATH}" ); AutoDownloadBrowser = false; } var ReportJSFolderPath = Path.Combine( ServerBootConfig.AYANOVA_CONTENT_ROOT_PATH, "resource", "rpt" ); //Keep for debugging headfully //var lo = new LaunchOptions { Headless = false }; var lo = new LaunchOptions { Headless = true }; if (!AutoDownloadBrowser) { lo.ExecutablePath = ServerBootConfig.AYANOVA_REPORT_RENDER_BROWSER_PATH; /* troubleshooting links: https://developers.google.com/web/tools/puppeteer/troubleshooting These links might be worth looking at in future if diagnosing other issues: https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md#running-on-alpine https://github.com/puppeteer/puppeteer/issues/1825 const chromeFlags = [ '--headless', '--no-sandbox', "--disable-gpu", "--single-process", "--no-zygote" ] */ } else { log.LogDebug($"Calling browserFetcher download async now:"); //await new BrowserFetcher().DownloadAsync(BrowserFetcher.DefaultChromiumRevision); //case 4586 //puppeteer has gone chrome dev by default and so does puppeteersharp which tests ok so I'm going to allow the default //this means the files go into binary folder under Chrome and ChromeHeadlessShell await new BrowserFetcher().DownloadAsync(); } //Set Chromium args //*** DANGER: --disable-dev-shm-usage will crash linux ayanova when it runs out of memory **** //that was only suitable for dockerized scenario as it had an alt swap system //var OriginalDefaultArgs = "--disable-dev-shm-usage --single-process --no-sandbox --disable-gpu --no-zygote "; //Keep for debugging headfully // var DefaultArgs = "--no-sandbox"; var DefaultArgs = "--headless --no-sandbox"; if (!string.IsNullOrWhiteSpace(ServerBootConfig.AYANOVA_REPORT_RENDER_BROWSER_PARAMS)) { log.LogDebug( $"AYANOVA_REPORT_RENDER_BROWSER_PARAMS will be used: {ServerBootConfig.AYANOVA_REPORT_RENDER_BROWSER_PARAMS}" ); lo.Args = new string[] { $"{ServerBootConfig.AYANOVA_REPORT_RENDER_BROWSER_PARAMS}", }; // AYANOVA_REPORT_RENDER_BROWSER_PARAMS } else { log.LogDebug( $"AYANOVA_REPORT_RENDER_BROWSER_PARAMS not set, using defaults '{DefaultArgs}'" ); lo.Args = new string[] { DefaultArgs }; } System.Text.StringBuilder PageLog = new System.Text.StringBuilder(); if (!ReportRenderManager.KeepGoing(job.GId)) return; //API DOCS http://www.puppeteersharp.com/api/index.html log.LogDebug($"Launching headless Browser and new page now:"); using (var browser = await Puppeteer.LaunchAsync(lo)) using (var page = (await browser.PagesAsync())[0]) { //track this process for timeout purposes ReportRenderManager.SetProcess(job.GId, browser.Process.Id, log); page.DefaultTimeout = 0; //infinite timeout as we are controlling how long the process can live for with the reportprocessmanager try { //info and error logging //case 4586 not calling task whenall now means non async so this page.Console += async (sender, args) => is now this: page.Console += (sender, args) => { switch (args.Message.Type) { case ConsoleType.Error: try { //case 4586 todo force an error here and see what we can do with it old vs new behaviour test on current release same report with erorr triggered //ok, I tested it with an exception and it worked just find so shrug emoji PageLog.AppendLine($"ERROR: {args.Message.Text}"); //case 4586 this no longer works due to no executioncontext in arg in teh block below // var errorArgs = await Task.WhenAll(args.Message.Args.Select(arg => arg.ExecutionContext.EvaluateFunctionAsync("(arg) => arg instanceof Error ? arg.message : arg", arg))); // PageLog.AppendLine($"ERROR: {args.Message.Text} args: [{string.Join(", ", errorArgs)}]"); } catch { } break; case ConsoleType.Warning: PageLog.AppendLine($"WARNING: {args.Message.Text}"); break; default: PageLog.AppendLine($"INFO: {args.Message.Text}"); break; } }; log.LogDebug($"Preparing page: adding base reporting scripts to page"); if (!ReportRenderManager.KeepGoing(job.GId)) return; //Add Handlebars JS for compiling and presenting //https://handlebarsjs.com/ await page.AddScriptTagAsync( new AddTagOptions() { Path = Path.Combine(ReportJSFolderPath, "ay-hb.js") } ); //add Marked for markdown processing //https://github.com/markedjs/marked await page.AddScriptTagAsync( new AddTagOptions() { Path = Path.Combine(ReportJSFolderPath, "ay-md.js") } ); //add DOM Purify for markdown template sanitization processing //https://github.com/cure53/DOMPurify await page.AddScriptTagAsync( new AddTagOptions() { Path = Path.Combine(ReportJSFolderPath, "ay-pf.js") } ); //add Bar code library if our bar code helper is referenced //https://github.com/metafloor/bwip-js if (report.Template.Contains("ayBC ") || report.JsHelpers.Contains("ayBC ")) await page.AddScriptTagAsync( new AddTagOptions() { Path = Path.Combine(ReportJSFolderPath, "ay-bc.js"), } ); //add stock helpers await page.AddScriptTagAsync( new AddTagOptions() { Path = Path.Combine(ReportJSFolderPath, "ay-report.js"), } ); //execute to add to handlebars await page.EvaluateExpressionAsync("ayRegisterHelpers();"); if (!ReportRenderManager.KeepGoing(job.GId)) return; log.LogDebug( $"Preparing page: adding this report's scripts, style and templates to page" ); //add report pre-render, helpers and style if (string.IsNullOrWhiteSpace(report.JsPrerender)) { report.JsPrerender = "async function ayPrepareData(reportData){return reportData;}"; } await page.AddScriptTagAsync( new AddTagOptions() { Content = report.JsPrerender } ); if (!string.IsNullOrWhiteSpace(report.JsHelpers)) await page.AddScriptTagAsync( new AddTagOptions() { Content = report.JsHelpers } ); log.LogDebug($"Preparing page: adding Client meta data"); //Client meta data to JSON string var clientMeta = reportRequest.ClientMeta.ToString(); log.LogDebug($"Preparing page: adding Server meta data"); //Server meta data var logo = await ct.Logo.AsNoTracking().SingleOrDefaultAsync(); var HasSmallLogo = "false"; var HasMediumLogo = "false"; var HasLargeLogo = "false"; if (logo != null) { if (logo.Small != null) HasSmallLogo = "true"; if (logo.Medium != null) HasMediumLogo = "true"; if (logo.Large != null) HasLargeLogo = "true"; } var HasPostalAddress = !string.IsNullOrWhiteSpace( ServerGlobalBizSettings.Cache.PostAddress ) ? "true" : "false"; var HasStreetAddress = !string.IsNullOrWhiteSpace( ServerGlobalBizSettings.Cache.Address ) ? "true" : "false"; //case 4209 //latitude and longitude are the only nullable fields in global biz settings and need to be converted to empty strings if null string sLatitude = "null"; string sLongitude = "null"; if (ServerGlobalBizSettings.Cache.Latitude != null) sLatitude = ServerGlobalBizSettings.Cache.Latitude.ToString(); if (ServerGlobalBizSettings.Cache.Longitude != null) sLongitude = ServerGlobalBizSettings.Cache.Longitude.ToString(); var serverMeta = $"{{ayApiUrl:`{apiUrl}`, HasSmallLogo:{HasSmallLogo}, HasMediumLogo:{HasMediumLogo}, HasLargeLogo:{HasLargeLogo},CompanyName: `{AyaNova.Core.License.ActiveKey.RegisteredTo}`,CompanyWebAddress:`{ServerGlobalBizSettings.Cache.WebAddress}`,CompanyEmailAddress:`{ServerGlobalBizSettings.Cache.EmailAddress}`,CompanyPhone1:`{ServerGlobalBizSettings.Cache.Phone1}`,CompanyPhone2:`{ServerGlobalBizSettings.Cache.Phone2}`,HasPostalAddress:{HasPostalAddress},CompanyPostAddress:`{ServerGlobalBizSettings.Cache.PostAddress}`,CompanyPostCity:`{ServerGlobalBizSettings.Cache.PostCity}`,CompanyPostRegion:`{ServerGlobalBizSettings.Cache.PostRegion}`,CompanyPostCountry:`{ServerGlobalBizSettings.Cache.PostCountry}`,CompanyPostCode:`{ServerGlobalBizSettings.Cache.PostCode}`,HasStreetAddress:{HasStreetAddress},CompanyAddress:`{ServerGlobalBizSettings.Cache.Address}`,CompanyCity:`{ServerGlobalBizSettings.Cache.City}`,CompanyRegion:`{ServerGlobalBizSettings.Cache.Region}`,CompanyCountry:`{ServerGlobalBizSettings.Cache.Country}`,CompanyAddressPostal:`{ServerGlobalBizSettings.Cache.AddressPostal}`,CompanyLatitude:{sLatitude},CompanyLongitude:{sLongitude}}}"; log.LogDebug($"Preparing page: adding Report meta data"); //Custom fields definition for report usage string CustomFieldsTemplate = "null"; var FormCustomization = await ct .FormCustom.AsNoTracking() .SingleOrDefaultAsync(z => z.FormKey == report.AType.ToString()); if (FormCustomization != null) { CustomFieldsTemplate = FormCustomization.Template; } //Report meta data var reportMeta = $"{{Id:{report.Id},Name:`{report.Name}`,Notes:`{report.Notes}`,AType:`{report.AType}`,CustomFieldsDefinition:{CustomFieldsTemplate},DataListKey:`{reportRequest.DataListKey}`,SelectedRowIds: `{string.Join(",", reportRequest.SelectedRowIds)}`}}"; //duplicate meta data in report page wide variable for use by our internal functions await page.AddScriptTagAsync( new AddTagOptions() { Content = $"var AYMETA={{ ayReportMetaData:{reportMeta}, ayClientMetaData:{clientMeta}, ayServerMetaData:{serverMeta} }}", } ); if (!ReportRenderManager.KeepGoing(job.GId)) return; //prePareData / preRender var ReportDataObject = $"{{ ayReportData:{ReportData}, ayReportMetaData:{reportMeta}, ayClientMetaData:{clientMeta}, ayServerMetaData:{serverMeta} }}"; //case 4209 log.LogDebug( $"ReportData object about to be pre-rendered is:{ReportDataObject}" ); log.LogDebug($"PageLog before render:{PageLog.ToString()}"); log.LogDebug($"Calling ayPreRender..."); await page.WaitForExpressionAsync($"ayPreRender({ReportDataObject})"); log.LogDebug($"ayPreRender completed"); if (!ReportRenderManager.KeepGoing(job.GId)) return; //compile the template log.LogDebug($"Calling Handlebars.compile..."); var compileScript = $"Handlebars.compile(`{report.Template}`)(PreParedReportDataObject);"; if (!ReportRenderManager.KeepGoing(job.GId)) return; var compiledHTML = await page.EvaluateExpressionAsync(compileScript); if (!ReportRenderManager.KeepGoing(job.GId)) return; //render report as HTML log.LogDebug($"Setting render page content style and compiled HTML"); await page.SetContentAsync($"{compiledHTML}"); string outputFileName = StringUtil.ReplaceLastOccurrence(FileUtil.NewRandomFileName, ".", "") + ".pdf"; string outputFullPath = System.IO.Path.Combine( FileUtil.TemporaryFilesFolder, outputFileName ); //Set PDF options //https://pptr.dev/#?product=Puppeteer&version=v5.3.0&show=api-pagepdfoptions log.LogDebug($"Resolving PDF Options from report settings"); var PdfOptions = new PdfOptions() { }; PdfOptions.DisplayHeaderFooter = report.DisplayHeaderFooter; if (report.DisplayHeaderFooter) { var ClientPDFDate = reportRequest.ClientMeta["PDFDate"].Value(); var ClientPDFTime = reportRequest.ClientMeta["PDFTime"].Value(); PdfOptions.HeaderTemplate = report .HeaderTemplate.Replace("PDFDate", ClientPDFDate) .Replace("PDFTime", ClientPDFTime); PdfOptions.FooterTemplate = report .FooterTemplate.Replace("PDFDate", ClientPDFDate) .Replace("PDFTime", ClientPDFTime); } if (report.PaperFormat != ReportPaperFormat.NotSet) { switch (report.PaperFormat) { case ReportPaperFormat.A0: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A0; break; case ReportPaperFormat.A1: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A1; break; case ReportPaperFormat.A2: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A2; break; case ReportPaperFormat.A3: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A3; break; case ReportPaperFormat.A4: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A4; break; case ReportPaperFormat.A5: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A5; break; case ReportPaperFormat.A6: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A6; break; case ReportPaperFormat.Ledger: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.Ledger; break; case ReportPaperFormat.Legal: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.Legal; break; case ReportPaperFormat.Letter: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.Letter; break; case ReportPaperFormat.Tabloid: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.Tabloid; break; } } PdfOptions.Landscape = report.Landscape; if (!string.IsNullOrWhiteSpace(report.MarginOptionsBottom)) PdfOptions.MarginOptions.Bottom = report.MarginOptionsBottom; if (!string.IsNullOrWhiteSpace(report.MarginOptionsLeft)) PdfOptions.MarginOptions.Left = report.MarginOptionsLeft; if (!string.IsNullOrWhiteSpace(report.MarginOptionsRight)) PdfOptions.MarginOptions.Right = report.MarginOptionsRight; if (!string.IsNullOrWhiteSpace(report.MarginOptionsTop)) PdfOptions.MarginOptions.Top = report.MarginOptionsTop; PdfOptions.PreferCSSPageSize = report.PreferCSSPageSize; PdfOptions.PrintBackground = report.PrintBackground; //Defaults to 1. Scale amount must be between 0.1 and 2. PdfOptions.Scale = report.Scale; if (!ReportRenderManager.KeepGoing(job.GId)) return; //render to pdf and return log.LogDebug($"Calling render page contents to PDF"); await page.PdfAsync(outputFullPath, PdfOptions); log.LogDebug($"Closing Page"); await page.CloseAsync(); log.LogDebug($"Closing Browser"); await browser.CloseAsync(); log.LogDebug( $"Render completed successfully, output filename is: {outputFileName}, logging to job for client" ); var json = Newtonsoft.Json.JsonConvert.SerializeObject( new { reportfilename = outputFileName }, Newtonsoft.Json.Formatting.None ); await JobsBiz.LogJobAsync(job.GId, json); await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Completed); return; } catch (ReportRenderTimeOutException) { await HandleTimeOut(job, log, reportRequest, userName); return; } catch (PuppeteerSharp.TargetClosedException) { log.LogDebug( "Caught PuppeteerSharp.TargetClosedException - report was cancelled by user OR timed out" ); //we closed it because the timeout hit and the CoreJobReportRenderEngineProcessCleanup job cleaned it out //so return the error the client expects in this scenario await HandleTimeOut(job, log, reportRequest, userName); return; } catch (Exception ex) { log.LogDebug(ex, $"Error during report rendering"); //This is the error when a helper is used on the template but doesn't exist: //Evaluation failed: d //(it might also mean other things wrong with template) if (PageLog.Length > 0) { log.LogInformation( $"Exception caught while rendering report \"{report.Name}\", report Page console log:" ); log.LogInformation(PageLog.ToString()); var json = Newtonsoft.Json.JsonConvert.SerializeObject( new { rendererror = new { pagelog = PageLog.ToString(), exception = ExceptionUtil.ExtractAllExceptionMessages(ex), }, }, Newtonsoft.Json.Formatting.None ); await JobsBiz.LogJobAsync(job.GId, json); } else { var json = Newtonsoft.Json.JsonConvert.SerializeObject( new { rendererror = new { exception = ExceptionUtil.ExtractAllExceptionMessages(ex), }, }, Newtonsoft.Json.Formatting.None ); await JobsBiz.LogJobAsync(job.GId, json); } await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Failed); // var v=await page.GetContentAsync();//for debugging purposes return; } finally { log.LogDebug($"reached finally block"); if (!page.IsClosed) { log.LogDebug($"Page not closed in finally block, closing now"); await page.CloseAsync(); } if (!browser.IsClosed) { log.LogDebug($"Browser not closed in finally block, closing now"); await browser.CloseAsync(); } log.LogDebug( $"Calling ReportRenderManager.RemoveJob to stop tracking this job/process" ); await ReportRenderManager.RemoveJob(job.GId, log, false); } } static async Task HandleTimeOut( OpsJob job, ILogger log, DataListReportRequest reportRequest, string userName ) { log.LogDebug( $"Report render cancelled by user OR exceeded timeout setting of {ServerBootConfig.AYANOVA_REPORT_RENDERING_TIMEOUT} minutes, report id: {reportRequest.ReportId}, record count:{reportRequest.SelectedRowIds.LongLength}, user:{userName}" ); var json = Newtonsoft.Json.JsonConvert.SerializeObject( new { rendererror = new { timeout = true, timeoutsetting = ServerBootConfig.AYANOVA_REPORT_RENDERING_TIMEOUT, }, }, Newtonsoft.Json.Formatting.None ); //"{\"rendererror\":{\"timeout\":1}}" await JobsBiz.LogJobAsync(job.GId, json); await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Failed); } } public async Task CancelJob(Guid jobId) { await ReportRenderManager.RemoveJob( jobId, AyaNova.Util.ApplicationLogging.CreateLogger("ReportBiz::CancelJob"), true ); } //////////////////////////////////////////////////////////////////////////////////////////////// //JOB / OPERATIONS // public async Task HandleJobAsync(OpsJob job) { switch (job.JobType) { case JobType.RenderReport: await DoRenderJob(job); break; default: throw new System.ArgumentOutOfRangeException( $"ReportBiz.HandleJob-> Invalid job type{job.JobType.ToString()}" ); } } //Other job handlers here... ///////////////////////////////////////////////////////////////////// } //eoc } //eons