How a Custom IMediaPathScheme Came to the Rescue for our Migrated Media!

13th November, 2023

Owain Jones

How I used a custom IMediaPathScheme to solve an issue with the slugs in our migrated media!

 Photo by RodrigoQuarteu - Wikimedia Commons

First, the backstory

When planning on migrating our client sites from Umbraco 8 to Umbraco 10, we decided to take it as an opportunity to tidy up our doctypes (which on some sites included switching from inheritance to compositions). This then meant that we could not do direct database upgrades, as the doctypes had changed too much, therefore I created an in-house migration package that generates "migration snapshots" (.zip files) from the v7/8 sites which could then be imported into the v10+ sites. (mappings are handled with C# mappers)

This approach worked very well and also allowed us to avoid lengthy content freezes, on the v7/8 sites, as we could simply make a new snapshot, and import it again, at any point during the upgrade's development, UAT and go-live!

The Issue with Media Replacement

Some of our clients have a specific requirement: the need to update media files by replacing them with new files of the same name. This process ensures that external sites linking to these resources maintain their integrity without the need for widespread URL updates. But we hit an unexpected snag: the GUID slugs in our media URLs were changing upon re-uploading, which meant the replaced media files were getting new URLs.. 😱 but only for migrated media nodes, newly created media nodes would keep the same slug!

Note: I'm using the term "slug" to refer to unique identifier in media paths. (E.g. the "/u1meypsn/" in "/media/u1meypsn/myDocument.pdf")
See, now the slug picture makes sense! 😜

Understanding the Root of the Problem

Prior to this, I wrongly assumed that the umbracoMediaVersion database table was used to track media paths/slugs. Turns out, I was completely wrong, I honestly couldn't have been further from the mark!

Umbraco uses implementations of IMediaPathScheme to generate the paths for Media files, the default one is called UniqueMediaPathScheme.cs.

And how does UniqueMediaPathScheme generate the slugs you ask? Well, it takes the GUID of the media node and the GUID of the property, combines them, then base32's them into an 8-character long string!

    public string GetFilePath(MediaFileManager fileManager, Guid itemGuid, Guid propertyGuid, string filename)
    {
        Guid combinedGuid = GuidUtils.Combine(itemGuid, propertyGuid);
        var directory = GuidUtils.ToBase32String(combinedGuid, DirectoryLength);

        return Path.Combine(directory, filename).Replace('\\', '/');
    }

Ah ha! So that explains why our slugs are changing! My migration package uses the media service to import the media nodes, which means the nodes and properties all have different GUIDs from what they had in the old site! Whilst my migration package accounts for this when migrating references in media pickers etc.. I never even considered that this could affect the media path slug generation!

So how did we fix this?

We created a custom IMediaPathScheme by extending the default UniqueMediaPathScheme, so we can keep the default behaviour, and adding in some custom logic to create a redirect if all the following things are true:

  1. The uploaded file already has a file path.
  2. The new filename is identical to the old filename.
  3. The slug has changed.

This then ensures that redirects are generated if the files are replaced on our migrated media.

GIF showing the media path scheme in action:

A gif demoing the IMediaPathScheme automatically creating a redirect when a migrated media file is changed and it's path slug changes.

And here's the code:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Skybrud.Umbraco.Redirects.Models;
using Skybrud.Umbraco.Redirects.Models.Options;
using Skybrud.Umbraco.Redirects.Services;
using System.Text.RegularExpressions;
using UmbracoMigrator.Target.Core.Options;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.IO.MediaPathSchemes;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;

namespace UmbracoMigrator.Target.Core.MediaPathSchemes
{
    /// <summary>
    /// Adds redirects if the media path slug changes on a file change
    /// </summary>
    public class MigratedUrlRedirectMediaPathScheme : UniqueMediaPathScheme, IMediaPathScheme
    {
        private readonly IServiceProvider _serviceProvider;
        private readonly IOptions<MigratorTargetSettings> _settings;

        public MigratedUrlRedirectMediaPathScheme(IServiceProvider serviceProvider, IOptions<MigratorTargetSettings> settings) : base()
        {
            _serviceProvider = serviceProvider;
            _settings = settings;
        }

        public new string GetFilePath(MediaFileManager fileManager, Guid itemGuid, Guid propertyGuid, string filename)
        {
            var baseResult = base.GetFilePath(fileManager, itemGuid, propertyGuid, filename);

            // For testing
            //return $"oldMigratedSlug/{filename}";

            if (_settings.Value.EnableMediaRedirectGeneration == false)
            {
                return baseResult;
            }

            var fileUploadPropertyTypeAlias = "umbracoFile";
            if (_settings.Value.MediaFileUploadPropertyAlias.IsNullOrWhiteSpace() == false)
            {
                fileUploadPropertyTypeAlias = _settings.Value.MediaFileUploadPropertyAlias;
            }

            // Get the media service (can't inject this as that would cause a circular dependency)
            var scope = _serviceProvider.CreateScope();
            var mediaService = scope.ServiceProvider.GetService<IMediaService>();
            if (mediaService == null)
            {
                return baseResult;
            }

            // Get the media node from the db
            var mediaItem = mediaService.GetById(itemGuid);
            if (mediaItem == null)
            {
                return baseResult;
            }

            // Get the old file path
            var oldFilePath = mediaItem.GetValue<string>(fileUploadPropertyTypeAlias);
            if (oldFilePath.IsNullOrWhiteSpace())
            {
                return baseResult;
            }

            // Get the old slug
            var oldSlug = "";
            var oldFilename = "";
            var newSlug = baseResult.Split('/')[0];
            var newFilename = baseResult.Split('/')[1];

            string pattern = @"/media/([a-zA-Z0-9]+)/";
            Match match = Regex.Match(oldFilePath, pattern);

            if (match.Success)
            {
                oldSlug = match.Groups[1].Value;
                oldFilename = oldFilePath.Replace($"/media/{oldSlug}/", "");
            }

            // If the old slug matches the new slug, we don't care
            if (oldSlug == newSlug)
            {
                return baseResult;
            }

            // If the filename is different, we don't care
            if (oldFilename != newFilename)
            {
                return baseResult;
            }

            // Add a redirect to the new slug url
            var redirectsService = scope.ServiceProvider.GetService<IRedirectsService>();
            if (redirectsService != null)
            {
                var redirect = new AddRedirectOptions()
                {
                    Type = RedirectType.Permanent,
                    OriginalUrl = $"{oldFilePath}",
                    Destination = new RedirectDestination()
                    {
                        Type = RedirectDestinationType.Media,
                        Key = mediaItem.Key,
                        Id = mediaItem.Id,
                        Url = $"/media/{baseResult}"
                    },
                    
                };
                redirectsService.AddRedirect(redirect);
            }

            return baseResult;
        }
    }
}

Why Skybrud Redirects?

Initially, we considered simply retaining the original slug during the media replacement process. However, this approach risked potential slug collisions—a risk we weren't willing to take.

Our next idea was to leverage Umbraco's redirects service. But unfortunately, it wasn't designed for media nodes, only content nodes...

So we decided to use the Skybrud Redirects package mainly because it supports media redirects, but also because all of our sites already used it, so it seemed like a no-brainer to use it here!

Final thoughts

This experience is honestly a testament to the flexibility and extensibility of Umbraco. Its ability to adapt through custom solutions, like the IMediaPathScheme interface, is simply brilliant and just so cool!

Going into this I had no idea we could customize media paths in this way, but now I know, and so do you! So if a new client comes along, with a weird requirement for media paths, well, we'll know exactly what to do!