using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Text.RegularExpressions; using Umbraco.Core; using umbraco.cms.businesslogic.web; namespace Umbraco.Web.Routing { /// /// Provides utilities to handle site domains. /// public class SiteDomainHelper : ISiteDomainHelper { #region Configure private static readonly ReaderWriterLockSlim ConfigLock = new ReaderWriterLockSlim(); private static Dictionary _sites; private static Dictionary> _bindings; private static Dictionary> _qualifiedSites; // these are for unit tests *only* internal static Dictionary Sites { get { return _sites; } } internal static Dictionary> Bindings { get { return _bindings; } } // these are for validation //private const string DomainValidationSource = @"^(\*|((?i:http[s]?://)?([-\w]+(\.[-\w]+)*)(:\d+)?(/[-\w]*)?))$"; private const string DomainValidationSource = @"^(((?i:http[s]?://)?([-\w]+(\.[-\w]+)*)(:\d+)?(/)?))$"; private static readonly Regex DomainValidation = new Regex(DomainValidationSource, RegexOptions.IgnoreCase | RegexOptions.Compiled); /// /// Returns a disposable object that represents safe write access to config. /// /// Should be used in a using(SiteDomainHelper.ConfigWriteLock) { ... } mode. protected static IDisposable ConfigWriteLock { get { return new WriteLock(ConfigLock); } } /// /// Returns a disposable object that represents safe read access to config. /// /// Should be used in a using(SiteDomainHelper.ConfigWriteLock) { ... } mode. protected static IDisposable ConfigReadLock { get { return new ReadLock(ConfigLock); } } /// /// Clears the entire configuration. /// public static void Clear() { using (ConfigWriteLock) { _sites = null; _bindings = null; _qualifiedSites = null; } } private static IEnumerable ValidateDomains(IEnumerable domains) { // must use authority format w/optional scheme and port, but no path // any domain should appear only once return domains.Select(domain => { if (!DomainValidation.IsMatch(domain)) throw new ArgumentOutOfRangeException("domains", string.Format("Invalid domain: \"{0}\"", domain)); return domain; }); } /// /// Adds a site. /// /// A key uniquely identifying the site. /// The site domains. /// At the moment there is no public way to remove a site. Clear and reconfigure. public static void AddSite(string key, IEnumerable domains) { using (ConfigWriteLock) { _sites = _sites ?? new Dictionary(); _sites[key] = ValidateDomains(domains).ToArray(); _qualifiedSites = null; } } /// /// Adds a site. /// /// A key uniquely identifying the site. /// The site domains. /// At the moment there is no public way to remove a site. Clear and reconfigure. public static void AddSite(string key, params string[] domains) { using (ConfigWriteLock) { _sites = _sites ?? new Dictionary(); _sites[key] = ValidateDomains(domains).ToArray(); _qualifiedSites = null; } } /// /// Removes a site. /// /// A key uniquely identifying the site. internal static void RemoveSite(string key) { using (ConfigWriteLock) { if (_sites != null && _sites.ContainsKey(key)) { _sites.Remove(key); if (_sites.Count == 0) _sites = null; if (_bindings != null && _bindings.ContainsKey(key)) { foreach (var b in _bindings[key]) { _bindings[b].Remove(key); if (_bindings[b].Count == 0) _bindings.Remove(b); } _bindings.Remove(key); if (_bindings.Count > 0) _bindings = null; } _qualifiedSites = null; } } } /// /// Binds some sites. /// /// The keys uniquely identifying the sites to bind. /// /// At the moment there is no public way to unbind sites. Clear and reconfigure. /// If site1 is bound to site2 and site2 is bound to site3 then site1 is bound to site3. /// public static void BindSites(params string[] keys) { using (ConfigWriteLock) { foreach (var key in keys.Where(key => !_sites.ContainsKey(key))) throw new ArgumentException(string.Format("Not an existing site key: {0}", key), "keys"); _bindings = _bindings ?? new Dictionary>(); var allkeys = _bindings .Where(kvp => keys.Contains(kvp.Key)) .SelectMany(kvp => kvp.Value) .Union(keys) .ToArray(); foreach (var key in allkeys) { if (!_bindings.ContainsKey(key)) _bindings[key] = new List(); var xkey = key; var addKeys = allkeys.Where(k => k != xkey).Except(_bindings[key]); _bindings[key].AddRange(addKeys); } } } #endregion #region Map domains /// /// Filters a list of DomainAndUri to pick one that best matches the current request. /// /// The Uri of the current request. /// The list of DomainAndUri to filter. /// The selected DomainAndUri. /// /// If the filter is invoked then is _not_ empty and /// is _not_ null, and could not be /// matched with anything in . /// The filter _must_ return something else an exception will be thrown. /// public virtual DomainAndUri MapDomain(Uri current, DomainAndUri[] domainAndUris) { var currentAuthority = current.GetLeftPart(UriPartial.Authority); var qualifiedSites = GetQualifiedSites(current); return MapDomain(domainAndUris, qualifiedSites, currentAuthority); } /// /// Filters a list of DomainAndUri to pick those that best matches the current request. /// /// The Uri of the current request. /// The list of DomainAndUri to filter. /// A value indicating whether to exclude the current/default domain. /// The selected DomainAndUri items. /// The filter must return something, even empty, else an exception will be thrown. public virtual IEnumerable MapDomains(Uri current, DomainAndUri[] domainAndUris, bool excludeDefault) { var currentAuthority = current.GetLeftPart(UriPartial.Authority); KeyValuePair[] candidateSites = null; IEnumerable ret = domainAndUris; using (ConfigReadLock) // so nothing changes between GetQualifiedSites and access to bindings { var qualifiedSites = GetQualifiedSitesInsideLock(current); if (excludeDefault) { // exclude the current one (avoid producing the absolute equivalent of what GetUrl returns) var hintWithSlash = current.EndPathWithSlash(); var hinted = domainAndUris.FirstOrDefault(d => d.Uri.EndPathWithSlash().IsBaseOf(hintWithSlash)); if (hinted != null) ret = ret.Where(d => d != hinted); // exclude the default one (avoid producing a possible duplicate of what GetUrl returns) // only if the default one cannot be the current one ie if hinted is not null if (hinted == null && domainAndUris.Any()) { // it is illegal to call MapDomain if domainAndUris is empty // also, domainAndUris should NOT contain current, hence the test on hinted var mainDomain = MapDomain(domainAndUris, qualifiedSites, currentAuthority); // what GetUrl would get ret = ret.Where(d => d != mainDomain); } } // we do our best, but can't do the impossible if (qualifiedSites == null) return ret; // find a site that contains the current authority var currentSite = qualifiedSites.FirstOrDefault(site => site.Value.Contains(currentAuthority)); // if current belongs to a site, pick every element from domainAndUris that also belong // to that site -- or to any site bound to that site if (!currentSite.Equals(default(KeyValuePair))) { candidateSites = new[] { currentSite }; if (_bindings != null && _bindings.ContainsKey(currentSite.Key)) { var boundSites = qualifiedSites.Where(site => _bindings[currentSite.Key].Contains(site.Key)); candidateSites = candidateSites.Union(boundSites).ToArray(); // .ToArray ensures it is evaluated before the configuration lock is exited } } } // if we are able to filter, then filter, else return the whole lot return candidateSites == null ? ret : ret.Where(d => { var authority = d.Uri.GetLeftPart(UriPartial.Authority); return candidateSites.Any(site => site.Value.Contains(authority)); }); } private static Dictionary GetQualifiedSites(Uri current) { using (ConfigReadLock) { return GetQualifiedSitesInsideLock(current); } } private static Dictionary GetQualifiedSitesInsideLock(Uri current) { // we do our best, but can't do the impossible if (_sites == null) return null; // cached? if (_qualifiedSites != null && _qualifiedSites.ContainsKey(current.Scheme)) return _qualifiedSites[current.Scheme]; _qualifiedSites = _qualifiedSites ?? new Dictionary>(); // convert sites into authority sites based upon current scheme // because some domains in the sites might not have a scheme -- and cache return _qualifiedSites[current.Scheme] = _sites .ToDictionary( kvp => kvp.Key, kvp => kvp.Value.Select(d => new Uri(UriUtility.StartWithScheme(d, current.Scheme)).GetLeftPart(UriPartial.Authority)).ToArray() ); // .ToDictionary will evaluate and create the dictionary immediately // the new value is .ToArray so it will also be evaluated immediately // therefore it is safe to return and exit the configuration lock } private static DomainAndUri MapDomain(DomainAndUri[] domainAndUris, Dictionary qualifiedSites, string currentAuthority) { if (domainAndUris == null) throw new ArgumentNullException("domainAndUris"); if (!domainAndUris.Any()) throw new ArgumentException("Cannot be empty.", "domainAndUris"); // we do our best, but can't do the impossible if (qualifiedSites == null) return domainAndUris.First(); // find a site that contains the current authority var currentSite = qualifiedSites.FirstOrDefault(site => site.Value.Contains(currentAuthority)); // if current belongs to a site - try to pick the first element // from domainAndUris that also belongs to that site var ret = currentSite.Equals(default(KeyValuePair)) ? null : domainAndUris.FirstOrDefault(d => currentSite.Value.Contains(d.Uri.GetLeftPart(UriPartial.Authority))); // no match means that either current does not belong to a site, or the site it belongs to // does not contain any of domainAndUris. Yet we have to return something. here, it becomes // a bit arbitrary. // look through sites in order and pick the first domainAndUri that belongs to a site ret = ret ?? qualifiedSites .Where(site => site.Key != currentSite.Key) .Select(site => domainAndUris.FirstOrDefault(domainAndUri => site.Value.Contains(domainAndUri.Uri.GetLeftPart(UriPartial.Authority)))) .FirstOrDefault(domainAndUri => domainAndUri != null); // random, really ret = ret ?? domainAndUris.First(); return ret; } #endregion } }