Moving Dynamics 365 attachments to Azure blob storage.

shutterstock_528925720

Everyone knows that storage space in Dynamics 365 is expensive, so normally if our customers need to store large amounts of unstructured data in Dynamics 365, we highly recommend to use SharePoint Online or Azure Blob Storage which is, even more, cheaper than SharePoint Online. In this article, I will tell you how to store all your unstructured data in the Azure blob with one simple Dynamics 365 plugin.

There are also several ready-made solutions for this topic, like Attachment manager from Microsoft Labs. Attachment manager can automatically push your attachments into the Azure Blob and creates stub files in your Dynamics 365 organization, once you want to access the file, Attachment manager will initiate background operation to download your file from Azure blob and replace it back in the CRM organization, so you would be able to access it, but this approach doesn’t work well on heavy files. My idea is to replace the file by the so-called  “.URL” link, so once the user would like to access the file he or she will be automatically redirected to the file content in Azure Blob. All modern browsers support this file extension.

Let’s start and create storage account where we will store our data from Dynamics 365. Go to the Azure portal and click on “Create a resource”, then go to section “Storage” and choose “Storage account – blob, file, table, queue”. You can see it below:

azurestorage

On the next screen you need to provide some more information regarding your blob storage, so let’s do this and click on the button “Create”.

createblob

Once you created your blob storage, the next step is to create a container. By creating containers you can easily split your unstructured data into different types, like entity or content. So let’s open our newly created blob and add a container to it.

addcontainer

Now you have your blob storage account with the container to store your data. Let’s create Share Access Signature to be able to access data in our storage. To do this let’s open our blob storage account and choose “Share access signature” from the settings section.

sas

Configure SAS settings and click on the button “Generate SAS and connection string”. In the bottom part of this screen, you will find your SAS token, which we will use in our code samples. Please be sure that you have properly configured the timeframe during which your SAS token will be valid.  As we want our users to have a direct access to the blob we need to properly configure our container. To configure the container open it and switch to the “Access policy” under “Settings” section.

accesss

Choose “Public access level” to “Blob (anonymous read access for blobs only)”. Now we are ready to write some code, which will access the Azure Blob storage. Below you can find AzureBlobManager class which will help you to access AzureBlob from Dynamics 365.


 public class AzureBlobManager
    {
        private readonly IOrganizationService _service;
        private readonly string _endPoint;
        private readonly string _sasKey;
 
        public AzureBlobManager(IOrganizationService crmService, string storageAccount, string sasKey)
        {
            _service = crmService;
            _sasKey = sasKey;
            _endPoint = "https://" + storageAccount + ".blob.core.windows.net/";
        }
 
      
        public void UploadToAzureBlob(SortedList<string, string=""> metadataList,
            string recordId,
            string containerName, 
            string body, 
            string annotationGuid, 
            string originalFileName)
        {
 
            string originalBlobName = $"{recordId}_{originalFileName}";
 
            var builder = new StringBuilder();
            builder.AppendLine("[InternetShortcut]");
            builder.AppendLine($"URL={_endPoint}{containerName}/{recordId}_{originalFileName}");
            builder.AppendLine("IconFile=");
            builder.AppendLine("IconIndex=0");
 
            var documentBody = Convert.ToBase64String(
                new UnicodeEncoding().GetBytes(builder.ToString()));
 
            PutBlob(containerName, originalBlobName, body, metadataList);
            var entity = new Entity("annotation", new Guid(annotationGuid))
            {
                ["filename"] = Uri.UnescapeDataString(originalFileName + ".cloud"),
                ["documentbody"] = documentBody,
                EntityState = EntityState.Changed
            };
            _service.Update(entity);
        }
 
 
        public void DeleteFromAzureBlob(string container, string originalFileName, string recordId)
        {
            using (var httpWebResponse =
                (HttpWebResponse) CreateRestRequest("DELETE", $"{container}/{recordId}_{originalFileName}").GetResponse())
                httpWebResponse.Close();
        }
 
        private void PutBlob(string container, string originalBlobName, string content,
            SortedList<string, string=""> metadataList = null)
        {
            var headers = new SortedList<string, string=""> {{"x-ms-blob-type", "BlockBlob"}};
            if (metadataList != null)
            {
                foreach (var pair in metadataList)
                {
                    var key = "x-ms-meta-" + pair.Key;
                    headers.Add(key, pair.Value);
                }
            }
            if (!(CreateRestRequest("PUT", $"{container}/{originalBlobName}", content, headers).GetResponse() is
                HttpWebResponse response)) return;
            response.Close();
        }
 
        public HttpWebRequest CreateRestRequest(string method, string resource, string requestBody = null, SortedList<string, string=""> headers = null, string ifMatch = "", string md5 = "")
        {
            byte[] buffer = null;
            var utcNow = DateTime.UtcNow;
            if (!(WebRequest.Create(_endPoint + resource + _sasKey) is HttpWebRequest request))
            {
                return null;
            }
            request.Method = method;
            request.ContentLength = 0L;
            request.Headers.Add("x-ms-date", utcNow.ToString("R", CultureInfo.InvariantCulture));
            request.Headers.Add("x-ms-version", "2015-12-11");
            if (headers != null)
            {
                foreach (var pair in headers)
                {
                    request.Headers.Add(pair.Key, pair.Value);
                }
            }
            if (!string.IsNullOrEmpty(requestBody))
            {
                request.Headers.Add("Accept-Charset", "UTF-8");
                buffer = Convert.FromBase64String(requestBody);
                request.ContentLength = buffer.Length;
            }
            if (!string.IsNullOrEmpty(requestBody) && buffer != null)
            {
                request.GetRequestStream().Write(buffer, 0, buffer.Length);
            }
            return request;
        }
 
        public SortedList<string, string=""> GetBlobProperties(string container, string blobName, string recordId)
        {
            var sortedList = new SortedList<string, string="">();
            try
            {
                if (!(CreateRestRequest("HEAD", $"{container}/{recordId}_{blobName}").GetResponse() is HttpWebResponse
                    httpWebResponse)) return sortedList;
                httpWebResponse.Close();
                if (httpWebResponse.StatusCode != HttpStatusCode.OK || httpWebResponse.Headers == null)
                    return sortedList;
                for (var i = 0; i < httpWebResponse.Headers.Count; i++)
                {
                    sortedList.Add(httpWebResponse.Headers.Keys[i], httpWebResponse.Headers[i]);
                }
            }
            catch (WebException ex)
            {
                if (ex.Response is
                        HttpWebResponse errorResponse 
                    && errorResponse.StatusCode == HttpStatusCode.NotFound)
                {
                    return sortedList;
                }
                throw;
            }
 
          
 
            return sortedList;
        }}
