November Happy Hour will be moved to Thursday December 5th.

Quan Tran
Jan 14, 2019
  11385
(7 votes)

EPiServer.Forms storing upload file in HttpSession

Few months ago I got a Forms support case from a customer. They had a concern for security perspective as well as GDPR perspective. The question is "Is it possible to avoid saving files that are uploaded using the fileupload element and attach them to the e-mail instead of uploading to the the medie library and linking to them in the e-mail ?The customer did not want to upload the files to the media library because malicious files could find their way in, and also because the file that is uploaded is very important so it needs to be attached to the e-mail as a file attachment.

Short answer is: Yes it's totally possible.

In order to avoid uploading files to media library and send attachment files instead of sending file links we have to override a couple of classes and methods.

  • The idea is that, we will save the uploaded files in HttpSession and later on when sending email in actor, we will get it back and attach them to the e-mail then remove it from HttpSession. But by doing so, we'll encounter some downsides regarding to Performance issue . Because by default, SendEmailAfterSubmissionActor runs asynchronously after form submission therefore the HttpContext.Current will be lost and we can not retrieve file from HttpSession. Thus, SendEmailAfterSubmissionActor must be run synchronously (will be overridden) to get HttpContext.Current . Storing files in HttpSession will cause performance penalties if thousands of users uploading large files at the same time .Furthermore, if file size is too large so it will not be able to attach to Email.
  • The approach above it works but not very well. Fortunately, we can implement in another way. We can upload file to third party (like GoogleDriver for example) and get it back then attach to Email.

For the sake of simplicity, I'm going to implement the first approach. The steps are as below:

1. Create a class to override InsertPostedFilesToSubmittedData method in DataSubmissionService. The first argument elementFiles contains uploaded file in HttpPostedFileBase. From there, we'll save file to HttpSession with a key. That key will be saved into HttpContext and used later on when sending mail.

/// <summary>
/// Model for storing upload file in session
/// </summary>
public class SessionStoreFile
{
    public string FileName { get; set; }
    public string Extension { get; set; }
    public byte[] Data { get; set; }
}
/// <summary>
/// Helper class for handling file
/// </summary>
public class FileHelper
{
    /// <summary>
    /// Get binary data from <see cref="HttpPostedFileBase"/>
    /// </summary>
    /// <param name="file"></param>
    /// <returns></returns>
    public static byte[] GetBlobFromPostedFile(HttpPostedFileBase file)
    {
        byte[] blob;
        using (BinaryReader reader = new BinaryReader(file.InputStream))
        {
            blob = reader.ReadBytes((Int32)file.InputStream.Length);
        }

        return blob;
    }
}
public class CustomFormsDataSubmissionService : DataSubmissionService
{
    protected override void InsertPostedFilesToSubmittedData(IEnumerable<Tuple<string, ContentReference, HttpPostedFileBase>> elementFiles, HttpContextBase httpContext, ref Dictionary<string, object> submittedData)
    {

        // IDEA : Store file in HttpSession and get it back when sending mail.AFter sending mail, remove it from session
        // If you don't want to store in Session, you can upload it to third party like GoogleDrive

        // if there's no upload file, do nothing
        if (elementFiles == null || elementFiles.Count() == 0)
        {
            return;
        }

        // using DateTime.Now.Ticks as ID of each Post
        var postId = DateTime.Now.Ticks;
        var sessionStoreFiles = new List<SessionStoreFile>();

        foreach (var item in elementFiles)
        {
            var postedFileBase = item.Item3;
            if (postedFileBase == null)
                continue;

            sessionStoreFiles.Add(new SessionStoreFile
            {
                FileName = postedFileBase.FileName,
                Extension = Path.GetExtension(postedFileBase.FileName),
                Data = FileHelper.GetBlobFromPostedFile(postedFileBase)
            });
        }

        if (sessionStoreFiles.Count == 0)
        {
            return;
        }

        var fileStoreKey = string.Format("_EpiFormUploadFile_{0}", postId);

        // save file data to Session
        var session = System.Web.HttpContext.Current.Session;
        session[fileStoreKey] = sessionStoreFiles;

        // save the file store key into HttpContext. This will be used in email actor later on.
        httpContext.Items.Add("__EpiFormUploadFile_Session_StoreKey", fileStoreKey);
    }
}

2. Create a class name SendEmailAfterSubmissionActor to override Run method of EPiServer.Forms.Implementation.Actors.SendEmailAfterSubmissionActor. Note that for some reasons, the new overridden class name must be exact SendEmailAfterSubmissionActor. Here, we will get uploaded files from HttpSession and attach it in the email then remove them from HttpSession.

using EPiServer.Forms.Implementation.Actors;
using System;
using System.Linq;
using System.Web;
using EPiServer.Logging;
using EPiServer.Forms.Core.Internal;
using EPiServer.ServiceLocation;
using EPiServer.Forms.Helpers.Internal;
using System.Text.RegularExpressions;
using System.Net.Mail;
using System.Collections.Generic;
using System.IO;
using FormsFileSession.Customization;

