diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/PartialViewControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/PartialViewControllerBase.cs index 57af2377bab4..22df980a88b8 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/PartialViewControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/PartialViewControllerBase.cs @@ -38,6 +38,10 @@ protected IActionResult PartialViewOperationStatusResult(PartialViewOperationSta .WithDetail("The partial view name is invalid.") .Build()), PartialViewOperationStatus.NotFound => PartialViewNotFound(), + PartialViewOperationStatus.NotAllowedInProductionMode => BadRequest(problemDetailsBuilder + .WithTitle("Not allowed in production mode") + .WithDetail("Partial view modifications are not allowed when running in production mode.") + .Build()), _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder .WithTitle("Unknown partial view operation status.") .Build()), diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Template/TemplateControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Template/TemplateControllerBase.cs index d0bc1db9250c..6bd203057909 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Template/TemplateControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Template/TemplateControllerBase.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.Builders; @@ -42,6 +42,14 @@ protected IActionResult TemplateOperationStatusResult(TemplateOperationStatus st .WithTitle("Master template cannot be deleted") .WithDetail("The master templates cannot be deleted. Please ensure the template is not a master template before you delete.") .Build()), + TemplateOperationStatus.NotAllowedInProductionMode => BadRequest(problemDetailsBuilder + .WithTitle("Not allowed in production mode") + .WithDetail("Template modifications are not allowed when running in production mode.") + .Build()), + TemplateOperationStatus.ContentChangeNotAllowedInProductionMode => BadRequest(problemDetailsBuilder + .WithTitle("Content change not allowed in production mode") + .WithDetail("Template content changes are not allowed when running in production mode. Metadata updates are permitted.") + .Build()), _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder .WithTitle("Unknown template operation status.") .Build()), diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 3d13fe7c58aa..703020f91f14 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -328,11 +328,11 @@ private void AddCoreServices() Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); - Services.AddUnique(); + Services.AddUnique(sp => ActivatorUtilities.CreateInstance(sp)); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); - Services.AddUnique(); + Services.AddUnique(sp => ActivatorUtilities.CreateInstance(sp)); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); diff --git a/src/Umbraco.Core/Services/OperationStatus/PartialViewOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/PartialViewOperationStatus.cs index 4093240c315c..03cfbb73c7a4 100644 --- a/src/Umbraco.Core/Services/OperationStatus/PartialViewOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/PartialViewOperationStatus.cs @@ -43,5 +43,10 @@ public enum PartialViewOperationStatus /// /// The specified partial view was not found. /// - NotFound + NotFound, + + /// + /// The operation is not allowed when running in production mode. + /// + NotAllowedInProductionMode } diff --git a/src/Umbraco.Core/Services/OperationStatus/TemplateOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/TemplateOperationStatus.cs index 9671130fdbd3..ed19b644f06a 100644 --- a/src/Umbraco.Core/Services/OperationStatus/TemplateOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/TemplateOperationStatus.cs @@ -44,4 +44,6 @@ public enum TemplateOperationStatus /// The operation failed because the master template cannot be deleted while it has child templates. /// MasterTemplateCannotBeDeleted, + NotAllowedInProductionMode, + ContentChangeNotAllowedInProductionMode, } diff --git a/src/Umbraco.Core/Services/PartialViewService.cs b/src/Umbraco.Core/Services/PartialViewService.cs index fe19e9711199..7227da47f326 100644 --- a/src/Umbraco.Core/Services/PartialViewService.cs +++ b/src/Umbraco.Core/Services/PartialViewService.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; @@ -23,6 +25,14 @@ namespace Umbraco.Cms.Core.Services; public class PartialViewService : FileServiceOperationBase, IPartialViewService { private readonly PartialViewSnippetCollection _snippetCollection; + private readonly IOptions _runtimeSettings; + + // TODO (V18): Remove obsolete constructors and the ActivatorUtilitiesConstructor attribute. + // Also update UmbracoBuilder where this service is registered using: + // Services.AddUnique(sp => ActivatorUtilities.CreateInstance(sp)); + // We do this to allow the ActivatorUtilitiesConstructor to be used (it's otherwise ignored by AddUnique). + // Revert it to: + // Services.AddUnique(); /// /// Initializes a new instance of the class. @@ -35,6 +45,8 @@ public class PartialViewService : FileServiceOperationBaseThe resolver for converting user keys to IDs. /// The service for audit logging. /// The collection of available partial view snippets. + /// The runtime configuration settings. + [ActivatorUtilitiesConstructor] public PartialViewService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, @@ -43,22 +55,15 @@ public PartialViewService( ILogger logger, IUserIdKeyResolver userIdKeyResolver, IAuditService auditService, - PartialViewSnippetCollection snippetCollection) + PartialViewSnippetCollection snippetCollection, + IOptions runtimeSettings) : base(provider, loggerFactory, eventMessagesFactory, repository, logger, userIdKeyResolver, auditService) - => _snippetCollection = snippetCollection; + { + _snippetCollection = snippetCollection; + _runtimeSettings = runtimeSettings; + } - /// - /// Initializes a new instance of the class. - /// - /// The core scope provider for managing database transactions. - /// The factory for creating loggers. - /// The factory for creating event messages. - /// The repository for partial view file operations. - /// The logger instance for logging operations. - /// The resolver for converting user keys to IDs. - /// The repository for audit logging (obsolete). - /// The collection of available partial view snippets. - [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 19.")] + [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 18.")] public PartialViewService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, @@ -76,23 +81,12 @@ public PartialViewService( logger, userIdKeyResolver, StaticServiceProvider.Instance.GetRequiredService(), - snippetCollection) + snippetCollection, + StaticServiceProvider.Instance.GetRequiredService>()) { } - /// - /// Initializes a new instance of the class. - /// - /// The core scope provider for managing database transactions. - /// The factory for creating loggers. - /// The factory for creating event messages. - /// The repository for partial view file operations. - /// The logger instance for logging operations. - /// The resolver for converting user keys to IDs. - /// The service for audit logging. - /// The repository for audit logging (obsolete). - /// The collection of available partial view snippets. - [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 19.")] + [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 18.")] public PartialViewService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, @@ -111,13 +105,40 @@ public PartialViewService( logger, userIdKeyResolver, auditService, - snippetCollection) + snippetCollection, + StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 18.")] + public PartialViewService( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IPartialViewRepository repository, + ILogger logger, + IUserIdKeyResolver userIdKeyResolver, + IAuditService auditService, + PartialViewSnippetCollection snippetCollection) + : this( + provider, + loggerFactory, + eventMessagesFactory, + repository, + logger, + userIdKeyResolver, + auditService, + snippetCollection, + StaticServiceProvider.Instance.GetRequiredService>()) { } /// protected override string[] AllowedFileExtensions { get; } = { ".cshtml" }; + /// + private bool IsProductionMode => _runtimeSettings.Value.Mode == RuntimeMode.Production; + /// protected override PartialViewOperationStatus Success => PartialViewOperationStatus.Success; @@ -189,17 +210,45 @@ public Task> GetSnippetsAsync(int skip, int t /// public async Task DeleteAsync(string path, Guid userKey) - => await HandleDeleteAsync(path, userKey); + { + if (IsProductionMode) + { + return PartialViewOperationStatus.NotAllowedInProductionMode; + } + + return await HandleDeleteAsync(path, userKey); + } /// public async Task> CreateAsync(PartialViewCreateModel createModel, Guid userKey) - => await HandleCreateAsync(createModel.Name, createModel.ParentPath, createModel.Content, userKey); + { + if (IsProductionMode) + { + return Attempt.FailWithStatus(PartialViewOperationStatus.NotAllowedInProductionMode, null); + } + + return await HandleCreateAsync(createModel.Name, createModel.ParentPath, createModel.Content, userKey); + } /// public async Task> UpdateAsync(string path, PartialViewUpdateModel updateModel, Guid userKey) - => await HandleUpdateAsync(path, updateModel.Content, userKey); + { + if (IsProductionMode) + { + return Attempt.FailWithStatus(PartialViewOperationStatus.NotAllowedInProductionMode, null); + } + + return await HandleUpdateAsync(path, updateModel.Content, userKey); + } /// public async Task> RenameAsync(string path, PartialViewRenameModel renameModel, Guid userKey) - => await HandleRenameAsync(path, renameModel.Name, userKey); + { + if (IsProductionMode) + { + return Attempt.FailWithStatus(PartialViewOperationStatus.NotAllowedInProductionMode, null); + } + + return await HandleRenameAsync(path, renameModel.Name, userKey); + } } diff --git a/src/Umbraco.Core/Services/TemplateService.cs b/src/Umbraco.Core/Services/TemplateService.cs index 00149dc545b9..d87ec0e29b74 100644 --- a/src/Umbraco.Core/Services/TemplateService.cs +++ b/src/Umbraco.Core/Services/TemplateService.cs @@ -1,11 +1,12 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; -using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services.OperationStatus; @@ -23,6 +24,14 @@ public class TemplateService : RepositoryService, ITemplateService private readonly ITemplateRepository _templateRepository; private readonly IAuditService _auditService; private readonly ITemplateContentParserService _templateContentParserService; + private readonly IOptions _runtimeSettings; + + // TODO (V18): Remove obsolete constructors and the ActivatorUtilitiesConstructor attribute. + // Also update UmbracoBuilder where this service is registered using: + // Services.AddUnique(sp => ActivatorUtilities.CreateInstance(sp)); + // We do this to allow the ActivatorUtilitiesConstructor to be used (it's otherwise ignored by AddUnique). + // Revert it to: + // Services.AddUnique(); /// /// Initializes a new instance of the class. @@ -34,6 +43,8 @@ public class TemplateService : RepositoryService, ITemplateService /// The repository for template data access. /// The audit service for recording audit entries. /// The service for parsing template content. + /// The runtime configuration settings. + [ActivatorUtilitiesConstructor] public TemplateService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, @@ -41,28 +52,39 @@ public TemplateService( IShortStringHelper shortStringHelper, ITemplateRepository templateRepository, IAuditService auditService, - ITemplateContentParserService templateContentParserService) + ITemplateContentParserService templateContentParserService, + IOptions runtimeSettings) : base(provider, loggerFactory, eventMessagesFactory) { _shortStringHelper = shortStringHelper; _templateRepository = templateRepository; _auditService = auditService; _templateContentParserService = templateContentParserService; + _runtimeSettings = runtimeSettings; } - /// - /// Initializes a new instance of the class. - /// - /// The scope provider for unit of work operations. - /// The logger factory for creating loggers. - /// The factory for creating event messages. - /// The helper for short string operations. - /// The repository for template data access. - /// The audit repository (unused, kept for backward compatibility). - /// The service for parsing template content. - /// The resolver for converting user IDs to keys (unused, kept for backward compatibility). - /// The provider for default view content (unused, kept for backward compatibility). - [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 19.")] + [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 18.")] + public TemplateService( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IShortStringHelper shortStringHelper, + ITemplateRepository templateRepository, + IAuditService auditService, + ITemplateContentParserService templateContentParserService) + : this( + provider, + loggerFactory, + eventMessagesFactory, + shortStringHelper, + templateRepository, + auditService, + templateContentParserService, + StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 18.")] public TemplateService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, @@ -84,20 +106,7 @@ public TemplateService( { } - /// - /// Initializes a new instance of the class. - /// - /// The scope provider for unit of work operations. - /// The logger factory for creating loggers. - /// The factory for creating event messages. - /// The helper for short string operations. - /// The repository for template data access. - /// The audit service for recording audit entries. - /// The audit repository (unused, kept for backward compatibility). - /// The service for parsing template content. - /// The resolver for converting user IDs to keys (unused, kept for backward compatibility). - /// The provider for default view content (unused, kept for backward compatibility). - [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 19.")] + [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 18.")] public TemplateService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, @@ -120,6 +129,8 @@ public TemplateService( { } + private bool IsProductionMode => _runtimeSettings.Value.Mode == RuntimeMode.Production; + /// [Obsolete("Use the overload that includes name and alias parameters instead. Scheduled for removal in Umbraco 19.")] public async Task> CreateForContentTypeAsync( @@ -205,9 +216,14 @@ public async Task> CreateAsync(ITemp /// /// The template to validate. /// The operation status indicating the result of the validation. - private TemplateOperationStatus ValidateCreate(ITemplate templateToCreate) + private async Task ValidateCreateAsync(ITemplate templateToCreate) { - ITemplate? existingTemplate = GetAsync(templateToCreate.Alias).GetAwaiter().GetResult(); + if (IsProductionMode) + { + return TemplateOperationStatus.NotAllowedInProductionMode; + } + + ITemplate? existingTemplate = await GetAsync(templateToCreate.Alias); if (existingTemplate is not null) { return TemplateOperationStatus.DuplicateAlias; @@ -283,17 +299,16 @@ public async Task> UpdateAsync(ITemp template, AuditType.Save, userKey, - // fail the attempt if the template does not exist within the scope - () => ValidateUpdate(template)); + () => ValidateUpdateAsync(template)); /// /// Validates that a template can be updated. /// /// The template to validate. /// The operation status indicating the result of the validation. - private TemplateOperationStatus ValidateUpdate(ITemplate templateToUpdate) + private async Task ValidateUpdateAsync(ITemplate templateToUpdate) { - ITemplate? existingTemplate = GetAsync(templateToUpdate.Alias).GetAwaiter().GetResult(); + ITemplate? existingTemplate = await GetAsync(templateToUpdate.Alias); if (existingTemplate is not null && existingTemplate.Key != templateToUpdate.Key) { return TemplateOperationStatus.DuplicateAlias; @@ -304,6 +319,19 @@ private TemplateOperationStatus ValidateUpdate(ITemplate templateToUpdate) return TemplateOperationStatus.TemplateNotFound; } + // In production mode, block updates if the content is being changed. + if (IsProductionMode) + { + // Reuse existingTemplate if keys match (same template), otherwise fetch by key. + ITemplate? existingByKey = existingTemplate?.Key == templateToUpdate.Key + ? existingTemplate + : await GetAsync(templateToUpdate.Key); + if (existingByKey is not null && existingByKey.Content != templateToUpdate.Content) + { + return TemplateOperationStatus.ContentChangeNotAllowedInProductionMode; + } + } + return TemplateOperationStatus.Success; } @@ -317,11 +345,11 @@ private TemplateOperationStatus ValidateUpdate(ITemplate templateToUpdate) /// The optional content type alias for the saving notification. /// An attempt result containing the template and operation status. private async Task> SaveAsync( - ITemplate template, - AuditType auditType, - Guid userKey, - Func? scopeValidator = null, - string? contentTypeAlias = null) + ITemplate template, + AuditType auditType, + Guid userKey, + Func>? scopeValidatorAsync = null, + string? contentTypeAlias = null) { if (IsValidAlias(template.Alias) == false) { @@ -330,7 +358,9 @@ private async Task> SaveAsync( using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { - TemplateOperationStatus scopeValidatorStatus = scopeValidator?.Invoke() ?? TemplateOperationStatus.Success; + TemplateOperationStatus scopeValidatorStatus = scopeValidatorAsync is not null + ? await scopeValidatorAsync() + : TemplateOperationStatus.Success; if (scopeValidatorStatus != TemplateOperationStatus.Success) { return Attempt.FailWithStatus(scopeValidatorStatus, template); @@ -533,7 +563,7 @@ private async Task> CreateAsync(ITem { // file might already be on disk, if so grab the content to avoid overwriting template.Content = GetViewContent(template.Alias) ?? template.Content; - return await SaveAsync(template, AuditType.New, userKey, () => ValidateCreate(template), contentTypeAlias); + return await SaveAsync(template, AuditType.New, userKey, () => ValidateCreateAsync(template), contentTypeAlias); } catch (PathTooLongException ex) { @@ -550,6 +580,11 @@ private async Task> CreateAsync(ITem /// An attempt result containing the deleted template and operation status. private async Task> DeleteAsync(Func> getTemplate, Guid userKey) { + if (IsProductionMode) + { + return Attempt.FailWithStatus(TemplateOperationStatus.NotAllowedInProductionMode, null); + } + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { ITemplate? template = await getTemplate(); diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PartialViewRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PartialViewRepository.cs index 4aecb0f90e37..0efc7167315d 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PartialViewRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PartialViewRepository.cs @@ -1,4 +1,6 @@ using System.Text; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence.Repositories; @@ -6,18 +8,20 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +/// +/// Provides methods to manage and retrieve partial view items from a file system. +/// internal sealed class PartialViewRepository : FileRepository, IPartialViewRepository { - public PartialViewRepository(FileSystems fileSystems) - : base(fileSystems.PartialViewsFileSystem) - { - } + private readonly IOptionsMonitor _runtimeSettings; - private PartialViewRepository(IFileSystem? fileSystem) - : base(fileSystem) - { - } + /// + /// Initializes a new instance of the class. + /// + public PartialViewRepository(FileSystems fileSystems, IOptionsMonitor runtimeSettings) + : base(fileSystems.PartialViewsFileSystem) => _runtimeSettings = runtimeSettings; + /// public override IPartialView? Get(string? id) { if (FileSystem is null) @@ -57,19 +61,19 @@ private PartialViewRepository(IFileSystem? fileSystem) return view; } + /// public override void Save(IPartialView entity) { - var partialView = entity as PartialView; - base.Save(entity); // ensure that from now on, content is lazy-loaded - if (partialView != null && partialView.GetFileContent == null) + if (entity is PartialView partialView && partialView.GetFileContent == null) { partialView.GetFileContent = file => GetFileContent(file.OriginalPath); } } + /// public override IEnumerable GetMany(params string[]? ids) { // ensure they are de-duplicated, easy win if people don't do this as this can cause many excess queries @@ -120,12 +124,42 @@ public Stream GetFileContentStream(string filepath) public void SetFileContent(string filepath, Stream content) => FileSystem?.AddFile(filepath, content, true); /// - /// Gets a stream that is used to write to the file + /// Persists a new partial view item, but only when not in production runtime mode. + /// + /// The partial view entity to persist. + protected override void PersistNewItem(IPartialView entity) + { + // Only save file when not in production runtime mode. + if (_runtimeSettings.CurrentValue.Mode == RuntimeMode.Production) + { + return; + } + + base.PersistNewItem(entity); + } + + /// + /// Persists an updated partial view item, but only when not in production runtime mode. + /// + /// The partial view entity to persist. + protected override void PersistUpdatedItem(IPartialView entity) + { + // Only save file when not in production runtime mode. + if (_runtimeSettings.CurrentValue.Mode == RuntimeMode.Production) + { + return; + } + + base.PersistUpdatedItem(entity); + } + + /// + /// Gets a stream that is used to write to the file. /// /// /// /// - /// This ensures the stream includes a utf8 BOM + /// This ensures the stream includes a utf8 BOM. /// protected override Stream GetContentStream(string content) { diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts index d5637954eafb..69b96f463ee2 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -1617,6 +1617,7 @@ export default { tabRules: 'Editor', }, template: { + productionMode: 'Production Mode', runtimeModeProduction: 'Content is not editable when using runtime mode Production.', deleteByIdFailed: 'Failed to delete template with ID %0%', edittemplate: 'Edit template', diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/dropdown/dropdown.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/dropdown/dropdown.element.ts index fb5004f57204..3bf0c1d62109 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/dropdown/dropdown.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/dropdown/dropdown.element.ts @@ -43,6 +43,9 @@ export class UmbDropdownElement extends UmbLitElement { @property({ type: Boolean }) compact = false; + @property({ type: Boolean, reflect: true }) + disabled = false; + @property({ type: Boolean, attribute: 'hide-expand' }) hideExpand = false; @@ -87,7 +90,8 @@ export class UmbDropdownElement extends UmbLitElement { .look=${this.look} .color=${this.color} .label=${this.label ?? ''} - .compact=${this.compact}> + .compact=${this.compact} + ?disabled=${this.disabled}> ${when( !this.hideExpand, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/manifests.ts index 72edb2907f6d..2821eaee833d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/manifests.ts @@ -22,6 +22,7 @@ import { manifests as recycleBinManifests } from './recycle-bin/manifests.js'; import { manifests as routerManifests } from './router/manifests.js'; import { manifests as searchManifests } from './search/manifests.js'; import { manifests as sectionManifests } from './section/manifests.js'; +import { manifests as serverManifests } from './server/manifests.js'; import { manifests as serverFileSystemManifests } from './server-file-system/manifests.js'; import { manifests as temporaryFileManifests } from './temporary-file/manifests.js'; import { manifests as themeManifests } from './themes/manifests.js'; @@ -55,6 +56,7 @@ export const manifests: Array = ...routerManifests, ...searchManifests, ...sectionManifests, + ...serverManifests, ...serverFileSystemManifests, ...temporaryFileManifests, ...themeManifests, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/index.ts index a673c9a5e996..a856ba3f1fe5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/index.ts @@ -1,4 +1,5 @@ export * from './confirm/index.js'; export * from './discard-changes/index.js'; export * from './error-viewer/index.js'; +export * from './info/index.js'; export * from './item-picker/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/info/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/info/index.ts new file mode 100644 index 000000000000..f5cce1188a6e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/info/index.ts @@ -0,0 +1,3 @@ +export * from './info-modal.controller.js'; +export * from './info-modal.element.js'; +export * from './info-modal.token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/info/info-modal.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/info/info-modal.controller.ts new file mode 100644 index 000000000000..0dd63b1396fd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/info/info-modal.controller.ts @@ -0,0 +1,13 @@ +import { UmbOpenModalController } from '../../controller/open-modal.controller.js'; +import { UMB_INFO_MODAL, type UmbInfoModalData } from './info-modal.token.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +/** + * + * @param host {UmbControllerHost} - The host controller + * @param data {UmbInfoModalData} - The data to pass to the modal + * @returns {UmbOpenModalController} The modal controller instance + */ +export function umbInfoModal(host: UmbControllerHost, data: UmbInfoModalData) { + return new UmbOpenModalController(host).open(UMB_INFO_MODAL, { data }); +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/info/info-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/info/info-modal.element.ts new file mode 100644 index 000000000000..543887558e67 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/info/info-modal.element.ts @@ -0,0 +1,52 @@ +import type { UmbModalContext } from '../../context/index.js'; +import type { UmbInfoModalData, UmbInfoModalValue } from './info-modal.token.js'; +import { html, customElement, property, css, unsafeHTML } from '@umbraco-cms/backoffice/external/lit'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UmbLitElement, umbFocus } from '@umbraco-cms/backoffice/lit-element'; + +@customElement('umb-info-modal') +export class UmbInfoModalElement extends UmbLitElement { + @property({ attribute: false }) + modalContext?: UmbModalContext; + + @property({ type: Object, attribute: false }) + data?: UmbInfoModalData; + + private _handleClose() { + this.modalContext?.reject(); + } + + override render() { + return html` + + ${typeof this.data?.content === 'string' + ? unsafeHTML(this.localize.string(this.data?.content)) + : this.data?.content} + + + + `; + } + + static override styles = [ + UmbTextStyles, + css` + uui-dialog-layout { + max-inline-size: 60ch; + } + `, + ]; +} + +export default UmbInfoModalElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-info-modal': UmbInfoModalElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/info/info-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/info/info-modal.token.ts new file mode 100644 index 000000000000..c2d5e6ed7172 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/info/info-modal.token.ts @@ -0,0 +1,15 @@ +import { UmbModalToken } from '../../token/index.js'; +import type { TemplateResult } from '@umbraco-cms/backoffice/external/lit'; + +export interface UmbInfoModalData { + headline: string; + content: TemplateResult | string; +} + +export type UmbInfoModalValue = undefined; + +export const UMB_INFO_MODAL = new UmbModalToken('Umb.Modal.Info', { + modal: { + type: 'dialog', + }, +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/info/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/info/manifests.ts new file mode 100644 index 000000000000..671494c5a59e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/info/manifests.ts @@ -0,0 +1,8 @@ +export const manifests: Array = [ + { + type: 'modal', + alias: 'Umb.Modal.Info', + name: 'Info Modal', + element: () => import('./info-modal.element.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/manifests.ts index f8b7cd7531d6..6404c1cf5d9f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/manifests.ts @@ -1,11 +1,13 @@ import { manifests as confirmManifests } from './confirm/manifests.js'; import { manifests as discardChangesManifests } from './discard-changes/manifests.js'; import { manifests as errorViewerManifests } from './error-viewer/manifest.js'; +import { manifests as infoManifests } from './info/manifests.js'; import { manifests as itemPickerManifests } from './item-picker/manifests.js'; export const manifests: Array = [ ...confirmManifests, ...discardChangesManifests, ...errorViewerManifests, + ...infoManifests, ...itemPickerManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/server/conditions/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/server/conditions/constants.ts new file mode 100644 index 000000000000..e890a15fc73e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/server/conditions/constants.ts @@ -0,0 +1,4 @@ +/** + * Server Is Production Mode condition alias + */ +export const UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS = 'Umb.Condition.Server.IsProductionMode'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/server/conditions/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/server/conditions/index.ts new file mode 100644 index 000000000000..0273dec3019b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/server/conditions/index.ts @@ -0,0 +1,2 @@ +export * from './constants.js'; +export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/server/conditions/is-production-mode.condition.ts b/src/Umbraco.Web.UI.Client/src/packages/core/server/conditions/is-production-mode.condition.ts new file mode 100644 index 000000000000..0d1ab6a26923 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/server/conditions/is-production-mode.condition.ts @@ -0,0 +1,34 @@ +import { UMB_SERVER_CONTEXT } from '../server.context-token.js'; +import type { UmbIsServerProductionModeConditionConfig } from './types.js'; +import { UmbConditionBase } from '@umbraco-cms/backoffice/extension-registry'; +import type { UmbConditionControllerArguments, UmbExtensionCondition } from '@umbraco-cms/backoffice/extension-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +const ObserveSymbol = Symbol(); + +export class UmbIsServerProductionModeCondition + extends UmbConditionBase + implements UmbExtensionCondition +{ + constructor( + host: UmbControllerHost, + args: UmbConditionControllerArguments, + ) { + super(host, args); + + // Default to not permitted until we know the server's runtime mode (safe default). + this.permitted = false; + + this.consumeContext(UMB_SERVER_CONTEXT, (context) => { + this.observe( + context?.isProductionMode, + (isProduction) => { + if (isProduction !== undefined) { + this.permitted = isProduction === (this.config.match ?? true); + } + }, + ObserveSymbol, + ); + }); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/server/conditions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/server/conditions/manifests.ts new file mode 100644 index 000000000000..d38255fae830 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/server/conditions/manifests.ts @@ -0,0 +1,11 @@ +import { UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS } from './constants.js'; +import { UmbIsServerProductionModeCondition } from './is-production-mode.condition.js'; + +export const manifests: Array = [ + { + type: 'condition', + name: 'Server Production Mode Condition', + alias: UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS, + api: UmbIsServerProductionModeCondition, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/server/conditions/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/server/conditions/types.ts new file mode 100644 index 000000000000..d375e822c766 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/server/conditions/types.ts @@ -0,0 +1,17 @@ +import type { UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS } from './constants.js'; +import type { UmbConditionConfigBase } from '@umbraco-cms/backoffice/extension-api'; + +export interface UmbIsServerProductionModeConditionConfig + extends UmbConditionConfigBase { + /** + * If true (default), the condition is permitted when in Production mode. + * If false, the condition is permitted when NOT in Production mode. + */ + match?: boolean; +} + +declare global { + interface UmbExtensionConditionConfigMap { + umbIsServerProductionModeConditionConfig: UmbIsServerProductionModeConditionConfig; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/server/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/server/index.ts index 22bc53a8f59c..c138a5860d65 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/server/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/server/index.ts @@ -1,5 +1,6 @@ export * from './server-connection.js'; export * from './server.context-token.js'; export * from './server.context.js'; +export * from './conditions/index.js'; export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/server/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/server/manifests.ts new file mode 100644 index 000000000000..3c080ed100a4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/server/manifests.ts @@ -0,0 +1,3 @@ +import { manifests as conditionManifests } from './conditions/manifests.js'; + +export const manifests: Array = [...conditionManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/server/server.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/server/server.context.ts index ce8ff9e75fc2..55a22fa05818 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/server/server.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/server/server.context.ts @@ -1,18 +1,58 @@ import { UMB_SERVER_CONTEXT } from './server.context-token.js'; import type { UmbServerContextConfig } from './types.js'; +import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth'; +import { RuntimeModeModel, ServerService } from '@umbraco-cms/backoffice/external/backend-api'; +import type { ServerInformationResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; +import { tryExecute } from '@umbraco-cms/backoffice/resources'; export class UmbServerContext extends UmbContextBase { #serverUrl: string; #backofficePath: string; #serverConnection; + #serverInformation = new UmbObjectState(undefined); + + /** + * Observable that emits true when the server is running in Production mode, + * false when not in Production mode, or undefined until server information is loaded. + * UI consumers should treat undefined as restricted (safe default). + */ + public readonly isProductionMode = this.#serverInformation.asObservablePart((info) => + info ? info.runtimeMode === RuntimeModeModel.PRODUCTION : undefined, + ); + + /** + * Observable that provides the full server information. + */ + public readonly serverInformation = this.#serverInformation.asObservable(); + constructor(host: UmbControllerHost, config: UmbServerContextConfig) { super(host, UMB_SERVER_CONTEXT.toString()); this.#serverUrl = config.serverUrl; this.#backofficePath = config.backofficePath; this.#serverConnection = config.serverConnection; + + // Wait for authentication before fetching server information + this.consumeContext(UMB_AUTH_CONTEXT, (authContext) => { + if (!authContext) return; + this.observe(authContext.isAuthorized, (isAuthorized) => { + if (isAuthorized) { + this.#fetchServerInformation(); + } + }); + }); + } + + async #fetchServerInformation() { + const { data } = await tryExecute(this._host, ServerService.getServerInformation(), { + disableNotifications: true, + }); + if (data) { + this.#serverInformation.setValue(data); + } } getBackofficePath() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/local-components/insert-menu/insert-menu.element.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/local-components/insert-menu/insert-menu.element.ts index ddc4c266627d..0ed06b674187 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/local-components/insert-menu/insert-menu.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/local-components/insert-menu/insert-menu.element.ts @@ -18,6 +18,9 @@ export class UmbTemplatingInsertMenuElement extends UmbLitElement { @property() value = ''; + @property({ type: Boolean, reflect: true }) + disabled = false; + @property({ type: Boolean }) hidePartialViews = false; @@ -127,6 +130,7 @@ export class UmbTemplatingInsertMenuElement extends UmbLitElement { ${this.localize.term('template_insert')} @@ -134,6 +138,7 @@ export class UmbTemplatingInsertMenuElement extends UmbLitElement { diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/local-components/production-mode-workspace-action/production-mode-workspace-action.api.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/local-components/production-mode-workspace-action/production-mode-workspace-action.api.ts new file mode 100644 index 000000000000..fe6005d0d46a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/local-components/production-mode-workspace-action/production-mode-workspace-action.api.ts @@ -0,0 +1,14 @@ +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; +import { umbInfoModal } from '@umbraco-cms/backoffice/modal'; +import { UmbWorkspaceActionBase } from '@umbraco-cms/backoffice/workspace'; + +export class UmbTemplatingProductionModeWorkspaceActionApi extends UmbWorkspaceActionBase { + #localize = new UmbLocalizationController(this); + + public override async execute(): Promise { + await umbInfoModal(this, { + headline: this.#localize.term('template_productionMode'), + content: this.#localize.term('template_runtimeModeProduction'), + }).catch(() => undefined); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/local-components/production-mode-workspace-action/production-mode-workspace-action.element.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/local-components/production-mode-workspace-action/production-mode-workspace-action.element.ts new file mode 100644 index 000000000000..88f34fdaee04 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/local-components/production-mode-workspace-action/production-mode-workspace-action.element.ts @@ -0,0 +1,32 @@ +import type { UmbTemplatingProductionModeWorkspaceActionApi } from './production-mode-workspace-action.api.js'; +import { css, customElement, html } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; + +@customElement('umb-templating-production-mode-workspace-action') +export class UmbTemplatingProductionModeWorkspaceActionElement extends UmbLitElement { + api: UmbTemplatingProductionModeWorkspaceActionApi | undefined; + + override render() { + return html` + this.api?.execute()} compact> + + + + + `; + } + + static override styles = [ + css` + uui-tag { + text-wrap: nowrap; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-templating-production-mode-workspace-action': UmbTemplatingProductionModeWorkspaceActionElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/local-components/production-mode-workspace-action/production-mode-workspace-action.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/local-components/production-mode-workspace-action/production-mode-workspace-action.ts new file mode 100644 index 000000000000..104d3c4aefc3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/local-components/production-mode-workspace-action/production-mode-workspace-action.ts @@ -0,0 +1,2 @@ +export { UmbTemplatingProductionModeWorkspaceActionApi as api } from './production-mode-workspace-action.api.js'; +export { UmbTemplatingProductionModeWorkspaceActionElement as element } from './production-mode-workspace-action.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/create/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/create/manifests.ts index 242d9100a719..edcf6d822899 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/create/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/create/manifests.ts @@ -1,4 +1,5 @@ import { UMB_PARTIAL_VIEW_FOLDER_ENTITY_TYPE, UMB_PARTIAL_VIEW_ROOT_ENTITY_TYPE } from '../../entity.js'; +import { UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/server'; export const manifests: Array = [ { @@ -14,6 +15,12 @@ export const manifests: Array = [ label: '#actions_createFor', additionalOptions: true, }, + conditions: [ + { + alias: UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS, + match: false, + }, + ], }, { type: 'modal', diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/manifests.ts index f80134642d83..75e194692265 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/manifests.ts @@ -2,6 +2,7 @@ import { UMB_PARTIAL_VIEW_DETAIL_REPOSITORY_ALIAS, UMB_PARTIAL_VIEW_ITEM_REPOSIT import { UMB_PARTIAL_VIEW_ENTITY_TYPE } from '../entity.js'; import { manifests as createManifests } from './create/manifests.js'; import { manifests as renameManifests } from './rename/manifests.js'; +import { UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/server'; export const manifests: Array = [ { @@ -14,6 +15,12 @@ export const manifests: Array = [ detailRepositoryAlias: UMB_PARTIAL_VIEW_DETAIL_REPOSITORY_ALIAS, itemRepositoryAlias: UMB_PARTIAL_VIEW_ITEM_REPOSITORY_ALIAS, }, + conditions: [ + { + alias: UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS, + match: false, + }, + ], }, ...createManifests, ...renameManifests, diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/rename/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/rename/manifests.ts index 103cc07d0f1f..5a320b970900 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/rename/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/rename/manifests.ts @@ -1,5 +1,6 @@ import { UMB_PARTIAL_VIEW_ENTITY_TYPE } from '../../entity.js'; import { UMB_PARTIAL_VIEW_ITEM_REPOSITORY_ALIAS } from '../../repository/item/manifests.js'; +import { UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/server'; export const UMB_RENAME_PARTIAL_VIEW_REPOSITORY_ALIAS = 'Umb.Repository.PartialView.Rename'; export const UMB_RENAME_PARTIAL_VIEW_ENTITY_ACTION_ALIAS = 'Umb.EntityAction.PartialView.Rename'; @@ -21,5 +22,11 @@ export const manifests: Array = [ renameRepositoryAlias: UMB_RENAME_PARTIAL_VIEW_REPOSITORY_ALIAS, itemRepositoryAlias: UMB_PARTIAL_VIEW_ITEM_REPOSITORY_ALIAS, }, + conditions: [ + { + alias: UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS, + match: false, + }, + ], }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/tree/folder/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/tree/folder/manifests.ts index fc48a58a909e..753a8ab0dbcc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/tree/folder/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/tree/folder/manifests.ts @@ -1,6 +1,7 @@ import { UMB_PARTIAL_VIEW_FOLDER_ENTITY_TYPE } from '../../entity.js'; import { UMB_PARTIAL_VIEW_FOLDER_REPOSITORY_ALIAS, manifests as repositoryManifests } from './repository/manifests.js'; import { manifests as workspaceManifests } from './workspace/manifests.js'; +import { UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/server'; export const UMB_DELETE_PARTIAL_VIEW_FOLDER_ENTITY_ACTION_ALIAS = 'Umb.EntityAction.PartialView.Folder.Delete'; @@ -14,6 +15,12 @@ export const manifests: Array = [ meta: { folderRepositoryAlias: UMB_PARTIAL_VIEW_FOLDER_REPOSITORY_ALIAS, }, + conditions: [ + { + alias: UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS, + match: false, + }, + ], }, ...repositoryManifests, ...workspaceManifests, diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/workspace/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/workspace/manifests.ts index 4ab987402a7d..9146a1846d4c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/workspace/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/workspace/manifests.ts @@ -1,4 +1,5 @@ import { UmbSubmitWorkspaceAction, UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; +import { UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/server'; export const UMB_PARTIAL_VIEW_WORKSPACE_ALIAS = 'Umb.Workspace.PartialView'; @@ -29,6 +30,30 @@ export const manifests: Array = [ alias: UMB_WORKSPACE_CONDITION_ALIAS, match: UMB_PARTIAL_VIEW_WORKSPACE_ALIAS, }, + { + alias: UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS, + match: false, + }, + ], + }, + { + type: 'workspaceAction', + alias: 'Umb.WorkspaceAction.PartialView.ProductionMode', + name: 'Partial View Production Mode', + api: () => + import('../../local-components/production-mode-workspace-action/production-mode-workspace-action.js'), + element: () => + import('../../local-components/production-mode-workspace-action/production-mode-workspace-action.js'), + weight: 60, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_PARTIAL_VIEW_WORKSPACE_ALIAS, + }, + { + alias: UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS, + match: true, + }, ], }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/workspace/partial-view-workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/workspace/partial-view-workspace-editor.element.ts index 21ee15a8b24d..4ef0f8085e5d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/workspace/partial-view-workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/workspace/partial-view-workspace-editor.element.ts @@ -6,6 +6,7 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; import { UMB_TEMPLATE_QUERY_BUILDER_MODAL } from '@umbraco-cms/backoffice/template'; import type { UmbCodeEditorElement } from '@umbraco-cms/backoffice/code-editor'; +import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server'; import '@umbraco-cms/backoffice/code-editor'; import '../../local-components/insert-menu/index.js'; @@ -18,6 +19,13 @@ export class UmbPartialViewWorkspaceEditorElement extends UmbLitElement { @state() private _isNew?: boolean; + /** + * Whether editing is restricted. True when in production mode OR when runtime mode is still unknown. + * This ensures a safe default (restricted) until we confirm the runtime mode. + */ + @state() + private _isRestricted = true; + @query('umb-code-editor') private _codeEditor?: UmbCodeEditorElement; @@ -26,6 +34,13 @@ export class UmbPartialViewWorkspaceEditorElement extends UmbLitElement { constructor() { super(); + this.consumeContext(UMB_SERVER_CONTEXT, (context) => { + this.observe(context?.isProductionMode, (isProductionMode) => { + // Restricted until we confirm it's NOT production mode (safe default). + this._isRestricted = isProductionMode !== false; + }); + }); + this.consumeContext(UMB_PARTIAL_VIEW_WORKSPACE_CONTEXT, (workspaceContext) => { this.#workspaceContext = workspaceContext; @@ -64,14 +79,18 @@ export class UmbPartialViewWorkspaceEditorElement extends UmbLitElement { + ?readonly=${this._isNew === false || this._isRestricted}>
- + Query builder @@ -93,6 +112,7 @@ export class UmbPartialViewWorkspaceEditorElement extends UmbLitElement { id="content" language="razor" .code=${this._content ?? ''} + ?readonly=${this._isRestricted} @input=${this.#onCodeEditorInput}> `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/templates/entity-actions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/templates/entity-actions/manifests.ts index 4485d593ffeb..119cf6190971 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/templates/entity-actions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/templates/entity-actions/manifests.ts @@ -1,6 +1,7 @@ import { UMB_TEMPLATE_DETAIL_REPOSITORY_ALIAS, UMB_TEMPLATE_ITEM_REPOSITORY_ALIAS } from '../constants.js'; import { UMB_TEMPLATE_ENTITY_TYPE, UMB_TEMPLATE_ROOT_ENTITY_TYPE } from '../entity.js'; import { UMB_TEMPLATE_ALLOW_DELETE_ACTION_CONDITION_ALIAS } from '../conditions/allow-delete/constants.js'; +import { UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/server'; export const manifests: Array = [ { @@ -16,6 +17,12 @@ export const manifests: Array = [ label: '#actions_createFor', additionalOptions: true, }, + conditions: [ + { + alias: UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS, + match: false, + }, + ], }, { type: 'entityAction', @@ -27,6 +34,12 @@ export const manifests: Array = [ detailRepositoryAlias: UMB_TEMPLATE_DETAIL_REPOSITORY_ALIAS, itemRepositoryAlias: UMB_TEMPLATE_ITEM_REPOSITORY_ALIAS, }, - conditions: [{ alias: UMB_TEMPLATE_ALLOW_DELETE_ACTION_CONDITION_ALIAS }], + conditions: [ + { alias: UMB_TEMPLATE_ALLOW_DELETE_ACTION_CONDITION_ALIAS }, + { + alias: UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS, + match: false, + }, + ], }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/templates/workspace/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/templates/workspace/manifests.ts index 8f9c84b80087..e2dbc3d69716 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/templates/workspace/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/templates/workspace/manifests.ts @@ -1,4 +1,5 @@ import { UmbSubmitWorkspaceAction, UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; +import { UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/server'; export const UMB_TEMPLATE_WORKSPACE_ALIAS = 'Umb.Workspace.Template'; @@ -30,6 +31,30 @@ export const manifests: Array = [ alias: UMB_WORKSPACE_CONDITION_ALIAS, match: UMB_TEMPLATE_WORKSPACE_ALIAS, }, + { + alias: UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS, + match: false, + }, + ], + }, + { + type: 'workspaceAction', + alias: 'Umb.WorkspaceAction.Template.ProductionMode', + name: 'Template Production Mode', + api: () => + import('../../local-components/production-mode-workspace-action/production-mode-workspace-action.js'), + element: () => + import('../../local-components/production-mode-workspace-action/production-mode-workspace-action.js'), + weight: 60, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_TEMPLATE_WORKSPACE_ALIAS, + }, + { + alias: UMB_IS_SERVER_PRODUCTION_MODE_CONDITION_ALIAS, + match: true, + }, ], }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/templates/workspace/template-workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/templates/workspace/template-workspace-editor.element.ts index 7d65f90c855a..7b948c6bbfec 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/templates/workspace/template-workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/templates/workspace/template-workspace-editor.element.ts @@ -11,6 +11,7 @@ import type { UmbCodeEditorElement } from '@umbraco-cms/backoffice/code-editor'; import type { UmbInputWithAliasElement } from '@umbraco-cms/backoffice/components'; import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal'; import { umbBindToValidation } from '@umbraco-cms/backoffice/validation'; +import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server'; import '@umbraco-cms/backoffice/code-editor'; import '../../local-components/insert-menu/index.js'; @@ -37,6 +38,13 @@ export class UmbTemplateWorkspaceEditorElement extends UmbLitElement { @state() private _masterTemplateName?: string | null = null; + /** + * Whether editing is restricted. True when in production mode OR when runtime mode is still unknown. + * This ensures a safe default (restricted) until we confirm the runtime mode. + */ + @state() + private _isRestricted = true; + @query('umb-code-editor') private _codeEditor?: UmbCodeEditorElement; @@ -52,6 +60,13 @@ export class UmbTemplateWorkspaceEditorElement extends UmbLitElement { this.#modalContext = instance; }); + this.consumeContext(UMB_SERVER_CONTEXT, (context) => { + this.observe(context?.isProductionMode, (isProductionMode) => { + // Restricted until we confirm it's NOT production mode (safe default). + this._isRestricted = isProductionMode !== false; + }); + }); + this.consumeContext(UMB_TEMPLATE_WORKSPACE_CONTEXT, (workspaceContext) => { this.#templateWorkspaceContext = workspaceContext; this.observe(this.#templateWorkspaceContext?.name, (name) => { @@ -152,12 +167,18 @@ export class UmbTemplateWorkspaceEditorElement extends UmbLitElement { @click=${this.#openMasterTemplatePicker} look="secondary" id="master-template-button" + ?disabled=${this._isRestricted} label="${this.localize.term('template_mastertemplate')}: ${this._masterTemplateName ? this._masterTemplateName : this.localize.term('template_noMaster')}"> ${this._masterTemplateName - ? html` - + ? html` + ` : nothing} @@ -177,6 +198,7 @@ export class UmbTemplateWorkspaceEditorElement extends UmbLitElement { .alias=${this._alias} alias-pattern=${UMB_TEMPLATE_ALIAS_PATTERN} ?auto-generate-alias=${this.#isNew} + ?readonly=${this._isRestricted} @change=${this.#onNameAndAliasChange} required ${umbBindToValidation(this)} @@ -186,11 +208,13 @@ export class UmbTemplateWorkspaceEditorElement extends UmbLitElement {
${this.#renderMasterTemplatePicker()}
- + + ${this.localize.term('template_queryBuilder')} @@ -198,6 +222,7 @@ export class UmbTemplateWorkspaceEditorElement extends UmbLitElement { look="secondary" id="sections-button" label=${this.localize.term('template_insertSections')} + ?disabled=${this._isRestricted} @click=${this.#openInsertSectionModal}> ${this.localize.term('template_insertSections')} @@ -215,18 +240,13 @@ export class UmbTemplateWorkspaceEditorElement extends UmbLitElement { id="content" language="razor" .code=${this._content ?? ''} + ?readonly=${this._isRestricted} @input=${this.#onCodeEditorInput}> `; } static override styles = [ css` - :host { - display: block; - width: 100%; - height: 100%; - } - #loader-container { display: grid; place-items: center; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs new file mode 100644 index 000000000000..dd6bc857dde8 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PartialViewServiceTests.cs @@ -0,0 +1,231 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Attributes; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +internal sealed class PartialViewServiceTests : UmbracoIntegrationTest +{ + private IPartialViewService PartialViewService => GetRequiredService(); + + /// + /// Configures the runtime mode to Production for tests decorated with [ConfigureBuilder]. + /// + public static void ConfigureProductionMode(IUmbracoBuilder builder) + { + builder.Services.Configure(settings => settings.Mode = RuntimeMode.Production); + } + + [SetUp] + public void SetUp() => DeleteAllPartialViewFiles(); + + [TearDown] + public void TearDownPartialViewFiles() => DeleteAllPartialViewFiles(); + + [Test] + public async Task Can_Create_PartialView() + { + var createModel = new PartialViewCreateModel + { + Name = "TestPartialView.cshtml", + Content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Test

" + }; + + var result = await PartialViewService.CreateAsync(createModel, Constants.Security.SuperUserKey); + + Assert.IsTrue(result.Success); + Assert.AreEqual(PartialViewOperationStatus.Success, result.Status); + Assert.IsNotNull(result.Result); + Assert.AreEqual("TestPartialView.cshtml", result.Result.Name); + } + + [Test] + public async Task Can_Update_PartialView() + { + var createModel = new PartialViewCreateModel + { + Name = "TestPartialView.cshtml", + Content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Original

" + }; + var createResult = await PartialViewService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(createResult.Success); + + var updateModel = new PartialViewUpdateModel + { + Content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Updated

" + }; + + var result = await PartialViewService.UpdateAsync(createResult.Result!.Path, updateModel, Constants.Security.SuperUserKey); + + Assert.IsTrue(result.Success); + Assert.AreEqual(PartialViewOperationStatus.Success, result.Status); + Assert.IsNotNull(result.Result); + Assert.That(result.Result.Content, Does.Contain("Updated")); + } + + [Test] + public async Task Can_Delete_PartialView() + { + var createModel = new PartialViewCreateModel + { + Name = "TestPartialView.cshtml", + Content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Test

" + }; + var createResult = await PartialViewService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(createResult.Success); + + var result = await PartialViewService.DeleteAsync(createResult.Result!.Path, Constants.Security.SuperUserKey); + + Assert.AreEqual(PartialViewOperationStatus.Success, result); + + var getResult = await PartialViewService.GetAsync(createResult.Result.Path); + Assert.IsNull(getResult); + } + + [Test] + public async Task Can_Rename_PartialView() + { + var createModel = new PartialViewCreateModel + { + Name = "OriginalName.cshtml", + Content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Test

" + }; + var createResult = await PartialViewService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(createResult.Success); + + var renameModel = new PartialViewRenameModel + { + Name = "RenamedPartialView.cshtml" + }; + + var result = await PartialViewService.RenameAsync(createResult.Result!.Path, renameModel, Constants.Security.SuperUserKey); + + Assert.IsTrue(result.Success); + Assert.AreEqual(PartialViewOperationStatus.Success, result.Status); + Assert.IsNotNull(result.Result); + Assert.AreEqual("RenamedPartialView.cshtml", result.Result.Name); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] + public async Task Cannot_Create_PartialView_In_Production_Mode() + { + var createModel = new PartialViewCreateModel + { + Name = "TestPartialView.cshtml", + Content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Test

" + }; + + var result = await PartialViewService.CreateAsync(createModel, Constants.Security.SuperUserKey); + + Assert.IsFalse(result.Success); + Assert.AreEqual(PartialViewOperationStatus.NotAllowedInProductionMode, result.Status); + Assert.IsNull(result.Result); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] + public async Task Cannot_Update_PartialView_In_Production_Mode() + { + // Create file directly via filesystem since service blocks creation in production mode + var fileSystems = GetRequiredService(); + var partialViewFileSystem = fileSystems.PartialViewsFileSystem!; + const string fileName = "ExistingPartialView.cshtml"; + const string originalContent = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Original

"; + + using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(originalContent))) + { + partialViewFileSystem.AddFile(fileName, stream); + } + + var updateModel = new PartialViewUpdateModel + { + Content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Updated

" + }; + + var result = await PartialViewService.UpdateAsync(fileName, updateModel, Constants.Security.SuperUserKey); + + Assert.IsFalse(result.Success); + Assert.AreEqual(PartialViewOperationStatus.NotAllowedInProductionMode, result.Status); + + // Verify the file was not changed + using var fileStream = partialViewFileSystem.OpenFile(fileName); + using var reader = new StreamReader(fileStream); + var content = await reader.ReadToEndAsync(); + Assert.That(content, Does.Contain("Original")); + Assert.That(content, Does.Not.Contain("Updated")); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] + public async Task Cannot_Delete_PartialView_In_Production_Mode() + { + var fileSystems = GetRequiredService(); + var partialViewFileSystem = fileSystems.PartialViewsFileSystem!; + const string fileName = "PartialViewToDelete.cshtml"; + const string content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Test

"; + + using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content))) + { + partialViewFileSystem.AddFile(fileName, stream); + } + + Assert.IsTrue(partialViewFileSystem.FileExists(fileName), "File should exist before delete attempt"); + + var result = await PartialViewService.DeleteAsync(fileName, Constants.Security.SuperUserKey); + + Assert.AreEqual(PartialViewOperationStatus.NotAllowedInProductionMode, result); + Assert.IsTrue(partialViewFileSystem.FileExists(fileName), "File should still exist after blocked delete"); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] + public async Task Cannot_Rename_PartialView_In_Production_Mode() + { + var fileSystems = GetRequiredService(); + var partialViewFileSystem = fileSystems.PartialViewsFileSystem!; + const string originalFileName = "OriginalName.cshtml"; + const string content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Test

"; + + using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content))) + { + partialViewFileSystem.AddFile(originalFileName, stream); + } + + Assert.IsTrue(partialViewFileSystem.FileExists(originalFileName), "Original file should exist"); + + var renameModel = new PartialViewRenameModel + { + Name = "RenamedFile.cshtml" + }; + + var result = await PartialViewService.RenameAsync(originalFileName, renameModel, Constants.Security.SuperUserKey); + + Assert.IsFalse(result.Success); + Assert.AreEqual(PartialViewOperationStatus.NotAllowedInProductionMode, result.Status); + Assert.IsTrue(partialViewFileSystem.FileExists(originalFileName), "Original file should still exist"); + Assert.IsFalse(partialViewFileSystem.FileExists("RenamedFile.cshtml"), "Renamed file should not exist"); + } + + private void DeleteAllPartialViewFiles() + { + var fileSystems = GetRequiredService(); + var partialViewFileSystem = fileSystems.PartialViewsFileSystem!; + foreach (var file in partialViewFileSystem.GetFiles(string.Empty).ToArray()) + { + partialViewFileSystem.DeleteFile(file); + } + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/PartialViewRepositoryTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/PartialViewRepositoryTests.cs index 9bf01f0daa11..dd502466716e 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/PartialViewRepositoryTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/PartialViewRepositoryTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Moq; using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; @@ -39,8 +40,18 @@ public void TearDownFiles() private IHostingEnvironment HostingEnvironment => GetRequiredService(); + private IOptionsMonitor RuntimeSettings => GetRequiredService>(); + private IFileSystem _fileSystem; + private IOptionsMonitor CreateRuntimeSettingsMonitor(RuntimeMode mode) + { + var settings = new RuntimeSettings { Mode = mode }; + var monitor = new Mock>(); + monitor.Setup(m => m.CurrentValue).Returns(settings); + return monitor.Object; + } + [Test] public void PathTests() { @@ -58,7 +69,7 @@ public void PathTests() var provider = ScopeProvider; using (var scope = provider.CreateScope()) { - var repository = new PartialViewRepository(fileSystems); + var repository = new PartialViewRepository(fileSystems, RuntimeSettings); IPartialView partialView = new PartialView("test-path-1.cshtml") { Content = "// partialView" }; @@ -118,6 +129,130 @@ public void PathTests() } } + [Test] + public void Save_In_Production_Mode_Does_Not_Write_New_File() + { + // Arrange + var fileSystems = FileSystemsCreator.CreateTestFileSystems( + LoggerFactory, + IOHelper, + GetRequiredService>(), + HostingEnvironment, + _fileSystem, + null, + null, + null); + + var productionRuntimeSettings = CreateRuntimeSettingsMonitor(RuntimeMode.Production); + var repository = new PartialViewRepository(fileSystems, productionRuntimeSettings); + + IPartialView partialView = new PartialView("production-test-new.cshtml") { Content = "// partialView" }; + + // Act + repository.Save(partialView); + + // Assert - file should NOT be created in production mode + Assert.IsFalse(_fileSystem.FileExists("production-test-new.cshtml")); + } + + [Test] + public void Save_In_Production_Mode_Does_Not_Update_Existing_File() + { + // Arrange - create file in development mode first. + var fileSystems = FileSystemsCreator.CreateTestFileSystems( + LoggerFactory, + IOHelper, + GetRequiredService>(), + HostingEnvironment, + _fileSystem, + null, + null, + null); + + var developmentRuntimeSettings = CreateRuntimeSettingsMonitor(RuntimeMode.Development); + var developmentRepository = new PartialViewRepository(fileSystems, developmentRuntimeSettings); + + IPartialView partialView = new PartialView("production-test-update.cshtml") { Content = "// original content" }; + developmentRepository.Save(partialView); + Assert.IsTrue(_fileSystem.FileExists("production-test-update.cshtml")); + + // Read original content. + using var originalStream = _fileSystem.OpenFile("production-test-update.cshtml"); + using var originalReader = new StreamReader(originalStream); + var originalContent = originalReader.ReadToEnd(); + Assert.That(originalContent, Does.Contain("original content")); + + // Act - try to update in production mode. + var productionRuntimeSettings = CreateRuntimeSettingsMonitor(RuntimeMode.Production); + var productionRepository = new PartialViewRepository(fileSystems, productionRuntimeSettings); + + IPartialView updatedPartialView = productionRepository.Get("production-test-update.cshtml"); + Assert.IsNotNull(updatedPartialView); + + // Modify and try to save. + updatedPartialView.Content = "// modified content"; + productionRepository.Save(updatedPartialView); + + // Assert - file should still have original content. + using var updatedStream = _fileSystem.OpenFile("production-test-update.cshtml"); + using var updatedReader = new StreamReader(updatedStream); + var updatedContent = updatedReader.ReadToEnd(); + Assert.That(updatedContent, Does.Contain("original content")); + Assert.That(updatedContent, Does.Not.Contain("modified content")); + } + + [Test] + public void Save_In_Development_Mode_Writes_File() + { + // Arrange + var fileSystems = FileSystemsCreator.CreateTestFileSystems( + LoggerFactory, + IOHelper, + GetRequiredService>(), + HostingEnvironment, + _fileSystem, + null, + null, + null); + + var developmentRuntimeSettings = CreateRuntimeSettingsMonitor(RuntimeMode.Development); + var repository = new PartialViewRepository(fileSystems, developmentRuntimeSettings); + + IPartialView partialView = new PartialView("development-test.cshtml") { Content = "// partialView" }; + + // Act + repository.Save(partialView); + + // Assert - file should be created in development mode. + Assert.IsTrue(_fileSystem.FileExists("development-test.cshtml")); + } + + [Test] + public void Save_In_BackofficeDevelopment_Mode_Writes_File() + { + // Arrange + var fileSystems = FileSystemsCreator.CreateTestFileSystems( + LoggerFactory, + IOHelper, + GetRequiredService>(), + HostingEnvironment, + _fileSystem, + null, + null, + null); + + var backofficeDevelopmentRuntimeSettings = CreateRuntimeSettingsMonitor(RuntimeMode.BackofficeDevelopment); + var repository = new PartialViewRepository(fileSystems, backofficeDevelopmentRuntimeSettings); + + IPartialView partialView = new PartialView("backoffice-development-test.cshtml") { Content = "// partialView" }; + + // Act + repository.Save(partialView); + + // Assert - file should be created in backoffice development mode. + Assert.IsTrue(_fileSystem.FileExists("backoffice-development-test.cshtml")); + } + private void Purge(PhysicalFileSystem fs, string path) { var files = fs.GetFiles(path, "*.cshtml"); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TemplateRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TemplateRepositoryTest.cs index 129f4ebaf592..4c654c9e6928 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TemplateRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TemplateRepositoryTest.cs @@ -63,6 +63,17 @@ public void TearDown() private ITemplateRepository CreateRepository(IScopeProvider provider, AppCaches? appCaches = null) => new TemplateRepository((IScopeAccessor)provider, appCaches ?? AppCaches.Disabled, LoggerFactory.CreateLogger(), LoggerFactory, FileSystems, ShortStringHelper, ViewHelper, RuntimeSettings, Mock.Of(), Mock.Of()); + private ITemplateRepository CreateRepository(IScopeProvider provider, IOptionsMonitor runtimeSettings) => + new TemplateRepository((IScopeAccessor)provider, AppCaches.Disabled, LoggerFactory.CreateLogger(), FileSystems, ShortStringHelper, ViewHelper, runtimeSettings, Mock.Of(), Mock.Of()); + + private static IOptionsMonitor CreateRuntimeSettingsMonitor(RuntimeMode mode) + { + var settings = new RuntimeSettings { Mode = mode }; + var monitor = new Mock>(); + monitor.Setup(m => m.CurrentValue).Returns(settings); + return monitor.Object; + } + [Test] public void Can_Instantiate_Repository() { @@ -809,17 +820,108 @@ public void Path_Is_Set_Correctly_On_Update_With_Master_Template_Removal() } } - private Stream CreateStream(string contents = null) + [Test] + public void Save_In_Production_Mode_Does_Not_Write_New_File() + { + // Arrange + var provider = ScopeProvider; + + using (provider.CreateScope()) + { + var productionRuntimeSettings = CreateRuntimeSettingsMonitor(RuntimeMode.Production); + var repository = CreateRepository(provider, productionRuntimeSettings); + + // Act + var template = new Template(ShortStringHelper, "productionTestNew", "productionTestNew") { Content = "mock-content" }; + repository.Save(template); + + // Assert - database record should be created but file should NOT be created in production mode. + Assert.That(repository.Get("productionTestNew"), Is.Not.Null); + Assert.That(FileSystems.MvcViewsFileSystem.FileExists("productionTestNew.cshtml"), Is.False); + } + } + + [Test] + public void Save_In_Production_Mode_Does_Not_Update_Existing_File() + { + // Arrange - create template in development mode first. + var provider = ScopeProvider; + + using (provider.CreateScope()) + { + var developmentRuntimeSettings = CreateRuntimeSettingsMonitor(RuntimeMode.Development); + var developmentRepository = CreateRepository(provider, developmentRuntimeSettings); + + var template = new Template(ShortStringHelper, "productionTestUpdate", "productionTestUpdate") { Content = "original-content" }; + developmentRepository.Save(template); + Assert.That(FileSystems.MvcViewsFileSystem.FileExists("productionTestUpdate.cshtml"), Is.True); + + // Read original content. + using var originalStream = FileSystems.MvcViewsFileSystem.OpenFile("productionTestUpdate.cshtml"); + using var originalReader = new StreamReader(originalStream); + var originalContent = originalReader.ReadToEnd(); + Assert.That(originalContent, Does.Contain("original-content")); + + // Act - try to update in production mode. + var productionRuntimeSettings = CreateRuntimeSettingsMonitor(RuntimeMode.Production); + var productionRepository = CreateRepository(provider, productionRuntimeSettings); + + var updatedTemplate = productionRepository.Get("productionTestUpdate"); + Assert.IsNotNull(updatedTemplate); + + // Modify and try to save. + updatedTemplate.Content = "modified-content"; + productionRepository.Save(updatedTemplate); + + // Assert - file should still have original content. + using var updatedStream = FileSystems.MvcViewsFileSystem.OpenFile("productionTestUpdate.cshtml"); + using var updatedReader = new StreamReader(updatedStream); + var updatedContent = updatedReader.ReadToEnd(); + Assert.That(updatedContent, Does.Contain("original-content")); + Assert.That(updatedContent, Does.Not.Contain("modified-content")); + } + } + + [Test] + public void Save_In_Development_Mode_Writes_File() { - if (string.IsNullOrEmpty(contents)) + // Arrange + var provider = ScopeProvider; + + using (provider.CreateScope()) { - contents = "/* test */"; + var developmentRuntimeSettings = CreateRuntimeSettingsMonitor(RuntimeMode.Development); + var repository = CreateRepository(provider, developmentRuntimeSettings); + + // Act + var template = new Template(ShortStringHelper, "developmentTest", "developmentTest") { Content = "mock-content" }; + repository.Save(template); + + // Assert - file should be created in development mode. + Assert.That(repository.Get("developmentTest"), Is.Not.Null); + Assert.That(FileSystems.MvcViewsFileSystem.FileExists("developmentTest.cshtml"), Is.True); } + } + + [Test] + public void Save_In_BackofficeDevelopment_Mode_Writes_File() + { + // Arrange + var provider = ScopeProvider; - var bytes = Encoding.UTF8.GetBytes(contents); - var stream = new MemoryStream(bytes); + using (provider.CreateScope()) + { + var backofficeDevelopmentRuntimeSettings = CreateRuntimeSettingsMonitor(RuntimeMode.BackofficeDevelopment); + var repository = CreateRepository(provider, backofficeDevelopmentRuntimeSettings); + + // Act + var template = new Template(ShortStringHelper, "backofficeDevelopmentTest", "backofficeDevelopmentTest") { Content = "mock-content" }; + repository.Save(template); - return stream; + // Assert - file should be created in backoffice development mode. + Assert.That(repository.Get("backofficeDevelopmentTest"), Is.Not.Null); + Assert.That(FileSystems.MvcViewsFileSystem.FileExists("backofficeDevelopmentTest.cshtml"), Is.True); + } } private IEnumerable CreateHierarchy(ITemplateRepository repository) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TemplateServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TemplateServiceTests.cs index 859244ac8b93..bfeafd89ecc4 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TemplateServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TemplateServiceTests.cs @@ -1,12 +1,15 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Attributes; using Umbraco.Cms.Tests.Integration.Testing; namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; @@ -17,9 +20,20 @@ internal sealed class TemplateServiceTests : UmbracoIntegrationTest { private ITemplateService TemplateService => GetRequiredService(); + /// + /// Configures the runtime mode to Production for tests decorated with [ConfigureBuilder]. + /// + public static void ConfigureProductionMode(IUmbracoBuilder builder) + { + builder.Services.Configure(settings => settings.Mode = RuntimeMode.Production); + } + [SetUp] public void SetUp() => DeleteAllTemplateViewFiles(); + [TearDown] + public void TearDownTemplateFiles() => DeleteAllTemplateViewFiles(); + [Test] public async Task Can_Create_Template_Then_Assign_Child() { @@ -289,6 +303,111 @@ public async Task Can_Create_Template_With_Key() Assert.IsNotNull(template); Assert.AreEqual(key, template.Key); }); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] + public async Task Cannot_Create_Template_In_Production_Mode() + { + var result = await TemplateService.CreateAsync("Template", "template", "test", Constants.Security.SuperUserKey); + + Assert.IsFalse(result.Success); + Assert.AreEqual(TemplateOperationStatus.NotAllowedInProductionMode, result.Status); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] + public async Task Cannot_Delete_Template_In_Production_Mode() + { + // The production mode check happens before the template lookup, + // so we don't need an actual template in the database for this test. + var result = await TemplateService.DeleteAsync("AnyTemplateAlias", Constants.Security.SuperUserKey); + + Assert.IsFalse(result.Success); + Assert.AreEqual(TemplateOperationStatus.NotAllowedInProductionMode, result.Status); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] + public async Task Cannot_Update_Template_Content_In_Production_Mode() + { + // Create template directly via repository to bypass the service's production mode check, + // allowing us to have an existing template to test the update behavior against. + var fileSystems = GetRequiredService(); + var viewFileSystem = fileSystems.MvcViewsFileSystem!; + const string fileName = "ExistingTemplate.cshtml"; + const string originalContent = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Original

"; + + using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(originalContent))) + { + viewFileSystem.AddFile(fileName, stream); + } + + // Create the template in the database. + var shortStringHelper = GetRequiredService(); + var template = new Template(shortStringHelper, "ExistingTemplate", "ExistingTemplate") + { + Content = originalContent + }; + + // Save via scope to bypass service production check. + using (var scope = ScopeProvider.CreateScope()) + { + var templateRepository = GetRequiredService(); + templateRepository.Save(template); + scope.Complete(); + } + + // Now try to update the content in production mode. + template.Content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Modified

"; + var result = await TemplateService.UpdateAsync(template, Constants.Security.SuperUserKey); + + Assert.IsFalse(result.Success); + Assert.AreEqual(TemplateOperationStatus.ContentChangeNotAllowedInProductionMode, result.Status); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureProductionMode))] + public async Task Can_Update_Template_Metadata_In_Production_Mode() + { + // Create template directly via repository to bypass the service's production mode check. + var fileSystems = GetRequiredService(); + var viewFileSystem = fileSystems.MvcViewsFileSystem!; + const string fileName = "MetadataTemplate.cshtml"; + const string content = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\n

Test

"; + + using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content))) + { + viewFileSystem.AddFile(fileName, stream); + } + + var shortStringHelper = GetRequiredService(); + var template = new Template(shortStringHelper, "MetadataTemplate", "MetadataTemplate") + { + Content = content + }; + + using (var scope = ScopeProvider.CreateScope()) + { + var templateRepository = GetRequiredService(); + templateRepository.Save(template); + scope.Complete(); + } + + // Reload to get the saved version. + template = (Template?)await TemplateService.GetAsync(template.Key); + Assert.IsNotNull(template); + + // Now update only the name (metadata), keeping content the same. + template.Name = "Updated Template Name"; + var result = await TemplateService.UpdateAsync(template, Constants.Security.SuperUserKey); + + Assert.IsTrue(result.Success); + Assert.AreEqual(TemplateOperationStatus.Success, result.Status); + // Verify the name was updated. + var updatedTemplate = await TemplateService.GetAsync(template.Key); + Assert.IsNotNull(updatedTemplate); + Assert.AreEqual("Updated Template Name", updatedTemplate.Name); } }