</string,></string,></string,></string,></string,></string,>

You can see that I’ve used “.cloud” extension for the overridden file in CRM. URL file type is a good fit for our solution, however, it is not possible to create files of such format in Dynamics 365, as CRM tries to automatically download the content and save it inside CRM.  My Idea is to register the same system behavior for another type of file. So, I’ve created .cloud file extension using Windows registry and applied the same system behavior for it as for .url. You can then apply these changes to all computers in your network by using Group Policy as we did. That’s pretty simple, the only thing that you need to do is to find .url registration key in your Windows Registry and recreate it with all subnodes for the .cloud extension. Now let’s create a plugin which will replace our data in Dynamics 365. The plugin is very simple and I will only cover replacement here. You will definitely be able to implement delete operation by your self.


public class BlobManagerPlugin : IPlugin
    {
 
        public const string StorageAccountName = "d365blobstorage";
        public const string ContainerName = "notescontainer";
        public const string SaSkey = 
            "YourKey";
        public void Execute(IServiceProvider serviceProvider)
        {
            IPluginExecutionContext context = (IPluginExecutionContext)
                serviceProvider.GetService(typeof(IPluginExecutionContext));
 
            IOrganizationServiceFactory serviceFactory =
                (IOrganizationServiceFactory) serviceProvider.GetService(typeof(IOrganizationServiceFactory));
            IOrganizationService serviceProxy = serviceFactory.CreateOrganizationService(context.UserId);
 
            if (context.InputParameters.Contains("Target") &&
                context.InputParameters["Target"] is Entity entity)
            {
                if (entity.LogicalName == "annotation")
                {
                    if (entity.Attributes.Contains("objectid") && entity.Attributes.Contains("documentbody"))
                    {
                        var referencedEntityId = (EntityReference)entity.Attributes["objectid"];
 
                        var blob = new AzureBlobManager(serviceProxy, StorageAccountName, SaSkey);
                         
 
                        var metadataList = new SortedList<string, string="">
                        {
                            {
                                "Owner",
                                "BlobManagerPlugin"
                            }
                        };
 
                        string fileName = Uri.EscapeDataString(entity["filename"].ToString());
 
                        blob.UploadToAzureBlob(metadataList,
                            referencedEntityId.Id.ToString(),
                            ContainerName,
                            entity.GetAttributeValue("documentbody"),
                            entity.Id.ToString().ToLower(),
                            fileName);
                    }
                }
            }
        }
    }
</string,>

As you can see the plugin is very simple, and of course, the code is not ideal and I don’t recommend to use it on production, but I hope that you got the idea how to manage Azure Blob storage and how to access it from Dynamics 365. Another important topic here is security. This solution is not very secure as you can actually access blob data by direct link. For our customer, it was not that important, because all files were encrypted and special software was used to decrypt files on the fly.

Leave a Reply

Your email address will not be published. Required fields are marked *