/// <summary>
/// Custom actor for sending email.
/// </summary>
public class SendEmailAfterSubmissionActor : EPiServer.Forms.Implementation.Actors.SendEmailAfterSubmissionActor
{
    private readonly Injected<PlaceHolderService> _placeHolderService;
    private bool _sendMessageInHTMLFormat = false;
    private static SmtpClient _smtpClient = new SmtpClient();

    /// <summary>
    /// By default, this Actor run asynchronously after forms submission
    /// In order to get file data from Session, we have to set this to FALSE so it will run synchronously right after form submission
    /// NOTE: This will increase submission time 
    /// </summary>
    public override bool IsSyncedWithSubmissionProcess { get { return true; } }

    public SendEmailAfterSubmissionActor()
    {
        _sendMessageInHTMLFormat = _formConfig.Service.SendMessageInHTMLFormat;
    }

    public override object Run(object input)
    {
        var emailConfigurationCollection = Model as IEnumerable<EmailTemplateActorModel>;
        if (emailConfigurationCollection == null || emailConfigurationCollection.Count() < 1)
        {
            _logger.Debug("There is no emailConfigurationCollection for this actor to work");
            return null;
        }

        _logger.Information("Start sending email with {0} configuration entries in emailConfigurationCollection", emailConfigurationCollection.Count());
        foreach (var emailConfiguration in emailConfigurationCollection)
        {
            SendMessage(emailConfiguration);
        }

        return null;
    }

    private void SendMessage(EmailTemplateActorModel emailConfig)
    {
        var toEmails = emailConfig.ToEmails;
        if (string.IsNullOrEmpty(toEmails))
        {
            _logger.Debug("There is no ToEmails to send. Skip.");
            return;
        }

        // replace placeholders with their value
        var bodyPlaceHolders = GetBodyPlaceHolders(null);
        var emailAddressToSend = _placeHolderService.Service.Replace(toEmails, bodyPlaceHolders, false);

        // split To, get multiple email addresses and send email to each mail address later
        var toEmailCollection = emailAddressToSend.SplitBySeparators(new[] { ",", ";", Environment.NewLine });
        if (toEmailCollection == null || toEmailCollection.Count() == 0)
        {
            _logger.Debug("There is no ToEmails to send. Skip.");
            return;
        }

        try
        {
            var subjectPlaceHolders = GetSubjectPlaceHolders(null);
            var subject = _placeHolderService.Service.Replace(emailConfig.Subject, subjectPlaceHolders, false);

            //replace line breaks by spaces in subject
            var regexLineBreak = new Regex("(\r\n|\r|\n)");
            subject = regexLineBreak.Replace(subject, " ");

            // because the subject cannot display as HTML, we need to decode it
            subject = HttpUtility.HtmlDecode(subject);

            var bodyHtmlString = emailConfig.Body == null ? string.Empty : _formBusinessService.Service.ToHtmlStringWithFriendlyUrls(emailConfig.Body);

            // body is inputted via tinyEMC will be saved as a HTML-encoded string, we should decode it before replacing with placeholders
            var decodedBody = HttpUtility.HtmlDecode(bodyHtmlString);
            var body = _placeHolderService.Service.Replace(decodedBody, bodyPlaceHolders, false);

            var message = new MailMessage();
            message.Subject = subject;
            message.Body = RewriteUrls(body);
            message.IsBodyHtml = _sendMessageInHTMLFormat;

            #region ATTACHMENT_FILES
            ////////////////////////// ATTACHMENT ///////////////////////////

            // get session store key of upload file
            var httpContext = new System.Web.HttpContextWrapper(System.Web.HttpContext.Current) as HttpContextBase;

            if (httpContext.Items.Contains("__EpiFormUploadFile_Session_StoreKey"))
            {
                var fileStoreKey = (string)httpContext.Items["__EpiFormUploadFile_Session_StoreKey"];
                var httpSession = System.Web.HttpContext.Current.Session; // this.HttpRequestContext.RequestContext.HttpContext.Session;
                if (httpSession != null)
                {
                    var files = (List<SessionStoreFile>)httpSession[fileStoreKey];
                    if (files != null && files.Count > 0)
                    {
                        foreach (var file in files)
                        {
                            message.Attachments.Add(new Attachment(new MemoryStream(file.Data), file.FileName));
                        }
                    }

                    // remove file in session
                    httpSession[fileStoreKey] = null;
                    httpSession.Remove(fileStoreKey);
                }
            }

            /////////////////////////// END ATTACHMENT /////////////////////////
            #endregion

            if (!string.IsNullOrEmpty(emailConfig.FromEmail))
            {
                var emailAddressFrom = _placeHolderService.Service.Replace(emailConfig.FromEmail, subjectPlaceHolders, false);
                message.From = new MailAddress(emailAddressFrom);
            }

            foreach (var emailAddress in toEmailCollection)
            {
                try
                {
                    // email from the EndUser cannot be trust, wrong email format can not be added.
                    message.To.Add(new MailAddress(emailAddress));
                }
                catch (Exception ex)
                {
                    _logger.Debug(string.Format(@"{0} is not valid email addresses for email.To", emailAddress), ex);
                }
            }

            _smtpClient.Send(message);
        }
        catch (Exception ex)
        {
            _logger.Error("Failed to send email", ex);
        }
    }
}

