using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Web; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Routing { /// /// Provides utilities to handle domains. /// public static class DomainUtilities { #region Document Culture /// /// Gets the culture assigned to a document by domains, in the context of a current Uri. /// /// The document identifier. /// The document path. /// An optional current Uri. /// An Umbraco context. /// The site domain helper. /// The culture assigned to the document by domains. /// /// In 1:1 multilingual setup, a document contains several cultures (there is not /// one document per culture), and domains, withing the context of a current Uri, assign /// a culture to that document. /// public static string? GetCultureFromDomains(int contentId, string contentPath, Uri? current, IUmbracoContext umbracoContext, ISiteDomainMapper siteDomainMapper) { if (umbracoContext == null) throw new InvalidOperationException("A current UmbracoContext is required."); if (current == null) current = umbracoContext.CleanedUmbracoUrl; // get the published route, else the preview route // if both are null then the content does not exist var route = umbracoContext.Content.GetRouteById(contentId) ?? umbracoContext.Content.GetRouteById(true, contentId); if (route == null) return null; var pos = route.IndexOf('/'); var domain = pos == 0 ? null : DomainForNode(umbracoContext.Domains, siteDomainMapper, int.Parse(route.Substring(0, pos), CultureInfo.InvariantCulture), current); var rootContentId = domain?.ContentId ?? -1; var wcDomain = FindWildcardDomainInPath(umbracoContext.Domains?.GetAll(true), contentPath, rootContentId); if (wcDomain != null) return wcDomain.Culture; if (domain != null) return domain.Culture; return umbracoContext.Domains?.DefaultCulture; } #endregion #region Domain for Document /// /// Finds the domain for the specified node, if any, that best matches a specified uri. /// /// A domain cache. /// The site domain helper. /// The node identifier. /// The uri, or null. /// The culture, or null. /// The domain and its uri, if any, that best matches the specified uri and culture, else null. /// /// If at least a domain is set on the node then the method returns the domain that /// best matches the specified uri and culture, else it returns null. /// If culture is null, uses the default culture for the installation instead. Otherwise, /// will try with the specified culture, else return null. /// internal static DomainAndUri? DomainForNode(IDomainCache? domainCache, ISiteDomainMapper siteDomainMapper, int nodeId, Uri current, string? culture = null) { // be safe if (nodeId <= 0) return null; // get the domains on that node var domains = domainCache?.GetAssigned(nodeId).ToArray(); // none? if (domains is null || domains.Length == 0) return null; // else filter // it could be that none apply (due to culture) return SelectDomain(domains, current, culture, domainCache?.DefaultCulture, siteDomainMapper.MapDomain); } /// /// Find the domains for the specified node, if any, that match a specified uri. /// /// A domain cache. /// The site domain helper. /// The node identifier. /// The uri, or null. /// A value indicating whether to exclude the current/default domain. True by default. /// The domains and their uris, that match the specified uri, else null. /// If at least a domain is set on the node then the method returns the domains that /// best match the specified uri, else it returns null. internal static IEnumerable? DomainsForNode(IDomainCache? domainCache, ISiteDomainMapper siteDomainMapper, int nodeId, Uri current, bool excludeDefault = true) { // be safe if (nodeId <= 0) return null; // get the domains on that node var domains = domainCache?.GetAssigned(nodeId).ToArray(); // none? if (domains is null || domains.Length == 0) return null; // get the domains and their uris var domainAndUris = SelectDomains(domains, current).ToArray(); // filter return siteDomainMapper.MapDomains(domainAndUris, current, excludeDefault, null, domainCache?.DefaultCulture).ToArray(); } #endregion #region Selects Domain(s) /// /// Selects the domain that best matches a specified uri and cultures, from a set of domains. /// /// The group of domains. /// An optional uri. /// An optional culture. /// An optional default culture. /// An optional function to filter the list of domains, if more than one applies. /// The domain and its normalized uri, that best matches the specified uri and cultures. /// /// TODO: must document and explain this all /// If is null, pick the first domain that matches , /// else the first that matches , else the first one (ordered by id), else null. /// If is not null, look for domains that would be a base uri of the current uri, /// If more than one domain matches, then the function is used to pick /// the right one, unless it is null, in which case the method returns null. /// The filter, if any, will be called only with a non-empty argument, and _must_ return something. /// public static DomainAndUri? SelectDomain(IEnumerable? domains, Uri uri, string? culture = null, string? defaultCulture = null, Func, Uri, string?, string?, DomainAndUri?>? filter = null) { // sanitize the list to have proper uris for comparison (scheme, path end with /) // we need to end with / because example.com/foo cannot match example.com/foobar // we need to order so example.com/foo matches before example.com/ var domainsAndUris = domains? .Where(d => d.IsWildcard == false) .Select(d => new DomainAndUri(d, uri)) .OrderByDescending(d => d.Uri.ToString()) .ToList(); // nothing = no magic, return null if (domainsAndUris is null || domainsAndUris.Count == 0) return null; // sanitize cultures culture = culture?.NullOrWhiteSpaceAsNull(); defaultCulture = defaultCulture?.NullOrWhiteSpaceAsNull(); if (uri == null) { // no uri - will only rely on culture return GetByCulture(domainsAndUris, culture, defaultCulture); } // else we have a uri, // try to match that uri, else filter // if a culture is specified, then try to get domains for that culture // (else cultureDomains will be null) // do NOT specify a default culture, else it would pick those domains var cultureDomains = SelectByCulture(domainsAndUris, culture, defaultCulture: null); IReadOnlyCollection considerForBaseDomains = domainsAndUris; if (cultureDomains != null) { if (cultureDomains.Count == 1) // only 1, return return cultureDomains.First(); // else restrict to those domains, for base lookup considerForBaseDomains = cultureDomains; } // look for domains that would be the base of the uri var baseDomains = SelectByBase(considerForBaseDomains, uri, culture); if (baseDomains.Count > 0) // found, return return baseDomains.First(); // if nothing works, then try to run the filter to select a domain // either restricting on cultureDomains, or on all domains if (filter != null) { var domainAndUri = filter(cultureDomains ?? domainsAndUris, uri, culture, defaultCulture); return domainAndUri; } return null; } private static bool IsBaseOf(DomainAndUri domain, Uri uri) => domain.Uri.EndPathWithSlash().IsBaseOf(uri); private static bool MatchesCulture(DomainAndUri domain, string? culture) => culture == null || domain.Culture.InvariantEquals(culture); private static IReadOnlyCollection SelectByBase(IReadOnlyCollection domainsAndUris, Uri uri, string? culture) { // look for domains that would be the base of the uri // ie current is www.example.com/foo/bar, look for domain www.example.com var currentWithSlash = uri.EndPathWithSlash(); var baseDomains = domainsAndUris.Where(d => IsBaseOf(d, currentWithSlash) && MatchesCulture(d, culture)).ToList(); // if none matches, try again without the port // ie current is www.example.com:1234/foo/bar, look for domain www.example.com var currentWithoutPort = currentWithSlash.WithoutPort(); if (baseDomains.Count == 0) baseDomains = domainsAndUris.Where(d => IsBaseOf(d, currentWithoutPort)).ToList(); return baseDomains; } private static IReadOnlyCollection? SelectByCulture(IReadOnlyCollection domainsAndUris, string? culture, string? defaultCulture) { // we try our best to match cultures, but may end with a bogus domain if (culture != null) // try the supplied culture { var cultureDomains = domainsAndUris.Where(x => x.Culture.InvariantEquals(culture)).ToList(); if (cultureDomains.Count > 0) return cultureDomains; } if (defaultCulture != null) // try the defaultCulture culture { var cultureDomains = domainsAndUris.Where(x => x.Culture.InvariantEquals(defaultCulture)).ToList(); if (cultureDomains.Count > 0) return cultureDomains; } return null; } private static DomainAndUri GetByCulture(IReadOnlyCollection domainsAndUris, string? culture, string? defaultCulture) { DomainAndUri? domainAndUri; // we try our best to match cultures, but may end with a bogus domain if (culture != null) // try the supplied culture { domainAndUri = domainsAndUris.FirstOrDefault(x => x.Culture.InvariantEquals(culture)); if (domainAndUri != null) return domainAndUri; } if (defaultCulture != null) // try the defaultCulture culture { domainAndUri = domainsAndUris.FirstOrDefault(x => x.Culture.InvariantEquals(defaultCulture)); if (domainAndUri != null) return domainAndUri; } return domainsAndUris.First(); // what else? } /// /// Selects the domains that match a specified uri, from a set of domains. /// /// The domains. /// The uri, or null. /// The domains and their normalized uris, that match the specified uri. internal static IEnumerable SelectDomains(IEnumerable domains, Uri uri) { // TODO: where are we matching ?!!? return domains .Where(d => d.IsWildcard == false) .Select(d => new DomainAndUri(d, uri)) .OrderByDescending(d => d.Uri.ToString()); } /// /// Parses a domain name into a URI. /// /// The domain name to parse /// The currently requested URI. If the domain name is relative, the authority of URI will be used. /// The domain name as a URI public static Uri ParseUriFromDomainName(string domainName, Uri currentUri) { // turn "/en" into "http://whatever.com/en" so it becomes a parseable uri var name = domainName.StartsWith("/") && currentUri != null ? currentUri.GetLeftPart(UriPartial.Authority) + domainName : domainName; var scheme = currentUri?.Scheme ?? Uri.UriSchemeHttp; return new Uri(UriUtilityCore.TrimPathEndSlash(UriUtilityCore.StartWithScheme(name, scheme))); } #endregion #region Utilities /// /// Gets a value indicating whether there is another domain defined down in the path to a node under the current domain's root node. /// /// The domains. /// The path to a node under the current domain's root node eg '-1,1234,5678'. /// The current domain root node identifier, or null. /// A value indicating if there is another domain defined down in the path. /// Looks _under_ rootNodeId but not _at_ rootNodeId. internal static bool ExistsDomainInPath(IEnumerable domains, string path, int? rootNodeId) { return FindDomainInPath(domains, path, rootNodeId) != null; } /// /// Gets the deepest non-wildcard Domain, if any, from a group of Domains, in a node path. /// /// The domains. /// The node path eg '-1,1234,5678'. /// The current domain root node identifier, or null. /// The deepest non-wildcard Domain in the path, or null. /// Looks _under_ rootNodeId but not _at_ rootNodeId. internal static Domain? FindDomainInPath(IEnumerable domains, string path, int? rootNodeId) { var stopNodeId = rootNodeId ?? -1; return path.Split(Constants.CharArrays.Comma) .Reverse() .Select(s => int.Parse(s, CultureInfo.InvariantCulture)) .TakeWhile(id => id != stopNodeId) .Select(id => domains.FirstOrDefault(d => d.ContentId == id && d.IsWildcard == false)) .SkipWhile(domain => domain == null) .FirstOrDefault(); } /// /// Gets the deepest wildcard Domain, if any, from a group of Domains, in a node path. /// /// The domains. /// The node path eg '-1,1234,5678'. /// The current domain root node identifier, or null. /// The deepest wildcard Domain in the path, or null. /// Looks _under_ rootNodeId but not _at_ rootNodeId. public static Domain? FindWildcardDomainInPath(IEnumerable? domains, string path, int? rootNodeId) { var stopNodeId = rootNodeId ?? -1; return path.Split(Constants.CharArrays.Comma) .Reverse() .Select(s => int.Parse(s, CultureInfo.InvariantCulture)) .TakeWhile(id => id != stopNodeId) .Select(id => domains?.FirstOrDefault(d => d.ContentId == id && d.IsWildcard)) .FirstOrDefault(domain => domain != null); } /// /// Returns the part of a path relative to the uri of a domain. /// /// The normalized uri of the domain. /// The full path of the uri. /// The path part relative to the uri of the domain. /// Eg the relative part of /foo/bar/nil to domain example.com/foo is /bar/nil. public static string PathRelativeToDomain(Uri domainUri, string path) => path.Substring(domainUri.GetAbsolutePathDecoded().Length).EnsureStartsWith('/'); #endregion } }