diff --git a/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Maintenance.cshtml b/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Maintenance.cshtml new file mode 100644 index 0000000000..46739cdef7 --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Maintenance.cshtml @@ -0,0 +1,69 @@ +@using Microsoft.Extensions.Options +@using Umbraco.Cms.Core.Configuration.Models +@using Umbraco.Cms.Core.Hosting +@using Umbraco.Cms.Core.Routing +@using Umbraco.Extensions +@inject IHostingEnvironment hostingEnvironment +@inject IOptions globalSettings +@{ + var backOfficePath = globalSettings.Value.GetBackOfficePath(hostingEnvironment); +} + + + + + + + + Website is Under Maintainance + + + + + + +
+
+
+

Website is Under Maintenance

+ +
+ +
+
+

This page can be replaced

+

+ Custom error handling might make your site look more on-brand and minimize the impact of errors on user experience - for example, a custom 404 with some helpful links (or a search function) could bring some value to the site. +

+ + Implementing custom error pages → +
+ +
+

Finish the maintenance

+

If you are an administrator, you finish the maintanance by going to backoffice.

+ + Handle the upgrade → +
+
+ +
+
+ +
+ + + diff --git a/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs b/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs index 92c443fd77..7631f243c8 100644 --- a/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs @@ -33,6 +33,7 @@ public class GlobalSettings internal const bool StaticSanitizeTinyMce = false; internal const int StaticMainDomReleaseSignalPollingInterval = 2000; private const bool StaticForceCombineUrlPathLeftToRight = true; + private const bool StaticShowMaintenancePageWhenInUpgradeState = true; /// /// Gets or sets a value for the reserved URLs (must end with a comma). @@ -252,4 +253,7 @@ public class GlobalSettings /// [DefaultValue(StaticForceCombineUrlPathLeftToRight)] public bool ForceCombineUrlPathLeftToRight { get; set; } = StaticForceCombineUrlPathLeftToRight; + + [DefaultValue(StaticShowMaintenancePageWhenInUpgradeState)] + public bool ShowMaintenancePageWhenInUpgradeState { get; set; } = StaticShowMaintenancePageWhenInUpgradeState; } diff --git a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs index c5567d1796..f8d579e952 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -120,6 +121,13 @@ public class BackOfficeController : UmbracoController // Check if we not are in an run state, if so we need to redirect if (_runtimeState.Level != RuntimeLevel.Run) { + if (_runtimeState.Level == RuntimeLevel.Upgrade) + { + return RedirectToAction(nameof(AuthorizeUpgrade), routeValues: new RouteValueDictionary() + { + ["redir"] = _globalSettings.GetBackOfficePath(_hostingEnvironment), + }); + } return Redirect("/"); } diff --git a/src/Umbraco.Web.BackOffice/Install/InstallAreaRoutes.cs b/src/Umbraco.Web.BackOffice/Install/InstallAreaRoutes.cs index 0ea55d861d..4e65aaef0f 100644 --- a/src/Umbraco.Web.BackOffice/Install/InstallAreaRoutes.cs +++ b/src/Umbraco.Web.BackOffice/Install/InstallAreaRoutes.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Hosting; @@ -28,23 +29,12 @@ public class InstallAreaRoutes : IAreaRoutes switch (_runtime.Level) { - case var _ when _runtime.EnableInstaller(): + case RuntimeLevel.Install: + case RuntimeLevel.Upgrade: + case RuntimeLevel.Run: + endpoints.MapUmbracoRoute(installPathSegment, Constants.Web.Mvc.InstallArea, "api", includeControllerNameInRoute: false); endpoints.MapUmbracoRoute(installPathSegment, Constants.Web.Mvc.InstallArea, string.Empty, includeControllerNameInRoute: false); - - // register catch all because if we are in install/upgrade mode then we'll catch everything - endpoints.MapFallbackToAreaController(nameof(InstallController.Index), ControllerExtensions.GetControllerName(), Constants.Web.Mvc.InstallArea); - - break; - case RuntimeLevel.Run: - // when we are in run mode redirect to the back office if the installer endpoint is hit - endpoints.MapGet($"{installPathSegment}/{{controller?}}/{{action?}}", context => - { - // redirect to umbraco - context.Response.Redirect(_linkGenerator.GetBackOfficeUrl(_hostingEnvironment)!, false); - return Task.CompletedTask; - }); - break; case RuntimeLevel.BootFailed: case RuntimeLevel.Unknown: diff --git a/src/Umbraco.Web.Common/ActionsResults/MaintenanceResult.cs b/src/Umbraco.Web.Common/ActionsResults/MaintenanceResult.cs new file mode 100644 index 0000000000..6e27321f00 --- /dev/null +++ b/src/Umbraco.Web.Common/ActionsResults/MaintenanceResult.cs @@ -0,0 +1,27 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Web; + +namespace Umbraco.Cms.Web.Common.ActionsResults; + +/// +/// Returns the Umbraco maintenance result +/// +public class MaintenanceResult : IActionResult +{ + /// + public async Task ExecuteResultAsync(ActionContext context) + { + HttpResponse response = context.HttpContext.Response; + + response.Clear(); + + response.StatusCode = StatusCodes.Status503ServiceUnavailable; + + var viewResult = new ViewResult { ViewName = "~/umbraco/UmbracoWebsite/Maintenance.cshtml" }; + + await viewResult.ExecuteResultAsync(context); + } +} diff --git a/src/Umbraco.Web.Common/Authorization/FeatureAuthorizeHandler.cs b/src/Umbraco.Web.Common/Authorization/FeatureAuthorizeHandler.cs index 485208064d..596cba7f3d 100644 --- a/src/Umbraco.Web.Common/Authorization/FeatureAuthorizeHandler.cs +++ b/src/Umbraco.Web.Common/Authorization/FeatureAuthorizeHandler.cs @@ -3,7 +3,11 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Features; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.DependencyInjection; namespace Umbraco.Cms.Web.Common.Authorization; @@ -13,8 +17,20 @@ namespace Umbraco.Cms.Web.Common.Authorization; public class FeatureAuthorizeHandler : AuthorizationHandler { private readonly UmbracoFeatures _umbracoFeatures; + private readonly IRuntimeState _runtimeState; - public FeatureAuthorizeHandler(UmbracoFeatures umbracoFeatures) => _umbracoFeatures = umbracoFeatures; + public FeatureAuthorizeHandler(UmbracoFeatures umbracoFeatures, IRuntimeState runtimeState) + { + _umbracoFeatures = umbracoFeatures; + _runtimeState = runtimeState; + } + + [Obsolete("Use ctor that is not obsolete. This will be removed in v13.")] + public FeatureAuthorizeHandler(UmbracoFeatures umbracoFeatures) + :this(umbracoFeatures, StaticServiceProvider.Instance.GetRequiredService()) + { + + } protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, @@ -35,6 +51,11 @@ public class FeatureAuthorizeHandler : AuthorizationHandler _globalSettings; + + public MaintenanceModeActionFilter(IRuntimeState runtimeState, IOptionsMonitor globalSettings) + { + _runtimeState = runtimeState; + _globalSettings = globalSettings; + } + + public void OnActionExecuting(ActionExecutingContext context) + { + if (_runtimeState.Level == RuntimeLevel.Upgrade && _globalSettings.CurrentValue.ShowMaintenancePageWhenInUpgradeState) + { + context.Result = new MaintenanceResult(); + } + + } + + public void OnActionExecuted(ActionExecutedContext context) + { + + } + } +} diff --git a/src/Umbraco.Web.Common/Controllers/RenderController.cs b/src/Umbraco.Web.Common/Controllers/RenderController.cs index a9771c0ab8..dad8d8a84b 100644 --- a/src/Umbraco.Web.Common/Controllers/RenderController.cs +++ b/src/Umbraco.Web.Common/Controllers/RenderController.cs @@ -16,6 +16,7 @@ namespace Umbraco.Cms.Web.Common.Controllers; /// [ModelBindingException] [PublishedRequestFilter] +[MaintenanceModeActionFilter] public class RenderController : UmbracoPageController, IRenderController { private readonly ILogger _logger; diff --git a/src/Umbraco.Web.Common/Middleware/UmbracoRequestMiddleware.cs b/src/Umbraco.Web.Common/Middleware/UmbracoRequestMiddleware.cs index cf73ba481c..b470b66752 100644 --- a/src/Umbraco.Web.Common/Middleware/UmbracoRequestMiddleware.cs +++ b/src/Umbraco.Web.Common/Middleware/UmbracoRequestMiddleware.cs @@ -216,7 +216,8 @@ public class UmbracoRequestMiddleware : IMiddleware if (_umbracoRequestPaths.IsBackOfficeRequest(absPath) || (absPath.Value?.InvariantStartsWith($"/{_smidgeOptions.UrlOptions.CompositeFilePath}") ?? false) - || (absPath.Value?.InvariantStartsWith($"/{_smidgeOptions.UrlOptions.BundleFilePath}") ?? false)) + || (absPath.Value?.InvariantStartsWith($"/{_smidgeOptions.UrlOptions.BundleFilePath}") ?? false) + || _runtimeState.EnableInstaller()) { LazyInitializer.EnsureInitialized(ref s_firstBackOfficeRequest, ref s_firstBackOfficeReqestFlag, ref s_firstBackOfficeRequestLocker, () => diff --git a/src/Umbraco.Web.Website/Routing/FrontEndRoutes.cs b/src/Umbraco.Web.Website/Routing/FrontEndRoutes.cs index 6ebf77727a..8c0e36a40f 100644 --- a/src/Umbraco.Web.Website/Routing/FrontEndRoutes.cs +++ b/src/Umbraco.Web.Website/Routing/FrontEndRoutes.cs @@ -41,13 +41,22 @@ public sealed class FrontEndRoutes : IAreaRoutes /// public void CreateRoutes(IEndpointRouteBuilder endpoints) { - if (_runtimeState.Level != RuntimeLevel.Run) + switch (_runtimeState.Level) { - return; + case RuntimeLevel.Install: + case RuntimeLevel.Upgrade: + case RuntimeLevel.Run: + + AutoRouteSurfaceControllers(endpoints); + AutoRouteFrontEndApiControllers(endpoints); + break; + case RuntimeLevel.BootFailed: + case RuntimeLevel.Unknown: + case RuntimeLevel.Boot: + break; } - AutoRouteSurfaceControllers(endpoints); - AutoRouteFrontEndApiControllers(endpoints); + } /// diff --git a/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs b/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs index c1cc360500..5617797ec7 100644 --- a/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs +++ b/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs @@ -117,9 +117,20 @@ public class UmbracoRouteValueTransformer : DynamicRouteValueTransformer public override async ValueTask TransformAsync( HttpContext httpContext, RouteValueDictionary values) { - // If we aren't running, then we have nothing to route - if (_runtime.Level != RuntimeLevel.Run) + // If we aren't running, then we have nothing to route. We allow the frontend to continue while in upgrade mode. + if (_runtime.Level != RuntimeLevel.Run && _runtime.Level != RuntimeLevel.Upgrade) { + if (_runtime.Level == RuntimeLevel.Install) + { + return new RouteValueDictionary() + { + //TODO figure out constants + [ControllerToken] = "Install", + [ActionToken] = "Index", + [AreaToken] = Constants.Web.Mvc.InstallArea, + }; + } + return null!; } @@ -184,6 +195,7 @@ public class UmbracoRouteValueTransformer : DynamicRouteValueTransformer // our default 404 page but we cannot return route values now because // it's possible that a developer is handling dynamic routes too. // Our 404 page will be handled with the NotFoundSelectorPolicy + return null!; } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/InstallAreaRoutesTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/InstallAreaRoutesTests.cs index c5a8eb5201..6655475e36 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/InstallAreaRoutesTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/InstallAreaRoutesTests.cs @@ -38,7 +38,7 @@ public class InstallAreaRoutesTests var endpoints = new TestRouteBuilder(); routes.CreateRoutes(endpoints); - Assert.AreEqual(2, endpoints.DataSources.Count); + Assert.AreEqual(1, endpoints.DataSources.Count); var route = endpoints.DataSources.First(); Assert.AreEqual(2, route.Endpoints.Count); @@ -64,10 +64,11 @@ public class InstallAreaRoutesTests endpoint2.RoutePattern.Defaults[AreaToken], typeof(InstallController).GetCustomAttribute(false).RouteValue); - var fallbackRoute = endpoints.DataSources.Last(); - Assert.AreEqual(1, fallbackRoute.Endpoints.Count); + var dataSource = endpoints.DataSources.Last(); + Assert.AreEqual(2, dataSource.Endpoints.Count); - Assert.AreEqual("Fallback {*path:nonfile}", fallbackRoute.Endpoints[0].ToString()); + Assert.AreEqual("Route: install/api/{action}/{id?}", dataSource.Endpoints[0].ToString()); + Assert.AreEqual("Route: install/{action}/{id?}", dataSource.Endpoints[1].ToString()); } [Test] @@ -79,10 +80,13 @@ public class InstallAreaRoutesTests Assert.AreEqual(1, endpoints.DataSources.Count); var route = endpoints.DataSources.First(); - Assert.AreEqual(1, route.Endpoints.Count); + Assert.AreEqual(2, route.Endpoints.Count); var routeEndpoint = (RouteEndpoint)route.Endpoints[0]; - Assert.AreEqual("install/{controller?}/{action?}", routeEndpoint.RoutePattern.RawText); + Assert.AreEqual("install/api/{action}/{id?}", routeEndpoint.RoutePattern.RawText); + + routeEndpoint = (RouteEndpoint)route.Endpoints[1]; + Assert.AreEqual("install/{action}/{id?}", routeEndpoint.RoutePattern.RawText); } private InstallAreaRoutes GetInstallAreaRoutes(RuntimeLevel level) =>