3. We have to disable the built-in SendEmailAfterSubmissionActor actor to prevent sending email twice.

using EPiServer.Forms.Core.PostSubmissionActor;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using EPiServer.Forms.Core.Models;
using EPiServer.Forms.Implementation.Elements;

/// <summary>
/// Provide submission data for Actors, instantiate and execute actors
/// </summary>
public class CustomActorsExecutingService : ActorsExecutingService
{
    public override IEnumerable<IPostSubmissionActor> GetFormSubmissionActors(Submission submission, FormContainerBlock formContainer, FormIdentity formIden, HttpRequestBase request, HttpResponseBase response, bool isFormFinalizedSubmission)
    {
        var submissionActors = base.GetFormSubmissionActors(submission, formContainer, formIden, request, response, isFormFinalizedSubmission);

        if (submissionActors == null || submissionActors.Count() == 0)
        {
            return Enumerable.Empty<IPostSubmissionActor>();
        }

        var filteredActors = submissionActors.ToList();
        // remove the system's email-actor
        filteredActors.RemoveAll(act => act.Name == typeof(EPiServer.Forms.Implementation.Actors.SendEmailAfterSubmissionActor).FullName);

        return filteredActors;
    }
}

4. And don't forget to register dependencies

public void ConfigureContainer(ServiceConfigurationContext context)
{
    //Implementations for custom interfaces can be registered here.

    context.ConfigurationComplete += (o, e) =>
    {
        context.Services.
             AddTransient<EPiServer.Forms.Core.Internal.DataSubmissionService, CustomFormsDataSubmissionService>()
            .AddTransient<EPiServer.Forms.Implementation.Actors.SendEmailAfterSubmissionActor, SendEmailAfterSubmissionActor>()
            .AddTransient<ActorsExecutingService, CustomActorsExecutingService>();
    };
}

If you have any questions or better approach, please drop me a comment :)

Jan 14, 2019

Comments

Thomas Schmidt
Thomas Schmidt Jan 16, 2019 08:44 AM

Using Session is generally a pretty bad idea when running on .net framework, it is queuing up requests for the same user, removes high availability possibilities because you need to use sticky load balancing which in turn also affects scalability on for example Azure, data is lost on app recycles and the list goes on. You could switch to a sql server session store but that is insanely slow.

It might work on .net core where it is not blocking like on .net framework and you can plugin a fast distributed session provider like Redis or CosmosDB, but would never recommend anyone to use session unless there is absolutely no way around it, and fortunately there almost always is :)

Now we just need EpiServer to remove all dependencies to Session from the core product, but thats a different story :)

Quan Tran
Quan Tran Jan 17, 2019 12:55 AM

Hi Thomas,

Yes I agree with you that using session is a bad idea. As i mentioned in the article it will cause performance penalties and I just want to demonstrate how to customize to store upload files with Forms and I leave the solution for those who implement the actual logic.
Yes, using Redis is a good idea causes it lightweight and supports load balancing. 

Thanks for your feedback Thomas.

Quan Tran
Quan Tran Jan 17, 2019 12:57 AM

Hi Thomas,

Yes I agree with you that using session is a bad idea. As i mentioned in the article it will cause performance penalties and I just want to demonstrate how to customize to store upload files with Forms and I leave the solution for those who implement the actual logic.
Yes, using Redis is a good idea because it's lightweight and supports load balancing.

Thanks for your feedback Thomas.

Please login to comment.
Latest blogs
Optimizely SaaS CMS + Coveo Search Page

Short on time but need a listing feature with filters, pagination, and sorting? Create a fully functional Coveo-powered search page driven by data...

Damian Smutek | Nov 21, 2024 | Syndicated blog

Optimizely SaaS CMS DAM Picker (Interim)

Simplify your Optimizely SaaS CMS workflow with the Interim DAM Picker Chrome extension. Seamlessly integrate your DAM system, streamlining asset...

Andy Blyth | Nov 21, 2024 | Syndicated blog

Optimizely CMS Roadmap

Explore Optimizely CMS's latest roadmap, packed with developer-focused updates. From SaaS speed to Visual Builder enhancements, developer tooling...

Andy Blyth | Nov 21, 2024 | Syndicated blog

Set Default Culture in Optimizely CMS 12

Take control over culture-specific operations like date and time formatting.

Tomas Hensrud Gulla | Nov 15, 2024 | Syndicated blog