Blog posts by Giang Nguyen2021-07-23T08:39:51.0000000Z/blogs/giang-nguyen/Optimizely WorldGet rid of Episerver Forms in-line scripts/blogs/giang-nguyen/dates/2021/7/get-rid-of-episerver-forms-in-line-scripts/2021-07-23T08:39:51.0000000Z<h2>Background</h2>
<p>Inline JavaScript is discouraged to use nowadays due to security concerns and optimization requirements. In fact, inline script is a huge problem if a strict Content Security Policy header has to be applied.</p>
<p>However, Episerver (Optimizely) Forms still rendering inline JS inside HTML document. The script initializes and defines the whole form structure, fields, error messages, JS events etc. Thus, it must be included in the front-end page in order to make forms work.</p>
<h2>Strategy</h2>
<ol>
<li>Creating an MVC controller, which returns correct scripts by each form GUID</li>
<li>Create a custom FormContainerBlock, which doesn't generating inline scripts</li>
<li>Make use of RequiredClientResourceList service to render <script> tag at sufficient place in HTML document </li>
</ol>
<h2>Step 1 - MVC Controllers</h2>
<p>There are two different scripts which Forms depends on:</p>
<ul>
<li>Initialization script: Initalize epi.EPiServer.Forms object with configurations in Forms.config and loads jQuery (if doesn't present on front-end)</li>
<li>"Requisite" script: This holds all information realted to the form, almost everything which has been set by editor in CMS</li>
</ul>
<p>Initialization script be loaded in <head>, after jQuery and before "Requisite".</p>
<h3>Initialize script (easy peasy part):</h3>
<pre class="language-csharp"><code> private Injected<IEPiServerFormsImplementationConfig> _formConfig;
[HttpGet]
public string InitScript()
{
Response.StatusCode = 200;
Response.ContentType = "text/javascript";
return @"var epi = epi ||{ }; epi.EPiServer = epi.EPiServer ||{ }; epi.EPiServer.Forms = epi.EPiServer.Forms ||{ };
epi.EPiServer.Forms.InjectFormOwnJQuery = " + _formConfig.Service.InjectFormOwnJQuery.ToString().ToLowerInvariant() +
@";epi.EPiServer.Forms.OriginalJQuery = typeof jQuery !== 'undefined' ? jQuery : undefined";
}</code></pre>
<h3>Requisite script (lengthy one):</h3>
<p>Some built-in services you may want to know them first: FormResourceService, FormExtensions and LocalizationService (if the site is in multilingual context).</p>
<p>Since this script varies across different forms, therefore, the controller for this requires an ID or GUID parameter to pass in. After that, it's super important that you'll have to validate that the param refers to an actual and published form or not. Hereby, I use GUID because we can check for the validity of the param string before hitting DB.</p>
<pre class="language-csharp"><code> private Injected<FormResourceService> _formResourceService;
private Injected<IContentLoader> _contentLoader;
private Injected<LocalizationService> _localizationService;
[HttpGet()]
public string Requisite(string guid)
{
guid = guid ?? Request.QueryString["guid"];
if (string.IsNullOrEmpty(guid))
{
Response.StatusCode = 404;
return "//invalid";
}
Guid contentGuid;
if (!Guid.TryParseExact(guid, "d", out contentGuid))
{
Response.StatusCode = 404;
return "//invalid ";
}
var formContainerBlock = _contentLoader.Service.Get<IContent>(contentGuid) as FormContainerBlock;
if (formContainerBlock == null)
{
Response.StatusCode = 404;
return "//not found";
}
Response.StatusCode = 200;
Response.ContentType = "text/javascript";
// To-do: Generate script and
return EPiServerForms_prerequisite_js;
}</code></pre>
<p>Firstly, generate script skeleton:</p>
<pre class="language-csharp"><code>var EPiServerForms_prerequisite_js =
EPiServer.Forms.Helpers.Internal.ModuleHelper.GetWebResourceContent(typeof(FormContainerBlockController), "custom/path/to/resource.js");</code></pre>
<p>Secondly, as we know, after execution of requisite script, it will load external CSS or JS, if configured. Therefore, we need to list those resources:</p>
<pre class="language-csharp"><code>// 1st round: Built-in resources
List<string> scripts, css;
_formResourceService.Service.GetFormExternalResources(out scripts, out css);
// 2nd round: Extra (and configurable) resources
List<string> elementExtraScripts, elementExtraCss;
_formResourceService.Service.GetFormElementExtraResources(formContainerBlock, out elementExtraScripts, out elementExtraCss);
scripts.AddRange(elementExtraScripts);
css.AddRange(elementExtraCss);</code></pre>
<p>Thirdly, acquire script and common messages:</p>
<pre class="language-csharp"><code>var formLanguage = FormsExtensions.GetCurrentFormLanguage(formContainerBlock);
var currentPageLanguage = FormsExtensions.GetCurrentPageLanguage();
var commonMessagesObj = new
{
viewMode = new
{
malformStepConfiguration = _localizationService.Service.GetString("/episerver/forms/viewmode/malformstepconfigruation"),
commonValidationFail = _localizationService.Service.GetString("/episerver/forms/viewmode/commonvalidationfail"),
},
fileUpload = new
{
overFileSize = _localizationService.Service.GetString("/episerver/forms/messages/fileupload/overFileSize"),
invalidFileType = _localizationService.Service.GetString("/episerver/forms/messages/fileupload/invalidfiletype"),
postedFile = _localizationService.Service.GetString("/episerver/forms/messages/fileupload/postedfile")
}
};
var commonMessages = commonMessagesObj.ToJson();</code></pre>
<p>Finally, replace "placeholders" in EPiServerForms_prerequisite_js at 1st step with acquired data (sorry in advance for the poor optimization):</p>
<pre class="language-csharp"><code>EPiServerForms_prerequisite_js = EPiServerForms_prerequisite_js
.Replace("___CurrentPageLink___", FormsExtensions.GetCurrentPageLink().ToString())
.Replace("___CurrentPageLanguage___", currentPageLanguage)
.Replace("___CurrentFormLanguage___", string.IsNullOrWhiteSpace(formLanguage) ? currentPageLanguage : formLanguage)
.Replace("___ExternalScriptSources___", scripts?.ToJson())
.Replace("___ExternalCssSources___", css?.ToJson())
.Replace("___UploadExtensionBlackList___", _formConfig.Service.DefaultUploadExtensionBlackList)
.Replace("___Messages___", commonMessages)
.Replace("___LocalizedResources___", FormsExtensions.GetLocalizedResources().ToJson());</code></pre>
<p>So, the string is ready to be returned.</p>
<h3>Retouch</h3>
<p>Create a custom path to make the scripts are devlivered in *.JS URL format.</p>
<pre class="language-csharp"><code> [InitializableModule]
[ModuleDependency(typeof(EPiServer.Forms.InitializationModule))]
public class RemoveInlineScriptInitialization : IInitializableModule
{
public void Initialize(InitializationEngine context)
{
var initRouteData = new RouteValueDictionary();
initRouteData.Add("Controller", "FormBlockScript");
initRouteData.Add("Action", "InitScript");
RouteTable.Routes.Add("FormBlockOriginalJqueryRoute",
new Route("custom/path/to/form-initialization.js", initRouteData, new MvcRouteHandler()) { RouteExistingFiles = false });
var requisiteRouteData = new RouteValueDictionary();
requisiteRouteData.Add("Controller", "FormBlockScript");
requisiteRouteData.Add("Action", "Requisite");
RouteTable.Routes.Add("FormBlockRequisiteScriptGuidRoute",
new Route("custom/path/to/form-requisite-{guid}.js", requisiteRouteData, new MvcRouteHandler()) { RouteExistingFiles = false });
}
public void Uninitialize(InitializationEngine context)
{
//Do nothing :)
}
}</code></pre>
<p>Why don't just editing Global.asax? Well, init module may be handly next step.</p>
<h2>Step 2 - Custom FormContainerBlock</h2>
<p>Firstly, create a custom form block, inherits the built-in one (why not!).</p>
<pre class="language-csharp"><code> // The model
[ContentType(GroupName = "FormsContainerElements", Order = 1000, DisplayName = "CSP-safe Form Block")]
public class NoInlineScriptFormContainerBlock : FormContainerBlock
{
// We don't expect any difference in edit mode
}
// The controller
[TemplateDescriptor(AvailableWithoutTag = true,
Default = true,
ModelType = typeof(NoInlineScriptFormContainerBlock),
TemplateTypeCategory = TemplateTypeCategories.MvcPartialController)]
public class NoInlineScriptFormContainerBlockController : FormContainerBlockController
{
public static readonly string INIT_SCRIPT = "/custom/path/to/form-initialization.js";
public static readonly string REQUISITE_SCRIPT_TEMPLATE = "/custom/path/to/form-requisite-{0}.js";
Injected<IEPiServerFormsImplementationConfig> _formConfig;
public string REQUISITE_SCRIPT { get; private set; } // Read-only
public override ActionResult Index(FormContainerBlock currentBlock)
{
REQUISITE_SCRIPT = string.Format(REQUISITE_SCRIPT_TEMPLATE, currentBlock.Content.ContentGuid.ToString("D"));
return base.Index(currentBlock);
}
// To-do: Force the controller to load custom JS - not the ugly inline
}</code></pre>
<h3>Retouch</h3>
<p>We can block editor to use the default Form block, so, every form won't create inline scripts anymore. We can make use of the new init module have created recently:</p>
<pre class="language-csharp"><code>var contentTypeRepository = ServiceLocator.Current.GetInstance<IContentTypeRepository>();
var defaultFormBlock = contentTypeRepository.Load<FormContainerBlock>();
if (defaultFormBlock != null && defaultFormBlock.IsAvailable)
{
var clone = defaultFormBlock.CreateWritableClone() as ContentType;
clone.IsAvailable = false;
contentTypeRepository.Save(clone);
}</code></pre>
<h2>Step 3 - Load the JS files</h2>
<p>Override the FormContainerBlockController.RegisterScriptResources, then load JS <script> tag into sufficient position in HTML doc using RequiredResourceList service:</p>
<pre class="language-csharp"><code> private Injected<IEPiServerFormsImplementationConfig> _formConfig;
public override void RegisterScriptResources(FormContainerBlock formContainerBlock)
{
var requiredResources = ServiceLocator.Current.GetInstance<IRequiredClientResourceList>();
// Add Init script
requiredResources.RequireScript(INIT_SCRIPT, ConstantsForms.StaticResource.JS.EPiServerFormsSaveOriginaljQuery, new List<string> { }).AtHeader();
requiredResources.RequireScript(REQUISITE_SCRIPT, ConstantsForms.StaticResource.JS.EPiServerFormsPrerequisite, new List<string> { ConstantsForms.StaticResource.JS.FormsjQuery }).AtHeader();
// Add form-own jQuery (by configuration)
if (_formConfig.Service.InjectFormOwnJQuery)
{
requiredResources.RequireScript(
ModuleHelper.GetWebResourceUrl(typeof(FormContainerBlockController), ConstantsForms.StaticResource.JS.FormsjQueryPath),
ConstantsForms.StaticResource.JS.FormsjQuery, new List<string> { ConstantsForms.StaticResource.JS.EPiServerFormsSaveOriginaljQuery }).AtHeader();
}
// Add requisite script
requiredResources.RequireScript(
ModuleHelper.GetWebResourceUrl(typeof(FormContainerBlockController), ConstantsForms.StaticResource.JS.EPiServerFormsMinifyPath), ConstantsForms.StaticResource.JS.EPiServerForms,
new List<string> { ConstantsForms.StaticResource.JS.FormsjQuery, ConstantsForms.StaticResource.JS.EPiServerFormsRerequisite }).AtFooter();
}</code></pre>
<h2>Afterwords</h2>
<p>This may not well-optimized, and not tested throughly. Please use it at your own risk.<br />However, I think this is a quite easy and possible way if you're striggling when setting up CSP header or trying to pass a regular penetration test.</p>Localize Forms post-submission actors/blogs/giang-nguyen/dates/2021/2/localize-forms-post-submission-actors/2021-02-23T04:51:41.0000000Z<p>The "Send email after submission" actor for Episerver Forms are not marked as LanguageSpecific. That's a real struggle for editors when they want to customize the email response for each language version.<br />I'm not sure about the background why this is not translateable by default. </p>
<p>However, there are a few workarounds for this trouble.</p>
<h2>Use the "Edit propery" function in CMS Admin</h2>
<p><img src="/link/767022e9af7147f887f62a15f0d579e1.aspx" width="486" height="405" /></p>
<h3>Pros:</h3>
<ul>
<li>Easy ans effortless </li>
</ul>
<h3>Cons:</h3>
<ul>
<li>Not safe</li>
<li>The setting might revert back to default after IIS recycle, nuget update or code deployment</li>
<li><strong>The translated data will be lost (permanently!) if the property reverts back to default</strong></li>
</ul>
<h2>Programmatically update the property attribute</h2>
<p>We can create an initialization module that executes after Forms finishes its init tasks.</p>
<pre class="language-csharp"><code> [InitializableModule]
[ModuleDependency(typeof(EPiServer.Forms.EditView.InitializationModule))] // Should initialize AFTER Epi Forms
public class FormsLanguageSpecific : IInitializableModule
{
Injected<IContentTypeRepository> _contentTypeRepository;
Injected<IPropertyDefinitionRepository> _propertyDefinitionRepository;
public void Initialize(InitializationEngine context)
{
var containerTypeList = typeof(IFormContainerBlock).GetDerivedTypes();
foreach (var containerType in containerTypeList)
{
var formContainerContentType = _contentTypeRepository.Service.Load(containerType);
if (formContainerContentType == null)
{
continue;
}
var actors = formContainerContentType.PropertyDefinitions
.Where(p => p.Type!=null && p.Type.TypeName != null
&& p.Type.TypeName.Equals("EPiServer.Forms.EditView.SpecializedProperties.PropertyEmailTemplateActorList"));
foreach (var actor in actors) // Iterates through all actors (incl. custom actors)
{
var clone = actor.CreateWritableClone();
clone.LanguageSpecific = true;
_propertyDefinitionRepository.Service.Save(clone);
}
}
}
}</code></pre>
<h3>Pros:</h3>
<ul>
<li>Don't need to worry about many msitake when using the Admin UI</li>
<li>It permanently changes the property (almost)</li>
<li>Personally, I haven't seen any issue after updating/deploying</li>
</ul>
<h3>Cons:</h3>
<ul>
<li>The site will fail to start in case of internal Forms code refactors</li>
</ul>Sending emails with ICS invitation using Episerver Forms/blogs/giang-nguyen/dates/2021/2/sending-emails-with-ics-invitation-using-episerver-forms/2021-02-18T11:22:50.0000000Z<p>This article describes a simple way to create an Episerver.Forms-based element that sends out ICS invitation.</p>
<p>If an email has a correct iCalendar structure (RFC 5545), major mail clients would display the email as an event and also add it to user's calendar automatically.<br />I will go with a simple and plain way to create a form, using all Episerver's built-in functionalities.</p>
<p><img src="/link/850bf0e0056e45ab854f6c344d5b3fa5.aspx" width="775" height="272" /></p>
<h2>Create a custom FormContainerBlock</h2>
<p>Pretty easy, just create a Block that extends FormContainerBlock</p>
<pre class="language-csharp"><code> [ContentType(DisplayName = "Form for Inviation", GUID = "xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", Description = "")]
public class FormInvitationBlock : FormContainerBlock
{
// To-do: Custom properties go here
}</code></pre>
<p>You'll need some field to hold neccessary fields. It depends on your need and iCalendar specification: <a href="https://icalendar.org/RFC-Specifications/iCalendar-RFC-5545/">https://icalendar.org/RFC-Specifications/iCalendar-RFC-5545/ </a>. For example:</p>
<ul>
<li>Title</li>
<li>Description</li>
<li>Location</li>
<li>Organizer</li>
<li>Start/End date time</li>
<li>(Optional) Alert settings</li>
</ul>
<p><img src="/link/aa53570600784efab32134e70fdb9c79.aspx" width="408" height="367" /></p>
<p>Other considerations:</p>
<ul>
<li>Validation, i.e. End time should be greater (aka after) Start time</li>
<li>Which field would be CultureSpecific</li>
<li>Timezone</li>
<li>Groupping fields into a separated tab</li>
</ul>
<h2>Create a custom Forms Actor</h2>
<p>It's good to extend and start with the default SendEmailAfterSubmissionActor, so we don't need to care much about its UI.<br />The idea is to override the default Run() method and use dotnet SmtpClient to send out emails.</p>
<pre class="language-csharp"><code> public class FormInvitationEmailActor : SendEmailAfterSubmissionActor, IUIPropertyCustomCollection
{
private static SmtpClient _smtpClient = new SmtpClient();
private readonly PlaceHolderService _placeHolderService = new PlaceHolderService();
private readonly Injected<IFormRepository> _formRepository;
public override object Run(object input)
{
IEnumerable<EmailTemplateActorModel> model = this.Model as IEnumerable<EmailTemplateActorModel>;
if (model == null || model.Count<EmailTemplateActorModel>() < 1)
return null;
foreach (EmailTemplateActorModel emailConfig in model)
SendInvitation(emailConfig);
return (object)null;
}
}</code></pre>
<p>The usage of PlaceHolderService is optional.</p>
<p><strong>Firstly</strong>, construct a MailMessage:</p>
<pre class="language-csharp"><code> try
{
IEnumerable<FriendlyNameInfo> friendlyNameInfos =
this._formRepository.Service.GetFriendlyNameInfos(this.FormIdentity, typeof(IExcludeInSubmission));
PlaceHolderService placeHolderService = new PlaceHolderService();
// Construct MailMessage
MailMessage message = new MailMessage();
message.Subject = emailConfig.Subject;
message.Body = "";
// From email
if (!string.IsNullOrEmpty(emailConfig.FromEmail))
{
MailMessage mailMessage = message;
placeHolderService = this._placeHolderService;
MailAddress mailAddress = new MailAddress(emailConfig.FromEmail);
mailMessage.From = mailAddress;
}
// To emails
var toEmails = emailConfig.ToEmails.SplitBySeparators(new string[] { "," });
foreach (string toEmail in toEmails)
{
MailAddressCollection to = message.To;
MailAddress mailAddress =
new MailAddress(placeHolderService.Replace(template, GetBodyPlaceHolders(friendlyNameInfos), false));
to.Add(mailAddress);
}
// Add ICalendar data to message
var icsFormBlock = this.FormIdentity.GetFormBlock() as FormInvitationBlock;
if (icsFormBlock != null)
{
GenerateInvitation(icsFormBlock, ref message);
}
// Send message
_smtpClient.Send(message);
message.Dispose();
}
catch (Exception ex)
{
_logger.Error("Failed to send e-mail: {0}", ex);
}</code></pre>
<p><strong>Secondly</strong>, create and attach invitation to outgoing email</p>
<p>All we need now is construct a string that follows iCalendar structure.<br />The invitation email needs an attachment with MIME type of text/calendar and an alternative view:</p>
<pre class="language-csharp"><code>System.Net.Mime.ContentType ct = new System.Net.Mime.ContentType("text/calendar");
ct.Parameters.Add("method", "REQUEST");
AlternateView avCal = AlternateView.CreateAlternateViewFromString(iCalendarString, ct);
message.AlternateViews.Add(avCal);</code></pre>
<h2>That's all, folks!</h2>
<p>Time for testing! Let's create a simple form with type of FormInvitationBlock, for example:</p>
<p><img src="/link/d1f35b7c73034afebde5c20330bc6c26.aspx" width="287" height="386" /></p>
<p>We now have a separated email actor just to attach the invitation:</p>
<p><img src="/link/d755dd28fd0f4c558cd27c7cf6d82acd.aspx" width="947" height="133" /></p>
<p>Don't forget to configure SMTP!</p>
<pre class="language-markup"><code> <system.net>
<mailSettings>
<smtp deliveryMethod="Network" deliveryFormat="SevenBit" from="foo@example.com">
<network host="smtp.example.com" port="587" userName="foo@example.com" password="********" enableSsl="true" />
</smtp>
</mailSettings>
</system.net></code></pre>
<p>On localhost, you can use deliveryMethod="SpecifiedPickupDirectory" with config <specifiedPickupDirectory pickupDirectoryLocation="C:\your\custom\path" /> instead of <network>.</p>
<p>The result, on Gmail:</p>
<p><img src="/link/204bfa1ba0844183991e933875e6fbf9.aspx" width="711" height="160" /></p>Media files tagging, post-analyzing for your better search function/blogs/giang-nguyen/dates/2021/2/azure-cognitive-for-better-media-files-analizing-and-searching/2021-02-04T09:56:21.0000000Z<h1>Why?</h1>
<p>I guess, most developers who use Episerver Find did run into HTTP 413 when indexing media files. If you have a closer look into indexed documents, there's a "SearchAttchment" field which consists of Base-64 content of the whole file. Somewhere under the hood, that Base-64 string will be processed to make search queries more efficient, aka more relevant items.<br />It's quite "extra" work since it doesn't sound good for high-res media, or, merely a huge PDF doc.</p>
<h3>Azure CV API</h3>
<p>Computer Vision API is a part of Azure Cognitive Services. The most important, for most devs, it offers free tier with a generous amount of 5,000 request/mo. (<a href="https://azure.microsoft.com/en-us/pricing/details/cognitive-services/computer-vision/">See pricing</a>)<br />Integration is down to earth easy since all transactions are made through HTTP.</p>
<h1>How?</h1>
<h2>1. Create Azure Portal account and resource</h2>
<p>You need an Azure account. From Home page, click "<strong>Create a resource</strong>" > Search for "<strong>Computer Vision</strong>" > Follow steps (if you don't have any resource group, create one, it won't bite)</p>
<p><img src="/link/b2cc5b38eebc49b28583c14d49d1c0f8.aspx" width="992" height="722" /></p>
<p>After creation, grab your API credentials.</p>
<p><img src="/link/c5fe043bc95541e0a0476faf13cfd342.aspx" width="1166" height="720" /></p>
<h2>2. Say hi to the service</h2>
<p>MSFT provides a great test page: <a href="https://westcentralus.dev.cognitive.microsoft.com/docs/services/computer-vision-v3-ga/operations/56f91f2e778daf14a499f21b">https://westcentralus.dev.cognitive.microsoft.com/docs/services/computer-vision-v3-ga/operations/56f91f2e778daf14a499f21b</a> <br />You can play around to get to know what's required to do, options, etc to make use of Azure CV.</p>
<h2>3. Time to integrate with CMS</h2>
<p>I'll demonstrate with the sample Alloy CMS site, MVC scheme. Hereby, I'll only give a decent integration for tagging and describing ImageData.</p>
<h3>Find a good place to store API credential</h3>
<p>For example, you can store configs in web.config <appSettings> section. It will be super easy to maintain and configure when deploy to hosting, like DXC.</p>
<pre class="language-markup"><code><configuration>
<appSettings>
<add key="COMPUTER_VISION_SUBSCRIPTION_KEY" value="*******" />
<add key="COMPUTER_VISION_ENDPOINT" value="https://******.cognitiveservices.azure.com/" />
</appSettings></code></pre>
<h3>Set-ups</h3>
<p>Create a new GroupDefinition to make our editor UI neat.</p>
<pre class="language-csharp"><code>// Add to Global.GroupNames
[Display(Order = 10)]
public const string CvImageData = "Computer Vision Data";</code></pre>
<p>Add new field to the ImageData model.</p>
<pre class="language-csharp"><code>// Models.Media.ImageFile
[Display(
Name = "Analyzed",
Description = "Uncheck this to force analyzing again",
GroupName = Global.GroupNames.CvImageData,
Order = 10)]
public virtual bool CvIsAnalyzed { get; set; }
[Display(
Name = "Tags",
Description = "",
GroupName = Global.GroupNames.CvImageData,
Order = 20)]
public virtual string CvTags { get; set; }
[Display(
Name = "Describe",
Description = "Auto-generated description of this image",
GroupName = Global.GroupNames.CvImageData,
Order = 30)]
[UIHint(UIHint.Textarea)]
public virtual string CvDescribe { get; set; }
[Display(
Name = "Accent color",
Description = "The dominant hue in the image",
GroupName = Global.GroupNames.CvImageData,
Order = 40)]
public virtual string CvAccentColor { get; set; }
[Display(
Name = "Raw data",
Description = "Response from Cognitive Service",
GroupName = Global.GroupNames.CvImageData,
Order = 100)]
[ReadOnly(true)]
[UIHint(UIHint.Textarea)]
public virtual string CvData { get; set; }</code></pre>
<h3>Integrate</h3>
<p>My plan is to use ContentEvents to analyze images by sending it to Azure CV service. To prevent excessive usage, the field CvIsAnalyzed controls if an image needs to be analyzed or not.</p>
<p>First of all, construct a new InitializationModule:</p>
<pre class="language-csharp"><code> [InitializableModule]
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class ImageCognitiveInitializationModule : IInitializableModule
{
private static Injected<IContentEvents> _contentSvc;
private static Injected<IContentRepository> _repoSvc;
private static Injected<ILogger> _logSvc;
// Load configurations from web.config
static string subscriptionKey = ConfigurationManager.AppSettings["COMPUTER_VISION_SUBSCRIPTION_KEY"];
static string endpoint = ConfigurationManager.AppSettings["COMPUTER_VISION_ENDPOINT"];
public void Initialize(InitializationEngine context)
{
var events = _contentSvc.Service;
events.SavedContent += Events_CreatedContent;
events.PublishedContent += Events_CreatedContent;
}
private void Events_CreatedContent(object sender, EPiServer.ContentEventArgs e)
{
// Todo: Analyze and update ImageData
}
}</code></pre>
<h3>Analyze image with Azure CV API</h3>
<p>The service requires you to POST an HttpRequest to URI [api_endpoint]/vision/v3.1/analyze with a bearer token in header "Ocp-Apim-Subscription-Key". The POST content is an octet-stream.<br />If you're on an non-DXC environment or using an external storage/CDN, make sure the server allows bufferring and chunking requests.</p>
<p>This is good enough for the request:</p>
<pre class="language-csharp"><code> string uriBase = "vision/v3.1/analyze"; // Prevent magic string
HttpClient client = new HttpClient();
client.BaseAddress = new Uri(new Uri(endpoint), uriBase);
client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", subscriptionKey);
HttpResponseMessage response;
byte[] byteData = GetImageAsByteArray(img);
using (ByteArrayContent content = new ByteArrayContent(byteData))
{
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
// Make it synchronize, or fire-and-forget approach goes here
response = client.PostAsync("?visualFeatures=Description,Tags,Color&language=en", content).GetAwaiter().GetResult();
}
string contentString = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();</code></pre>
<p>With default implementation (local files in ~\App_Data\blob or Azure Blobs), it's pretty easy to get a byte array of the image:</p>
<pre class="language-csharp"><code> private byte[] GetImageAsByteArray(ImageFile img)
{
using (Stream responseStream = img.BinaryData.OpenRead())
{
MemoryStream ms = new MemoryStream();
responseStream.CopyTo(ms);
ms.Seek(0, SeekOrigin.Begin);
BinaryReader binaryReader = new BinaryReader(ms);
return binaryReader.ReadBytes((int)ms.Length);
}
}</code></pre>
<p>All you need is deserialize the JSON in contentString variable and put it in the custom fields of your model. Don't forget:</p>
<ul>
<li>Make CvIsAnalyzed = true</li>
<li>Use Patch to prevent creation of a nonsense content version <br />_repoSvc.Service.Save(img, <strong>SaveAction.Patch</strong>, AccessLevel.NoAccess);</li>
<li>Do try-catch aggresively when reading info from JSON string</li>
</ul>
<h3>Polishing</h3>
<p>You'll need to control the pre-condition if an HttpRequest to Azure is needed. Or else, it cost you money.</p>
<pre class="language-csharp"><code> var img = e.Content as ImageFile;
if (img == null || img.CvIsAnalyzed) return;</code></pre>
<p>It may cause issue if you have a complex roles and restrictions, depends on your requirements, you can do this so everyone could re-analyze the image:</p>
<pre class="language-csharp"><code>PrincipalInfo.CurrentPrincipal = new GenericPrincipal(new GenericIdentity("[something meaningful so you would know it's your automated task]"),
new[] { "Administrators" });</code></pre>
<h3>Checking up</h3>
<p>Upload an image:</p>
<p><img src="/link/34d31ede2ddc449083c6d7b9cb5ee0f7.aspx" width="1192" alt="Image image3.png" height="430" /></p>
<p>Then check up the analyzed data. Voilà!</p>
<p><img src="/link/73b2f12e09aa46498b886a6a4fe91c5f.aspx" width="1167" height="722" /></p>
<h1>So what?</h1>
<p>Use the analyzed data for more accurate search!<br />Imagine, you're creating a website that sells stock photos. The search function will be awesome with tagged and well-analyzed image data.</p>
<p>Feel free to get rid of "SearchAttachment" field, good bye HTTP 413 when indexing media files!</p>
<pre class="language-csharp"><code> public class FilterConfig : IInitializableModule
{
private Injected<IClient> findClientSvc;
public void Initialize(InitializationEngine context)
{
findClientSvc.Service
.Conventions
.ForInstancesOf<EPiServer.Core.ImageData>()
.ExcludeField(x => x.SearchAttachment())
.ExcludeField(x => x.SearchAttachmentText());
}
}</code></pre>
<p>You can do the same thing to analyze PDFs, MS Office document with the same approach.</p>
<h1>Afterwords</h1>
<p>It's mere an example and demonstration, and obviously this is out-of-the-box implementation. Please use it on your own risk if you're planning to add those code below to your site. The content is made up by machines, so, it sometimes might be inappropriate for your site (look closely to the previous image 😅).</p>
<p>Plus, you may have a different idea than mine. If so, please feel free leave a comment below, sharing is caring :)</p>Problematic serialization of EPiServer.Url class in CMS 11/blogs/giang-nguyen/dates/2019/11/serializing-episerver-url-object-in-cms-11/2019-11-07T11:54:55.0000000Z<h2>What happened?</h2>
<p>Recently, I found out some problem with EPiServer.Url objects, mostly because of its serialization.</p>
<p>Let's start with a demonstration like this:</p>
<pre class="language-csharp"><code>// The page model
[ContentType GUID="..."]
public class MyPage : PageData
{
[EditorDescriptor(EditorDescriptorType = typeof(CollectionEditorDescriptor<MyType>))]
public virtual IList<MyType> MyTypeList { get; set; }
}
// The MyType class
[Serializable]
public class MyType
{
public string WorksNormal { get; set; }
public EPiServer.Url WontWork { get; set; }
}
// The definition so it won't cause problem with the editor
[PropertyDefinitionTypePlugIn]
public class DecisionItemListProperty : PropertyList<MyType>
{
}</code></pre>
<p>The innocent-look code above seems to be fine and quite simple to most developer. However, there are 2 dreadful error happen in CMS 11 (while it's totally fine with version 10)!</p>
<p>Firstly, the CMS editor's behavior becomes strange when I tried to add MyType objects to the list. Everything is okay in the editor from the popup, workflow, UI. Surprisingly, after saving the page, regardless it's published or saved as draft, every data of MyType.WontWork is gone while everything is saved as expected. <em>No warning or error or stack trace in log file!</em></p>
<p>Secondly, Find failed indexing the page, every traces point to MyType.WontWork object. I doubt that Newtonsoft.Json seems unable to serialize the Url class (it works okay if [JsonIgnore] is present).</p>
<h2>How come?</h2>
<p>What I found regards to this is here <a href="/link/2161e8ff2ba94928a6b9f35741c8d035.aspx">https://world.episerver.com/features/episerver-features---november-2017/</a>, in <em>PropertyList<T> improvements (Beta removal) </em>section. However, thing there point out directly if the Url class is affected or not, no warn or suggestion for code changes.</p>
<p>I am also awaiting a comment here to help me figure out the root cause of this.</p>
<h2>How to fix?</h2>
<p>Easy peasy. Just add some annotation to the Url property, so it will look like this:</p>
<pre class="language-csharp"><code>[JsonProperty]
[JsonConverter(typeof(UrlConverter))]
public EPiServer.Url WontWork { get; set; }</code></pre>
<p>Just that! It would be fine.</p>