diff --git a/.gitignore b/.gitignore index 1d37899b..43a699b8 100644 --- a/.gitignore +++ b/.gitignore @@ -360,3 +360,9 @@ MigrationBackup/ .mono .DS_store .vscode/ + +# AI +.claude/ + +# Settings +appsettings.Development.json \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index c53a712c..b4667589 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -771,4 +771,4 @@ Before working on this codebase, verify understanding of: --- *Last Updated: 2026-02-06* -*Generated for: Optimizely CMS 13 / .NET 10 / React 19* \ No newline at end of file +*Generated for: Optimizely CMS 13 / .NET 10 / React 19* diff --git a/Images/AuditTab.png b/Images/AuditTab.png index 4ad51ddd..2f95fb91 100644 Binary files a/Images/AuditTab.png and b/Images/AuditTab.png differ diff --git a/Images/ContextSwitcher.png b/Images/ContextSwitcher.png new file mode 100644 index 00000000..938785d3 Binary files /dev/null and b/Images/ContextSwitcher.png differ diff --git a/Images/CspSettingsTab-2A.png b/Images/CspSettingsTab-2A.png index 85f1266b..6ec45fda 100644 Binary files a/Images/CspSettingsTab-2A.png and b/Images/CspSettingsTab-2A.png differ diff --git a/Images/CspSettingsTab-2B.png b/Images/CspSettingsTab-2B.png index dc967842..b3289246 100644 Binary files a/Images/CspSettingsTab-2B.png and b/Images/CspSettingsTab-2B.png differ diff --git a/Images/CspViolationTab.png b/Images/CspViolationTab.png index 9f2b6a16..9dd9119a 100644 Binary files a/Images/CspViolationTab.png and b/Images/CspViolationTab.png differ diff --git a/Images/PermissionPolicy.png b/Images/PermissionPolicy.png index 3016ae61..0ac29632 100644 Binary files a/Images/PermissionPolicy.png and b/Images/PermissionPolicy.png differ diff --git a/Images/PreviewTab.png b/Images/PreviewTab.png index 7414fb86..4eaceb2c 100644 Binary files a/Images/PreviewTab.png and b/Images/PreviewTab.png differ diff --git a/Images/ResponseHeaders.png b/Images/ResponseHeaders.png index 2c99566a..0e8185c6 100644 Binary files a/Images/ResponseHeaders.png and b/Images/ResponseHeaders.png differ diff --git a/Images/TabList.png b/Images/TabList.png index f3287e4a..d456979e 100644 Binary files a/Images/TabList.png and b/Images/TabList.png differ diff --git a/README.md b/README.md index 46de8ea5..3c44b3a7 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,12 @@ # Stott Security -**Please ensure that you are using version 2.8.2 or later of Stott Security.** See [Stott Security 2.8](https://github.com/GeekInTheNorth/Stott.Security.Optimizely/discussions/244) - [![Platform](https://img.shields.io/badge/Platform-.NET%206--10-blue.svg?style=flat)](https://docs.microsoft.com/en-us/dotnet/) -[![Platform](https://img.shields.io/badge/Optimizely-%2012-blue.svg?style=flat)](http://world.episerver.com/cms/) +[![Platform](https://img.shields.io/badge/Optimizely-%2012%2C13-blue.svg?style=flat)](http://world.episerver.com/cms/) [![GitHub](https://img.shields.io/github/license/GeekInTheNorth/Stott.Security.Optimizely)](https://github.com/GeekInTheNorth/Stott.Security.Optimizely/blob/main/LICENSE.txt) ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/GeekInTheNorth/Stott.Security.Optimizely/dotnet.yml?branch=main) ![Nuget](https://img.shields.io/nuget/v/Stott.Security.Optimizely) -Stott.Security.Optimizely is a security header editor for Optimizely CMS 12 that provides the user with the ability to define the Content Security Policy (CSP), Cross-origin Resource Sharing (CORS) and other security headers. What makes this module unique in terms of Content Security Policy management is that users are presented with the ability to define a source and to select the permissions for that source. e.g. can https://www.example.com be used a script source, can it contain the current site in an iFrame, etc. +Stott.Security.Optimizely is a security header editor for Optimizely CMS 12 and CMS 13 that provides the user with the ability to define the Content Security Policy (CSP), Cross-origin Resource Sharing (CORS), Permissions Policy and other response headers. What makes this module unique in terms of Content Security Policy management is that users are presented with the ability to define a source and to select the permissions for that source. e.g. can https://www.example.com be used a script source, can it contain the current site in an iFrame, etc. If you have any questions, please feel free to start up a new discussion over on the [Discussions](https://github.com/GeekInTheNorth/Stott.Security.Optimizely/discussions) section for this repo. @@ -22,6 +20,7 @@ Stott Security is a completely free module, proudly offered under the [MIT Licen - **User Friendly Interface:** Manage individual domains and what permissions you grant them on a domain by domain basis. - **Searchable:** Search by domain or filter by permission to fully understand what permissions have been granted. +- **Extensible Configuration:** Sources can be defined at a Global, Application, Host and even page level. Allowing you to layer your CSP according to your requirements - **Reporting:** Supporting internal and external reporting endpoints, understand violations either directly within the add-on or in a third party service. - **Violation Handling:** Add blocked domains straight into your CSP straight from the violation report screen - **Agency Allow List:** Automatically update your Content Security Policies across multiple instances based on a centrally managed allow list. @@ -31,6 +30,7 @@ Stott Security is a completely free module, proudly offered under the [MIT Licen - **User Friendly Interface:** Manage individual directives in an easy-to-use interface. - **Searchable:** Search by domain or filter by permission to fully understand what permissions have been granted. +- **Overridable:** Declare a common Global set of permissions, or vary them entirely by Application or Host. ### 🌐 Cross Origin Resource Sharing (CORS) @@ -41,6 +41,7 @@ Stott Security is a completely free module, proudly offered under the [MIT Licen ### 📋 Response Headers - **User Friendly Interface:** Manage all of your classic response headers in a nice easy to manage interface. +- **Overridable:** Declare a common Global set of response headers, or vary them entirely by Application or Host. - **Specialized Security Headers:** - `Cross-Origin-Embedder-Policy` - `Cross-Origin-Opener-Policy` @@ -56,7 +57,7 @@ Stott Security is a completely free module, proudly offered under the [MIT Licen ### 📄 Security.txt - **User Friendly Interface:** Manage your security.txt files in an easy to interface that is familiar to users of Stott Robots Handler. -- **Multi-Domain / Host Support:** Write a single security.txt file for your entire CMS, or create them by site or even specific host. +- **Multi-Application / Host Support:** Write a single security.txt file for your entire CMS, or create them by site or even specific host. ### ✨ Additional Features @@ -71,18 +72,34 @@ Stott Security is a completely free module, proudly offered under the [MIT Licen The user interface is split into 8 tabs: -- Sections One to Four focus on the Content Security Policy. -- Section Five focuses on the Cross Origin Resource Sharing functionality. -- Section Six focuses on the Permissions Policy (**Introduced in v3.0.0**) -- Section Seven focuses on adding or removing simple Response Headers. -- Section Eight provides you with a preview of the headers the module will generate. -- Section Nine provides you with security.txt file management (**Introduced in v4.0.0**) -- Section Ten provides you with the audit history for all changes made within the module. -- Section Eleven provides you with additional tools to import and export settings. +- Content Security Policy +- Cross Origin Resource Sharing +- Permissions Policy +- Response Headers +- Header Preview +- Security.txt File Management +- Audit History +- Tools to Import and Export Settings ![Stott Security Menu](/Images/TabList.png) -### Content Security Policy - General Settings +Many of these features use a Context Switcher which allows you to view or vary your configuration at a Global, Application or Host level. + +![Stott Security Context Switcher](/Images/ContextSwitcher.png) + +- An entry for "All Applications" will exist as a Global Configuration. +- An entry will exist for each Application you have configured within your CMS. +- If an Application has more than one visitable Host, then entries will be shown for each available Host Name. + +> **TIP:** If you want to specify a different set of security headers for the frontend and the CMS backend of your application, then make sure you configure separate Primary and Edit host names. + +### Content Security Policy + +The Content Security Policy is split four tabs and has a single Context Selector defined. The idea of these settings it allows you to define a configuration at either a Global, Application or Host level. + +#### Content Security Policy - General Settings + +> **Context Switcher:** Allows you to configure global settings, or to override them at an Application or Host level. This section allows you to enable or disable your content security policy as well as to put it into a reporting only mode. If Use Report Only Mode is enabled, then any third party source that is not included in your list of CSP Sources will not be blocked, but will show up in your browser console as an error while still executing. It is recommended that you enable the Report Only mode when you are first configuring and testing your Content Security Policy. @@ -92,8 +109,6 @@ Some digital agencies will be responsible for multiple websites and will have a ![CSP Settings Tab - General Settings](/Images/CspSettingsTab-2A.png) -**Updated in version 4.0.0 to move NONCE values into the sources tab** - | Setting | Default | Recommended | |---------|---------|-------------| | Enable Content Security Policy (CSP) | false | true | @@ -106,18 +121,12 @@ Some digital agencies will be responsible for multiple websites and will have a | Remote CSP Allow List Address | *empty* | | | Upgrade Insecure Requests | false | false | -### Content Security Policy - Sandbox Settings +#### Content Security Policy - Sources -The CSP Sandbox section is dedicated to the **sandbox** directive. Unlike other directives such as **script-src**, the **sandbox** directive does not operate grant permissions to sources, but instead instruct the browser on what APIs and browser functionality the website can access. - -![CSP Settings Tab - Sandbox Settings Section](/Images/CspSettingsTab-2B.png) - -### Content Security Policy Sources +> **Context Switcher:** Allows you to configure global settings, or to **extend** them at an Application and/or Host level. Sources from matching Global, Application and Host levels will be merged and optimized to prevent duplication. The CSP Sources tab is the second of four tabs dedicated to managing your Content Security Policy. This tab has been designed with the premise of understanding what a third party can do and to allow you to grant a third party access to multiple directives all at once and so that you can remove the same third party source just as easily. Each directive is given a user friendly description to allow less technical people to understand what a third party can do. -**Updated in version 4.0.0 to migration nonce and strict dynamic from the settings tab to being included in the source options to grant more specific controls** - ![CSP Sources Tab](/Images/CspSourcesTab.png) Recommendations: @@ -125,6 +134,7 @@ Recommendations: - Only grant **default-src** to either the **'self'** or **'none'** directive. - Granting **'self'** the **default-src** directive will say that the current site can perform actions on itself by default. - Granting **'none'** the **default-src** directive will say that neither the current site or any third party can perform any action by default. This will require you to grant specific directives to **'self'** + - Using **'none'** for a directive will result in any other source at any level (Global, Application, Host or Page) being disregarded for that directive. - Make sure that you turn on Report Only mode when altering and testing your Content Security Policy. - Make sure that you turn off Report Only mode when you are confident the right sources have the right directives. - Make sure that you test all of the following to make sure they do not report errors before turning off report only mode. @@ -133,7 +143,17 @@ Recommendations: - Third Party Plugin Interface - Login / Logout functionality -### Content Security Policy Violations +#### Content Security Policy - Sandbox Settings + +> **Context Switcher:** Allows you to configure global settings, or to override them at an Application or Host level. + +The CSP Sandbox section is dedicated to the **sandbox** directive. Unlike other directives such as **script-src**, the **sandbox** directive does not operate grant permissions to sources, but instead instruct the browser on what APIs and browser functionality the website can access. + +![CSP Settings Tab - Sandbox Settings Section](/Images/CspSettingsTab-2B.png) + +#### Content Security Policy - Violations + +> **Context Switcher:** Shows you violations that relate to your selected Application and Host. When you use the "Create CSP Entry" CTA here, it will respect your currently chosen context. The CSP Violations tab is the forth tab dedicated to managing your Content Security Policy. This tab requires a developer to add the reporting view component to the website (read more below under CSP Reporting). When the plugin receives a report of a violation of the Content Security Policy, it will make a record of the third party source and what directive was violated. This is then presented to the user so that that can see how often a violation is happening and when it last happened. A handy **Create CSP Entry** button allows the user to quickly merge the violated source and directive into the Content Security Policy. @@ -141,6 +161,8 @@ The CSP Violations tab is the forth tab dedicated to managing your Content Secur ### Cross Origin Resource Sharing +> **Please Note:** CORS settings are applied at a server level, the Context Switcher is absent by design. + The CORS section is new in version 2.0.0 and allows the user to configure the Cross-Origin Resource Sharing headers for the website. This is used to grant permissions to third party websites to consume APIs and content from your website. As trends have moved towards headless and hybrid solutions, controlling your CORS headers can be essential to allowing hybrid solutions to work. ![CORS Tab](/Images/CorsTab.png) @@ -167,7 +189,9 @@ A new button has been added called "Add Content Delivery/Definition API Headers" ### Permission Policy -The Permission Policy section is new as of version 3.0.0 and introduces support for the `Permission-Policy` header. The header can be activated or deactivated as a whole and each directive can can be configured individually to: +> **Context Switcher:** Allows you to configure global settings, or to override them at an Application or Host level. When you choose to override at an application or host level, settings from the nearest lower level will be replicated. + +The Permission Policy section introduces support for the `Permission-Policy` header. The header can be activated or deactivated as a whole and each directive can can be configured individually to: - Disabled: Omitted from the Permission Policy - Allow None: Outputs as `directive-name:()` @@ -180,7 +204,7 @@ The Permission Policy section is new as of version 3.0.0 and introduces support ### Response Headers -**Rebuilt in version 5.0.0:** *Please note that response headers will need to be reconfigured when upgrading from v4.x.x to v5.x.x* +> **Context Switcher:** Allows you to configure global settings, or to override them at an Application or Host level. When you choose to override at an application or host level, settings from the nearest lower level will be replicated. The Response Headers tab allows you to manage adding and removing of simple response headers. As well as having recommended OWASP headers, users can define custom headers to be added or removed from page requests. @@ -192,6 +216,8 @@ For Headless users, the responsibility to add and remove the headers will take p ### Preview +> **Context Switcher:** Allows you preview what your headers will be at a Global, Application or Host level. + The preview screen will show you the compiled headers that will be returned as part of any GET request. This does not include CORS headers as these vary based on request or may only be exposed as part of a pre-flight request by the browser. ![CORS Tab](/Images/PreviewTab.png) @@ -214,7 +240,9 @@ Please note that this module does not contain any code that clears down the audi ### Tools -The tools tab introduces the ability to import and export your entire configuration. The Export function will provide you with a JSON file of all of your configuration settings. The Import function will require the same JSON file structure and will validate the content of the configuration before applying it. +The tools tab introduces the ability to import and export your entire configuration. The Export function will allow you to select a single Global, Application or Host Name context to export as a JSON file. The Import function will require the same JSON file structure and will allow you to choose any Global, Application or Host Name level to import the settings back in. + +There was a lot of consideration around whether export and import should include all contexts. A decision was made to export and import at a specific level. This allows you to copy settings from one Application to another irrespective of whether it is in the same environment or not. ![Tools Tab](/Images/ToolsTab.png) @@ -486,6 +514,8 @@ This module was originally built to support a traditional headed CMS solution. Both of the following APIs accept an optional query string of `pageId` which can be used to render the headers in the context of a specific content page. This allows the headless solution to support the extension of CSP Sources for pages implementing `IContentSecurityPolicyPage`. +If your solution is headless with multiple applications or hosts, you can also specify the `appId` or `hostName` you want to retrieve your headers for. + ### Header Listing API: *Please note that when a header has `"isRemoval": false`, then it should be removed from a response.* @@ -493,6 +523,8 @@ Both of the following APIs accept an optional query string of `pageId` which can Url Examples: - /stott.security.optimizely/api/compiled-headers/list/ - /stott.security.optimizely/api/compiled-headers/list/?pageId=123 +- /stott.security.optimizely/api/compiled-headers/list/?appId=cms13&hostName=example.com +- /stott.security.optimizely/api/compiled-headers/list/?appId=cms13&hostName=example.com&pageId=123 Example Response: ``` @@ -530,6 +562,8 @@ Example Response: Url Examples: - /stott.security.optimizely/api/compiled-headers/{headerName} - /stott.security.optimizely/api/compiled-headers/X-Frame-Options +- /stott.security.optimizely/api/compiled-headers/{headerName}?appId=cms13&hostName=example.com +- /stott.security.optimizely/api/compiled-headers/{headerName}?appId=cms13&hostName=example.com&pageId=123 Example Response: ``` @@ -569,11 +603,19 @@ I am open to contributions to the code base. The following rules should be foll ### Technologies Used -- .NET 6.0 / .NET 8.0 / .NET 9.0 / .NET 10.0 -- Optimizely CMS (EPiServer.CMS.UI.Core 12.23.0) +CMS 13 + +- CMS 12: + - .NET 6.0, 8.0, 9.0, 10.0 + - Optimizely CMS (EPiServer.CMS.UI.Core 12.x.x) + - Entity Framework (Microsoft.EntityFrameworkCore.SqlServer 6.x.x / 8.x.x / 9.x.x / 10.x.x) +- CMS 13: + - .NET 10.0 + - Optimizely CMS (EPiServer.CMS.UI.Core 13.x.x) + - Entity Framework (Microsoft.EntityFrameworkCore.SqlServer 10.x.x) - MVC - Razor Class Libraries - React - Bootstrap for React - NUnit & Moq -- Entity Framework (Microsoft.EntityFrameworkCore.SqlServer 6.0.6 / 8.0.1 / 9.0.0 / 10.0.0) + diff --git a/Sample/OptimizelyTwelveTest/Features/Blocks/Embed/EmbedBlock.cs b/Sample/OptimizelyTwelveTest/Features/Blocks/Embed/EmbedBlock.cs index 44984115..a69b4b0b 100644 --- a/Sample/OptimizelyTwelveTest/Features/Blocks/Embed/EmbedBlock.cs +++ b/Sample/OptimizelyTwelveTest/Features/Blocks/Embed/EmbedBlock.cs @@ -11,7 +11,8 @@ DisplayName = "Embed Block", GUID = "947e1f1d-54ad-4f3b-ba80-3741d2523ea7", Description = "", - GroupName = SystemTabNames.Content)] + GroupName = SystemTabNames.Content, + CompositionBehaviors = [CompositionBehavior.ElementEnabledKey])] public class EmbedBlock : BlockData { [CultureSpecific] diff --git a/Sample/OptimizelyTwelveTest/Features/Blocks/RichText/RichTextBlock.cs b/Sample/OptimizelyTwelveTest/Features/Blocks/RichText/RichTextBlock.cs index 3a9c29ff..890ed7ab 100644 --- a/Sample/OptimizelyTwelveTest/Features/Blocks/RichText/RichTextBlock.cs +++ b/Sample/OptimizelyTwelveTest/Features/Blocks/RichText/RichTextBlock.cs @@ -1,23 +1,26 @@ -namespace OptimizelyTwelveTest.Features.Blocks.RichText +namespace OptimizelyTwelveTest.Features.Blocks.RichText; + +using EPiServer.Core; +using EPiServer.DataAbstraction; +using EPiServer.DataAnnotations; +using System.ComponentModel.DataAnnotations; + +[ContentType( + DisplayName = "Rich Text Block", + GUID = "ad51630a-45a9-40d6-ba24-1817e7c3cdd7", + Description = "", + GroupName = SystemTabNames.Content, + CompositionBehaviors = [CompositionBehavior.ElementEnabledKey, CompositionBehavior.SectionEnabledKey])] +public class RichTextBlock : BlockData { - using EPiServer.Core; - using EPiServer.DataAbstraction; - using EPiServer.DataAnnotations; - using System.ComponentModel.DataAnnotations; + [Display(Name = "Css Class", Order = 2)] + public virtual string CustomClass { get; set; } - [ContentType( - DisplayName = "Rich Text Block", - GUID = "ad51630a-45a9-40d6-ba24-1817e7c3cdd7", - Description = "", - GroupName = SystemTabNames.Content)] - public class RichTextBlock : BlockData - { - [CultureSpecific] - [Display( - Name = "Content", - Description = "Rich Text Content", - GroupName = SystemTabNames.Content, - Order = 1)] - public virtual XhtmlString Content { get; set; } - } + [CultureSpecific] + [Display( + Name = "Content", + Description = "Rich Text Content", + GroupName = SystemTabNames.Content, + Order = 1)] + public virtual XhtmlString Content { get; set; } } \ No newline at end of file diff --git a/Sample/OptimizelyTwelveTest/Features/Blocks/Sections/StandardSection.cs b/Sample/OptimizelyTwelveTest/Features/Blocks/Sections/StandardSection.cs new file mode 100644 index 00000000..7a938d80 --- /dev/null +++ b/Sample/OptimizelyTwelveTest/Features/Blocks/Sections/StandardSection.cs @@ -0,0 +1,28 @@ +using EPiServer.DataAbstraction; +using EPiServer.DataAnnotations; +using EPiServer.VisualBuilder; + +namespace OptimizelyTwelveTest.Features.Blocks.Sections; + +[ContentType(DisplayName = "Standard Section", GUID = "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d", Description = "A standard section for campaign pages.")] +public class StandardSection : SectionData +{ + public override void SetDefaultValues(ContentType contentType) + { + base.SetDefaultValues(contentType); + // Layout.Type should be "grid" for the grid/row/column tag helpers + Layout = new Layout("grid"); + var row = new LayoutStructureNode("row"); + + var left = new LayoutStructureNode("column"); + left.DisplaySettings["col"] = "col-md-9"; + + var right = new LayoutStructureNode("column"); + right.DisplaySettings["col"] = "col-md-3"; + + row.Nodes.Add(left); + row.Nodes.Add(right); + + Layout.Nodes.Add(row); + } +} diff --git a/Sample/OptimizelyTwelveTest/Features/Campaign/CampaignPage.cs b/Sample/OptimizelyTwelveTest/Features/Campaign/CampaignPage.cs new file mode 100644 index 00000000..5743dc99 --- /dev/null +++ b/Sample/OptimizelyTwelveTest/Features/Campaign/CampaignPage.cs @@ -0,0 +1,9 @@ +using EPiServer.DataAnnotations; +using OptimizelyTwelveTest.Features.Common.Pages; + +namespace OptimizelyTwelveTest.Features.Campaign; + +[ContentType(DisplayName = "Campaign Page", GUID = "d1b9c8e5-9f3a-4c2b-8e5a-1f2b3c4d5e6f", Description = "A page type for creating rich campaigns.")] +public class CampaignPage : SiteExperienceData +{ +} diff --git a/Sample/OptimizelyTwelveTest/Features/Campaign/CampaignPageController.cs b/Sample/OptimizelyTwelveTest/Features/Campaign/CampaignPageController.cs new file mode 100644 index 00000000..8e38ea14 --- /dev/null +++ b/Sample/OptimizelyTwelveTest/Features/Campaign/CampaignPageController.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; +using OptimizelyTwelveTest.Features.Common; +using OptimizelyTwelveTest.Features.Security; + +namespace OptimizelyTwelveTest.Features.Campaign; + +public class CampaignPageController : PageControllerBase +{ + [SecurityHeaderAction] + public IActionResult Index(CampaignPage currentContent) + { + var model = new CampaignPageViewModel { CurrentPage = currentContent }; + + return View(model); + } +} diff --git a/Sample/OptimizelyTwelveTest/Features/Campaign/CampaignPageViewModel.cs b/Sample/OptimizelyTwelveTest/Features/Campaign/CampaignPageViewModel.cs new file mode 100644 index 00000000..f8c12102 --- /dev/null +++ b/Sample/OptimizelyTwelveTest/Features/Campaign/CampaignPageViewModel.cs @@ -0,0 +1,8 @@ +using OptimizelyTwelveTest.Features.Common.Pages; + +namespace OptimizelyTwelveTest.Features.Campaign; + +public class CampaignPageViewModel : ISitePageViewModel +{ + public CampaignPage CurrentPage { get; set; } +} diff --git a/Sample/OptimizelyTwelveTest/Features/Common/Pages/ISitePageData.cs b/Sample/OptimizelyTwelveTest/Features/Common/Pages/ISitePageData.cs index 67f42f07..07c9ca7c 100644 --- a/Sample/OptimizelyTwelveTest/Features/Common/Pages/ISitePageData.cs +++ b/Sample/OptimizelyTwelveTest/Features/Common/Pages/ISitePageData.cs @@ -2,7 +2,7 @@ { using EPiServer.Core; - public interface ISitePageData + public interface ISitePageData : IContent { string TeaserTitle { get; } diff --git a/Sample/OptimizelyTwelveTest/Features/Common/Pages/SiteExperienceData.cs b/Sample/OptimizelyTwelveTest/Features/Common/Pages/SiteExperienceData.cs new file mode 100644 index 00000000..fa74094f --- /dev/null +++ b/Sample/OptimizelyTwelveTest/Features/Common/Pages/SiteExperienceData.cs @@ -0,0 +1,55 @@ +namespace OptimizelyTwelveTest.Features.Common.Pages +{ + using EPiServer.Core; + using EPiServer.VisualBuilder; + using EPiServer.Web; + + using System.ComponentModel.DataAnnotations; + + public class SiteExperienceData : ExperienceData, ISitePageData + { + [Display( + Name = "Teaser Title", + Description = "A teaser title to use when rendering as a block", + GroupName = GroupNames.Teaser, + Order = 700)] + public virtual string TeaserTitle { get; set; } + + [Display( + Name = "Teaser Text", + Description = "Teaser text to use when rendering as a block", + GroupName = GroupNames.Teaser, + Order = 701)] + public virtual string TeaserText { get; set; } + + [Display( + Name = "Teaser Image", + Description = "The image to use when rendering as a block", + GroupName = GroupNames.Teaser, + Order = 702)] + [UIHint(UIHint.Image)] + public virtual ContentReference TeaserImage { get; set; } + + [Display( + Name = "Meta Title", + Description = "A meta title to be rendered within the page's title", + GroupName = GroupNames.SearchEngineOptimization, + Order = 800)] + public virtual string MetaTitle { get; set; } + + [Display( + Name = "Meta Description", + Description = "The meta description of the page to be shown in search results.", + GroupName = GroupNames.SearchEngineOptimization, + Order = 801)] + public virtual string MetaText { get; set; } + + [Display( + Name = "Meta Image", + Description = "The image to use for the meta image for the page", + GroupName = GroupNames.SearchEngineOptimization, + Order = 802)] + [UIHint(UIHint.Image)] + public virtual ContentReference MetaImage { get; set; } + } +} diff --git a/Sample/OptimizelyTwelveTest/Features/Configuration/SetupMigrationStep.cs b/Sample/OptimizelyTwelveTest/Features/Configuration/SetupMigrationStep.cs index ba4e995e..520a2219 100644 --- a/Sample/OptimizelyTwelveTest/Features/Configuration/SetupMigrationStep.cs +++ b/Sample/OptimizelyTwelveTest/Features/Configuration/SetupMigrationStep.cs @@ -3,12 +3,12 @@ using System.Linq; using EPiServer; +using EPiServer.Applications; using EPiServer.Core; using EPiServer.DataAbstraction.Migration; using EPiServer.DataAccess; using EPiServer.Security; using EPiServer.ServiceLocation; -using EPiServer.Web; using OptimizelyTwelveTest.Features.Home; using OptimizelyTwelveTest.Features.NotFound; @@ -22,7 +22,14 @@ public override void AddChanges() { try { - SetUpSystem(); + var appRepository = ServiceLocator.Current.GetInstance(); + if (!ConfigurationExists(appRepository)) + { + var contentRepository = ServiceLocator.Current.GetInstance(); + SetUpSystem(appRepository, contentRepository, 1, 5000, 5001); + SetUpSystem(appRepository, contentRepository, 2, 5002, 5003); + SetUpHeadlessSystem(appRepository, contentRepository, 3, 5004, 5005); + } } catch(Exception ex) { @@ -30,38 +37,91 @@ public override void AddChanges() } } - private void SetUpSystem() + private static bool ConfigurationExists(IApplicationRepository appRepository) { - var siteRepository = ServiceLocator.Current.GetInstance(); - var sites = siteRepository.List(); - if (sites.Any()) + var sites = appRepository.List(); + return sites.Any(); + } + + private static void SetUpSystem(IApplicationRepository appRepository, IContentRepository contentRepository, int siteNumber, int primaryPort, int cmsPort) + { + var culture = new CultureInfo("en"); + + // Create HomePage + var newHomePage = contentRepository.GetDefault(ContentReference.RootPage, culture); + newHomePage.Name = $"Home {siteNumber}"; + newHomePage.Heading = $"Home {siteNumber}"; + newHomePage.MetaTitle = $"Home {siteNumber}"; + + var homePageReference = contentRepository.Save(newHomePage, SaveAction.Publish, AccessLevel.NoAccess); + + // Create Not Found Page + var newNotFoundPage = contentRepository.GetDefault(homePageReference, culture); + newNotFoundPage.Name = $"Not Found {siteNumber}"; + newNotFoundPage.MetaTitle = $"Not Found {siteNumber}"; + + var notFoundReference = contentRepository.Save(newNotFoundPage, SaveAction.Publish, AccessLevel.NoAccess); + + // Create SiteSettings + var newSiteSettings = contentRepository.GetDefault(homePageReference, culture); + newSiteSettings.Name = $"[Site Settings {siteNumber}]"; + newSiteSettings.NotFoundPage = notFoundReference.ToReferenceWithoutVersion(); + newSiteSettings.SiteName = $"Site {siteNumber}"; + + var siteSettingsReference = contentRepository.Save(newSiteSettings, SaveAction.Publish, AccessLevel.NoAccess); + + // Update Home Page + var existingHomePage = contentRepository.Get(homePageReference, culture); + var editableHomePage = existingHomePage.CreateWritableClone() as HomePage; + editableHomePage.SiteSettings = siteSettingsReference.ToReferenceWithoutVersion(); + + homePageReference = contentRepository.Save(editableHomePage, SaveAction.Publish, AccessLevel.NoAccess); + + // Create Site + var newSite = new InProcessWebsite($"TestWebsite{siteNumber}", homePageReference.ToReferenceWithoutVersion()) { - return; - } + DisplayName = $"Test Website {siteNumber}" + }; + + newSite.Hosts.Add(new ApplicationHost($"localhost:{primaryPort}") + { + PreferredUrlScheme = UrlScheme.Https, + Type = ApplicationHostType.Primary + }); - var contentRepository = ServiceLocator.Current.GetInstance(); + newSite.Hosts.Add(new ApplicationHost($"localhost:{cmsPort}") + { + PreferredUrlScheme = UrlScheme.Https, + Type = ApplicationHostType.Edit + }); + + appRepository.SaveAsync(newSite).GetAwaiter().GetResult(); + } + + private static void SetUpHeadlessSystem(IApplicationRepository appRepository, IContentRepository contentRepository, int siteNumber, int primaryPort, int previewPort) + { var culture = new CultureInfo("en"); // Create HomePage var newHomePage = contentRepository.GetDefault(ContentReference.RootPage, culture); - newHomePage.Name = "Home"; - newHomePage.Heading = "Home"; - newHomePage.MetaTitle = "Home"; + newHomePage.Name = $"Home {siteNumber}"; + newHomePage.Heading = $"Home {siteNumber}"; + newHomePage.MetaTitle = $"Home {siteNumber}"; var homePageReference = contentRepository.Save(newHomePage, SaveAction.Publish, AccessLevel.NoAccess); // Create Not Found Page - var newNotFoundPage = contentRepository.GetDefault(ContentReference.RootPage, culture); - newNotFoundPage.Name = "Not Found"; - newNotFoundPage.MetaTitle = "Home"; + var newNotFoundPage = contentRepository.GetDefault(homePageReference, culture); + newNotFoundPage.Name = $"Not Found {siteNumber}"; + newNotFoundPage.MetaTitle = $"Not Found {siteNumber}"; var notFoundReference = contentRepository.Save(newNotFoundPage, SaveAction.Publish, AccessLevel.NoAccess); // Create SiteSettings var newSiteSettings = contentRepository.GetDefault(homePageReference, culture); - newSiteSettings.Name = "[Site Settings]"; + newSiteSettings.Name = $"[Site Settings {siteNumber}]"; newSiteSettings.NotFoundPage = notFoundReference.ToReferenceWithoutVersion(); - newSiteSettings.SiteName = "Stott Security Dev Site"; + newSiteSettings.SiteName = $"Site {siteNumber}"; var siteSettingsReference = contentRepository.Save(newSiteSettings, SaveAction.Publish, AccessLevel.NoAccess); @@ -73,20 +133,24 @@ private void SetUpSystem() homePageReference = contentRepository.Save(editableHomePage, SaveAction.Publish, AccessLevel.NoAccess); // Create Site - var newSite = SiteDefinition.Empty; - var editableSite = newSite.CreateWritableClone(); + var newSite = new Website($"TestWebsite{siteNumber}", homePageReference.ToReferenceWithoutVersion()) + { + DisplayName = $"Test Website {siteNumber}" + }; + + newSite.Hosts.Add(new ApplicationHost($"localhost:{primaryPort}") + { + PreferredUrlScheme = UrlScheme.Https, + Type = ApplicationHostType.Primary + }); - editableSite.Name = "Test Website"; - editableSite.StartPage = homePageReference.ToReferenceWithoutVersion(); - editableSite.SiteUrl = new Uri("https://localhost:44344/"); - editableSite.Hosts.Add(new HostDefinition + newSite.Hosts.Add(new ApplicationHost($"localhost:{previewPort}") { - Name = "localhost:44344", - Type = HostDefinitionType.Primary, - UseSecureConnection = true + PreferredUrlScheme = UrlScheme.Https, + Type = ApplicationHostType.Preview }); - siteRepository.Save(editableSite); + appRepository.SaveAsync(newSite).GetAwaiter().GetResult(); } } } diff --git a/Sample/OptimizelyTwelveTest/Features/Home/HomePageController.cs b/Sample/OptimizelyTwelveTest/Features/Home/HomePageController.cs index 1ec0b505..d05784e5 100644 --- a/Sample/OptimizelyTwelveTest/Features/Home/HomePageController.cs +++ b/Sample/OptimizelyTwelveTest/Features/Home/HomePageController.cs @@ -9,14 +9,9 @@ using Stott.Security.Optimizely.Features.Csp.Settings.Service; - public class HomePageController : PageControllerBase + public class HomePageController(ICspSettingsService cspSettingsService) : PageControllerBase { - private readonly ICspSettingsService _cspSettingsService; - - public HomePageController(ICspSettingsService cspSettingsService) - { - _cspSettingsService = cspSettingsService; - } + private readonly ICspSettingsService _cspSettingsService = cspSettingsService; [SecurityHeaderAction] public async Task Index(HomePage currentPage, bool resetReportMode) @@ -25,9 +20,9 @@ public async Task Index(HomePage currentPage, bool resetReportMod if (resetReportMode) { - var currentSettings = await _cspSettingsService.GetAsync(); + var currentSettings = await _cspSettingsService.GetAsync(null, null); currentSettings.IsReportOnly = true; - await _cspSettingsService.SaveAsync(currentSettings, "Mark Stott"); + await _cspSettingsService.SaveAsync(currentSettings, "Mark Stott", null, null); } return View(model); diff --git a/Sample/OptimizelyTwelveTest/Features/Listing/CategoryFilterBlock.cs b/Sample/OptimizelyTwelveTest/Features/Listing/CategoryFilterBlock.cs new file mode 100644 index 00000000..88e317ba --- /dev/null +++ b/Sample/OptimizelyTwelveTest/Features/Listing/CategoryFilterBlock.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using EPiServer.Core; +using EPiServer.DataAbstraction; +using EPiServer.DataAnnotations; + +namespace OptimizelyTwelveTest.Features.Listing; + +[ContentType( + DisplayName = "Category Filter Block", + Description = "Intended for defining a filter that the user can use to filter result.", + GUID = "4CEE4A26-0929-4A0E-B39B-237176527CFA", + Order = 110, + GroupName = SystemTabNames.Content, + AvailableInEditMode = false)] +public class CategoryFilterBlock : BlockData +{ + [Display(Name = "Title", GroupName = SystemTabNames.Content, Order = 10)] + [Required] + [CultureSpecific] + public virtual string Title { get; set; } + + [Display(Name = "Placeholder", GroupName = SystemTabNames.Content, Order = 20)] + [Required] + [CultureSpecific] + public virtual string Placeholder { get; set; } + + [Display(Name = "All Items Label", Description = "Label for the all items entry.", GroupName = SystemTabNames.Content, Order = 40)] + [Required] + [CultureSpecific] + public virtual string AllLabel { get; set; } + + //[Display(Name = "Categories", Description = "Select categories to show in filter list.", GroupName = SystemTabNames.Content, Order = 30)] + //[Required] + //[CultureSpecific] + //public virtual Category Categories { get; set; } +} diff --git a/Sample/OptimizelyTwelveTest/Features/Listing/ContentListingService.cs b/Sample/OptimizelyTwelveTest/Features/Listing/ContentListingService.cs new file mode 100644 index 00000000..5444d4e6 --- /dev/null +++ b/Sample/OptimizelyTwelveTest/Features/Listing/ContentListingService.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OptimizelyTwelveTest.Features.Listing; + +public class ContentListingService : IContentListingService +{ + public IList GetListings(string freeText, int page) + { + // In a real implementation, this would query the CMS for content items. + var listings = Enumerable.Range(0, 20).Select(x => new ListingResult { Title = $"Campaign {x}", Url = $"/campaign-{x}" }).ToList(); + + if (!string.IsNullOrWhiteSpace(freeText)) + { + listings = [.. listings.Where(x => x.Title.Contains(freeText, StringComparison.OrdinalIgnoreCase))]; + } + + var skip = (page - 1) * 10; + + return listings.Skip(skip).Take(10).ToList(); + } +} diff --git a/Sample/OptimizelyTwelveTest/Features/Listing/IContentListingService.cs b/Sample/OptimizelyTwelveTest/Features/Listing/IContentListingService.cs new file mode 100644 index 00000000..ef1c8c2f --- /dev/null +++ b/Sample/OptimizelyTwelveTest/Features/Listing/IContentListingService.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace OptimizelyTwelveTest.Features.Listing; + +public interface IContentListingService +{ + IList GetListings(string freeText, int page); +} diff --git a/Sample/OptimizelyTwelveTest/Features/Listing/ListingBlock.cs b/Sample/OptimizelyTwelveTest/Features/Listing/ListingBlock.cs new file mode 100644 index 00000000..c0c12cab --- /dev/null +++ b/Sample/OptimizelyTwelveTest/Features/Listing/ListingBlock.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using EPiServer.Core; +using EPiServer.DataAbstraction; +using EPiServer.DataAnnotations; + +namespace OptimizelyTwelveTest.Features.Listing; + +[ContentType( + DisplayName = "Listing Block", + GUID = "3757f8f8-e1e9-4e2d-85a4-d6bd4a034675", + GroupName = SystemTabNames.Content, + CompositionBehaviors = [CompositionBehavior.SectionEnabledKey])] +public class ListingBlock : BlockData +{ + [CultureSpecific] + [Display( + Name = "Content", + Description = "Rich Text Content", + GroupName = SystemTabNames.Content, + Order = 1)] + public virtual IList Filters { get; set; } +} diff --git a/Sample/OptimizelyTwelveTest/Features/Listing/ListingBlockViewComponent.cs b/Sample/OptimizelyTwelveTest/Features/Listing/ListingBlockViewComponent.cs new file mode 100644 index 00000000..81c829b0 --- /dev/null +++ b/Sample/OptimizelyTwelveTest/Features/Listing/ListingBlockViewComponent.cs @@ -0,0 +1,78 @@ +using System; +using System.Globalization; +using System.Linq; +using EPiServer.Core; +using EPiServer.Web.Mvc; +using EPiServer.Web.Routing; +using Microsoft.AspNetCore.Mvc; +using OptimizelyTwelveTest.Features.Common.Pages; + +namespace OptimizelyTwelveTest.Features.Listing; + +public class ListingBlockViewComponent : BlockComponent +{ + private readonly IContentRouteHelper contentRouteHelper; + + private readonly IContentListingService contentListingService; + + private readonly IUrlResolver urlResolver; + + public ListingBlockViewComponent( + IContentRouteHelper contentRouteHelper, IUrlResolver urlResolver) + { + this.contentRouteHelper = contentRouteHelper; + this.contentListingService = new ContentListingService(); + this.urlResolver = urlResolver; + } + + protected override IViewComponentResult InvokeComponent(ListingBlock currentContent) + { + var page = contentRouteHelper.Content as ISitePageData; + if (page == null) + { + return Content(string.Empty); + } + + var searchTerm = string.Empty; + if (Request.Query.TryGetValue("freeText", out var rawSearchTerm)) + { + searchTerm = rawSearchTerm.ToString(); + } + + var pageNumber = 1; + if (Request.Query.TryGetValue("page", out var rawPage) && int.TryParse(rawPage, out var parsedPageNumber)) + { + pageNumber = parsedPageNumber; + } + + var results = contentListingService.GetListings(searchTerm, pageNumber); + var pages = Enumerable.Range(1, 4).Select(x => new PaginationModel + { + PageNumber= x, + TotalPages = 4, + BaseUrl = GeneratePageUrl(page, x, searchTerm) + }); + + var model = new ListingBlockViewModel + { + Heading = string.IsNullOrWhiteSpace(searchTerm) ? "All Campaigns" : $"Search Results for '{searchTerm}'", + CurrentContent = currentContent, + Results = results, + Pagination = [..pages] + }; + + return View(model); + } + + private string GeneratePageUrl(ISitePageData page, int pageNumber, string searchTerm) + { + var url = urlResolver.GetUrl(page.ContentLink.ToReferenceWithoutVersion(), CultureInfo.CurrentUICulture.Name, new UrlResolverArguments { ContextMode = EPiServer.Web.ContextMode.Default, ForceAbsolute = true}); + var query = string.IsNullOrWhiteSpace(searchTerm) ? $"page={pageNumber}" : $"freeText={searchTerm}&page={pageNumber}"; + var uriBuilder = new UriBuilder(url) + { + Query = query + }; + + return uriBuilder.ToString(); + } +} diff --git a/Sample/OptimizelyTwelveTest/Features/Listing/ListingBlockViewModel.cs b/Sample/OptimizelyTwelveTest/Features/Listing/ListingBlockViewModel.cs new file mode 100644 index 00000000..51befc6a --- /dev/null +++ b/Sample/OptimizelyTwelveTest/Features/Listing/ListingBlockViewModel.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace OptimizelyTwelveTest.Features.Listing; + +public class ListingBlockViewModel +{ + public string Heading { get; set; } + + public ListingBlock CurrentContent { get; set; } + + public IList Results { get; set; } + + public List Pagination { get; set; } +} + +public class PaginationModel +{ + public int PageNumber { get; set; } + public int TotalPages { get; set; } + public string BaseUrl { get; set; } +} \ No newline at end of file diff --git a/Sample/OptimizelyTwelveTest/Features/Listing/ListingResult.cs b/Sample/OptimizelyTwelveTest/Features/Listing/ListingResult.cs new file mode 100644 index 00000000..5cc65185 --- /dev/null +++ b/Sample/OptimizelyTwelveTest/Features/Listing/ListingResult.cs @@ -0,0 +1,7 @@ +namespace OptimizelyTwelveTest.Features.Listing; + +public class ListingResult +{ + public string Title { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; +} diff --git a/Sample/OptimizelyTwelveTest/Features/MetaData/MetaDataViewComponent.cs b/Sample/OptimizelyTwelveTest/Features/MetaData/MetaDataViewComponent.cs index 3b458fe6..566dc529 100644 --- a/Sample/OptimizelyTwelveTest/Features/MetaData/MetaDataViewComponent.cs +++ b/Sample/OptimizelyTwelveTest/Features/MetaData/MetaDataViewComponent.cs @@ -1,23 +1,19 @@ namespace OptimizelyTwelveTest.Features.MetaData { + using System; + using System.Diagnostics.CodeAnalysis; + using EPiServer.VisualBuilder.Compositions; + using EPiServer.Web; + using EPiServer.Web.Mvc.TagHelpers; + using EPiServer.Web.Mvc.TagHelpers.Experience.Internal; using EPiServer.Web.Routing; - using Microsoft.AspNetCore.Mvc; - + using Microsoft.AspNetCore.Razor.TagHelpers; using OptimizelyTwelveTest.Features.Common.Pages; using OptimizelyTwelveTest.Features.Settings; - public class MetaDataViewComponent : ViewComponent + public class MetaDataViewComponent(ISiteSettingsResolver siteSettingsResolver, IUrlResolver urlResolver) : ViewComponent { - private readonly ISiteSettings _siteSettings; - private readonly UrlResolver _urlResolver; - - public MetaDataViewComponent(ISiteSettings siteSettings, UrlResolver urlResolver) - { - _siteSettings = siteSettings; - _urlResolver = urlResolver; - } - public IViewComponentResult Invoke(ISitePageData sitePage) { if (sitePage == null) @@ -25,11 +21,12 @@ public IViewComponentResult Invoke(ISitePageData sitePage) return Content(string.Empty); } + var siteSettings = siteSettingsResolver.Get(); var model = new MetaDataViewModel { - Title = $"{_siteSettings?.SiteName} | {sitePage.MetaTitle}", + Title = $"{siteSettings?.SiteName} | {sitePage.MetaTitle}", Description = sitePage.MetaText, - Image = _urlResolver.GetUrl(sitePage.MetaImage) + Image = urlResolver.GetUrl(sitePage.MetaImage) }; return View(model); diff --git a/Sample/OptimizelyTwelveTest/Features/NotFound/ErrorController.cs b/Sample/OptimizelyTwelveTest/Features/NotFound/ErrorController.cs index f6620da3..af6272b2 100644 --- a/Sample/OptimizelyTwelveTest/Features/NotFound/ErrorController.cs +++ b/Sample/OptimizelyTwelveTest/Features/NotFound/ErrorController.cs @@ -6,18 +6,8 @@ using OptimizelyTwelveTest.Features.Settings; -public sealed class ErrorController : Controller +public sealed class ErrorController(IContentLoader contentLoader, ISiteSettingsResolver siteSettingsResolver) : Controller { - private readonly IContentLoader _contentLoader; - - private readonly ISiteSettings _siteSettings; - - public ErrorController(IContentLoader contentLoader, ISiteSettings siteSettings) - { - _contentLoader = contentLoader; - _siteSettings = siteSettings; - } - [HttpGet] [Route("/error")] public IActionResult PageNotFound(int statusCode) @@ -30,7 +20,8 @@ public IActionResult PageNotFound(int statusCode) private NotFoundPage GetNotFoundPage() { - if (_contentLoader.TryGet(_siteSettings.NotFoundPage, out var notFoundPage)) + var siteSettings = siteSettingsResolver.Get(); + if (contentLoader.TryGet(siteSettings?.NotFoundPage, out var notFoundPage)) { return notFoundPage; } diff --git a/Sample/OptimizelyTwelveTest/Features/Search/SearchPage.cs b/Sample/OptimizelyTwelveTest/Features/Search/SearchPage.cs deleted file mode 100644 index abf7149a..00000000 --- a/Sample/OptimizelyTwelveTest/Features/Search/SearchPage.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace OptimizelyTwelveTest.Features.Search -{ - using EPiServer.Core; - using EPiServer.DataAbstraction; - using EPiServer.DataAnnotations; - using EPiServer.Web; - - using OptimizelyTwelveTest.Features.Common.Pages; - - using System.ComponentModel.DataAnnotations; - - [ContentType( - DisplayName = "Search Page", - GUID = "529f8bf1-2b92-4993-9bf7-d7af1a82b464", - Description = "A page for general search of the site.", - GroupName = SystemTabNames.Content)] - public class SearchPage : SitePageData - { - [Display( - Name = "Hero Image", - Description = "The image to render at the top of the page", - GroupName = SystemTabNames.Content, - Order = 10)] - [UIHint(UIHint.Image)] - public virtual ContentReference HeroImage { get; set; } - - [Display( - Name = "Heading", - Description = "The H1 to display", - GroupName = SystemTabNames.Content, - Order = 20)] - public virtual string Heading { get; set; } - - [Display( - Name = "Initial Page Size", - Description = "The number of results to display on the initial search.", - GroupName = SystemTabNames.Content, - Order = 30)] - [Range(1,100)] - public virtual int InitialPageSize { get; set; } - - [Display( - Name = "Load More Page Size", - Description = "The number of results to display on subsequent page loads.", - GroupName = SystemTabNames.Content, - Order = 40)] - [Range(1, 100)] - public virtual int LoadMorePageSize { get; set; } - - [Display( - Name = "Load More CTA Text", - Description = "The text to display on the Load More CTA button.", - GroupName = SystemTabNames.Content, - Order = 50)] - [Required] - public virtual string LoadMoreCtaText { get; set; } - - public override void SetDefaultValues(ContentType contentType) - { - base.SetDefaultValues(contentType); - - this.InitialPageSize = 12; - this.LoadMorePageSize = 9; - this.LoadMoreCtaText = "Load More"; - } - } -} \ No newline at end of file diff --git a/Sample/OptimizelyTwelveTest/Features/Search/SearchPageController.cs b/Sample/OptimizelyTwelveTest/Features/Search/SearchPageController.cs deleted file mode 100644 index bd58fbe1..00000000 --- a/Sample/OptimizelyTwelveTest/Features/Search/SearchPageController.cs +++ /dev/null @@ -1,62 +0,0 @@ -namespace OptimizelyTwelveTest.Features.Search -{ - using MediatR; - - using Microsoft.AspNetCore.Mvc; - - using Newtonsoft.Json; - - using OptimizelyTwelveTest.Features.Common; - - using System.Threading.Tasks; - - public class SearchPageController : PageControllerBase - { - private readonly IMediator _mediator; - - public SearchPageController(IMediator mediator) - { - _mediator = mediator; - } - - [HttpGet] - public async Task Index(SearchPage currentContent, string searchText, int page = 1) - { - var query = new SearchQuery - { - InitialPageSize = currentContent.InitialPageSize, - LoadMorePageSize = currentContent.LoadMorePageSize, - Page = page, - SearchText = searchText - }; - var response = await _mediator.Send(query); - var model = new SearchPageViewModel - { - CurrentPage = currentContent, - SearchResults = response - }; - - return View(model); - } - - [HttpGet] - public async Task Search(SearchPage currentContent, string searchText, int page) - { - var query = new SearchQuery - { - InitialPageSize = currentContent.InitialPageSize, - LoadMorePageSize = currentContent.LoadMorePageSize, - Page = page, - SearchText = searchText - }; - var response = await _mediator.Send(query); - - return new ContentResult - { - Content = JsonConvert.SerializeObject(response), - ContentType = "application/json", - StatusCode = 200 - }; - } - } -} diff --git a/Sample/OptimizelyTwelveTest/Features/Search/SearchPageViewModel.cs b/Sample/OptimizelyTwelveTest/Features/Search/SearchPageViewModel.cs deleted file mode 100644 index 6aa24fe2..00000000 --- a/Sample/OptimizelyTwelveTest/Features/Search/SearchPageViewModel.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace OptimizelyTwelveTest.Features.Search -{ - using OptimizelyTwelveTest.Features.Common.Pages; - - public class SearchPageViewModel: ISitePageViewModel - { - public SearchPage CurrentPage { get; set; } - - public SearchResponse SearchResults { get; set; } - } -} \ No newline at end of file diff --git a/Sample/OptimizelyTwelveTest/Features/Search/SearchQuery.cs b/Sample/OptimizelyTwelveTest/Features/Search/SearchQuery.cs deleted file mode 100644 index 19cb3141..00000000 --- a/Sample/OptimizelyTwelveTest/Features/Search/SearchQuery.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace OptimizelyTwelveTest.Features.Search -{ - using MediatR; - - public class SearchQuery : IRequest - { - public string SearchText { get; set; } - - public int InitialPageSize { get; set; } - - public int LoadMorePageSize { get; set; } - - public int Page { get; set; } - } -} \ No newline at end of file diff --git a/Sample/OptimizelyTwelveTest/Features/Search/SearchQueryHandler.cs b/Sample/OptimizelyTwelveTest/Features/Search/SearchQueryHandler.cs deleted file mode 100644 index 05f51448..00000000 --- a/Sample/OptimizelyTwelveTest/Features/Search/SearchQueryHandler.cs +++ /dev/null @@ -1,86 +0,0 @@ -namespace OptimizelyTwelveTest.Features.Search -{ - using System.Linq; - using EPiServer.Find; - using EPiServer.Find.Cms; - - using MediatR; - - using OptimizelyTwelveTest.Features.Common.Pages; - - using System.Threading; - using System.Threading.Tasks; - using EPiServer.Web.Routing; - using OptimizelyTwelveTest.Features.GeneralContent; - using OptimizelyTwelveTest.Features.Home; - - public class SearchQueryHandler : IRequestHandler - { - private readonly IClient _findClient; - - private readonly UrlResolver _urlResolver; - - public SearchQueryHandler(IClient findClient, UrlResolver urlResolver) - { - _findClient = findClient; - _urlResolver = urlResolver; - } - - public Task Handle(SearchQuery request, CancellationToken cancellationToken) - { - var pageSize = request.InitialPageSize; - var skip = 0; - if (request.Page > 1) - { - pageSize = request.LoadMorePageSize; - skip = request.InitialPageSize + (request.LoadMorePageSize * (request.Page - 1)); - } - - var searchResult = _findClient.Search() - .For(request.SearchText) - .UsingSynonyms() - .ApplyBestBets() - .Skip(skip) - .Take(pageSize) - .GetContentResult(); - - var response = new SearchResponse - { - TotalRecords = searchResult.TotalMatching, - Results = searchResult.Items.Select(ToSearchResultItem).ToList() - }; - - return Task.FromResult(response); - } - - private SearchResultItem ToSearchResultItem(SitePageData sitePageData) - { - if (sitePageData is HomePage homePage) - { - return new SearchResultItem - { - Title = homePage.TeaserTitle ?? homePage.Heading, - Description = homePage.TeaserText, - ImageUrl = _urlResolver.GetUrl(homePage.TeaserImage) - }; - } - - if (sitePageData is GeneralContentPage generalContentPage) - { - return new SearchResultItem - { - Title = generalContentPage.TeaserTitle ?? generalContentPage.Heading, - Description = generalContentPage.TeaserText, - ImageUrl = _urlResolver.GetUrl(generalContentPage.TeaserImage) - }; - } - - return new SearchResultItem - { - Title = sitePageData.TeaserTitle, - Description = sitePageData.TeaserText, - ImageUrl = _urlResolver.GetUrl(sitePageData.TeaserImage) - }; - } - } -} \ No newline at end of file diff --git a/Sample/OptimizelyTwelveTest/Features/Search/SearchResponse.cs b/Sample/OptimizelyTwelveTest/Features/Search/SearchResponse.cs deleted file mode 100644 index 50bd4cdb..00000000 --- a/Sample/OptimizelyTwelveTest/Features/Search/SearchResponse.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace OptimizelyTwelveTest.Features.Search -{ - using OptimizelyTwelveTest.Features.Common.Pages; - - using System.Collections.Generic; - - public class SearchResponse - { - public int TotalRecords { get; set; } - - public IList Results { get; set; } - } -} \ No newline at end of file diff --git a/Sample/OptimizelyTwelveTest/Features/Search/SearchResultItem.cs b/Sample/OptimizelyTwelveTest/Features/Search/SearchResultItem.cs deleted file mode 100644 index 05bbc9f7..00000000 --- a/Sample/OptimizelyTwelveTest/Features/Search/SearchResultItem.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace OptimizelyTwelveTest.Features.Search -{ - public class SearchResultItem - { - public string Title { get; set; } - - public string Description { get; set; } - - public string ImageUrl { get; set; } - } -} \ No newline at end of file diff --git a/Sample/OptimizelyTwelveTest/Features/Settings/ISiteSettings.cs b/Sample/OptimizelyTwelveTest/Features/Settings/ISiteSettings.cs index 9bc2af3a..bf27cd05 100644 --- a/Sample/OptimizelyTwelveTest/Features/Settings/ISiteSettings.cs +++ b/Sample/OptimizelyTwelveTest/Features/Settings/ISiteSettings.cs @@ -1,4 +1,6 @@ -using EPiServer.Core; +using EPiServer; +using EPiServer.Core; +using OptimizelyTwelveTest.Features.Home; namespace OptimizelyTwelveTest.Features.Settings { @@ -8,4 +10,28 @@ public interface ISiteSettings public ContentReference NotFoundPage { get; } } + + public interface ISiteSettingsResolver + { + ISiteSettings Get(); + } + + public class SiteSettingsResolver(IContentLoader contentLoader) : ISiteSettingsResolver + { + private ISiteSettings _siteSettings; + + public ISiteSettings Get() + { + if (_siteSettings == null) + { + if (contentLoader.TryGet(ContentReference.StartPage, out var homePage) && + contentLoader.TryGet(homePage.SiteSettings, out var siteSettingsPage)) + { + _siteSettings = siteSettingsPage; + } + } + + return _siteSettings; + } + } } \ No newline at end of file diff --git a/Sample/OptimizelyTwelveTest/OptimizelyTwelveTest.csproj b/Sample/OptimizelyTwelveTest/OptimizelyTwelveTest.csproj index a0aeb017..57af0792 100644 --- a/Sample/OptimizelyTwelveTest/OptimizelyTwelveTest.csproj +++ b/Sample/OptimizelyTwelveTest/OptimizelyTwelveTest.csproj @@ -1,20 +1,22 @@  - - net10.0 - - - - - - - - - - - - - - - + + net10.0 + + + + + + + + + + + + + + + + + diff --git a/Sample/OptimizelyTwelveTest/Program.cs b/Sample/OptimizelyTwelveTest/Program.cs index 7f625120..c8d563d4 100644 --- a/Sample/OptimizelyTwelveTest/Program.cs +++ b/Sample/OptimizelyTwelveTest/Program.cs @@ -1,45 +1,45 @@ namespace OptimizelyTwelveTest; +using System; + using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; -using System; - public class Program { - public static void Main(string[] args) + public static void Main(string[] args) => CreateHostBuilder(args).Build().Run(); + + public static IHostBuilder CreateHostBuilder(string[] args) { var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); - var isDevelopment = environment == Environments.Development; - - if (isDevelopment) - { - //Development configuration - } - - CreateHostBuilder(args, isDevelopment).Build().Run(); - } - public static IHostBuilder CreateHostBuilder(string[] args, bool isDevelopment) - { - if (isDevelopment) + if (environment == Environments.Development) { - //Development configuration can be addded here, like local logging. return Host.CreateDefaultBuilder(args) - .ConfigureCmsDefaults() - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); + .ConfigureCmsDefaults() + .ConfigureWebHostDefaults(webBuilder => webBuilder.ConfigureKestrel((context, options) => + { + // Add valid HTTPS ports for development that are compatible with Opti Id + options.ListenLocalhost(5000, options => { options.UseHttps(); }); + options.ListenLocalhost(5001, options => { options.UseHttps(); }); + options.ListenLocalhost(5002, options => { options.UseHttps(); }); + options.ListenLocalhost(5003, options => { options.UseHttps(); }); + + options.Limits.MaxRequestBodySize = 2147483648; + options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(60); + }) + .UseStartup()); } else { return Host.CreateDefaultBuilder(args) - .ConfigureCmsDefaults() - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); + .ConfigureCmsDefaults() + .ConfigureWebHostDefaults(webBuilder => webBuilder.ConfigureKestrel((context, options) => + { + options.Limits.MaxRequestBodySize = 2147483648; + options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(60); + }) + .UseStartup()); } } -} +} \ No newline at end of file diff --git a/Sample/OptimizelyTwelveTest/Properties/launchSettings.json b/Sample/OptimizelyTwelveTest/Properties/launchSettings.json index a9e97948..fc5d19dc 100644 --- a/Sample/OptimizelyTwelveTest/Properties/launchSettings.json +++ b/Sample/OptimizelyTwelveTest/Properties/launchSettings.json @@ -1,27 +1,12 @@ { - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:60396/", - "sslPort": 44344 - } - }, "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, "OptimizelyTwelveTest": { "commandName": "Project", "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, - "applicationUrl": "https://localhost:5001;http://localhost:5000" + "applicationUrl": "https://localhost:5000" } } } \ No newline at end of file diff --git a/Sample/OptimizelyTwelveTest/ServiceExtensions/StartupExtensions.cs b/Sample/OptimizelyTwelveTest/ServiceExtensions/StartupExtensions.cs deleted file mode 100644 index 265ee6ad..00000000 --- a/Sample/OptimizelyTwelveTest/ServiceExtensions/StartupExtensions.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace OptimizelyTwelveTest.ServiceExtensions -{ - using EPiServer; - using EPiServer.Core; - - using OptimizelyTwelveTest.Features.Home; - using OptimizelyTwelveTest.Features.Settings; - - using Microsoft.Extensions.DependencyInjection; - - public static class ServiceExtensions - { - public static void AddCustomDependencies(this IServiceCollection serviceCollection) - { - serviceCollection.AddScoped(provider => - { - if (provider.GetService(typeof(IContentLoader)) is IContentLoader contentLoader && - contentLoader.TryGet(ContentReference.StartPage, out var homePage) && - contentLoader.TryGet(homePage.SiteSettings, out var siteSettingsPage)) - { - return siteSettingsPage; - } - - return null; - }); - } - } -} diff --git a/Sample/OptimizelyTwelveTest/Startup.cs b/Sample/OptimizelyTwelveTest/Startup.cs index fd8a22c3..41a1720d 100644 --- a/Sample/OptimizelyTwelveTest/Startup.cs +++ b/Sample/OptimizelyTwelveTest/Startup.cs @@ -2,9 +2,11 @@ using System; -using EPiServer.Cms.Shell; using EPiServer.Cms.Shell.UI; using EPiServer.Cms.UI.AspNetIdentity; +using EPiServer.Cms.UI.VisitorGroups; +using EPiServer.Data; +using EPiServer.DependencyInjection; using EPiServer.Scheduler; using EPiServer.Web.Routing; @@ -15,37 +17,36 @@ using Microsoft.Extensions.Hosting; using Microsoft.Net.Http.Headers; +using Optimizely.Graph.DependencyInjection; + using OptimizelyTwelveTest.Features.Common; using OptimizelyTwelveTest.Features.Home; -using OptimizelyTwelveTest.ServiceExtensions; +using OptimizelyTwelveTest.Features.Settings; -using Stott.Optimizely.RobotsHandler.Configuration; using Stott.Security.Optimizely.Common; using Stott.Security.Optimizely.Features.Configuration; public sealed class Startup { - private readonly IWebHostEnvironment _webHostingEnvironment; - - public Startup(IWebHostEnvironment webHostingEnvironment) - { - _webHostingEnvironment = webHostingEnvironment; - } - public void ConfigureServices(IServiceCollection services) { - if (_webHostingEnvironment.IsDevelopment()) + services.AddCms() + .AddCmsAspNetIdentity() + .AddAdminUserRegistration(options => { options.Behavior = RegisterAdminUserBehaviors.Enabled; }) + .AddVisitorGroupsMvc() + .AddVisitorGroupsUI() + .AddContentGraph() + .AddContentManager() + .AddCmsTagHelpers(); + + services.Configure(options => { - services.Configure(o => - { - o.Enabled = false; - }); - } + options.UpdateDatabaseCompatibilityLevel = true; + }); - services.AddCmsAspNetIdentity(); - services.AddAdminUserRegistration(options => + services.Configure(o => { - options.Behavior = RegisterAdminUserBehaviors.Enabled; + o.Enabled = false; }); // Various serialization formats. @@ -55,17 +56,15 @@ public void ConfigureServices(IServiceCollection services) config.JsonSerializerOptions.PropertyNamingPolicy = new UpperCaseNamingPolicy(); }); - services.AddCms(); - services.AddFind(); - services.AddContentDeliveryApi(); services.AddMediatR(config => { config.RegisterServicesFromAssemblyContaining(typeof(HomePage)); }); - services.AddCustomDependencies(); - services.AddRobotsHandler(); + // services.AddRobotsHandler(); services.AddSwaggerGen(); + services.AddScoped(); + // Configuration App Settings (Simple) //// services.AddStottSecurity(); diff --git a/Sample/OptimizelyTwelveTest/Views/CampaignPage/Index.cshtml b/Sample/OptimizelyTwelveTest/Views/CampaignPage/Index.cshtml new file mode 100644 index 00000000..80df56d9 --- /dev/null +++ b/Sample/OptimizelyTwelveTest/Views/CampaignPage/Index.cshtml @@ -0,0 +1,27 @@ +@using EPiServer.Web.Mvc.Html +@model OptimizelyTwelveTest.Features.Campaign.CampaignPageViewModel + + +@if (Model?.CurrentPage is not null) +{ + + + + + + + + + + + + + + + + +} +else +{ +

No content available

+} \ No newline at end of file diff --git a/Sample/OptimizelyTwelveTest/Views/CustomAdminPage/Index.cshtml b/Sample/OptimizelyTwelveTest/Views/CustomAdminPage/Index.cshtml index 769473b6..41443056 100644 --- a/Sample/OptimizelyTwelveTest/Views/CustomAdminPage/Index.cshtml +++ b/Sample/OptimizelyTwelveTest/Views/CustomAdminPage/Index.cshtml @@ -8,7 +8,7 @@ Content Security Policy Management - @Html.CreatePlatformNavigationMenu() + @* @Html.CreatePlatformNavigationMenu() *@