using System; using System.Linq; using System.Threading; using System.Globalization; using System.IO; using System.Web.Security; using Umbraco.Core; using Umbraco.Core.Configuration; using Umbraco.Core.IO; using Umbraco.Core.Logging; using Umbraco.Core.Security; using umbraco; using umbraco.cms.businesslogic.web; using umbraco.cms.businesslogic.language; using umbraco.cms.businesslogic.member; using RenderingEngine = Umbraco.Core.RenderingEngine; namespace Umbraco.Web.Routing { internal class PublishedContentRequestEngine { private readonly PublishedContentRequest _pcr; private readonly RoutingContext _routingContext; /// /// Initializes a new instance of the class with a content request. /// /// The content request. public PublishedContentRequestEngine(PublishedContentRequest pcr) { if (pcr == null) throw new ArgumentException("pcr is null."); _pcr = pcr; _routingContext = pcr.RoutingContext; if (_routingContext == null) throw new ArgumentException("pcr.RoutingContext is null."); var umbracoContext = _routingContext.UmbracoContext; if (umbracoContext == null) throw new ArgumentException("pcr.RoutingContext.UmbracoContext is null."); if (umbracoContext.RoutingContext != _routingContext) throw new ArgumentException("RoutingContext confusion."); // no! not set yet. //if (umbracoContext.PublishedContentRequest != _pcr) throw new ArgumentException("PublishedContentRequest confusion."); } #region Public /// /// Prepares the request. /// /// /// Returns false if the request was not successfully prepared /// public bool PrepareRequest() { // note - at that point the original legacy module did something do handle IIS custom 404 errors // ie pages looking like /anything.aspx?404;/path/to/document - I guess the reason was to support // "directory urls" without having to do wildcard mapping to ASP.NET on old IIS. This is a pain // to maintain and probably not used anymore - removed as of 06/2012. @zpqrtbnk. // // to trigger Umbraco's not-found, one should configure IIS and/or ASP.NET custom 404 errors // so that they point to a non-existing page eg /redirect-404.aspx // TODO: SD: We need more information on this for when we release 4.10.0 as I'm not sure what this means. //find domain FindDomain(); // if request has been flagged to redirect then return // whoever called us is in charge of actually redirecting if (_pcr.IsRedirect) { return false; } // set the culture on the thread - once, so it's set when running document lookups Thread.CurrentThread.CurrentUICulture = Thread.CurrentThread.CurrentCulture = _pcr.Culture; //find the published content if it's not assigned. This could be manually assigned with a custom route handler, or // with something like EnsurePublishedContentRequestAttribute or UmbracoVirtualNodeRouteHandler. Those in turn call this method // to setup the rest of the pipeline but we don't want to run the finders since there's one assigned. if (_pcr.PublishedContent == null) { // find the document & template FindPublishedContentAndTemplate(); // set the culture on the thread -- again, 'cos it might have changed due to a wildcard domain Thread.CurrentThread.CurrentUICulture = Thread.CurrentThread.CurrentCulture = _pcr.Culture; } // trigger the Prepared event - at that point it is still possible to change about anything // even though the request might be flagged for redirection - we'll redirect _after_ the event // // also, OnPrepared() will make the PublishedContentRequest readonly, so nothing can change // _pcr.OnPrepared(); // we don't take care of anything so if the content has changed, it's up to the user // to find out the appropriate template //complete the PCR and assign the remaining values return ConfigureRequest(); } /// /// Called by PrepareRequest once everything has been discovered, resolved and assigned to the PCR. This method /// finalizes the PCR with the values assigned. /// /// /// Returns false if the request was not successfully configured /// /// /// This method logic has been put into it's own method in case developers have created a custom PCR or are assigning their own values /// but need to finalize it themselves. /// public bool ConfigureRequest() { if (_pcr.HasPublishedContent == false) { return false; } // set the culture on the thread -- again, 'cos it might have changed in the event handler Thread.CurrentThread.CurrentUICulture = Thread.CurrentThread.CurrentCulture = _pcr.Culture; // if request has been flagged to redirect, or has no content to display, // then return - whoever called us is in charge of actually redirecting if (_pcr.IsRedirect || _pcr.HasPublishedContent == false) { return false; } // we may be 404 _and_ have a content // can't go beyond that point without a PublishedContent to render // it's ok not to have a template, in order to give MVC a chance to hijack routes // note - the page() ctor below will cause the "page" to get the value of all its // "elements" ie of all the IPublishedContent property. If we use the object value, // that will trigger macro execution - which can't happen because macro execution // requires that _pcr.UmbracoPage is already initialized = catch-22. The "legacy" // pipeline did _not_ evaluate the macros, ie it is using the data value, and we // have to keep doing it because of that catch-22. // assign the legacy page back to the docrequest // handlers like default.aspx will want it and most macros currently need it _pcr.UmbracoPage = new page(_pcr); // used by many legacy objects _routingContext.UmbracoContext.HttpContext.Items["pageID"] = _pcr.PublishedContent.Id; _routingContext.UmbracoContext.HttpContext.Items["pageElements"] = _pcr.UmbracoPage.Elements; return true; } /// /// Updates the request when there is no template to render the content. /// /// This is called from Mvc when there's a document to render but no template. public void UpdateRequestOnMissingTemplate() { // clear content var content = _pcr.PublishedContent; _pcr.PublishedContent = null; HandlePublishedContent(); // will go 404 FindTemplate(); // if request has been flagged to redirect then return // whoever called us is in charge of redirecting if (_pcr.IsRedirect) return; if (!_pcr.HasPublishedContent) { // means the engine could not find a proper document to handle 404 // restore the saved content so we know it exists _pcr.PublishedContent = content; return; } if (!_pcr.HasTemplate) { // means we may have a document, but we have no template // at that point there isn't much we can do and there is no point returning // to Mvc since Mvc can't do much either return; } // see note in PrepareRequest() // assign the legacy page back to the docrequest // handlers like default.aspx will want it and most macros currently need it _pcr.UmbracoPage = new page(_pcr); // these two are used by many legacy objects _routingContext.UmbracoContext.HttpContext.Items["pageID"] = _pcr.PublishedContent.Id; _routingContext.UmbracoContext.HttpContext.Items["pageElements"] = _pcr.UmbracoPage.Elements; } #endregion #region Domain /// /// Finds the site root (if any) matching the http request, and updates the PublishedContentRequest accordingly. /// /// A value indicating whether a domain was found. internal bool FindDomain() { const string tracePrefix = "FindDomain: "; // note - we are not handling schemes nor ports here. LogHelper.Debug("{0}Uri=\"{1}\"", () => tracePrefix, () => _pcr.Uri); // try to find a domain matching the current request var domainAndUri = DomainHelper.DomainForUri(DomainHelper.GetAllDomains(false), _pcr.Uri); // handle domain if (domainAndUri != null) { // matching an existing domain LogHelper.Debug("{0}Matches domain=\"{1}\", rootId={2}, culture=\"{3}\"", () => tracePrefix, () => domainAndUri.Domain.Name, () => domainAndUri.Domain.RootNodeId, () => domainAndUri.Domain.Language.CultureAlias); _pcr.Domain = domainAndUri.Domain; _pcr.DomainUri = domainAndUri.Uri; _pcr.Culture = new CultureInfo(domainAndUri.Domain.Language.CultureAlias); // canonical? not implemented at the moment // if (...) // { // _pcr.RedirectUrl = "..."; // return true; // } } else { // not matching any existing domain LogHelper.Debug("{0}Matches no domain", () => tracePrefix); var defaultLanguage = Language.GetAllAsList().FirstOrDefault(); _pcr.Culture = defaultLanguage == null ? CultureInfo.CurrentUICulture : new CultureInfo(defaultLanguage.CultureAlias); } LogHelper.Debug("{0}Culture=\"{1}\"", () => tracePrefix, () => _pcr.Culture.Name); return _pcr.Domain != null; } /// /// Looks for wildcard domains in the path and updates Culture accordingly. /// internal void HandleWildcardDomains() { const string tracePrefix = "HandleWildcardDomains: "; if (!_pcr.HasPublishedContent) return; var nodePath = _pcr.PublishedContent.Path; LogHelper.Debug("{0}Path=\"{1}\"", () => tracePrefix, () => nodePath); var rootNodeId = _pcr.HasDomain ? _pcr.Domain.RootNodeId : (int?)null; var domain = DomainHelper.FindWildcardDomainInPath(DomainHelper.GetAllDomains(true), nodePath, rootNodeId); if (domain != null) { _pcr.Culture = new CultureInfo(domain.Language.CultureAlias); LogHelper.Debug("{0}Got domain on node {1}, set culture to \"{2}\".", () => tracePrefix, () => domain.RootNodeId, () => _pcr.Culture.Name); } else { LogHelper.Debug("{0}No match.", () => tracePrefix); } } #endregion #region Rendering engine /// /// Finds the rendering engine to use to render a template specified by its alias. /// /// The alias of the template. /// The rendering engine, or Unknown if the template was not found. internal RenderingEngine FindTemplateRenderingEngine(string alias) { if (string.IsNullOrWhiteSpace(alias)) return RenderingEngine.Unknown; alias = alias.Replace('\\', '/'); // forward slashes only // NOTE: we could start with what's the current default? if (FindTemplateRenderingEngineInDirectory(new DirectoryInfo(IOHelper.MapPath(SystemDirectories.MvcViews)), alias, new[] { ".cshtml", ".vbhtml" })) return RenderingEngine.Mvc; if (FindTemplateRenderingEngineInDirectory(new DirectoryInfo(IOHelper.MapPath(SystemDirectories.Masterpages)), alias, new[] { ".master" })) return RenderingEngine.WebForms; return RenderingEngine.Unknown; } internal bool FindTemplateRenderingEngineInDirectory(DirectoryInfo directory, string alias, string[] extensions) { if (directory == null || !directory.Exists) return false; var pos = alias.IndexOf('/'); if (pos > 0) { // recurse var subdir = directory.GetDirectories(alias.Substring(0, pos)).FirstOrDefault(); alias = alias.Substring(pos + 1); return subdir != null && FindTemplateRenderingEngineInDirectory(subdir, alias, extensions); } // look here return directory.GetFiles().Any(f => extensions.Any(e => f.Name.InvariantEquals(alias + e))); } #endregion #region Document and template /// /// Finds the Umbraco document (if any) matching the request, and updates the PublishedContentRequest accordingly. /// /// A value indicating whether a document and template were found. private void FindPublishedContentAndTemplate() { const string tracePrefix = "FindPublishedContentAndTemplate: "; LogHelper.Debug("{0}Path=\"{1}\"", () => tracePrefix, () => _pcr.Uri.AbsolutePath); // run the document finders FindPublishedContent(); // if request has been flagged to redirect then return // whoever called us is in charge of actually redirecting // -- do not process anything any further -- if (_pcr.IsRedirect) return; // not handling umbracoRedirect here but after LookupDocument2 // so internal redirect, 404, etc has precedence over redirect // handle not-found, redirects, access... HandlePublishedContent(); // find a template FindTemplate(); // handle umbracoRedirect FollowExternalRedirect(); // handle wildcard domains HandleWildcardDomains(); } /// /// Tries to find the document matching the request, by running the IPublishedContentFinder instances. /// /// There is no finder collection. internal void FindPublishedContent() { const string tracePrefix = "FindPublishedContent: "; // look for the document // the first successful finder, if any, will set this.PublishedContent, and may also set this.Template // some finders may implement caching using (DisposableTimer.DebugDuration( () => string.Format("{0}Begin finders", tracePrefix), () => string.Format("{0}End finders, {1}", tracePrefix, (_pcr.HasPublishedContent ? "a document was found" : "no document was found")))) { if (_routingContext.PublishedContentFinders == null) throw new InvalidOperationException("There is no finder collection."); _routingContext.PublishedContentFinders.Any(finder => finder.TryFindContent(_pcr)); } // indicate that the published content (if any) we have at the moment is the // one that was found by the standard finders before anything else took place. _pcr.SetIsInitialPublishedContent(); } /// /// Handles the published content (if any). /// /// /// Handles "not found", internal redirects, access validation... /// things that must be handled in one place because they can create loops /// private void HandlePublishedContent() { const string tracePrefix = "HandlePublishedContent: "; // because these might loop, we have to have some sort of infinite loop detection int i = 0, j = 0; const int maxLoop = 8; do { LogHelper.Debug("{0}{1}", () => tracePrefix, () => (i == 0 ? "Begin" : "Loop")); // handle not found if (!_pcr.HasPublishedContent) { _pcr.Is404 = true; LogHelper.Debug("{0}No document, try last chance lookup", () => tracePrefix); // if it fails then give up, there isn't much more that we can do var lastChance = _routingContext.PublishedContentLastChanceFinder; if (lastChance == null || !lastChance.TryFindContent(_pcr)) { LogHelper.Debug("{0}Failed to find a document, give up", () => tracePrefix); break; } LogHelper.Debug("{0}Found a document", () => tracePrefix); } // follow internal redirects as long as it's not running out of control ie infinite loop of some sort j = 0; while (FollowInternalRedirects() && j++ < maxLoop) { } if (j == maxLoop) // we're running out of control break; // ensure access if (_pcr.HasPublishedContent) EnsurePublishedContentAccess(); // loop while we don't have page, ie the redirect or access // got us to nowhere and now we need to run the notFoundLookup again // as long as it's not running out of control ie infinite loop of some sort } while (!_pcr.HasPublishedContent && i++ < maxLoop); if (i == maxLoop || j == maxLoop) { LogHelper.Debug("{0}Looks like we're running into an infinite loop, abort", () => tracePrefix); _pcr.PublishedContent = null; } LogHelper.Debug("{0}End", () => tracePrefix); } /// /// Follows internal redirections through the umbracoInternalRedirectId document property. /// /// A value indicating whether redirection took place and led to a new published document. /// /// Redirecting to a different site root and/or culture will not pick the new site root nor the new culture. /// As per legacy, if the redirect does not work, we just ignore it. /// private bool FollowInternalRedirects() { const string tracePrefix = "FollowInternalRedirects: "; if (_pcr.PublishedContent == null) throw new InvalidOperationException("There is no PublishedContent."); bool redirect = false; var internalRedirect = _pcr.PublishedContent.GetPropertyValue(Constants.Conventions.Content.InternalRedirectId); if (!string.IsNullOrWhiteSpace(internalRedirect)) { LogHelper.Debug("{0}Found umbracoInternalRedirectId={1}", () => tracePrefix, () => internalRedirect); int internalRedirectId; if (!int.TryParse(internalRedirect, out internalRedirectId)) internalRedirectId = -1; if (internalRedirectId <= 0) { // bad redirect - log and display the current page (legacy behavior) //_pcr.Document = null; // no! that would be to force a 404 LogHelper.Debug("{0}Failed to redirect to id={1}: invalid value", () => tracePrefix, () => internalRedirect); } else if (internalRedirectId == _pcr.PublishedContent.Id) { // redirect to self LogHelper.Debug("{0}Redirecting to self, ignore", () => tracePrefix); } else { // redirect to another page var node = _routingContext.UmbracoContext.ContentCache.GetById(internalRedirectId); _pcr.SetInternalRedirectPublishedContent(node); // don't use .PublishedContent here if (node != null) { redirect = true; LogHelper.Debug("{0}Redirecting to id={1}", () => tracePrefix, () => internalRedirectId); } else { LogHelper.Debug("{0}Failed to redirect to id={1}: no such published document", () => tracePrefix, () => internalRedirectId); } } } return redirect; } /// /// Ensures that access to current node is permitted. /// /// Redirecting to a different site root and/or culture will not pick the new site root nor the new culture. private void EnsurePublishedContentAccess() { const string tracePrefix = "EnsurePublishedContentAccess: "; if (_pcr.PublishedContent == null) throw new InvalidOperationException("There is no PublishedContent."); var path = _pcr.PublishedContent.Path; if (Access.IsProtected(_pcr.PublishedContent.Id, path)) { LogHelper.Debug("{0}Page is protected, check for access", () => tracePrefix); //TODO: We coud speed this up, the only reason we are looking up the members is for it's // ProviderUserKey (id). We could store this id in the FormsAuth cookie custom data when // a member logs in. Then we can check if the value exists and just use that, otherwise lookup // the member like we are currently doing. System.Web.Security.MembershipUser user = null; try { var provider = Core.Security.MembershipProviderExtensions.GetMembersMembershipProvider(); user = provider.GetCurrentUser(); } catch (ArgumentException) { LogHelper.Debug("{0}Membership.GetUser returned ArgumentException", () => tracePrefix); } if (user == null || !Member.IsLoggedOn()) { LogHelper.Debug("{0}Not logged in, redirect to login page", () => tracePrefix); var loginPageId = Access.GetLoginPage(path); if (loginPageId != _pcr.PublishedContent.Id) _pcr.PublishedContent = _routingContext.UmbracoContext.ContentCache.GetById(loginPageId); } else if (!Access.HasAccces(_pcr.PublishedContent.Id, user.ProviderUserKey)) { LogHelper.Debug("{0}Current member has not access, redirect to error page", () => tracePrefix); var errorPageId = Access.GetErrorPage(path); if (errorPageId != _pcr.PublishedContent.Id) _pcr.PublishedContent = _routingContext.UmbracoContext.ContentCache.GetById(errorPageId); } else { LogHelper.Debug("{0}Current member has access", () => tracePrefix); } } else { LogHelper.Debug("{0}Page is not protected", () => tracePrefix); } } /// /// Finds a template for the current node, if any. /// private void FindTemplate() { // NOTE: at the moment there is only 1 way to find a template, and then ppl must // use the Prepared event to change the template if they wish. Should we also // implement an ITemplateFinder logic? const string tracePrefix = "FindTemplate: "; if (_pcr.PublishedContent == null) { _pcr.TemplateModel = null; return; } // read the alternate template alias, from querystring, form, cookie or server vars, // only if the published content is the initial once, else the alternate template // does not apply // + optionnally, apply the alternate template on internal redirects var useAltTemplate = _pcr.IsInitialPublishedContent || (UmbracoConfig.For.UmbracoSettings().WebRouting.InternalRedirectPreservesTemplate && _pcr.IsInternalRedirectPublishedContent); string altTemplate = useAltTemplate ? _routingContext.UmbracoContext.HttpContext.Request[Constants.Conventions.Url.AltTemplate] : null; if (string.IsNullOrWhiteSpace(altTemplate)) { // we don't have an alternate template specified. use the current one if there's one already, // which can happen if a content lookup also set the template (LookupByNiceUrlAndTemplate...), // else lookup the template id on the document then lookup the template with that id. if (_pcr.HasTemplate) { LogHelper.Debug("{0}Has a template already, and no alternate template.", () => tracePrefix); return; } // TODO: When we remove the need for a database for templates, then this id should be irrelavent, // not sure how were going to do this nicely. var templateId = _pcr.PublishedContent.TemplateId; if (templateId > 0) { LogHelper.Debug("{0}Look for template id={1}", () => tracePrefix, () => templateId); var template = ApplicationContext.Current.Services.FileService.GetTemplate(templateId); if (template == null) throw new InvalidOperationException("The template with Id " + templateId + " does not exist, the page cannot render"); _pcr.TemplateModel = template; LogHelper.Debug("{0}Got template id={1} alias=\"{2}\"", () => tracePrefix, () => template.Id, () => template.Alias); } else { LogHelper.Debug("{0}No specified template.", () => tracePrefix); } } else { // we have an alternate template specified. lookup the template with that alias // this means the we override any template that a content lookup might have set // so /path/to/page/template1?altTemplate=template2 will use template2 // ignore if the alias does not match - just trace if (_pcr.HasTemplate) LogHelper.Debug("{0}Has a template already, but also an alternate template.", () => tracePrefix); LogHelper.Debug("{0}Look for alternate template alias=\"{1}\"", () => tracePrefix, () => altTemplate); var template = ApplicationContext.Current.Services.FileService.GetTemplate(altTemplate); if (template != null) { _pcr.TemplateModel = template; LogHelper.Debug("{0}Got template id={1} alias=\"{2}\"", () => tracePrefix, () => template.Id, () => template.Alias); } else { LogHelper.Debug("{0}The template with alias=\"{1}\" does not exist, ignoring.", () => tracePrefix, () => altTemplate); } } if (!_pcr.HasTemplate) { LogHelper.Debug("{0}No template was found.", () => tracePrefix); // initial idea was: if we're not already 404 and UmbracoSettings.HandleMissingTemplateAs404 is true // then reset _pcr.Document to null to force a 404. // // but: because we want to let MVC hijack routes even though no template is defined, we decide that // a missing template is OK but the request will then be forwarded to MVC, which will need to take // care of everything. // // so, don't set _pcr.Document to null here } else { LogHelper.Debug("{0}Running with template id={1} alias=\"{2}\"", () => tracePrefix, () => _pcr.TemplateModel.Id, () => _pcr.TemplateModel.Alias); } } /// /// Follows external redirection through umbracoRedirect document property. /// /// As per legacy, if the redirect does not work, we just ignore it. private void FollowExternalRedirect() { if (!_pcr.HasPublishedContent) return; var redirectId = _pcr.PublishedContent.GetPropertyValue(Constants.Conventions.Content.Redirect, -1); var redirectUrl = "#"; if (redirectId > 0) redirectUrl = _routingContext.UrlProvider.GetUrl(redirectId); if (redirectUrl != "#") _pcr.SetRedirect(redirectUrl); } #endregion } }