diff --git a/.github/BUILD.md b/.github/BUILD.md index 7c8c9d7c6936..2e081548fee4 100644 --- a/.github/BUILD.md +++ b/.github/BUILD.md @@ -1,5 +1,7 @@ # Umbraco CMS Build +This guide will explain how you can build the Umbraco CMS from the source code. You will most likely want to do this if your are setting up a local development environment for contributing code updates to the project. You will need this in order to develop and test your fix or feature. + ## Are you sure? In order to use Umbraco as a CMS and build your website with it, you should not build it yourself. If you're reading this then you're trying to contribute to Umbraco or you're debugging a complex issue. @@ -13,7 +15,7 @@ If the answer is yes, please read on. Otherwise, make sure to head on over [to t ↖️ You can jump to any section by using the "table of contents" button ( ![Table of contents icon](img/tableofcontentsicon.svg) ) above. -## Debugging source locally +## Working with the Umbraco source code Did you read ["Are you sure"](#are-you-sure)? @@ -21,73 +23,109 @@ Did you read ["Are you sure"](#are-you-sure)? If you want to run a build without debugging, see [Building from source](#building-from-source) below. This runs the build in the same way it is run on our build servers. -> [!NOTE] -> The caching for the back office has been described as 'aggressive' so we often find it's best when making back office changes to [disable caching in the browser (check "Disable cache" on the "Network" tab of developer tools)][disable browser caching] to help you to see the changes you're making. +If you've got this far and are keen to get stuck in helping us fix a bug or implement a feature, great! Please read on... -#### Debugging with VS Code +### Prerequisites -In order to build the Umbraco source code locally with Visual Studio Code, first make sure you have the following installed. +In order to work with the Umbraco source code locally, first make sure you have the following installed. -- [Visual Studio Code](https://code.visualstudio.com/) +- Your favourite IDE: [Visual Studio 2022 v17+ with .NET 7+](https://visualstudio.microsoft.com/vs/), [Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/) - [dotnet SDK v9+](https://dotnet.microsoft.com/en-us/download) - [Node.js v20+](https://nodejs.org/en/download/) - npm v10+ (installed with Node.js) - [Git command line](https://git-scm.com/download/) -Open the root folder of the repository in Visual Studio Code. +### Familiarizing yourself with the code + +Umbraco is a .NET application using C#. The solution is broken down into multiple projects. There are several class libraries. The `Umbraco.Web.UI` project is the main project that hosts the back office and login screen. This is the project you will want to run to see your changes. -To build the front-end you'll need to open the command pallet (Ctrl + Shift + P) and run `>Tasks: Run Task` followed by `Client Build`. +There are two web projects in the solution with client-side assets based on TypeScript, `Umbraco.Web.UI.Client` and `Umbraco.Web.UI.Login`. -You can also run the tasks manually on the command line: +There are a few different ways to work locally when implementing features or fixing issues with the Umbraco CMS. Depending on whether you are working solely on the front-end, solely on the back-end, or somewhere in between, you may find different workflows work best for you. + +Here are some suggestions based on how we work on developing Umbraco at HQ. + +### First checkout + +When you first clone the source code, build the whole solution via your IDE. You can then start the `Umbraco.Web.UI` project via the IDE or the command line and should find everything across front and back-end is built and running. ``` -cd src\Umbraco.Web.UI.Client -npm i -npm run build:for:cms +cd \src\Umbraco.Web.UI +dotnet run --no-build ``` -If you want to make changes to the UI, you can choose to run a front-end development server. To learn more please read the Umbraco.Web.UI.Client README.md [Run against a local Umbraco instance](../src/Umbraco.Web.UI.Client/.github/README.md#run-against-a-local-umbraco-instance) for more information. +When the page loads in your web browser, you can follow the installer to set up a database for debugging. When complete, you will have an empty Umbraco installation to begin working with. You may also wish to install a [starter kit][https://marketplace.umbraco.com/category/themes-&-starter-kits] to ease your debugging. + +### Back-end only changes -The login screen is a different frontend build, for that one you can run it as follows: +If you are working on back-end only features, when switching branches or pulling down the latest from GitHub, you will find the front-end getting rebuilt periodically when you look to build the back-end changes. This can take a while and slow you down. So if for a period of time you don't care about changes in the front-end, you can disable this build step. + +Go to `Umbraco.Cms.StaticAssets.csproj` and comment out the following lines of MsBuild by adding a REM statement in front: ``` -cd src\Umbraco.Web.UI.Login -npm i -npm run dev +REM npm ci --no-fund --no-audit --prefer-offline +REM npm run build:for:cms ``` -If you just want to build the Login screen to `Umbraco.Web.UI` then instead of running `dev`, you can do: `npm run build`. +Just be careful not to include this change in your PR. + +### Front-end only changes -To run the C# portion of the project, either hit F5 to begin debugging, or manually using the command line: +Conversely, if you are working on front-end only, you want to build the back-end once and then run it. Before you do so, update the configuration in `appSettings.json` to add the following under `Umbraco:Cms:Security`: ``` -dotnet watch --project .\src\Umbraco.Web.UI\Umbraco.Web.UI.csproj +"BackOfficeHost": "http://localhost:5173", +"AuthorizeCallbackPathName": "/oauth_complete", +"AuthorizeCallbackLogoutPathName": "/logout", +"AuthorizeCallbackErrorPathName": "/error" ``` -**The initial C# build might take a _really_ long time (seriously, go and make a cup of coffee!) - but don't worry, this will be faster on subsequent runs.** +Then run Umbraco from the command line. -When the page eventually loads in your web browser, you can follow the installer to set up a database for debugging. You may also wish to install a [starter kit][starter kits] to ease your debugging. +``` +cd \src\Umbraco.Web.UI +dotnet run --no-build +``` -#### Debugging with Visual Studio +In another terminal window, run the following to watch the front-end changes and launch Umbraco using the URL indicated from this task. -In order to build the Umbraco source code locally with Visual Studio, first make sure you have the following installed. +``` +cd \src\Umbraco.Web.UI.Client +npm run dev:server +``` -- [Visual Studio 2022 v17+ with .NET 7+](https://visualstudio.microsoft.com/vs/) ([the community edition is free](https://www.visualstudio.com/thank-you-downloading-visual-studio/?sku=Community&rel=15) for you to use to contribute to Open Source projects) -- [Node.js v20+](https://nodejs.org/en/download/) -- npm v10+ (installed with Node.js) -- [Git command line](https://git-scm.com/download/) +You'll find as you make changes to the front-end files, the updates will be picked up and your browser refreshed automatically. + +> [!NOTE] +> The caching for the back office has been described as 'aggressive' so we often find it's best when making back office changes to [disable caching in the browser (check "Disable cache" on the "Network" tab of developer tools)][disable browser caching] to help you to see the changes you're making. -The easiest way to get started is to open `umbraco.sln` in Visual Studio. +Whilst most of the backoffice code lives in `Umbraco.Web.UI.Client`, the login screen is in a separate project. If you do any work with that you can build with: -Umbraco is a C# based codebase, which is mostly ASP.NET Core MVC based. You can make changes, build them in Visual Studio, and hit F5 to see the result. There are two web projects in the solution, `Umbraco.Web.UI.Client` and `Umbraco.Web.UI.Login`. They each have their own build process and can be run separately. The `Umbraco.Web.UI` project is the main project that hosts the back office and login screen. This is the project you will want to run to see your changes. It will automatically restore and build the client projects when you run it. +``` +cd \src\Umbraco.Web.UI.Login +npm run build +``` -If you want to watch the UI Client to `Umbraco.Web.UI` then you can open a terminal in `src/Umbraco.Web.UI.Client` where you can run `npm run dev:mock` manually. This will launch the Vite dev server on http://localhost:5173 and watch for changes with mocked API responses. +In both front-end projects, if you've refreshed your branch from the latest on GitHub you may need to update front-end dependencies. -You can also run `npm run dev:server` to run the Vite server against a local Umbraco instance. In this case, you need to have the .NET project running and accept connections from the Vite server. Please see the Umbraco.Web.UI.Client README.md [Run against a local Umbraco instance](../src/Umbraco.Web.UI.Client/.github/README.md#run-against-a-local-umbraco-instance) for more information. +To do that, run: -**The initial C# build might take a _really_ long time (seriously, go and make a cup of coffee!) - but don't worry, this will be faster on subsequent runs.** +``` +npm ci --no-fund --no-audit --prefer-offline +``` + +### Full-stack changes + +If working across both front and back-end, follow both methods and use `dotnet watch`, or re-run `dotnet run` (or `dotnet build` followed by `dotnet run --no-build`) whenever you need to update the back-end code. + +Request and response models used by the management APIs are made available client-side as generated code. If you make changes to the management API, you can re-generate the typed client code with: + +``` +cd \src\Umbraco.Web.UI.Client +npm run generate:server-api-dev +``` -When the page eventually loads in your web browser, you can follow the installer to set up a database for debugging. You may also wish to install a [starter kit][starter kits] to ease your debugging. +Please also update the `OpenApi.json` file held in the solution by copying and pasting the output from `/umbraco/swagger/management/swagger.json`. ## Building from source @@ -148,5 +186,4 @@ The produced artifacts are published in a container that can be downloaded from Git might have issues dealing with long file paths during build. You may want/need to enable `core.longpaths` support (see [this page](https://github.com/msysgit/msysgit/wiki/Git-cannot-create-a-file-or-directory-with-a-long-path) for details). [ contribution guidelines]: CONTRIBUTING.md "Read the guide to contributing for more details on contributing to Umbraco" -[ starter kits ]: https://our.umbraco.com/packages/?category=Starter%20Kits&version=9 "Browse starter kits available for v9 on Our " [ disable browser caching ]: https://techwiser.com/disable-cache-google-chrome-firefox "Instructions on how to disable browser caching in Chrome and Firefox" diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 431fa7c2fad5..b2e6ac484fd9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -4,34 +4,43 @@ These contribution guidelines are mostly just that - guidelines, not rules. This is what we've found to work best over the years, but if you choose to ignore them, we still love you! 💖 Use your best judgement, and feel free to propose changes to this document in a pull request. -## Getting Started We have a guide on [what to consider before you start](contributing-before-you-start.md) and more detailed guides at the end of this article. -The following steps are a quick-start guide: +## Contribution guide + +This guide describes each step to make your first contribution: 1. **Fork** Create a fork of [`Umbraco-CMS` on GitHub](https://github.com/umbraco/Umbraco-CMS) - + ![Fork the repository](img/forkrepository.png) - + 2. **Clone** When GitHub has created your fork, you can clone it in your favorite Git tool or on the command line with `git clone https://github.com/[YourUsername]/Umbraco-CMS`. - - ![Clone the fork](img/clonefork.png) - + + ![Clone the fork](img/clonefork.png) + 3. **Switch to the correct branch** Switch to the `contrib` branch -4. **Build** +4. **Branch out** + + Create a new branch based on `contrib` and name it after the issue you're fixing, For example: `v15/bugfix/18132-rte-tinymce-onchange-value-check`. + + Please follow this format for branches: `v{major}/{feature|bugfix|task}/{issue}-{description}`. + + This is a development branch for the particular issue you're working on, in this case a bug-fix for issue number `18132` that affects Umbraco v.15. + + Don't commit to `contrib`, create a new branch first. - Build your fork of Umbraco locally [as described in the build documentation](BUILD.md), you can build with any IDE that supports dotnet or the command line. +5. **Build or run a Development Server** -5. **Branch** + You can build or run a Development Server with any IDE that supports DotNet or the command line. - Create a new branch now and name it after the issue you're fixing, we usually follow the format: `temp/12345`. This means it's a temporary branch for the particular issue you're working on, in this case issue number `12345`. Don't commit to `contrib`, create a new branch first. + Read [Build or run a Development Server](BUILD.md) for the right approach to your needs. 6. **Change** @@ -41,7 +50,7 @@ The following steps are a quick-start guide: Done? Yay! 🎉 - Remember to commit to your new `temp` branch, and don't commit to `contrib`. Then you can push the changes up to your fork on GitHub. + Remember to commit to your branch. When it's ready push the changes to your fork on GitHub. 8. **Create pull request** diff --git a/.github/workflows/test-backoffice.yml b/.github/workflows/test-backoffice.yml index 1c5776c470bf..92d89fcdd80b 100644 --- a/.github/workflows/test-backoffice.yml +++ b/.github/workflows/test-backoffice.yml @@ -43,6 +43,8 @@ jobs: - name: Check for circular dependencies run: node devops/circular/index.js src - run: npm run lint:errors + - run: npm run generate:tsconfig + - run: npm run generate:icons - run: npm run build:for:cms - run: npm run check:paths - run: npm run generate:jsonschema:dist diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index 318ae7907ac0..966c4b982e6c 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -9,6 +9,11 @@ parameters: displayName: Run SQL Server Linux Acceptance Tests type: boolean default: false + # Skipped due to DB locks, the tests are still being run on the Nightly build + - name: sqliteAcceptanceTests + displayName: Run SQLite Acceptance Tests + type: boolean + default: false - name: myGetDeploy displayName: Deploy to MyGet type: boolean @@ -484,6 +489,8 @@ stages: # E2E Tests - job: displayName: E2E Tests (SQLite) + # currently disabled due to DB locks randomly occuring. + condition: eq(${{parameters.sqliteAcceptanceTests}}, True) variables: # Connection string CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=Umbraco;Mode=Memory;Cache=Shared;Foreign Keys=True;Pooling=True @@ -492,22 +499,22 @@ stages: matrix: LinuxPart1Of3: vmImage: "ubuntu-latest" - testCommand: "npm run smokeTest -- --shard=1/3" + testCommand: "npm run smokeTestSqlite -- --shard=1/3" LinuxPart2Of3: vmImage: "ubuntu-latest" - testCommand: "npm run smokeTest -- --shard=2/3" + testCommand: "npm run smokeTestSqlite -- --shard=2/3" LinuxPart3Of3: vmImage: "ubuntu-latest" - testCommand: "npm run smokeTest -- --shard=3/3" + testCommand: "npm run smokeTestSqlite -- --shard=3/3" WindowsPart1Of3: vmImage: "windows-latest" - testCommand: "npm run smokeTest -- --shard=1/3" + testCommand: "npm run smokeTestSqlite -- --shard=1/3" WindowsPart2Of3: vmImage: "windows-latest" - testCommand: "npm run smokeTest -- --shard=2/3" + testCommand: "npm run smokeTestSqlite -- --shard=2/3" WindowsPart3Of3: vmImage: "windows-latest" - testCommand: "npm run smokeTest -- --shard=3/3" + testCommand: "npm run smokeTestSqlite -- --shard=3/3" pool: vmImage: $(vmImage) steps: @@ -593,7 +600,6 @@ stages: # Test - pwsh: $(testCommand) displayName: Run Playwright tests - continueOnError: true workingDirectory: tests/Umbraco.Tests.AcceptanceTest env: CI: true @@ -603,11 +609,11 @@ stages: # Stop application - bash: kill -15 $(AcceptanceTestProcessId) displayName: Stop application (Linux) - condition: and(succeeded(), ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Linux')) + condition: and(ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Linux')) - pwsh: Stop-Process -Id $(AcceptanceTestProcessId) displayName: Stop application (Windows) - condition: and(succeeded(), ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Windows_NT')) + condition: and(ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Windows_NT')) # Copy artifacts - pwsh: | @@ -634,29 +640,29 @@ stages: matrix: ${{ if eq(parameters.sqlServerLinuxAcceptanceTests, True) }}: LinuxPart1Of3: - testCommand: "npm run smokeTestSqlite -- --shard=1/3" + testCommand: "npm run smokeTest -- --shard=1/3" vmImage: "ubuntu-latest" SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);TrustServerCertificate=True" LinuxPart2Of3: - testCommand: "npm run smokeTestSqlite -- --shard=2/3" + testCommand: "npm run smokeTest -- --shard=2/3" vmImage: "ubuntu-latest" SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);TrustServerCertificate=True" LinuxPart3Of3: - testCommand: "npm run smokeTestSqlite -- --shard=3/3" + testCommand: "npm run smokeTest -- --shard=3/3" vmImage: "ubuntu-latest" SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);TrustServerCertificate=True" WindowsPart1Of3: vmImage: "windows-latest" - testCommand: "npm run smokeTestSqlite -- --shard=1/3" + testCommand: "npm run smokeTest -- --shard=1/3" WindowsPart2Of3: vmImage: "windows-latest" - testCommand: "npm run smokeTestSqlite -- --shard=2/3" + testCommand: "npm run smokeTest -- --shard=2/3" WindowsPart3Of3: vmImage: "windows-latest" - testCommand: "npm run smokeTestSqlite -- --shard=3/3" + testCommand: "npm run smokeTest -- --shard=3/3" pool: vmImage: $(vmImage) steps: @@ -750,7 +756,6 @@ stages: # Test - pwsh: $(testCommand) displayName: Run Playwright tests - continueOnError: true workingDirectory: tests/Umbraco.Tests.AcceptanceTest env: CI: true @@ -760,20 +765,20 @@ stages: # Stop application - bash: kill -15 $(AcceptanceTestProcessId) displayName: Stop application (Linux) - condition: and(succeeded(), ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Linux')) + condition: and(ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Linux')) - pwsh: Stop-Process -Id $(AcceptanceTestProcessId) displayName: Stop application (Windows) - condition: and(succeeded(), ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Windows_NT')) + condition: and(ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Windows_NT')) # Stop SQL Server - pwsh: docker stop mssql displayName: Stop SQL Server Docker image (Linux) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + condition: eq(variables['Agent.OS'], 'Linux') - pwsh: SqlLocalDB stop MSSQLLocalDB displayName: Stop SQL Server LocalDB (Windows) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) + condition: eq(variables['Agent.OS'], 'Windows_NT') # Copy artifacts - pwsh: | @@ -798,7 +803,7 @@ stages: dependsOn: - Unit - Integration - # - E2E + - E2E condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.myGetDeploy}})) jobs: - job: diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/QueryOptionBase.cs b/src/Umbraco.Cms.Api.Delivery/Querying/QueryOptionBase.cs index 2b47ea166eb9..a01f8ee9af54 100644 --- a/src/Umbraco.Cms.Api.Delivery/Querying/QueryOptionBase.cs +++ b/src/Umbraco.Cms.Api.Delivery/Querying/QueryOptionBase.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Extensions; @@ -10,8 +11,8 @@ public abstract class QueryOptionBase { private readonly IRequestRoutingService _requestRoutingService; private readonly IRequestPreviewService _requestPreviewService; - private readonly IRequestCultureService _requestCultureService; private readonly IApiDocumentUrlService _apiDocumentUrlService; + private readonly IVariationContextAccessor _variationContextAccessor; [Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")] public QueryOptionBase( @@ -20,8 +21,8 @@ public QueryOptionBase( : this( requestRoutingService, StaticServiceProvider.Instance.GetRequiredService(), - StaticServiceProvider.Instance.GetRequiredService(), - StaticServiceProvider.Instance.GetRequiredService()) + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) { } @@ -31,21 +32,22 @@ public QueryOptionBase( IRequestRoutingService requestRoutingService, IRequestPreviewService requestPreviewService, IRequestCultureService requestCultureService, - IApiDocumentUrlService apiDocumentUrlService) - : this(requestRoutingService, requestPreviewService, requestCultureService, apiDocumentUrlService) + IApiDocumentUrlService apiDocumentUrlService, + IVariationContextAccessor variationContextAccessor) + : this(requestRoutingService, requestPreviewService, apiDocumentUrlService, variationContextAccessor) { } public QueryOptionBase( IRequestRoutingService requestRoutingService, IRequestPreviewService requestPreviewService, - IRequestCultureService requestCultureService, - IApiDocumentUrlService apiDocumentUrlService) + IApiDocumentUrlService apiDocumentUrlService, + IVariationContextAccessor variationContextAccessor) { _requestRoutingService = requestRoutingService; _requestPreviewService = requestPreviewService; - _requestCultureService = requestCultureService; _apiDocumentUrlService = apiDocumentUrlService; + _variationContextAccessor = variationContextAccessor; } protected Guid? GetGuidFromQuery(string queryStringValue) @@ -64,7 +66,7 @@ public QueryOptionBase( var contentRoute = _requestRoutingService.GetContentRoute(queryStringValue); return _apiDocumentUrlService.GetDocumentKeyByRoute( contentRoute, - _requestCultureService.GetRequestedCulture(), + _variationContextAccessor.VariationContext?.Culture, _requestPreviewService.IsPreview()); } } diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/AncestorsSelector.cs b/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/AncestorsSelector.cs index 7a3a79311809..b3a8dca0d6af 100644 --- a/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/AncestorsSelector.cs +++ b/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/AncestorsSelector.cs @@ -2,6 +2,7 @@ using Umbraco.Cms.Api.Delivery.Indexing.Selectors; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Services.Navigation; @@ -20,10 +21,9 @@ public AncestorsSelector( IRequestPreviewService requestPreviewService) : this( requestRoutingService, - StaticServiceProvider.Instance.GetRequiredService(), - StaticServiceProvider.Instance.GetRequiredService(), - StaticServiceProvider.Instance.GetRequiredService(), + requestPreviewService, StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), navigationQueryService) { } @@ -35,10 +35,9 @@ public AncestorsSelector( IDocumentNavigationQueryService navigationQueryService) : this( requestRoutingService, - StaticServiceProvider.Instance.GetRequiredService(), StaticServiceProvider.Instance.GetRequiredService(), - StaticServiceProvider.Instance.GetRequiredService(), StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), navigationQueryService) { } @@ -47,10 +46,9 @@ public AncestorsSelector( public AncestorsSelector(IPublishedContentCache publishedContentCache, IRequestRoutingService requestRoutingService) : this( requestRoutingService, - StaticServiceProvider.Instance.GetRequiredService(), StaticServiceProvider.Instance.GetRequiredService(), - StaticServiceProvider.Instance.GetRequiredService(), StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), StaticServiceProvider.Instance.GetRequiredService()) { } @@ -58,10 +56,10 @@ public AncestorsSelector(IPublishedContentCache publishedContentCache, IRequestR public AncestorsSelector( IRequestRoutingService requestRoutingService, IRequestPreviewService requestPreviewService, - IRequestCultureService requestCultureService, IApiDocumentUrlService apiDocumentUrlService, + IVariationContextAccessor variationContextAccessor, IDocumentNavigationQueryService navigationQueryService) - : base(requestRoutingService, requestPreviewService, requestCultureService, apiDocumentUrlService) + : base(requestRoutingService, requestPreviewService, apiDocumentUrlService, variationContextAccessor) => _navigationQueryService = navigationQueryService; [Obsolete("Use the constructor that takes all parameters. Scheduled for removal in V17.")] @@ -69,10 +67,10 @@ public AncestorsSelector( IRequestRoutingService requestRoutingService, IPublishedContentCache publishedContentCache, IRequestPreviewService requestPreviewService, - IRequestCultureService requestCultureService, IApiDocumentUrlService apiDocumentUrlService, + IVariationContextAccessor variationContextAccessor, IDocumentNavigationQueryService navigationQueryService) - : this(requestRoutingService, requestPreviewService, requestCultureService, apiDocumentUrlService, navigationQueryService) + : this(requestRoutingService, requestPreviewService, apiDocumentUrlService, variationContextAccessor, navigationQueryService) { } diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/ChildrenSelector.cs b/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/ChildrenSelector.cs index ba986e08127d..4040371f6581 100644 --- a/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/ChildrenSelector.cs +++ b/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/ChildrenSelector.cs @@ -2,6 +2,7 @@ using Umbraco.Cms.Api.Delivery.Indexing.Selectors; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Extensions; @@ -16,8 +17,8 @@ public ChildrenSelector(IPublishedContentCache publishedContentCache, IRequestRo : this( requestRoutingService, StaticServiceProvider.Instance.GetRequiredService(), - StaticServiceProvider.Instance.GetRequiredService(), - StaticServiceProvider.Instance.GetRequiredService()) + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) { } @@ -26,18 +27,18 @@ public ChildrenSelector( IPublishedContentCache publishedContentCache, IRequestRoutingService requestRoutingService, IRequestPreviewService requestPreviewService, - IRequestCultureService requestCultureService, - IApiDocumentUrlService apiDocumentUrlService) - : this(requestRoutingService, requestPreviewService, requestCultureService, apiDocumentUrlService) + IApiDocumentUrlService apiDocumentUrlService, + IVariationContextAccessor variationContextAccessor) + : this(requestRoutingService, requestPreviewService, apiDocumentUrlService, variationContextAccessor) { } public ChildrenSelector( IRequestRoutingService requestRoutingService, IRequestPreviewService requestPreviewService, - IRequestCultureService requestCultureService, - IApiDocumentUrlService apiDocumentUrlService) - : base(requestRoutingService, requestPreviewService, requestCultureService, apiDocumentUrlService) + IApiDocumentUrlService apiDocumentUrlService, + IVariationContextAccessor variationContextAccessor) + : base(requestRoutingService, requestPreviewService, apiDocumentUrlService, variationContextAccessor) { } diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/DescendantsSelector.cs b/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/DescendantsSelector.cs index 7ce0c066c4b6..d46d5499b485 100644 --- a/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/DescendantsSelector.cs +++ b/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/DescendantsSelector.cs @@ -2,6 +2,7 @@ using Umbraco.Cms.Api.Delivery.Indexing.Selectors; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Extensions; @@ -16,8 +17,8 @@ public DescendantsSelector(IPublishedContentCache publishedContentCache, IReques : this( requestRoutingService, StaticServiceProvider.Instance.GetRequiredService(), - StaticServiceProvider.Instance.GetRequiredService(), - StaticServiceProvider.Instance.GetRequiredService()) + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) { } @@ -26,18 +27,18 @@ public DescendantsSelector( IPublishedContentCache publishedContentCache, IRequestRoutingService requestRoutingService, IRequestPreviewService requestPreviewService, - IRequestCultureService requestCultureService, - IApiDocumentUrlService apiDocumentUrlService) - : this(requestRoutingService, requestPreviewService, requestCultureService, apiDocumentUrlService) + IApiDocumentUrlService apiDocumentUrlService, + IVariationContextAccessor variationContextAccessor) + : this(requestRoutingService, requestPreviewService, apiDocumentUrlService, variationContextAccessor) { } public DescendantsSelector( IRequestRoutingService requestRoutingService, IRequestPreviewService requestPreviewService, - IRequestCultureService requestCultureService, - IApiDocumentUrlService apiDocumentUrlService) - : base(requestRoutingService, requestPreviewService, requestCultureService, apiDocumentUrlService) + IApiDocumentUrlService apiDocumentUrlService, + IVariationContextAccessor variationContextAccessor) + : base(requestRoutingService, requestPreviewService, apiDocumentUrlService, variationContextAccessor) { } diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs index 6189e7154e27..188e6db7eff1 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs @@ -42,31 +42,42 @@ public RequestRedirectService( { requestedPath = requestedPath.EnsureStartsWith("/"); + IPublishedContent? startItem = GetStartItem(); + // must append the root content url segment if it is not hidden by config, because // the URL tracking is based on the actual URL, including the root content url segment - if (_globalSettings.HideTopLevelNodeFromPath == false) + if (_globalSettings.HideTopLevelNodeFromPath == false && startItem?.UrlSegment != null) { - IPublishedContent? startItem = GetStartItem(); - if (startItem?.UrlSegment != null) - { - requestedPath = $"{startItem.UrlSegment.EnsureStartsWith("/")}{requestedPath}"; - } + requestedPath = $"{startItem.UrlSegment.EnsureStartsWith("/")}{requestedPath}"; } var culture = _requestCultureService.GetRequestedCulture(); - // append the configured domain content ID to the path if we have a domain bound request, - // because URL tracking registers the tracked url like "{domain content ID}/{content path}" - Uri contentRoute = GetDefaultRequestUri(requestedPath); - DomainAndUri? domainAndUri = GetDomainAndUriForRoute(contentRoute); - if (domainAndUri != null) + // important: redirect URLs are always tracked without trailing slashes + requestedPath = requestedPath.TrimEnd("/"); + IRedirectUrl? redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath, culture); + + // if a redirect URL was not found, try by appending the start item ID because URL tracking might have tracked + // a redirect with "{root content ID}/{content path}" + if (redirectUrl is null && startItem is not null) { - requestedPath = GetContentRoute(domainAndUri, contentRoute); - culture ??= domainAndUri.Culture; + redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl($"{startItem.Id}{requestedPath}", culture); + } + + // still no redirect URL found - try looking for a configured domain if we have a domain bound request, + // because URL tracking might have tracked a redirect with "{domain content ID}/{content path}" + if (redirectUrl is null) + { + Uri contentRoute = GetDefaultRequestUri(requestedPath); + DomainAndUri? domainAndUri = GetDomainAndUriForRoute(contentRoute); + if (domainAndUri is not null) + { + requestedPath = GetContentRoute(domainAndUri, contentRoute); + culture ??= domainAndUri.Culture; + redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath, culture); + } } - // important: redirect URLs are always tracked without trailing slashes - IRedirectUrl? redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath.TrimEnd("/"), culture); IPublishedContent? content = redirectUrl != null ? _apiPublishedContentCache.GetById(redirectUrl.ContentKey) : null; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentCollectionControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentCollectionControllerBase.cs index 8012da466936..2adfef96648f 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentCollectionControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentCollectionControllerBase.cs @@ -21,6 +21,7 @@ public abstract class ContentCollectionControllerBase _mapper = mapper; + [Obsolete("This method is no longer used and will be removed in Umbraco 17.")] protected IActionResult CollectionResult(ListViewPagedModel result) { PagedModel collectionItemsResult = result.Items; @@ -47,6 +48,17 @@ protected IActionResult CollectionResult(ListViewPagedModel result) return Ok(pageViewModel); } + protected IActionResult CollectionResult(List collectionResponseModels, long totalNumberOfItems) + { + var pageViewModel = new PagedViewModel + { + Items = collectionResponseModels, + Total = totalNumberOfItems, + }; + + return Ok(pageViewModel); + } + protected IActionResult ContentCollectionOperationStatusResult(ContentCollectionOperationStatus status, string type) => OperationStatusResult(status, problemDetailsBuilder => status switch { diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/ByKeyDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/ByKeyDocumentController.cs index a8f92bbcc767..bb90d7f57a7e 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/ByKeyDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/ByKeyDocumentController.cs @@ -1,4 +1,4 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/ByKeyPublishedDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/ByKeyPublishedDocumentController.cs index 2368afc73208..57710b472f7f 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/ByKeyPublishedDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/ByKeyPublishedDocumentController.cs @@ -1,4 +1,4 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -53,6 +53,7 @@ public async Task ByKeyPublished(CancellationToken cancellationTo } PublishedDocumentResponseModel model = await _documentPresentationFactory.CreatePublishedResponseModelAsync(content); + return Ok(model); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/Collection/ByKeyDocumentCollectionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/Collection/ByKeyDocumentCollectionController.cs index 526d5a0272db..6f2dd43824db 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/Collection/ByKeyDocumentCollectionController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/Collection/ByKeyDocumentCollectionController.cs @@ -1,9 +1,12 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.Document.Collection; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; @@ -17,15 +20,32 @@ public class ByKeyDocumentCollectionController : DocumentCollectionControllerBas { private readonly IContentListViewService _contentListViewService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IDocumentCollectionPresentationFactory _documentCollectionPresentationFactory; + [Obsolete("Please use the constructor taking all parameters.")] public ByKeyDocumentCollectionController( IContentListViewService contentListViewService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor, IUmbracoMapper mapper) + : this( + contentListViewService, + backOfficeSecurityAccessor, + mapper, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + [ActivatorUtilitiesConstructor] + public ByKeyDocumentCollectionController( + IContentListViewService contentListViewService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IUmbracoMapper mapper, + IDocumentCollectionPresentationFactory documentCollectionPresentationFactory) : base(mapper) { _contentListViewService = contentListViewService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _documentCollectionPresentationFactory = documentCollectionPresentationFactory; } [HttpGet("{id:guid}")] @@ -55,8 +75,12 @@ public async Task ByKey( skip, take); - return collectionAttempt.Success - ? CollectionResult(collectionAttempt.Result!) - : CollectionOperationStatusResult(collectionAttempt.Status); + if (collectionAttempt.Success is false) + { + return CollectionOperationStatusResult(collectionAttempt.Status); + } + + List collectionResponseModels = await _documentCollectionPresentationFactory.CreateCollectionModelAsync(collectionAttempt.Result!); + return CollectionResult(collectionResponseModels, collectionAttempt.Result!.Items.Total); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs index 67b9c6900847..62f06993429f 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs @@ -1,11 +1,11 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Management.Security.Authorization.Content; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentPublishing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Security.Authorization; @@ -53,11 +53,27 @@ public async Task PublishWithDescendants(CancellationToken cancel Attempt attempt = await _contentPublishingService.PublishBranchAsync( id, requestModel.Cultures, - requestModel.IncludeUnpublishedDescendants, + BuildPublishBranchFilter(requestModel), CurrentUserKey(_backOfficeSecurityAccessor)); return attempt.Success ? Ok() : DocumentPublishingOperationStatusResult(attempt.Status, failedBranchItems: attempt.Result.FailedItems); } + + private static PublishBranchFilter BuildPublishBranchFilter(PublishDocumentWithDescendantsRequestModel requestModel) + { + PublishBranchFilter publishBranchFilter = PublishBranchFilter.Default; + if (requestModel.IncludeUnpublishedDescendants) + { + publishBranchFilter |= PublishBranchFilter.IncludeUnpublished; + } + + if (requestModel.ForceRepublish) + { + publishBranchFilter |= PublishBranchFilter.ForceRepublish; + } + + return publishBranchFilter; + } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedByDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedByDocumentController.cs index a818e70af89f..47df66fd6df6 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedByDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedByDocumentController.cs @@ -1,4 +1,4 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; @@ -37,7 +37,7 @@ public async Task>> Referen int skip = 0, int take = 20) { - PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, false); + PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, true); var pagedViewModel = new PagedViewModel { diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/RollbackDocumentVersionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/RollbackDocumentVersionController.cs index a1039836a332..1c024f3ef90f 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/RollbackDocumentVersionController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/RollbackDocumentVersionController.cs @@ -1,10 +1,18 @@ using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Security.Authorization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Controllers.DocumentVersion; @@ -13,13 +21,29 @@ public class RollbackDocumentVersionController : DocumentVersionControllerBase { private readonly IContentVersionService _contentVersionService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IAuthorizationService _authorizationService; + [ActivatorUtilitiesConstructor] public RollbackDocumentVersionController( IContentVersionService contentVersionService, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IAuthorizationService authorizationService) { _contentVersionService = contentVersionService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _authorizationService = authorizationService; + } + + // TODO (V16): Remove this constructor. + [Obsolete("Please use the constructor taking all parameters. This constructor will be removed in V16.")] + public RollbackDocumentVersionController( + IContentVersionService contentVersionService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : this( + contentVersionService, + backOfficeSecurityAccessor, + StaticServiceProvider.Instance.GetRequiredService()) + { } [MapToApiVersion("1.0")] @@ -29,11 +53,29 @@ public RollbackDocumentVersionController( [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] public async Task Rollback(CancellationToken cancellationToken, Guid id, string? culture) { - Attempt attempt = + Attempt getContentAttempt = + await _contentVersionService.GetAsync(id); + if (getContentAttempt.Success is false || getContentAttempt.Result is null) + { + return MapFailure(getContentAttempt.Status); + } + + IContent content = getContentAttempt.Result; + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ContentPermissionResource.WithKeys(ActionRollback.ActionLetter, content.Key), + AuthorizationPolicies.ContentPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt rollBackAttempt = await _contentVersionService.RollBackAsync(id, culture, CurrentUserKey(_backOfficeSecurityAccessor)); - return attempt.Success + return rollBackAttempt.Success ? Ok() - : MapFailure(attempt.Result); + : MapFailure(rollBackAttempt.Result); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/Collection/ByKeyMediaCollectionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/Collection/ByKeyMediaCollectionController.cs index 36d1192269b9..6a37c2ad40b2 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Media/Collection/ByKeyMediaCollectionController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/Collection/ByKeyMediaCollectionController.cs @@ -1,9 +1,12 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.Media.Collection; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; @@ -17,15 +20,32 @@ public class ByKeyMediaCollectionController : MediaCollectionControllerBase { private readonly IMediaListViewService _mediaListViewService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IMediaCollectionPresentationFactory _mediaCollectionPresentationFactory; + [Obsolete("Please use the constructor taking all parameters.")] public ByKeyMediaCollectionController( IMediaListViewService mediaListViewService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor, IUmbracoMapper mapper) + : this( + mediaListViewService, + backOfficeSecurityAccessor, + mapper, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + [ActivatorUtilitiesConstructor] + public ByKeyMediaCollectionController( + IMediaListViewService mediaListViewService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IUmbracoMapper mapper, + IMediaCollectionPresentationFactory mediaCollectionPresentationFactory) : base(mapper) { _mediaListViewService = mediaListViewService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _mediaCollectionPresentationFactory = mediaCollectionPresentationFactory; } [HttpGet] @@ -53,8 +73,13 @@ public async Task ByKey( skip, take); - return collectionAttempt.Success - ? CollectionResult(collectionAttempt.Result!) - : CollectionOperationStatusResult(collectionAttempt.Status); + + if (collectionAttempt.Success is false) + { + return CollectionOperationStatusResult(collectionAttempt.Status); + } + + List collectionResponseModels = await _mediaCollectionPresentationFactory.CreateCollectionModelAsync(collectionAttempt.Result!); + return CollectionResult(collectionResponseModels, collectionAttempt.Result!.Items.Total); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedByMediaController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedByMediaController.cs index b457ee602c1e..63748741b1f7 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedByMediaController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedByMediaController.cs @@ -1,4 +1,4 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; @@ -37,7 +37,7 @@ public async Task>> Referen int skip = 0, int take = 20) { - PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, false); + PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, true); var pagedViewModel = new PagedViewModel { diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Member/MemberControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Member/MemberControllerBase.cs index d371865c14a2..b2ef50589fa3 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Member/MemberControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Member/MemberControllerBase.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; @@ -58,7 +58,8 @@ protected IActionResult MemberEditingOperationStatusResult(MemberEditingOperatio .WithTitle("Invalid name supplied") .Build()), MemberEditingOperationStatus.InvalidUsername => BadRequest(problemDetailsBuilder - .WithTitle("Invalid username supplied") + .WithTitle("Invalid username") + .WithDetail("The username is either empty or contains one or more invalid characters.") .Build()), MemberEditingOperationStatus.InvalidEmail => BadRequest(problemDetailsBuilder .WithTitle("Invalid email supplied") diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs index 65490a9c2104..43db8355b70a 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs @@ -35,6 +35,10 @@ protected IActionResult UserOperationStatusResult(UserOperationStatus status, Er .WithTitle("Email Cannot be changed") .WithDetail("Local login is disabled, so the email cannot be changed.") .Build()), + UserOperationStatus.InvalidUserName => BadRequest(problemDetailsBuilder + .WithTitle("Invalid username") + .WithDetail("The username contains one or more invalid characters.") + .Build()), UserOperationStatus.DuplicateUserName => BadRequest(problemDetailsBuilder .WithTitle("Duplicate Username") .WithDetail("The username is already in use.") diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Webhook/AllWebhookController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Webhook/AllWebhookController.cs index c5429ab8145e..db11f532f108 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Webhook/AllWebhookController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Webhook/AllWebhookController.cs @@ -34,7 +34,7 @@ public async Task>> All( var viewModel = new PagedViewModel { Total = result.Total, - Items = webhooks.Select(x => _webhookPresentationFactory.CreateResponseModel(x)), + Items = webhooks.Select(_webhookPresentationFactory.CreateResponseModel), }; return Ok(viewModel); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Webhook/Logs/AllWebhookLogController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Webhook/Logs/AllWebhookLogController.cs index a40b9d4e6a9c..d9ab9e9963ac 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Webhook/Logs/AllWebhookLogController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Webhook/Logs/AllWebhookLogController.cs @@ -1,8 +1,8 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.Factories; -using Umbraco.Cms.Api.Management.ViewModels.Webhook; using Umbraco.Cms.Api.Management.ViewModels.Webhook.Logs; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; @@ -23,12 +23,11 @@ public AllWebhookLogController(IWebhookLogService webhookLogService, IWebhookPre [HttpGet("logs")] [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(WebhookResponseModel), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] public async Task Logs(CancellationToken cancellationToken, int skip = 0, int take = 100) { PagedModel logs = await _webhookLogService.Get(skip, take); - IEnumerable logResponseModels = logs.Items.Select(x => _webhookPresentationFactory.CreateResponseModel(x)); - return Ok(logResponseModels); + PagedViewModel viewModel = CreatePagedWebhookLogResponseModel(logs, _webhookPresentationFactory); + return Ok(viewModel); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Webhook/Logs/WebhookLogController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Webhook/Logs/WebhookLogController.cs new file mode 100644 index 000000000000..9d353b797953 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Webhook/Logs/WebhookLogController.cs @@ -0,0 +1,33 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Webhook.Logs; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Webhook.Logs; + +[ApiVersion("1.0")] +public class WebhookLogController : WebhookLogControllerBase +{ + private readonly IWebhookLogService _webhookLogService; + private readonly IWebhookPresentationFactory _webhookPresentationFactory; + + public WebhookLogController(IWebhookLogService webhookLogService, IWebhookPresentationFactory webhookPresentationFactory) + { + _webhookLogService = webhookLogService; + _webhookPresentationFactory = webhookPresentationFactory; + } + + [HttpGet("{id:guid}/logs")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task Logs(CancellationToken cancellationToken, Guid id, int skip = 0, int take = 100) + { + PagedModel logs = await _webhookLogService.Get(id, skip, take); + PagedViewModel viewModel = CreatePagedWebhookLogResponseModel(logs, _webhookPresentationFactory); + return Ok(viewModel); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Webhook/Logs/WebhookLogControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Webhook/Logs/WebhookLogControllerBase.cs index 84888184abdb..658133516510 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Webhook/Logs/WebhookLogControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Webhook/Logs/WebhookLogControllerBase.cs @@ -1,9 +1,26 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Api.Management.ViewModels.Webhook.Logs; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; namespace Umbraco.Cms.Api.Management.Controllers.Webhook.Logs; [VersionedApiBackOfficeRoute($"{Constants.UdiEntityType.Webhook}")] [ApiExplorerSettings(GroupName = "Webhook")] -public class WebhookLogControllerBase : ManagementApiControllerBase; +public class WebhookLogControllerBase : ManagementApiControllerBase +{ + protected PagedViewModel CreatePagedWebhookLogResponseModel(PagedModel logs, IWebhookPresentationFactory webhookPresentationFactory) + { + WebhookLogResponseModel[] logResponseModels = logs.Items.Select(webhookPresentationFactory.CreateResponseModel).ToArray(); + + return new PagedViewModel + { + Total = logs.Total, + Items = logResponseModels, + }; + } + +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs index f69800ad9902..2f9689336f76 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.Mapping.Document; using Umbraco.Cms.Core.DependencyInjection; @@ -18,6 +18,7 @@ internal static IUmbracoBuilder AddDocuments(this IUmbracoBuilder builder) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.WithCollectionBuilder() .Add() diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/MediaBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/MediaBuilderExtensions.cs index e72992c2090d..7292531bda56 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/MediaBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/MediaBuilderExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.Mapping.Media; using Umbraco.Cms.Api.Management.Routing; @@ -18,6 +18,7 @@ internal static IUmbracoBuilder AddMedia(this IUmbracoBuilder builder) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddScoped(); + builder.Services.AddTransient(); builder.WithCollectionBuilder().Add(); diff --git a/src/Umbraco.Cms.Api.Management/Factories/ContentCollectionPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/ContentCollectionPresentationFactory.cs new file mode 100644 index 000000000000..0607df51084c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/ContentCollectionPresentationFactory.cs @@ -0,0 +1,43 @@ +using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Factories; + +public abstract class ContentCollectionPresentationFactory + where TContent : class, IContentBase + where TCollectionResponseModel : ContentResponseModelBase + where TValueResponseModelBase : ValueResponseModelBase + where TVariantResponseModel : VariantResponseModelBase +{ + private readonly IUmbracoMapper _mapper; + + protected ContentCollectionPresentationFactory(IUmbracoMapper mapper) => _mapper = mapper; + + public async Task> CreateCollectionModelAsync(ListViewPagedModel contentCollection) + { + PagedModel collectionItemsResult = contentCollection.Items; + ListViewConfiguration collectionConfiguration = contentCollection.ListViewConfiguration; + + var collectionPropertyAliases = collectionConfiguration + .IncludeProperties + .Select(p => p.Alias) + .WhereNotNull() + .ToArray(); + + List collectionResponseModels = + _mapper.MapEnumerable(collectionItemsResult.Items, context => + { + context.SetIncludedProperties(collectionPropertyAliases); + }); + + await SetUnmappedProperties(contentCollection, collectionResponseModels); + + return collectionResponseModels; + } + + protected virtual Task SetUnmappedProperties(ListViewPagedModel contentCollection, List collectionResponseModels) => Task.CompletedTask; +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentCollectionPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentCollectionPresentationFactory.cs new file mode 100644 index 000000000000..228952b46960 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentCollectionPresentationFactory.cs @@ -0,0 +1,34 @@ +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Document.Collection; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Factories; + +public class DocumentCollectionPresentationFactory : ContentCollectionPresentationFactory, IDocumentCollectionPresentationFactory +{ + private readonly IPublicAccessService _publicAccessService; + + public DocumentCollectionPresentationFactory(IUmbracoMapper mapper, IPublicAccessService publicAccessService) + : base(mapper) + { + _publicAccessService = publicAccessService; + } + + protected override Task SetUnmappedProperties(ListViewPagedModel contentCollection, List collectionResponseModels) + { + foreach (DocumentCollectionResponseModel item in collectionResponseModels) + { + IContent? matchingContentItem = contentCollection.Items.Items.FirstOrDefault(x => x.Key == item.Id); + if (matchingContentItem is null) + { + continue; + } + + item.IsProtected = _publicAccessService.IsProtected(matchingContentItem).Success; + } + + return Task.CompletedTask; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentPresentationFactory.cs index f4d7d3f3d70e..b48e4aa2b7b8 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentPresentationFactory.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Api.Management.Mapping.Content; +using Umbraco.Cms.Api.Management.Mapping.Content; using Umbraco.Cms.Api.Management.ViewModels; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Api.Management.ViewModels.Document.Item; diff --git a/src/Umbraco.Cms.Api.Management/Factories/IContentCollectionPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IContentCollectionPresentationFactory.cs new file mode 100644 index 000000000000..a725a2b35957 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/IContentCollectionPresentationFactory.cs @@ -0,0 +1,14 @@ +using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Api.Management.Factories; + +public interface IContentCollectionPresentationFactory + where TContent : class, IContentBase + where TCollectionResponseModel : ContentResponseModelBase + where TValueResponseModelBase : ValueResponseModelBase + where TVariantResponseModel : VariantResponseModelBase +{ + Task> CreateCollectionModelAsync(ListViewPagedModel content); +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentCollectionPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentCollectionPresentationFactory.cs new file mode 100644 index 000000000000..2e0b043857fa --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentCollectionPresentationFactory.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Document.Collection; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.Factories; + +public interface IDocumentCollectionPresentationFactory : IContentCollectionPresentationFactory +{ +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentPresentationFactory.cs index ae725e97a903..34e07940704e 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IDocumentPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentPresentationFactory.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Api.Management.ViewModels.Document.Item; using Umbraco.Cms.Api.Management.ViewModels.DocumentBlueprint.Item; diff --git a/src/Umbraco.Cms.Api.Management/Factories/IMediaCollectionPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IMediaCollectionPresentationFactory.cs new file mode 100644 index 000000000000..d345148e30a0 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/IMediaCollectionPresentationFactory.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Api.Management.ViewModels.Media; +using Umbraco.Cms.Api.Management.ViewModels.Media.Collection; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.Factories; + +public interface IMediaCollectionPresentationFactory : IContentCollectionPresentationFactory +{ +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/MediaCollectionPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/MediaCollectionPresentationFactory.cs new file mode 100644 index 000000000000..25d60c44b9a9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/MediaCollectionPresentationFactory.cs @@ -0,0 +1,14 @@ +using Umbraco.Cms.Api.Management.ViewModels.Media; +using Umbraco.Cms.Api.Management.ViewModels.Media.Collection; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.Factories; + +public class MediaCollectionPresentationFactory : ContentCollectionPresentationFactory, IMediaCollectionPresentationFactory +{ + public MediaCollectionPresentationFactory(IUmbracoMapper mapper) + : base(mapper) + { + } +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/WebhookPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/WebhookPresentationFactory.cs index 491b235664fb..95a69dfeb536 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/WebhookPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/WebhookPresentationFactory.cs @@ -29,6 +29,8 @@ public WebhookResponseModel CreateResponseModel(IWebhook webhook) var target = new WebhookResponseModel { Events = webhook.Events.Select(Create).ToArray(), + Name = webhook.Name, + Description = webhook.Description, Url = webhook.Url, Enabled = webhook.Enabled, Id = webhook.Key, @@ -44,6 +46,8 @@ public IWebhook CreateWebhook(CreateWebhookRequestModel webhookRequestModel) var target = new Webhook(webhookRequestModel.Url, webhookRequestModel.Enabled, webhookRequestModel.ContentTypeKeys, webhookRequestModel.Events, webhookRequestModel.Headers) { Key = webhookRequestModel.Id ?? Guid.NewGuid(), + Name = webhookRequestModel.Name, + Description = webhookRequestModel.Description, }; return target; } @@ -53,6 +57,8 @@ public IWebhook CreateWebhook(UpdateWebhookRequestModel webhookRequestModel, Gui var target = new Webhook(webhookRequestModel.Url, webhookRequestModel.Enabled, webhookRequestModel.ContentTypeKeys, webhookRequestModel.Events, webhookRequestModel.Headers) { Key = existingWebhookkey, + Name = webhookRequestModel.Name, + Description = webhookRequestModel.Description, }; return target; } diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs index ec40a8de2784..dbc51a6f4197 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Api.Management.Mapping.Content; +using Umbraco.Cms.Api.Management.Mapping.Content; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Api.Management.ViewModels.Document.Collection; using Umbraco.Cms.Api.Management.ViewModels.DocumentBlueprint; @@ -16,8 +16,7 @@ public class DocumentMapDefinition : ContentMapDefinition _commonMapper = commonMapper; + : base(propertyEditorCollection) => _commonMapper = commonMapper; public void DefineMaps(IUmbracoMapper mapper) { @@ -68,7 +67,7 @@ private void Map(IContent source, PublishedDocumentResponseModel target, MapperC target.IsTrashed = source.Trashed; } - // Umbraco.Code.MapAll + // Umbraco.Code.MapAll -IsProtected private void Map(IContent source, DocumentCollectionResponseModel target, MapperContext context) { target.Id = source.Key; @@ -76,6 +75,7 @@ private void Map(IContent source, DocumentCollectionResponseModel target, Mapper target.SortOrder = source.SortOrder; target.Creator = _commonMapper.GetOwnerName(source, context); target.Updater = _commonMapper.GetCreatorName(source, context); + target.IsTrashed = source.Trashed; // If there's a set of property aliases specified in the collection configuration, we will check if the current property's // value should be mapped. If it isn't one of the ones specified in 'includeProperties', we will just return the result @@ -119,7 +119,10 @@ private void Map(ContentScheduleCollection source, DocumentResponseModel target, { foreach (ContentSchedule schedule in source.FullSchedule) { - DocumentVariantResponseModel? variant = target.Variants.FirstOrDefault(v => v.Culture == schedule.Culture || (v.Culture.IsNullOrWhiteSpace() && schedule.Culture.IsNullOrWhiteSpace())); + DocumentVariantResponseModel? variant = target.Variants + .FirstOrDefault(v => + v.Culture == schedule.Culture || + (IsInvariant(v.Culture) && IsInvariant(schedule.Culture))); if (variant is null) { continue; @@ -136,4 +139,6 @@ private void Map(ContentScheduleCollection source, DocumentResponseModel target, } } } + + private static bool IsInvariant(string? culture) => culture.IsNullOrWhiteSpace() || culture == Core.Constants.System.InvariantCulture; } diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Item/ItemTypeMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Item/ItemTypeMapDefinition.cs index 5b588623776f..3430f6aaba72 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Item/ItemTypeMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Item/ItemTypeMapDefinition.cs @@ -120,7 +120,7 @@ private static void Map(IUserGroup source, UserGroupItemResponseModel target, Ma // Umbraco.Code.MapAll private static void Map(IWebhook source, WebhookItemResponseModel target, MapperContext context) { - target.Name = string.Empty; //source.Name; + target.Name = source.Name ?? source.Url; target.Url = source.Url; target.Enabled = source.Enabled; target.Events = string.Join(",", source.Events); diff --git a/src/Umbraco.Cms.Api.Management/Middleware/BackOfficeAuthorizationInitializationMiddleware.cs b/src/Umbraco.Cms.Api.Management/Middleware/BackOfficeAuthorizationInitializationMiddleware.cs index 4da821337df2..7c6d109979ea 100644 --- a/src/Umbraco.Cms.Api.Management/Middleware/BackOfficeAuthorizationInitializationMiddleware.cs +++ b/src/Umbraco.Cms.Api.Management/Middleware/BackOfficeAuthorizationInitializationMiddleware.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; @@ -15,15 +14,13 @@ namespace Umbraco.Cms.Api.Management.Middleware; public class BackOfficeAuthorizationInitializationMiddleware : IMiddleware { - private bool _firstBackOfficeRequest; // this only works because this is a singleton private SemaphoreSlim _firstBackOfficeRequestLocker = new(1); // this only works because this is a singleton + private ISet _knownHosts = new HashSet(); // this only works because this is a singleton private readonly UmbracoRequestPaths _umbracoRequestPaths; private readonly IServiceProvider _serviceProvider; private readonly IRuntimeState _runtimeState; - private readonly IOptions _globalSettings; - private readonly IOptions _webRoutingSettings; - private readonly IHostingEnvironment _hostingEnvironment; + private readonly WebRoutingSettings _webRoutingSettings; [Obsolete("Use the non-obsolete constructor. This will be removed in Umbraco 16.")] public BackOfficeAuthorizationInitializationMiddleware( @@ -34,13 +31,11 @@ public BackOfficeAuthorizationInitializationMiddleware( umbracoRequestPaths, serviceProvider, runtimeState, - StaticServiceProvider.Instance.GetRequiredService>(), - StaticServiceProvider.Instance.GetRequiredService>(), - StaticServiceProvider.Instance.GetRequiredService() - ) + StaticServiceProvider.Instance.GetRequiredService>()) { } + [Obsolete("Use the non-obsolete constructor. This will be removed in Umbraco 17.")] public BackOfficeAuthorizationInitializationMiddleware( UmbracoRequestPaths umbracoRequestPaths, IServiceProvider serviceProvider, @@ -48,13 +43,20 @@ public BackOfficeAuthorizationInitializationMiddleware( IOptions globalSettings, IOptions webRoutingSettings, IHostingEnvironment hostingEnvironment) + : this(umbracoRequestPaths, serviceProvider, runtimeState, webRoutingSettings) + { + } + + public BackOfficeAuthorizationInitializationMiddleware( + UmbracoRequestPaths umbracoRequestPaths, + IServiceProvider serviceProvider, + IRuntimeState runtimeState, + IOptions webRoutingSettings) { _umbracoRequestPaths = umbracoRequestPaths; _serviceProvider = serviceProvider; _runtimeState = runtimeState; - _globalSettings = globalSettings; - _webRoutingSettings = webRoutingSettings; - _hostingEnvironment = hostingEnvironment; + _webRoutingSettings = webRoutingSettings.Value; } public async Task InvokeAsync(HttpContext context, RequestDelegate next) @@ -65,11 +67,6 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) private async Task InitializeBackOfficeAuthorizationOnceAsync(HttpContext context) { - if (_firstBackOfficeRequest) - { - return; - } - // Install is okay without this, because we do not need a token to install, // but upgrades do, so we need to execute for everything higher then or equal to upgrade. if (_runtimeState.Level < RuntimeLevel.Upgrade) @@ -77,25 +74,35 @@ private async Task InitializeBackOfficeAuthorizationOnceAsync(HttpContext contex return; } - if (_umbracoRequestPaths.IsBackOfficeRequest(context.Request.Path) == false) { return; } - await _firstBackOfficeRequestLocker.WaitAsync(); - if (_firstBackOfficeRequest == false) + if (_knownHosts.Add($"{context.Request.Scheme}://{context.Request.Host}") is false) { - Uri? backOfficeUrl = string.IsNullOrWhiteSpace(_webRoutingSettings.Value.UmbracoApplicationUrl) is false - ? new Uri($"{_webRoutingSettings.Value.UmbracoApplicationUrl.TrimEnd('/')}{_globalSettings.Value.GetBackOfficePath(_hostingEnvironment).EnsureStartsWith('/')}") - : null; + return; + } - using IServiceScope scope = _serviceProvider.CreateScope(); - IBackOfficeApplicationManager backOfficeApplicationManager = scope.ServiceProvider.GetRequiredService(); - await backOfficeApplicationManager.EnsureBackOfficeApplicationAsync(backOfficeUrl ?? new Uri(context.Request.GetDisplayUrl())); - _firstBackOfficeRequest = true; + await _firstBackOfficeRequestLocker.WaitAsync(); + + // ensure we explicitly add UmbracoApplicationUrl if configured (https://github.com/umbraco/Umbraco-CMS/issues/16179) + if (_webRoutingSettings.UmbracoApplicationUrl.IsNullOrWhiteSpace() is false) + { + _knownHosts.Add(_webRoutingSettings.UmbracoApplicationUrl); } + Uri[] backOfficeHosts = _knownHosts + .Select(host => Uri.TryCreate(host, UriKind.Absolute, out Uri? hostUri) + ? hostUri + : null) + .WhereNotNull() + .ToArray(); + + using IServiceScope scope = _serviceProvider.CreateScope(); + IBackOfficeApplicationManager backOfficeApplicationManager = scope.ServiceProvider.GetRequiredService(); + await backOfficeApplicationManager.EnsureBackOfficeApplicationAsync(backOfficeHosts); + _firstBackOfficeRequestLocker.Release(); } } diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index af29c9fd8d3e..9ff09a431ab1 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -33846,13 +33846,22 @@ ] } }, - "/umbraco/management/api/v1/webhook/events": { + "/umbraco/management/api/v1/webhook/{id}/logs": { "get": { "tags": [ "Webhook" ], - "operationId": "GetWebhookEvents", + "operationId": "GetWebhookByIdLogs", "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, { "name": "skip", "in": "query", @@ -33880,7 +33889,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedWebhookEventModel" + "$ref": "#/components/schemas/PagedWebhookLogResponseModel" } ] } @@ -33889,9 +33898,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user do not have access to this resource" } }, "security": [ @@ -33901,12 +33907,12 @@ ] } }, - "/umbraco/management/api/v1/webhook/logs": { + "/umbraco/management/api/v1/webhook/events": { "get": { "tags": [ "Webhook" ], - "operationId": "GetWebhookLogs", + "operationId": "GetWebhookEvents", "parameters": [ { "name": "skip", @@ -33935,21 +33941,62 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/WebhookResponseModel" + "$ref": "#/components/schemas/PagedWebhookEventModel" } ] } } } }, - "404": { - "description": "Not Found", + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user do not have access to this resource" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/webhook/logs": { + "get": { + "tags": [ + "Webhook" + ], + "operationId": "GetWebhookLogs", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedWebhookLogResponseModel" } ] } @@ -35893,6 +35940,14 @@ "enabled": { "type": "boolean" }, + "name": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, "url": { "minLength": 1, "type": "string" @@ -36462,10 +36517,14 @@ }, "DefaultReferenceResponseModel": { "required": [ + "$type", "id" ], "type": "object", "properties": { + "$type": { + "type": "string" + }, "id": { "type": "string", "format": "uuid" @@ -36483,7 +36542,13 @@ "nullable": true } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DefaultReferenceResponseModel": "#/components/schemas/DefaultReferenceResponseModel" + } + } }, "DeleteUserGroupsRequestModel": { "required": [ @@ -37003,11 +37068,15 @@ }, "DocumentReferenceResponseModel": { "required": [ + "$type", "documentType", "id" ], "type": "object", "properties": { + "$type": { + "type": "string" + }, "id": { "type": "string", "format": "uuid" @@ -37028,7 +37097,13 @@ ] } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DocumentReferenceResponseModel": "#/components/schemas/DocumentReferenceResponseModel" + } + } }, "DocumentResponseModel": { "required": [ @@ -39136,11 +39211,15 @@ }, "MediaReferenceResponseModel": { "required": [ + "$type", "id", "mediaType" ], "type": "object", "properties": { + "$type": { + "type": "string" + }, "id": { "type": "string", "format": "uuid" @@ -39157,7 +39236,13 @@ ] } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "MediaReferenceResponseModel": "#/components/schemas/MediaReferenceResponseModel" + } + } }, "MediaResponseModel": { "required": [ @@ -42320,6 +42405,30 @@ }, "additionalProperties": false }, + "PagedWebhookLogResponseModel": { + "required": [ + "items", + "total" + ], + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/WebhookLogResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, "PagedWebhookResponseModel": { "required": [ "items", @@ -42686,6 +42795,7 @@ "PublishDocumentWithDescendantsRequestModel": { "required": [ "cultures", + "forceRepublish", "includeUnpublishedDescendants" ], "type": "object", @@ -42693,6 +42803,9 @@ "includeUnpublishedDescendants": { "type": "boolean" }, + "forceRepublish": { + "type": "boolean" + }, "cultures": { "type": "array", "items": { @@ -45377,6 +45490,14 @@ "enabled": { "type": "boolean" }, + "name": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, "url": { "minLength": 1, "type": "string" @@ -46190,6 +46311,70 @@ }, "additionalProperties": false }, + "WebhookLogResponseModel": { + "required": [ + "date", + "eventAlias", + "exceptionOccured", + "isSuccessStatusCode", + "key", + "requestBody", + "requestHeaders", + "responseBody", + "responseHeaders", + "retryCount", + "statusCode", + "url", + "webhookKey" + ], + "type": "object", + "properties": { + "key": { + "type": "string", + "format": "uuid" + }, + "webhookKey": { + "type": "string", + "format": "uuid" + }, + "statusCode": { + "type": "string" + }, + "isSuccessStatusCode": { + "type": "boolean" + }, + "date": { + "type": "string", + "format": "date-time" + }, + "eventAlias": { + "type": "string" + }, + "url": { + "type": "string" + }, + "retryCount": { + "type": "integer", + "format": "int32" + }, + "requestHeaders": { + "type": "string" + }, + "requestBody": { + "type": "string" + }, + "responseHeaders": { + "type": "string" + }, + "responseBody": { + "type": "string" + }, + "exceptionOccured": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "WebhookResponseModel": { "required": [ "contentTypeKeys", @@ -46204,6 +46389,14 @@ "enabled": { "type": "boolean" }, + "name": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, "url": { "minLength": 1, "type": "string" diff --git a/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs b/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs index cbf0fcd8addb..f748a5ab75ae 100644 --- a/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs +++ b/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs @@ -32,7 +32,11 @@ public BackOfficeApplicationManager( _authorizeCallbackLogoutPathName = securitySettings.Value.AuthorizeCallbackLogoutPathName; } + [Obsolete("Please use the overload that allows for multiple back-office hosts. Will be removed in V17.")] public async Task EnsureBackOfficeApplicationAsync(Uri backOfficeUrl, CancellationToken cancellationToken = default) + => await EnsureBackOfficeApplicationAsync([backOfficeUrl], cancellationToken); + + public async Task EnsureBackOfficeApplicationAsync(IEnumerable backOfficeHosts, CancellationToken cancellationToken = default) { // Install is okay without this, because we do not need a token to install, // but upgrades do, so we need to execute for everything higher then or equal to upgrade. @@ -41,13 +45,14 @@ public async Task EnsureBackOfficeApplicationAsync(Uri backOfficeUrl, Cancellati return; } - if (backOfficeUrl.IsAbsoluteUri is false) + Uri[] backOfficeHostsAsArray = backOfficeHosts as Uri[] ?? backOfficeHosts.ToArray(); + if (backOfficeHostsAsArray.Any(url => url.IsAbsoluteUri) is false) { - throw new ArgumentException($"Expected an absolute URL, got: {backOfficeUrl}", nameof(backOfficeUrl)); + throw new ArgumentException($"Expected absolute URLs, got: {string.Join(", ", backOfficeHostsAsArray.Select(url => url.ToString()))}", nameof(backOfficeHosts)); } await CreateOrUpdate( - BackofficeOpenIddictApplicationDescriptor(backOfficeUrl), + BackofficeOpenIddictApplicationDescriptor(backOfficeHostsAsArray), cancellationToken); if (_webHostEnvironment.IsProduction()) @@ -57,77 +62,60 @@ await CreateOrUpdate( } else { - var developerClientTimeOutValue = new GlobalSettings().TimeOut.ToString("c", CultureInfo.InvariantCulture); - await CreateOrUpdate( - new OpenIddictApplicationDescriptor - { - DisplayName = "Umbraco Swagger access", - ClientId = Constants.OAuthClientIds.Swagger, - RedirectUris = - { - CallbackUrlFor(backOfficeUrl, "/umbraco/swagger/oauth2-redirect.html") - }, - ClientType = OpenIddictConstants.ClientTypes.Public, - Permissions = - { - OpenIddictConstants.Permissions.Endpoints.Authorization, - OpenIddictConstants.Permissions.Endpoints.Token, - OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, - OpenIddictConstants.Permissions.ResponseTypes.Code - }, - Settings = - { - // use a fixed access token lifetime for tokens issued to the Swagger application. - [OpenIddictConstants.Settings.TokenLifetimes.AccessToken] = developerClientTimeOutValue - } - }, + DeveloperOpenIddictApplicationDescriptor( + "Umbraco Swagger access", + Constants.OAuthClientIds.Swagger, + backOfficeHostsAsArray.Select(backOfficeUrl => CallbackUrlFor(backOfficeUrl, "/umbraco/swagger/oauth2-redirect.html")).ToArray()), cancellationToken); await CreateOrUpdate( - new OpenIddictApplicationDescriptor - { - DisplayName = "Umbraco Postman access", - ClientId = Constants.OAuthClientIds.Postman, - RedirectUris = - { - new Uri("https://oauth.pstmn.io/v1/callback"), new Uri("https://oauth.pstmn.io/v1/browser-callback") - }, - ClientType = OpenIddictConstants.ClientTypes.Public, - Permissions = - { - OpenIddictConstants.Permissions.Endpoints.Authorization, - OpenIddictConstants.Permissions.Endpoints.Token, - OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, - OpenIddictConstants.Permissions.ResponseTypes.Code - }, - Settings = - { - // use a fixed access token lifetime for tokens issued to the Postman application. - [OpenIddictConstants.Settings.TokenLifetimes.AccessToken] = developerClientTimeOutValue - } - }, + DeveloperOpenIddictApplicationDescriptor( + "Umbraco Postman access", + Constants.OAuthClientIds.Postman, + [new Uri("https://oauth.pstmn.io/v1/callback"), new Uri("https://oauth.pstmn.io/v1/browser-callback")]), cancellationToken); } } + public async Task EnsureBackOfficeClientCredentialsApplicationAsync(string clientId, string clientSecret, CancellationToken cancellationToken = default) + { + var applicationDescriptor = new OpenIddictApplicationDescriptor + { + DisplayName = $"Umbraco client credentials back-office access: {clientId}", + ClientId = clientId, + ClientSecret = clientSecret, + ClientType = OpenIddictConstants.ClientTypes.Confidential, + Permissions = + { + OpenIddictConstants.Permissions.Endpoints.Token, + OpenIddictConstants.Permissions.Endpoints.Revocation, + OpenIddictConstants.Permissions.GrantTypes.ClientCredentials + } + }; + + await CreateOrUpdate(applicationDescriptor, cancellationToken); + } + + public async Task DeleteBackOfficeClientCredentialsApplicationAsync(string clientId, CancellationToken cancellationToken = default) + => await Delete(clientId, cancellationToken); + + [Obsolete("Do not use - for internal usage only. Will be made internal in V17.")] public OpenIddictApplicationDescriptor BackofficeOpenIddictApplicationDescriptor(Uri backOfficeUrl) + => BackofficeOpenIddictApplicationDescriptor([backOfficeUrl]); + + internal OpenIddictApplicationDescriptor BackofficeOpenIddictApplicationDescriptor(Uri[] backOfficeHosts) { - Uri CallbackUrl(string path) => CallbackUrlFor(_backOfficeHost ?? backOfficeUrl, path); - return new OpenIddictApplicationDescriptor + if (_backOfficeHost is not null) + { + backOfficeHosts = [_backOfficeHost]; + } + + var descriptor = new OpenIddictApplicationDescriptor { DisplayName = "Umbraco back-office access", ClientId = Constants.OAuthClientIds.BackOffice, - RedirectUris = - { - CallbackUrl(_authorizeCallbackPathName), - }, ClientType = OpenIddictConstants.ClientTypes.Public, - PostLogoutRedirectUris = - { - CallbackUrl(_authorizeCallbackPathName), - CallbackUrl(_authorizeCallbackLogoutPathName), - }, Permissions = { OpenIddictConstants.Permissions.Endpoints.Authorization, @@ -139,29 +127,47 @@ public OpenIddictApplicationDescriptor BackofficeOpenIddictApplicationDescriptor OpenIddictConstants.Permissions.ResponseTypes.Code, }, }; + + foreach (Uri backOfficeHost in backOfficeHosts) + { + descriptor.RedirectUris.Add(CallbackUrlFor(backOfficeHost, _authorizeCallbackPathName)); + descriptor.PostLogoutRedirectUris.Add(CallbackUrlFor(backOfficeHost, _authorizeCallbackPathName)); + descriptor.PostLogoutRedirectUris.Add(CallbackUrlFor(backOfficeHost, _authorizeCallbackLogoutPathName)); + } + + return descriptor; } - public async Task EnsureBackOfficeClientCredentialsApplicationAsync(string clientId, string clientSecret, CancellationToken cancellationToken = default) + internal OpenIddictApplicationDescriptor DeveloperOpenIddictApplicationDescriptor(string name, string clientId, Uri[] redirectUrls) { - var applicationDescriptor = new OpenIddictApplicationDescriptor + var developerClientTimeOutValue = new GlobalSettings().TimeOut.ToString("c", CultureInfo.InvariantCulture); + + var descriptor = new OpenIddictApplicationDescriptor { - DisplayName = $"Umbraco client credentials back-office access: {clientId}", + DisplayName = name, ClientId = clientId, - ClientSecret = clientSecret, - ClientType = OpenIddictConstants.ClientTypes.Confidential, + ClientType = OpenIddictConstants.ClientTypes.Public, Permissions = { + OpenIddictConstants.Permissions.Endpoints.Authorization, OpenIddictConstants.Permissions.Endpoints.Token, - OpenIddictConstants.Permissions.Endpoints.Revocation, - OpenIddictConstants.Permissions.GrantTypes.ClientCredentials + OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, + OpenIddictConstants.Permissions.ResponseTypes.Code + }, + Settings = + { + // use a fixed access token lifetime for tokens issued to the developer applications. + [OpenIddictConstants.Settings.TokenLifetimes.AccessToken] = developerClientTimeOutValue } }; - await CreateOrUpdate(applicationDescriptor, cancellationToken); - } + foreach (Uri redirectUrl in redirectUrls) + { + descriptor.RedirectUris.Add(redirectUrl); + } - public async Task DeleteBackOfficeClientCredentialsApplicationAsync(string clientId, CancellationToken cancellationToken = default) - => await Delete(clientId, cancellationToken); + return descriptor; + } private static Uri CallbackUrlFor(Uri url, string relativePath) => new Uri($"{url.GetLeftPart(UriPartial.Authority)}/{relativePath.TrimStart(Constants.CharArrays.ForwardSlash)}"); } diff --git a/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj b/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj index 34df686bd654..02a8f7958021 100644 --- a/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj +++ b/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj @@ -3,7 +3,7 @@ Umbraco CMS - Management API Contains the presentation layer for the Umbraco CMS Management API. - + $(WarningsNotAsErrors),SA1117,SA1401,SA1134,CS0108,CS0618,CS9042,CS1998,CS8524,IDE0060,SA1649,CS0419,CS1573,CS1574 - + @@ -38,6 +38,9 @@ <_Parameter1>Umbraco.Tests.UnitTests + + <_Parameter1>Umbraco.Tests.Integration + <_Parameter1>DynamicProxyGenAssembly2 diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/Collection/DocumentCollectionResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/Collection/DocumentCollectionResponseModel.cs index b67f4088ce8d..a7c209ad2a20 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/Collection/DocumentCollectionResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/Collection/DocumentCollectionResponseModel.cs @@ -7,5 +7,9 @@ public class DocumentCollectionResponseModel : ContentCollectionResponseModelBas { public DocumentTypeCollectionReferenceResponseModel DocumentType { get; set; } = new(); + public bool IsTrashed { get; set; } + + public bool IsProtected { get; set; } + public string? Updater { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantResponseModel.cs index 58b4768cde9d..b6990c1b3c71 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantResponseModel.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Api.Management.ViewModels.Content; namespace Umbraco.Cms.Api.Management.ViewModels.Document; diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/Item/DocumentItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/Item/DocumentItemResponseModel.cs index ad7d991a00b1..25f4975b9f74 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/Item/DocumentItemResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/Item/DocumentItemResponseModel.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Api.Management.ViewModels.Content; using Umbraco.Cms.Api.Management.ViewModels.DocumentType; using Umbraco.Cms.Api.Management.ViewModels.Item; diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishDocumentWithDescendantsRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishDocumentWithDescendantsRequestModel.cs index 755626f7f2b6..2557adf9a415 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishDocumentWithDescendantsRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishDocumentWithDescendantsRequestModel.cs @@ -1,8 +1,10 @@ -namespace Umbraco.Cms.Api.Management.ViewModels.Document; +namespace Umbraco.Cms.Api.Management.ViewModels.Document; public class PublishDocumentWithDescendantsRequestModel { public bool IncludeUnpublishedDescendants { get; set; } + public bool ForceRepublish { get; set; } + public required IEnumerable Cultures { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/IReferenceResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/IReferenceResponseModel.cs index 83edaf05bea1..e16fb0c29e65 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/IReferenceResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/IReferenceResponseModel.cs @@ -1,6 +1,8 @@ -namespace Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; +using Umbraco.Cms.Api.Common.OpenApi; -public interface IReferenceResponseModel +namespace Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; + +public interface IReferenceResponseModel : IOpenApiDiscriminator { public Guid Id { get; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Webhook/WebhookModelBase.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Webhook/WebhookModelBase.cs index 10f497c54359..e121f6f575cf 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Webhook/WebhookModelBase.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Webhook/WebhookModelBase.cs @@ -6,6 +6,10 @@ public class WebhookModelBase { public bool Enabled { get; set; } = true; + public string? Name { get; set; } + + public string? Description { get; set; } + [Required] public string Url { get; set; } = string.Empty; diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs index dda779cd0c02..b286eccdb70a 100644 --- a/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs @@ -172,9 +172,20 @@ private void HandleMemoryCache(JsonPayload payload) var branchKeys = descendantsKeys.ToList(); branchKeys.Add(key); - foreach (Guid branchKey in branchKeys) + // If the branch is unpublished, we need to remove it from cache instead of refreshing it + if (IsBranchUnpublished(payload)) { - _documentCacheService.RefreshMemoryCacheAsync(branchKey).GetAwaiter().GetResult(); + foreach (Guid branchKey in branchKeys) + { + _documentCacheService.RemoveFromMemoryCacheAsync(branchKey).GetAwaiter().GetResult(); + } + } + else + { + foreach (Guid branchKey in branchKeys) + { + _documentCacheService.RefreshMemoryCacheAsync(branchKey).GetAwaiter().GetResult(); + } } } } @@ -190,6 +201,15 @@ private void HandleMemoryCache(JsonPayload payload) } } + private bool IsBranchUnpublished(JsonPayload payload) + { + // If unpublished cultures has one or more values, but published cultures does not, this means that the branch is unpublished entirely + // And therefore should no longer be resolve-able from the cache, so we need to remove it instead. + // Otherwise, some culture is still published, so it should be resolve-able from cache, and published cultures should instead be used. + return payload.UnpublishedCultures is not null && payload.UnpublishedCultures.Length != 0 && + (payload.PublishedCultures is null || payload.PublishedCultures.Length == 0); + } + private void HandleNavigation(JsonPayload payload) { diff --git a/src/Umbraco.Core/Cache/RepositoryCachePolicyOptions.cs b/src/Umbraco.Core/Cache/RepositoryCachePolicyOptions.cs index ba7b251aa0fc..fa334e5c4a21 100644 --- a/src/Umbraco.Core/Cache/RepositoryCachePolicyOptions.cs +++ b/src/Umbraco.Core/Cache/RepositoryCachePolicyOptions.cs @@ -11,6 +11,7 @@ public class RepositoryCachePolicyOptions public RepositoryCachePolicyOptions(Func performCount) { PerformCount = performCount; + CacheNullValues = false; GetAllCacheValidateCount = true; GetAllCacheAllowZeroCount = false; } @@ -21,6 +22,7 @@ public RepositoryCachePolicyOptions(Func performCount) public RepositoryCachePolicyOptions() { PerformCount = null; + CacheNullValues = false; GetAllCacheValidateCount = false; GetAllCacheAllowZeroCount = false; } @@ -30,6 +32,11 @@ public RepositoryCachePolicyOptions() /// public Func? PerformCount { get; set; } + /// + /// True if the Get method will cache null results so that the db is not hit for repeated lookups + /// + public bool CacheNullValues { get; set; } + /// /// True/false as to validate the total item count when all items are returned from cache, the default is true but this /// means that a db lookup will occur - though that lookup will probably be significantly less expensive than the diff --git a/src/Umbraco.Core/Composing/TypeFinder.cs b/src/Umbraco.Core/Composing/TypeFinder.cs index b18239298f2f..6728b2c7a6b3 100644 --- a/src/Umbraco.Core/Composing/TypeFinder.cs +++ b/src/Umbraco.Core/Composing/TypeFinder.cs @@ -34,7 +34,7 @@ public class TypeFinder : ITypeFinder "ServiceStack.", "SqlCE4Umbraco,", "Superpower,", // used by Serilog "System.", "TidyNet,", "TidyNet.", "WebDriver,", "itextsharp,", "mscorlib,", "NUnit,", "NUnit.", "NUnit3.", "Selenium.", "ImageProcessor", "MiniProfiler.", "Owin,", "SQLite", - "ReSharperTestRunner", "ReSharperTestRunner32", "ReSharperTestRunner64", // These are used by the Jetbrains Rider IDE and Visual Studio ReSharper Extension + "ReSharperTestRunner", "ReSharperTestRunner32", "ReSharperTestRunner64", "ReSharperTestRunnerArm32", "ReSharperTestRunnerArm64", // These are used by the Jetbrains Rider IDE and Visual Studio ReSharper Extension }; private static readonly ConcurrentDictionary TypeNamesCache = new(); diff --git a/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs b/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs index be86cf1f2b7c..127b7d9330df 100644 --- a/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs @@ -16,6 +16,7 @@ public class ModelsBuilderSettings internal const string StaticModelsDirectory = "~/umbraco/models"; internal const bool StaticAcceptUnsafeModelsDirectory = false; internal const int StaticDebugLevel = 0; + internal const bool StaticIncludeVersionNumberInGeneratedModels = true; private bool _flagOutOfDateModels = true; /// @@ -78,4 +79,16 @@ public bool FlagOutOfDateModels /// 0 means minimal (safe on live site), anything else means more and more details (maybe not safe). [DefaultValue(StaticDebugLevel)] public int DebugLevel { get; set; } = StaticDebugLevel; + + /// + /// Gets or sets a value indicating whether the version number should be included in generated models. + /// + /// + /// By default this is written to the output in + /// generated code for each property of the model. This can be useful for debugging purposes but isn't essential, + /// and it has the causes the generated code to change every time Umbraco is upgraded. In turn, this leads + /// to unnecessary code file changes that need to be checked into source control. Default is true. + /// + [DefaultValue(StaticIncludeVersionNumberInGeneratedModels)] + public bool IncludeVersionNumberInGeneratedModels { get; set; } = StaticIncludeVersionNumberInGeneratedModels; } diff --git a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs index 2db42cc3e673..d359f6d20864 100644 --- a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs +++ b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs @@ -20,6 +20,8 @@ public class SecuritySettings internal const bool StaticAllowEditInvariantFromNonDefault = false; internal const bool StaticAllowConcurrentLogins = false; internal const string StaticAuthCookieName = "UMB_UCONTEXT"; + internal const bool StaticUsernameIsEmail = true; + internal const bool StaticMemberRequireUniqueEmail = true; internal const string StaticAllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+\\"; @@ -64,7 +66,14 @@ public class SecuritySettings /// /// Gets or sets a value indicating whether the user's email address is to be considered as their username. /// - public bool UsernameIsEmail { get; set; } = true; + [DefaultValue(StaticUsernameIsEmail)] + public bool UsernameIsEmail { get; set; } = StaticUsernameIsEmail; + + /// + /// Gets or sets a value indicating whether the member's email address must be unique. + /// + [DefaultValue(StaticMemberRequireUniqueEmail)] + public bool MemberRequireUniqueEmail { get; set; } = StaticMemberRequireUniqueEmail; /// /// Gets or sets the set of allowed characters for a username diff --git a/src/Umbraco.Core/Configuration/Models/Validation/GlobalSettingsValidator.cs b/src/Umbraco.Core/Configuration/Models/Validation/GlobalSettingsValidator.cs index f78ce306dda2..34fa737fdeb3 100644 --- a/src/Umbraco.Core/Configuration/Models/Validation/GlobalSettingsValidator.cs +++ b/src/Umbraco.Core/Configuration/Models/Validation/GlobalSettingsValidator.cs @@ -32,16 +32,14 @@ private bool ValidateSmtpSetting(SmtpSettings? value, out string message) => private bool ValidateSqlWriteLockTimeOutSetting(TimeSpan configuredTimeOut, out string message) { - // Only apply this setting if it's not excessively high or low + // Only apply this setting if it's not excessively low const int minimumTimeOut = 100; - const int maximumTimeOut = 20000; // between 0.1 and 20 seconds - if (configuredTimeOut.TotalMilliseconds < minimumTimeOut || - configuredTimeOut.TotalMilliseconds > maximumTimeOut) + if (configuredTimeOut.TotalMilliseconds < minimumTimeOut) { message = - $"The `{Constants.Configuration.ConfigGlobal}:{nameof(GlobalSettings.DistributedLockingWriteLockDefaultTimeout)}` setting is not between the minimum of {minimumTimeOut} ms and maximum of {maximumTimeOut} ms"; + $"The `{Constants.Configuration.ConfigGlobal}:{nameof(GlobalSettings.DistributedLockingWriteLockDefaultTimeout)}` should not be configured as less than {minimumTimeOut} ms"; return false; } diff --git a/src/Umbraco.Core/DeliveryApi/ApiPublishedContentCache.cs b/src/Umbraco.Core/DeliveryApi/ApiPublishedContentCache.cs index e5996595fc0a..0d6f9ecbf558 100644 --- a/src/Umbraco.Core/DeliveryApi/ApiPublishedContentCache.cs +++ b/src/Umbraco.Core/DeliveryApi/ApiPublishedContentCache.cs @@ -12,9 +12,9 @@ namespace Umbraco.Cms.Core.DeliveryApi; public sealed class ApiPublishedContentCache : IApiPublishedContentCache { private readonly IRequestPreviewService _requestPreviewService; - private readonly IRequestCultureService _requestCultureService; private readonly IApiDocumentUrlService _apiDocumentUrlService; private readonly IPublishedContentCache _publishedContentCache; + private readonly IVariationContextAccessor _variationContextAccessor; private DeliveryApiSettings _deliveryApiSettings; [Obsolete("Use the non-obsolete constructor. Will be removed in V17.")] @@ -24,7 +24,12 @@ public ApiPublishedContentCache( IOptionsMonitor deliveryApiSettings, IDocumentUrlService documentUrlService, IPublishedContentCache publishedContentCache) - : this(requestPreviewService, requestCultureService, deliveryApiSettings, StaticServiceProvider.Instance.GetRequiredService(), publishedContentCache) + : this( + requestPreviewService, + deliveryApiSettings, + StaticServiceProvider.Instance.GetRequiredService(), + publishedContentCache, + StaticServiceProvider.Instance.GetRequiredService()) { } @@ -35,22 +40,23 @@ public ApiPublishedContentCache( IOptionsMonitor deliveryApiSettings, IDocumentUrlService documentUrlService, IApiDocumentUrlService apiDocumentUrlService, - IPublishedContentCache publishedContentCache) - : this(requestPreviewService, requestCultureService, deliveryApiSettings, apiDocumentUrlService, publishedContentCache) + IPublishedContentCache publishedContentCache, + IVariationContextAccessor variationContextAccessor) + : this(requestPreviewService, deliveryApiSettings, apiDocumentUrlService, publishedContentCache, variationContextAccessor) { } public ApiPublishedContentCache( IRequestPreviewService requestPreviewService, - IRequestCultureService requestCultureService, IOptionsMonitor deliveryApiSettings, IApiDocumentUrlService apiDocumentUrlService, - IPublishedContentCache publishedContentCache) + IPublishedContentCache publishedContentCache, + IVariationContextAccessor variationContextAccessor) { _requestPreviewService = requestPreviewService; - _requestCultureService = requestCultureService; _apiDocumentUrlService = apiDocumentUrlService; _publishedContentCache = publishedContentCache; + _variationContextAccessor = variationContextAccessor; _deliveryApiSettings = deliveryApiSettings.CurrentValue; deliveryApiSettings.OnChange(settings => _deliveryApiSettings = settings); } @@ -61,7 +67,7 @@ public ApiPublishedContentCache( Guid? documentKey = _apiDocumentUrlService.GetDocumentKeyByRoute( route, - _requestCultureService.GetRequestedCulture(), + _variationContextAccessor.VariationContext?.Culture, _requestPreviewService.IsPreview()); IPublishedContent? content = documentKey.HasValue @@ -77,7 +83,7 @@ public ApiPublishedContentCache( Guid? documentKey = _apiDocumentUrlService.GetDocumentKeyByRoute( route, - _requestCultureService.GetRequestedCulture(), + _variationContextAccessor.VariationContext?.Culture, _requestPreviewService.IsPreview()); IPublishedContent? content = documentKey.HasValue diff --git a/src/Umbraco.Core/EmbeddedResources/Snippets/LoginStatus.cshtml b/src/Umbraco.Core/EmbeddedResources/Snippets/LoginStatus.cshtml index bbe6192908ec..a210a0655813 100644 --- a/src/Umbraco.Core/EmbeddedResources/Snippets/LoginStatus.cshtml +++ b/src/Umbraco.Core/EmbeddedResources/Snippets/LoginStatus.cshtml @@ -5,7 +5,7 @@ @using Umbraco.Extensions @{ - var isLoggedIn = Context.User?.Identity?.IsAuthenticated ?? false; + var isLoggedIn = Context.User.GetMemberIdentity()?.IsAuthenticated ?? false; var logoutModel = new PostRedirectModel(); // You can modify this to redirect to a different URL instead of the current one logoutModel.RedirectUrl = null; @@ -15,7 +15,7 @@ {