Compare commits

..
Author SHA1 Message Date
Niels Lyngsø 50e8f844ee unit test for controller-api 2026-05-09 19:19:02 +02:00
980 changed files with 5501 additions and 28293 deletions
-135
View File
@@ -1,135 +0,0 @@
---
name: umb-release-notes
description: Improve a set of auto-generated GitHub release notes for an Umbraco CMS release. Cross-checks the notes against every PR carrying the release label, adds any that are missing, re-files every PR under the most appropriate category, and strips purely-internal entries. Use whenever the user asks to tidy up, improve, complete, or recategorize release notes for a given version, or mentions a release-notes text file plus a version number.
argument-hint: <version> <path-to-generated-notes-file>
---
# Umbraco CMS - Improve Release Notes
Takes a file of auto-generated GitHub release notes and produces an improved version that:
1. **Is complete** — every merged PR carrying the `release/<version>` label appears.
2. **Is well-categorized** — every PR sits under the most appropriate heading.
3. **Is free of noise** — purely-internal entries of no value to a reader are removed.
The result is written to a **new** file alongside the input, so the user can diff the two.
**Run autonomously.** Do NOT use `AskUserQuestion` once the required arguments (version and input file path) are available — only ask if one of them is missing from `$ARGUMENTS` and cannot be inferred (see Arguments). Beyond that, make the categorization calls yourself using the rules below; if a handful are genuinely borderline, place them anyway and note the borderline ones in your closing summary so the user can override.
## Arguments
`$ARGUMENTS` contains two values:
1. **Version** — e.g. `17.5.0`, `18.1.0`. The GitHub label to search is `release/<version>` (so version `17.5.0` → label `release/17.5.0`).
2. **Input file path** — full path to the text file holding the auto-generated notes (e.g. `C:\Temp\release-17.5.0-rc.md`).
If either is missing, ask the user once for the missing value, then proceed.
## Prerequisites
Run `gh auth status`. If it fails, tell the user to authenticate `gh` (e.g. `gh auth login`) and stop — the skill needs the GitHub CLI to query PRs. The repo is always `umbraco/Umbraco-CMS`.
## Procedure
### 1. Read the input notes
Read the input file. Note its structure — it is GitHub's generated format:
- A leading HTML comment (`<!-- Release notes generated ... -->`).
- A `## What's Changed` heading followed by `### <emoji> <Category>` sub-headings, each with `* <title> by @<author> in <url>` bullets.
- A trailing `## New Contributors` section and a `**Full Changelog**: ...` line.
Extract the set of PR numbers already present (parse the `/pull/<number>` from each bullet). Preserve each existing bullet's **exact text** (title, author, URL) when you re-emit it — only its category placement may change.
### 2. Fetch every labelled PR
```bash
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 \
--json number,title,author,labels,mergedAt \
--jq '.[] | select(.mergedAt != null) | "\(.number)\t\(.author.login)\t\([.labels[].name] | join(", "))\t\(.title)"' | sort -n
```
This is the authoritative list of what the release *should* contain. Each row gives number, author, labels, title.
**Guard against silent truncation.** `gh pr list` caps at `--limit` without warning, so a large release could drop the overflow and the skill would still look "complete". Count the returned rows and compare against the limit:
```bash
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 --json number --jq 'length'
```
If this equals 1000, the limit was hit — raise `--limit` and re-fetch before continuing. Do **not** proceed on a truncated list.
### 3. Reconcile
- **Missing labelled PRs** (labelled but not in the input file): these must be **added**. Build a bullet as `* <title> by @<author> in https://github.com/umbraco/Umbraco-CMS/pull/<number>`.
- **Author handle.** `<author>` in the template is the raw `.author.login` value — the bullet supplies the leading `@`, so do not prepend another. `gh`'s `.author.login` already returns bot accounts with the `[bot]` suffix as part of the login — Dependabot comes back as `dependabot[bot]`, not `dependabot` or `app/dependabot` (the `app/` form only appears in git committer metadata and CODEOWNERS, never in `gh`'s JSON). So the login is already in the right shape; use it verbatim (e.g. `.author.login` of `dependabot[bot]` renders as `@dependabot[bot]`, matching what GitHub's generator wrote for the existing bullets). The only thing to guard against is accidentally stripping or altering the `[bot]` suffix.
- **PRs in the file but not labelled**: keep them. The generated notes span a commit range (see the `Full Changelog` compare link), so they legitimately include backports / earlier-version PRs that lack the current label. For any of these you need to categorize, fetch its labels with:
```bash
gh pr view <number> --repo umbraco/Umbraco-CMS --json number,title,labels \
--jq '"\(.number)\t\([.labels[].name] | join(", "))\t\(.title)"'
```
Do **not** invent or alter the `New Contributors` section — carry it over verbatim. You cannot reliably recompute first-time contributors, so leave it as the generator produced it (mention this in the summary).
### 4. Categorize every PR
Use exactly these headings, in this order. Omit any heading that ends up with no entries.
| Heading | What goes here | Primary signal |
|---|---|---|
| `### 🙌 Notable Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/notable` |
| `### 💥 Breaking Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/breaking` |
| `### 📦 Dependencies` | Dependency bumps | label `dependencies`; or dependabot author |
| `### 🚀 New Features` | New user- or developer-facing capability | label `type/feature` / `category/feature`; or title introduces/adds a genuinely new capability |
| `### 🚤 Performance` | Performance improvements | label `category/performance`; or `Performance:` title prefix |
| `### 🌈 Accessibility Improvements` | A11y improvements (labels, contrast, keyboard) | label `category/accessibility` / `accessibility`; or clear a11y intent (e.g. "improve contrast", "missing labels") |
| `### 🐛 Bug Fixes` | Fixes to broken/incorrect behaviour | default for anything describing a fix |
| `### 🧪 Testing` | Test additions/changes only | label `category/test-automation` / `area/test`; or `E2E`/`QA`/"acceptance tests"/"unit test coverage"/"add tests" titles |
| `### 🛡️ Code Quality, Documentation and Refactoring` | Refactors, deprecations, API tidy-ups, XML/MD documentation, knowledge-base (`MD`) updates | label `category/refactor`; or titles about refactoring, deprecating, renaming, documenting, constants extraction, MD/CLAUDE.md content |
| `### 🧑‍💻 Developer Experience` | Things that improve the experience of developers building on or contributing to Umbraco — dev tooling, build/watch ergonomics, test mocks/harnesses, backoffice dev utilities | `Developer Experience` title prefix; dev tooling; mock/harness changes |
**Rules:**
- **Notable and Breaking are off-limits for recategorization** — never move a PR that is *already in the input file* into or out of these sections; they are driven purely by their labels and the generator placed them correctly. This does **not** exempt them from completeness: a PR discovered as missing in step 3 that carries `category/notable` or `category/breaking` must still be **added** under the matching section.
- Label signals beat title wording, except a `Performance:`/`Developer Experience:` title prefix is decisive for its section.
- A PR with both `type/feature` and `category/refactor` whose title clearly describes a refactor (e.g. "swap relative imports", "re-export type") belongs under Code Quality, not New Features.
- "Add ... tests"/"unit test coverage" → Testing, even if it also touches docs. If a PR adds XML documentation *and* tests, lead with where the title's emphasis lies (documentation → Code Quality; test coverage → Testing).
- When a PR is genuinely 50/50, pick the more reader-useful heading and list it in your closing summary as borderline.
### 5. Remove purely-internal noise
Drop entries that have **no value to anyone reading release notes** — pure repository plumbing with no shipped impact. Examples:
- Branch/merge maintenance ("Fix main branch after merge issue").
- CI/pipeline fixes that don't change the product.
- Reverts of changes that never shipped in a release.
**Keep** anything that ships in the product or genuinely helps developers building on Umbraco — that includes documentation/MD updates, dev tooling, and test mocks (those go to Code Quality or Developer Experience, they are *not* noise). When unsure whether something is noise, keep it and flag it in the summary rather than silently dropping it. List every removal in your closing summary.
### 6. Write the output
Write to a new file in the **same folder** as the input, named by appending ` - with updates` before the extension:
- Input `C:\Temp\release-17.5.0-rc.md` → Output `C:\Temp\release-17.5.0-rc - with updates.md`
Preserve the leading HTML comment, the `## What's Changed` heading, the `## New Contributors` section, and the `**Full Changelog**` line exactly. Only the `### <category>` groupings and their bullets change.
### 7. Report
Give a concise summary:
- Count of PRs added (with their numbers), and which categories they landed in.
- Notable recategorizations (PRs moved out of the catch-all Bug Fixes into Features/Performance/Testing/etc.).
- Every entry removed, with the one-line reason.
- Any borderline calls the user may want to override.
- The output file path.
## Verification
Before reporting done, confirm:
- Every PR number from step 2 is present in the output (except any you deliberately removed in step 5 — and those must be in the removal list).
- No PR appears under more than one heading.
- Notable and Breaking sections are byte-for-byte unchanged from the input.
- The header comment, New Contributors, and Full Changelog lines are intact.
-18
View File
@@ -435,14 +435,6 @@ When a PR changes Management API controllers or models, the `OpenApi.json` file
The backoffice is published to npm as `@umbraco-cms/backoffice`. Runtime dependencies are provided via importmap; npm peerDependencies provide types only. For full details on dependency hoisting, version range logic, and plugin development, see `/src/Umbraco.Web.UI.Client/CLAUDE.md` → "npm Package Publishing".
### SQL Server 2100-parameter limit
Any `WHERE IN (@0, @1, ...)` built from a runtime-sized collection risks hitting SQL Server's 2100-parameter ceiling and throwing `SqlException` 8003 in production.
Batch with `IEnumerable<T>.InGroupsOf(Constants.Sql.MaxParameterCount)` or `Database.FetchByGroups(...)` whenever the collection size is driven by user data — not just when it currently fits. Watch for products of two scaling dimensions (documents × languages, properties × versions) and config-tunable batch sizes whose defaults are safe but ceilings aren't.
Full guidance, safe patterns and decision rule: see `/src/Umbraco.Infrastructure/CLAUDE.md` → "Avoiding the SQL Server 2100-parameter limit".
### Known Limitations
1. **Circular Dependencies**: Avoided via `Lazy<T>` or event notifications
@@ -539,16 +531,6 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
---
## 9. Testing Practices
### Tests for a bug fix must fail before the fix
Verify any test you add for a bug fix actually catches the bug: either write the failing test first (TDD), or temporarily revert the production change and confirm the test fails before re-applying. A test that passes both ways proves nothing. Watch for coincidental passes — default seed/sort orders can make a buggy path produce the right answer for the test's specific inputs; construct inputs so the broken and fixed behaviours give visibly different results.
For integration tests that exercise caching or cache refreshers, see `tests/Umbraco.Tests.Integration/CLAUDE.md` — the harness disables caching by default, which can produce false greens.
---
## Quick Reference
### Essential Commands
+4 -4
View File
@@ -45,15 +45,15 @@
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.1" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1" />
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
<PackageVersion Include="Examine" Version="3.8.0" />
<PackageVersion Include="Examine.Core" Version="3.8.0" />
<PackageVersion Include="Examine" Version="3.7.1" />
<PackageVersion Include="Examine.Core" Version="3.7.1" />
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
<PackageVersion Include="JsonPatch.Net" Version="3.3.0" />
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
<PackageVersion Include="MailKit" Version="4.16.0" />
<PackageVersion Include="Markdig" Version="0.45.0" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MessagePack" Version="3.1.7" />
<PackageVersion Include="MessagePack" Version="3.1.4" />
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
<PackageVersion Include="ncrontab" Version="3.4.0" />
@@ -92,4 +92,4 @@
<!-- TODO: Remove this pinned dependency when Examine updates its Microsoft.AspNetCore.DataProtection reference. -->
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.6" />
</ItemGroup>
</Project>
</Project>
+98 -32
View File
@@ -825,31 +825,74 @@ stages:
publishFeedCredentials: "MyGet - Umbraco Nightly"
${{ else }}:
publishFeedCredentials: "MyGet - Pre-releases"
# Pre-release/nightly feeds: keep the `latest` dist-tag default (no `next` split).
- job:
displayName: Push to pre-release feed (npm)
steps:
- template: templates/npm-publish.yml
parameters:
artifactName: npm
- checkout: none
- download: current
artifact: npm
- bash: |
# Check if we are on a nightly build
if [ $isNightly = "False" ]; then
echo "##[debug]Prerelease build detected"
registry="https://www.myget.org/F/umbracoprereleases/npm/"
else
echo "##[debug]Nightly build detected"
registry="https://www.myget.org/F/umbraconightly/npm/"
fi
echo "@umbraco-cms:registry=$registry" >> .npmrc
env:
isNightly: ${{parameters.isNightly}}
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm (MyGet)
inputs:
workingFile: "$(Pipeline.Workspace)/npm/.npmrc"
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
displayName: Push to npm (MyGet)
${{ if eq(parameters.isNightly, true) }}:
registry: https://www.myget.org/F/umbraconightly/npm/
${{ else }}:
registry: https://www.myget.org/F/umbracoprereleases/npm/
- bash: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push to npm (MyGet)
workingDirectory: $(Pipeline.Workspace)/npm
- job: PublishTestHelpersNpm
displayName: Push TestHelpers to pre-release feed (npm)
steps:
- template: templates/npm-publish.yml
parameters:
artifactName: npm-testhelpers
- checkout: none
- download: current
artifact: npm-testhelpers
- bash: |
# Check if we are on a nightly build
if [ $isNightly = "False" ]; then
echo "##[debug]Prerelease build detected"
registry="https://www.myget.org/F/umbracoprereleases/npm/"
else
echo "##[debug]Nightly build detected"
registry="https://www.myget.org/F/umbraconightly/npm/"
fi
echo "@umbraco-cms:registry=$registry" >> .npmrc
env:
isNightly: ${{parameters.isNightly}}
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm (MyGet)
inputs:
workingFile: "$(Pipeline.Workspace)/npm-testhelpers/.npmrc"
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
displayName: Push test helpers to npm (MyGet)
${{ if eq(parameters.isNightly, true) }}:
registry: https://www.myget.org/F/umbraconightly/npm/
${{ else }}:
registry: https://www.myget.org/F/umbracoprereleases/npm/
- bash: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push test helpers to npm (MyGet)
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
- stage: Deploy_NuGet
displayName: NuGet release
@@ -898,30 +941,53 @@ stages:
condition: and(in(dependencies.Deploy_NuGet.result, 'Succeeded', 'SucceededWithIssues'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.npmDeploy}}))
dependsOn:
- Deploy_NuGet
variables:
# `latest` for stable releases, `next` for prereleases.
npmDistTag: $[ iif(eq(stageDependencies.Build.A.outputs['build.NBGV_PrereleaseVersionNoLeadingHyphen'], ''), 'latest', 'next') ]
jobs:
- job: Publish
displayName: Push to NPM
steps:
- template: templates/npm-publish.yml
parameters:
artifactName: npm
registry: https://registry.npmjs.org/
- checkout: none
- download: current
artifact: npm
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/npm/.npmrc
customEndpoint: "NPM - Umbraco Backoffice"
displayName: Push to npm
npmTag: $(npmDistTag)
- script: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push to npm
workingDirectory: $(Pipeline.Workspace)/npm
- job: PublishTestHelpers
displayName: Push Test Helpers to NPM
steps:
- template: templates/npm-publish.yml
parameters:
artifactName: npm-testhelpers
registry: https://registry.npmjs.org/
- checkout: none
- download: current
artifact: npm-testhelpers
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/npm-testhelpers/.npmrc
customEndpoint: "NPM - Umbraco Backoffice"
displayName: Push test helpers to npm
npmTag: $(npmDistTag)
- script: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push test helpers to npm
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
- stage: Upload_API_Docs
pool:
+2 -2
View File
@@ -5,10 +5,10 @@ trigger: none
schedules:
- cron: '0 3 * * *'
displayName: Daily 3AM build (v17/dev)
displayName: Daily 3AM build (main)
branches:
include:
- v17/dev
- main
parameters:
- name: skipIntegrationTests
-28
View File
@@ -1,28 +0,0 @@
parameters:
- name: artifactName # "npm" or "npm-testhelpers"
type: string
- name: registry # scoped-registry URL to publish to
type: string
- name: customEndpoint # npmAuthenticate service connection(s)
type: string
- name: displayName # label for the publish step
type: string
- name: npmTag # dist-tag to publish under
type: string
default: latest
steps:
- checkout: none
- download: current
artifact: ${{ parameters.artifactName }}
- script: npm config set @umbraco-cms:registry ${{ parameters.registry }} --location=project
displayName: Add scoped registry to .npmrc
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/${{ parameters.artifactName }}/.npmrc
customEndpoint: ${{ parameters.customEndpoint }}
- script: npm publish *.tgz --tag ${{ parameters.npmTag }}
displayName: ${{ parameters.displayName }}
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
@@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Primitives;
using Umbraco.Cms.Api.Common.DependencyInjection;
using Umbraco.Cms.Api.Delivery.Accessors;
@@ -163,12 +162,6 @@ public static class UmbracoBuilderExtensions
builder.Services.AddUnique<IDeliveryApiOutputCacheRequestFilter, DefaultDeliveryApiOutputCacheRequestFilter>();
builder.Services.AddUnique<IDeliveryApiOutputCacheManager, DeliveryApiOutputCacheManager>();
// Signal that Umbraco has enabled output caching so the application builder registers
// the output cache middleware. Gated via a marker rather than IOutputCacheStore so that
// applications calling services.AddOutputCache(...) for their own purposes are not
// affected by Umbraco's automatic middleware registration.
builder.Services.TryAddSingleton<IUmbracoManagedOutputCacheMarker, UmbracoManagedOutputCacheMarker>();
return builder;
}
}
@@ -53,14 +53,11 @@ public class SearchDataTypeItemController : DatatypeItemControllerBase
return Ok(new PagedModel<DataTypeItemResponseModel> { Total = searchResult.Total });
}
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(keys);
IEnumerable<IDataType> orderedDataTypes = OrderByRequestedIds(dataTypes, keys);
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
var result = new PagedModel<DataTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(orderedDataTypes),
Total = searchResult.Total,
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(dataTypes),
Total = searchResult.Total
};
return Ok(result);
@@ -1,100 +0,0 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Actions;
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.Document;
/// <summary>
/// Provides an API endpoint for sorting the root-level documents by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenAtRootDocumentController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IContentEditingService _contentEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenAtRootDocumentController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="contentEditingService">Service for editing and managing content.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
public SortChildrenAtRootDocumentController(
IAuthorizationService authorizationService,
IContentEditingService contentEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_contentEditingService = contentEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the root-level documents by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
/// </returns>
[HttpPut("root/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[EndpointSummary("Sorts the root-level documents by a field.")]
[EndpointDescription("Sorts the root-level documents by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so documents that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortDocumentChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, (Guid?)null),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
parentKey: null,
UmbracoObjectTypes.Document,
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
AuthorizationPolicies.ContentPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
null,
requestModel.Field,
requestModel.Direction,
requestModel.Culture,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -1,102 +0,0 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Actions;
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.Document;
/// <summary>
/// Provides an API endpoint for sorting the children of a document by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenDocumentController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IContentEditingService _contentEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenDocumentController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="contentEditingService">Service for editing and managing content.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
public SortChildrenDocumentController(
IAuthorizationService authorizationService,
IContentEditingService contentEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_contentEditingService = contentEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the child documents of the specified parent document by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="id">The unique identifier of the parent document whose children should be sorted.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent document does not exist.
/// </returns>
[HttpPut("{id:guid}/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Sorts the children of a document by a field.")]
[EndpointDescription("Sorts the children of the specified parent document by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so children that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortDocumentChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, id),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
id,
UmbracoObjectTypes.Document,
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
AuthorizationPolicies.ContentPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
id,
requestModel.Field,
requestModel.Direction,
requestModel.Culture,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -1,98 +0,0 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
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.Media;
/// <summary>
/// Provides an API endpoint for sorting the root-level media items by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenAtRootMediaController : MediaControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IMediaEditingService _mediaEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenAtRootMediaController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="mediaEditingService">Service responsible for editing and sorting media items.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for backoffice security context.</param>
public SortChildrenAtRootMediaController(
IAuthorizationService authorizationService,
IMediaEditingService mediaEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_mediaEditingService = mediaEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the root-level media items by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
/// </returns>
[HttpPut("root/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[EndpointSummary("Sorts the root-level media items by a field.")]
[EndpointDescription("Sorts the root-level media items by a system field in the given direction.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortMediaChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
MediaPermissionResource.Root(),
AuthorizationPolicies.MediaPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
parentKey: null,
UmbracoObjectTypes.Media,
childKeys => MediaPermissionResource.WithKeys(childKeys),
AuthorizationPolicies.MediaPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _mediaEditingService.SortByFieldAsync(
null,
requestModel.Field,
requestModel.Direction,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -1,100 +0,0 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
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.Media;
/// <summary>
/// Provides an API endpoint for sorting the children of a media item by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenMediaController : MediaControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IMediaEditingService _mediaEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenMediaController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="mediaEditingService">Service responsible for editing and sorting media items.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for backoffice security context.</param>
public SortChildrenMediaController(
IAuthorizationService authorizationService,
IMediaEditingService mediaEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_mediaEditingService = mediaEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the child media items of the specified parent media item by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="id">The unique identifier of the parent media item whose children should be sorted.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent media item does not exist.
/// </returns>
[HttpPut("{id:guid}/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Sorts the children of a media item by a field.")]
[EndpointDescription("Sorts the children of the specified parent media item by a system field in the given direction.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortMediaChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
MediaPermissionResource.WithKeys(id),
AuthorizationPolicies.MediaPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
id,
UmbracoObjectTypes.Media,
childKeys => MediaPermissionResource.WithKeys(childKeys),
AuthorizationPolicies.MediaPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _mediaEditingService.SortByFieldAsync(
id,
requestModel.Field,
requestModel.Direction,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -54,14 +54,11 @@ public class SearchMediaTypeItemController : MediaTypeItemControllerBase
return Task.FromResult<IActionResult>(Ok(new PagedModel<MediaTypeItemResponseModel> { Total = searchResult.Total }));
}
Guid[] keys = searchResult.Items.Select(item => item.Key).ToArray();
IEnumerable<IMediaType> mediaTypes = _mediaTypeService.GetMany(keys.EmptyNull());
IEnumerable<IMediaType> orderedMediaTypes = OrderByRequestedIds(mediaTypes, keys);
IEnumerable<IMediaType> mediaTypes = _mediaTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray().EmptyNull());
var result = new PagedModel<MediaTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IMediaType, MediaTypeItemResponseModel>(orderedMediaTypes),
Total = searchResult.Total,
Items = _mapper.MapEnumerable<IMediaType, MediaTypeItemResponseModel>(mediaTypes),
Total = searchResult.Total
};
return Task.FromResult<IActionResult>(Ok(result));
@@ -32,14 +32,6 @@ public class SearchMemberTypeItemController : MemberTypeItemControllerBase
_mapper = mapper;
}
/// <summary>
/// Searches for member type items matching the specified query, with support for pagination.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <param name="query">The search query used to filter member type items.</param>
/// <param name="skip">The number of items to skip before starting to collect the result set (used for pagination).</param>
/// <param name="take">The maximum number of items to return in the result set (used for pagination).</param>
/// <returns>A task representing the asynchronous operation. The task result contains an <see cref="IActionResult"/> with a <see cref="PagedModel{MemberTypeItemResponseModel}"/> containing the search results.</returns>
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<MemberTypeItemResponseModel>), StatusCodes.Status200OK)]
@@ -53,14 +45,11 @@ public class SearchMemberTypeItemController : MemberTypeItemControllerBase
return Task.FromResult<IActionResult>(Ok(new PagedModel<MemberTypeItemResponseModel> { Total = searchResult.Total }));
}
Guid[] keys = searchResult.Items.Select(item => item.Key).ToArray();
IEnumerable<IMemberType> memberTypes = _memberTypeService.GetMany(keys);
IEnumerable<IMemberType> orderedMemberTypes = OrderByRequestedIds(memberTypes, keys);
IEnumerable<IMemberType> memberTypes = _memberTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray());
var result = new PagedModel<MemberTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IMemberType, MemberTypeItemResponseModel>(orderedMemberTypes),
Total = searchResult.Total,
Items = _mapper.MapEnumerable<IMemberType, MemberTypeItemResponseModel>(memberTypes),
Total = searchResult.Total
};
return Task.FromResult<IActionResult>(Ok(result));
@@ -8,38 +8,67 @@ using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
/// <summary>
/// Controller for setting the redirect URL tracking status. Retained for backwards compatibility only;
/// the endpoint no longer modifies any configuration.
/// Controller for setting the redirect URL tracking status.
/// </summary>
[ApiVersion("1.0")]
[Obsolete("This controller is deprecated and no longer modifies the configuration. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
public class SetStatusRedirectUrlManagementController : RedirectUrlManagementControllerBase
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IConfigManipulator _configManipulator;
/// <summary>
/// Initializes a new instance of the <see cref="SetStatusRedirectUrlManagementController"/> class.
/// </summary>
/// <param name="backOfficeSecurityAccessor">Ignored. Retained for binary compatibility.</param>
/// <param name="configManipulator">Ignored. Retained for binary compatibility.</param>
/// <param name="backOfficeSecurityAccessor">The back office security accessor.</param>
/// <param name="configManipulator">The configuration manipulator.</param>
public SetStatusRedirectUrlManagementController(
#pragma warning disable IDE0060 // Remove unused parameter
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IConfigManipulator configManipulator)
#pragma warning restore IDE0060 // Remove unused parameter
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_configManipulator = configManipulator;
}
// TODO: Consider if we should even allow this, or only allow using the appsettings
// We generally don't want to edit the appsettings from our code.
// But maybe there is a valid use case for doing it on the fly.
/// <summary>
/// Deprecated. Returns an OK response without modifying any configuration. To toggle redirect URL tracking,
/// set the <c>Umbraco:CMS:WebRouting:DisableRedirectUrlTracking</c> configuration key instead.
/// Sets the redirect URL tracking status.
/// </summary>
/// <param name="cancellationToken">The cancellation token for the HTTP request.</param>
/// <param name="status">The redirect status (ignored).</param>
/// <returns>An OK result.</returns>
/// <param name="status">The redirect status to set.</param>
/// <returns>An OK result if successful.</returns>
[HttpPost("status")]
[EndpointSummary("Deprecated. No longer changes the redirect URL tracking status.")]
[EndpointDescription("This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.")]
[EndpointSummary("Sets the redirect URL tracking status.")]
[EndpointDescription("Updates the redirect URL tracking configuration according to the provided status.")]
[MapToApiVersion("1.0")]
[Obsolete("This endpoint is deprecated and no longer modifies the configuration. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
public Task<IActionResult> SetStatus(CancellationToken cancellationToken, [FromQuery] RedirectStatus status)
=> Task.FromResult<IActionResult>(Ok());
public async Task<IActionResult> SetStatus(CancellationToken cancellationToken, [FromQuery] RedirectStatus status)
{
// TODO: uncomment this when auth is implemented.
// var userIsAdmin = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin();
// if (userIsAdmin is null or false)
// {
// return Unauthorized();
// }
var enable = status switch
{
RedirectStatus.Enabled => true,
RedirectStatus.Disabled => false,
_ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown redirect status")
};
// For now I'm not gonna change this to limit breaking, but it's weird to have a "disabled" switch,
// since you're essentially negating the boolean from the get go,
// it's much easier to reason with enabled = false == disabled.
await _configManipulator.SaveDisableRedirectUrlTrackingAsync(!enable);
// Taken from the existing implementation in RedirectUrlManagementController
// TODO this is ridiculous, but we need to ensure the configuration is reloaded, before this request is ended.
// otherwise we can read the old value in GetEnableState.
// The value is equal to JsonConfigurationSource.ReloadDelay
Thread.Sleep(250);
return Ok();
}
}
@@ -32,14 +32,6 @@ public class SearchTemplateItemController : TemplateItemControllerBase
_mapper = mapper;
}
/// <summary>
/// Searches for template items matching the specified query, with support for pagination.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <param name="query">The search query used to filter template items.</param>
/// <param name="skip">The number of items to skip before starting to collect the result set (used for pagination).</param>
/// <param name="take">The maximum number of items to return in the result set (used for pagination).</param>
/// <returns>A task representing the asynchronous operation. The task result contains an <see cref="IActionResult"/> with a <see cref="PagedModel{TemplateItemResponseModel}"/> containing the search results.</returns>
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<TemplateItemResponseModel>), StatusCodes.Status200OK)]
@@ -53,14 +45,11 @@ public class SearchTemplateItemController : TemplateItemControllerBase
return Ok(new PagedModel<TemplateItemResponseModel> { Total = searchResult.Total });
}
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
IEnumerable<ITemplate> templates = await _templateService.GetAllAsync(keys);
IEnumerable<ITemplate> orderedTemplates = OrderByRequestedIds(templates, keys);
IEnumerable<ITemplate> templates = await _templateService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
var result = new PagedModel<TemplateItemResponseModel>
{
Items = _mapper.MapEnumerable<ITemplate, TemplateItemResponseModel>(orderedTemplates),
Total = searchResult.Total,
Items = _mapper.MapEnumerable<ITemplate, TemplateItemResponseModel>(templates),
Total = searchResult.Total
};
return Ok(result);
@@ -5,7 +5,6 @@ using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Web.Common.Hosting;
using Umbraco.Cms.Web.Common.Middleware;
namespace Umbraco.Extensions;
@@ -69,10 +68,6 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IBackOfficeEnabledMarker, BackOfficeEnabledMarker>();
builder.Services.AddUnique<IBackOfficePathGenerator, UmbracoBackOfficePathGenerator>();
// Registered here rather than in AddWebComponents because the middleware depends on
// IBackOfficePathGenerator (registered just above). DI scope validation would otherwise
// fail in Delivery-only/Website-only bootstraps that never call AddBackOffice().
builder.Services.AddSingleton<UmbracoBackOfficeCacheHeadersMiddleware>();
builder.Services.AddUnique<IPhysicalFileSystem>(factory =>
{
var path = "~/";
@@ -1,8 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Api.Management.ViewModels.DataType;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Serialization;
@@ -19,7 +16,6 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
private readonly IDataValueEditorFactory _dataValueEditorFactory;
private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DataTypePresentationFactory> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
@@ -29,46 +25,18 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
/// <param name="logger">The logger.</param>
public DataTypePresentationFactory(
IDataTypeContainerService dataTypeContainerService,
PropertyEditorCollection propertyEditorCollection,
IDataValueEditorFactory dataValueEditorFactory,
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
TimeProvider timeProvider,
ILogger<DataTypePresentationFactory> logger)
{
_dataTypeContainerService = dataTypeContainerService;
_propertyEditorCollection = propertyEditorCollection;
_dataValueEditorFactory = dataValueEditorFactory;
_configurationEditorJsonSerializer = configurationEditorJsonSerializer;
_timeProvider = timeProvider;
_logger = logger;
}
/// <summary>
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
/// </summary>
/// <param name="dataTypeContainerService">Service used to manage data type containers.</param>
/// <param name="propertyEditorCollection">A collection containing all available property editors.</param>
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
[Obsolete("Please use the constructor that takes all parameters. Scheduled for removal in Umbraco 19.")]
public DataTypePresentationFactory(
IDataTypeContainerService dataTypeContainerService,
PropertyEditorCollection propertyEditorCollection,
IDataValueEditorFactory dataValueEditorFactory,
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
TimeProvider timeProvider)
: this(
dataTypeContainerService,
propertyEditorCollection,
dataValueEditorFactory,
configurationEditorJsonSerializer,
timeProvider,
StaticServiceProvider.Instance.GetRequiredService<ILogger<DataTypePresentationFactory>>())
{
_dataTypeContainerService = dataTypeContainerService;
_propertyEditorCollection = propertyEditorCollection;
_dataValueEditorFactory = dataValueEditorFactory;
_configurationEditorJsonSerializer = configurationEditorJsonSerializer;
_timeProvider = timeProvider;
}
/// <inheritdoc />
@@ -104,6 +72,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
dataType.Key = requestModel.Id.Value;
}
return Attempt.SucceedWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.Success, dataType);
}
@@ -113,7 +82,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
{
try
{
EntityContainer? parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
var parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
return parent is null
? Attempt.FailWithStatus(DataTypeOperationStatus.ParentNotFound, 0)
@@ -128,7 +97,6 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
return Attempt.SucceedWithStatus(DataTypeOperationStatus.Success, Constants.System.Root);
}
/// <inheritdoc/>
public Task<Attempt<IDataType, DataTypeOperationStatus>> CreateAsync(UpdateDataTypeRequestModel requestModel, IDataType current)
{
if (!_propertyEditorCollection.TryGet(requestModel.EditorAlias, out IDataEditor? editor))
@@ -136,7 +104,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
return Task.FromResult(Attempt.FailWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.PropertyEditorNotFound, new DataType(new VoidEditor(_dataValueEditorFactory), _configurationEditorJsonSerializer) ));
}
var dataType = (IDataType)current.DeepClone();
IDataType dataType = (IDataType)current.DeepClone();
IDictionary<string, object> configurationData = MapConfigurationData(requestModel, editor);
dataType.Name = requestModel.Name;
@@ -151,26 +119,12 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
private ValueStorageType GetEditorValueStorageType(IDataEditor editor, IDictionary<string, object> configurationData)
{
// Only editors whose configuration object implements IConfigureValueType derive their storage
// type from the configuration. Building the typed configuration object can throw for editors
// whose stored configuration doesn't cleanly deserialize into their configuration type; that
// must not fail the save, so fall back to the value editor's value type in that case.
try
var configurationObject = editor.GetConfigurationEditor()
.ToConfigurationObject(configurationData, _configurationEditorJsonSerializer);
if (configurationObject is IConfigureValueType configureValueType)
{
if (editor.GetConfigurationEditor().ToConfigurationObject(configurationData, _configurationEditorJsonSerializer)
is IConfigureValueType configureValueType)
{
return ValueTypes.ToStorageType(configureValueType.ValueType);
}
}
catch (Exception)
{
// Configuration editors are third-party and can throw anything when the stored configuration
// doesn't deserialize into their configuration type. Fall back to the value editor's value type
// rather than failing the save, but log so the misconfiguration remains observable.
_logger.LogError(
"Could not build the configuration object for editor {EditorAlias} to determine its value storage type; falling back to the value editor's value type.",
editor.Alias);
return ValueTypes.ToStorageType(configureValueType.ValueType);
}
var valueType = editor.GetValueEditor().ValueType;
+2 -549
View File
@@ -10888,150 +10888,6 @@
]
}
},
"/umbraco/management/api/v1/document/{id}/sort-children": {
"put": {
"tags": [
"Document"
],
"summary": "Sorts the children of a document by a field.",
"description": "Sorts the children of the specified parent document by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so children that do not vary by it (or an unrecognised culture) fall back to the invariant name.",
"operationId": "PutDocumentByIdSortChildren",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
},
"400": {
"description": "Bad Request",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"404": {
"description": "Not Found",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user does not have access to this resource",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
}
},
"security": [
{
"Backoffice-User": [ ]
}
]
}
},
"/umbraco/management/api/v1/document/{id}/unpublish": {
"put": {
"tags": [
@@ -11426,113 +11282,6 @@
]
}
},
"/umbraco/management/api/v1/document/root/sort-children": {
"put": {
"tags": [
"Document"
],
"summary": "Sorts the root-level documents by a field.",
"description": "Sorts the root-level documents by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so documents that do not vary by it (or an unrecognised culture) fall back to the invariant name.",
"operationId": "PutDocumentRootSortChildren",
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
},
"400": {
"description": "Bad Request",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user does not have access to this resource",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
}
},
"security": [
{
"Backoffice-User": [ ]
}
]
}
},
"/umbraco/management/api/v1/document/sort": {
"put": {
"tags": [
@@ -19409,150 +19158,6 @@
]
}
},
"/umbraco/management/api/v1/media/{id}/sort-children": {
"put": {
"tags": [
"Media"
],
"summary": "Sorts the children of a media item by a field.",
"description": "Sorts the children of the specified parent media item by a system field in the given direction. Media items do not vary by culture, so any supplied culture is ignored.",
"operationId": "PutMediaByIdSortChildren",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
},
"400": {
"description": "Bad Request",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"404": {
"description": "Not Found",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user does not have access to this resource",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
}
},
"security": [
{
"Backoffice-User": [ ]
}
]
}
},
"/umbraco/management/api/v1/media/{id}/validate": {
"put": {
"tags": [
@@ -19803,113 +19408,6 @@
]
}
},
"/umbraco/management/api/v1/media/root/sort-children": {
"put": {
"tags": [
"Media"
],
"summary": "Sorts the root-level media items by a field.",
"description": "Sorts the root-level media items by a system field in the given direction. Media items do not vary by culture, so any supplied culture is ignored.",
"operationId": "PutMediaRootSortChildren",
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
},
"400": {
"description": "Bad Request",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user does not have access to this resource",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
}
},
"security": [
{
"Backoffice-User": [ ]
}
]
}
},
"/umbraco/management/api/v1/media/sort": {
"put": {
"tags": [
@@ -28362,8 +27860,8 @@
"tags": [
"Redirect Management"
],
"summary": "Deprecated. No longer changes the redirect URL tracking status.",
"description": "This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.",
"summary": "Sets the redirect URL tracking status.",
"description": "Updates the redirect URL tracking configuration according to the provided status.",
"operationId": "PostRedirectManagementStatus",
"parameters": [
{
@@ -28409,7 +27907,6 @@
}
}
},
"deprecated": true,
"security": [
{
"Backoffice-User": [ ]
@@ -40358,14 +39855,6 @@
},
"additionalProperties": false
},
"ContentSortFieldModel": {
"enum": [
"Name",
"CreateDate",
"UpdateDate"
],
"type": "string"
},
"CopyDataTypeRequestModel": {
"type": "object",
"properties": {
@@ -50672,42 +50161,6 @@
},
"additionalProperties": false
},
"SortDocumentChildrenByFieldRequestModel": {
"required": [
"direction",
"field"
],
"type": "object",
"properties": {
"field": {
"$ref": "#/components/schemas/ContentSortFieldModel"
},
"direction": {
"$ref": "#/components/schemas/DirectionModel"
},
"culture": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"SortMediaChildrenByFieldRequestModel": {
"required": [
"direction",
"field"
],
"type": "object",
"properties": {
"field": {
"$ref": "#/components/schemas/ContentSortFieldModel"
},
"direction": {
"$ref": "#/components/schemas/DirectionModel"
}
},
"additionalProperties": false
},
"SortingRequestModel": {
"required": [
"sorting"
@@ -1,64 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Security.Authorization;
/// <summary>
/// Authorizes permissions on all direct children of a node.
/// </summary>
internal static class AllChildrenAuthorizer
{
/// <summary>
/// Determines whether the user is authorized for every direct child of the given parent (or the root).
/// </summary>
/// <param name="authorizationService">The authorization service.</param>
/// <param name="entityService">The entity service used to resolve the children.</param>
/// <param name="user">The current user.</param>
/// <param name="parentKey">The parent key, or <c>null</c> to authorize the root-level children.</param>
/// <param name="objectType">The object type of the children (and parent).</param>
/// <param name="resourceFactory">Builds the permission resource to authorize a batch of child keys against.</param>
/// <param name="policy">The authorization policy to apply.</param>
/// <returns><c>true</c> if the user is authorized against all children; otherwise <c>false</c>.</returns>
public static async Task<bool> IsAuthorizedForChildrenAsync(
IAuthorizationService authorizationService,
IEntityService entityService,
ClaimsPrincipal user,
Guid? parentKey,
UmbracoObjectTypes objectType,
Func<IEnumerable<Guid>, IPermissionResource> resourceFactory,
string policy)
{
const int pageSize = 500;
var page = 0;
long total;
do
{
Guid[] childKeys = entityService
.GetPagedChildren(parentKey, [objectType], objectType, page * pageSize, pageSize, out total)
.Select(child => child.Key)
.ToArray();
if (childKeys.Length > 0)
{
AuthorizationResult authorizationResult = await authorizationService.AuthorizeResourceAsync(
user,
resourceFactory(childKeys),
policy);
if (authorizationResult.Succeeded is false)
{
return false;
}
}
page++;
}
while (page * pageSize < total);
return true;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,21 +0,0 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.ContentEditing;
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
/// <summary>
/// Base request model for sorting the children of a node by a system field.
/// </summary>
public abstract class SortChildrenByFieldRequestModelBase
{
/// <summary>
/// Gets or sets the system field to sort the children by.
/// The create and update dates are node-level (not culture-specific).
/// </summary>
public required ContentSortField Field { get; init; }
/// <summary>
/// Gets or sets the direction to sort in.
/// </summary>
public required Direction Direction { get; init; }
}
@@ -1,16 +0,0 @@
using Umbraco.Cms.Core.Models.ContentEditing;
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
/// <summary>
/// Request model for sorting the children of a document by a system field.
/// </summary>
public class SortDocumentChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
{
/// <summary>
/// Gets or sets the culture whose variant name to sort by, or <c>null</c> to sort by the invariant name.
/// Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a document that
/// does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.
/// </summary>
public string? Culture { get; init; }
}
@@ -1,9 +0,0 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
/// <summary>
/// Request model for sorting the children of a media item by a system field.
/// Media items do not vary by culture, so no culture is accepted.
/// </summary>
public class SortMediaChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
{
}
@@ -59,7 +59,7 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
private int? _skipver;
private RoslynCompiler? _roslynCompiler;
private ModelsBuilderSettings _config;
private volatile bool _disposedValue;
private bool _disposedValue;
public InMemoryModelFactory(
Lazy<UmbracoServices> umbracoServices,
@@ -280,34 +280,25 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
}
}
// The factory is disposed on application shutdown (via IRegisteredObject.Stop), but in-flight
// requests can still reach this point. Bail out with the current models rather than touching
// the disposed lock. The catch below covers the small window where disposal happens after this
// check but before (or while) the lock is acquired.
if (_disposedValue)
// don't use an upgradeable lock here because only 1 thread at a time could enter it
try
{
return _infos;
_locker.EnterReadLock();
if (_hasModels)
{
return _infos;
}
}
finally
{
if (_locker.IsReadLockHeld)
{
_locker.ExitReadLock();
}
}
try
{
// don't use an upgradeable lock here because only 1 thread at a time could enter it
try
{
_locker.EnterReadLock();
if (_hasModels)
{
return _infos;
}
}
finally
{
if (_locker.IsReadLockHeld)
{
_locker.ExitReadLock();
}
}
_locker.EnterUpgradeableReadLock();
if (_hasModels)
@@ -368,12 +359,6 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
return _infos;
}
catch (ObjectDisposedException ex)
{
// Expected when the factory is disposed during shutdown mid-request; log so an unexpected disposal stays traceable.
_logger.LogDebug(ex, "EnsureModels interrupted by object disposal (assumed application shutdown); returning current models.");
return _infos;
}
finally
{
if (_locker.IsWriteLockHeld)
@@ -15,9 +15,8 @@ SQLite-specific EF Core provider for Umbraco CMS. Contains SQLite migrations and
This is a thin provider project that implements SQLite-specific functionality for the EF Core persistence layer:
1. **Migration Provider** - Executes SQLite-specific migrations
2. **Migration Provider Setup** - Configures DbContext to use SQLite (incl. transient-error retry)
2. **Migration Provider Setup** - Configures DbContext to use SQLite
3. **Migrations** - SQLite-specific migration files for OpenIddict tables
4. **Retrying Execution Strategy** - Retries transient SQLite lock errors on EF Core operations
### Folder Structure
@@ -31,8 +30,7 @@ Umbraco.Cms.Persistence.EFCore.Sqlite/
│ └── UmbracoDbContextModelSnapshot.cs # Current model state
├── EFCoreSqliteComposer.cs # DI registration
├── SqliteMigrationProvider.cs # IMigrationProvider impl
── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
└── SqliteRetryingExecutionStrategy.cs # IExecutionStrategy for transient lock errors
── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
```
### Relationship with Parent Project
@@ -67,19 +65,7 @@ Registers `IMigrationProvider` and `IMigrationProviderSetup` for SQLite.
### SqliteMigrationProviderSetup (line 11-14)
Configures `DbContextOptionsBuilder` with `UseSqlite`, the migrations assembly, and the
`SqliteRetryingExecutionStrategy` (see below). Invoked from
`UmbracoDbContext.ConfigureOptions` for every `UmbracoDbContext` instance, so all EF Core
access to the Umbraco database (including OpenIddict's token store) inherits the retry.
### SqliteRetryingExecutionStrategy
Custom `Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy` that retries on transient
SQLite errors (`SQLITE_BUSY`, `SQLITE_LOCKED`) using `SqliteExceptionExtensions.IsBusyOrLocked`
from the parent project. Defaults inherit `ExecutionStrategy.DefaultMaxRetryCount` (6) and
`ExecutionStrategy.DefaultMaxDelay` (30s), giving a ~56-second retry budget — see the class's
XML doc for the rationale and the unattended-upgrade escape hatch for very long migrations.
Added to resolve issue #22939 (OpenIddict token reads failing during long migrations).
Configures `DbContextOptionsBuilder` with `UseSqlite` and migrations assembly.
---
@@ -136,8 +122,7 @@ All tables prefixed with `umbraco`:
| File | Purpose |
|------|---------|
| `SqliteMigrationProvider.cs` | Migration execution |
| `SqliteMigrationProviderSetup.cs` | DbContext configuration (UseSqlite + retry strategy) |
| `SqliteRetryingExecutionStrategy.cs` | Retry on transient SQLite BUSY/LOCKED errors |
| `SqliteMigrationProviderSetup.cs` | DbContext configuration |
| `EFCoreSqliteComposer.cs` | DI registration |
| `Migrations/*.cs` | Migration files |
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Umbraco.Cms.Core;
using Umbraco.Cms.Persistence.EFCore.Migrations;
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
@@ -14,15 +15,6 @@ public class SqliteMigrationProviderSetup : IMigrationProviderSetup
/// <inheritdoc />
public void Setup(DbContextOptionsBuilder builder, string? connectionString)
{
builder.UseSqlite(connectionString, x =>
{
x.MigrationsAssembly(GetType().Assembly.FullName);
// Retry transient SQLite errors (BUSY / LOCKED). See SqliteRetryingExecutionStrategy
// for the rationale — long-running migrations or schema-modifying operations can
// briefly lock the database in a way that surfaces as a hard error to concurrent
// EF Core readers (notably OpenIddict token validation). See issue #22939.
x.ExecutionStrategy(deps => new SqliteRetryingExecutionStrategy(deps));
});
builder.UseSqlite(connectionString, x => x.MigrationsAssembly(GetType().Assembly.FullName));
}
}
@@ -1,71 +0,0 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore.Storage;
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
/// <summary>
/// EF Core execution strategy that retries on transient SQLite errors (BUSY / LOCKED).
/// </summary>
/// <remarks>
/// <para>
/// SQLite serialises writers at the database level, and schema-modifying statements briefly
/// block readers — even in WAL mode. Without retries, concurrent EF Core reads (for example
/// OpenIddict's token validation against <c>umbracoOpenIddictTokens</c>) surface those
/// transient locks as <see cref="SqliteException"/> and fail the caller's request.
/// </para>
/// <para>
/// Microsoft does not ship a built-in execution strategy for SQLite (only the SQL Server
/// equivalent), so we provide this one. It piggy-backs on <see cref="ExecutionStrategy"/>'s
/// default exponential backoff and re-uses its inherited
/// <see cref="ExecutionStrategy.DefaultMaxRetryCount"/> (6) and
/// <see cref="ExecutionStrategy.DefaultMaxDelay"/> (30 seconds), which produce a delay
/// schedule of roughly 0s, 1s, 3s, 7s, 15s, 30s — a ~56-second retry window.
/// </para>
/// <para>
/// On top of those EF Core delays, <c>SQLITE_BUSY</c> (error 5) is also retried internally
/// by Microsoft.Data.Sqlite for up to the connection's <c>Default Timeout</c> (30 seconds
/// by default) per attempt. <c>SQLITE_LOCKED</c> (error 6) is not — it returns immediately,
/// so EF Core's retry budget is the only buffer.
/// </para>
/// </remarks>
public class SqliteRetryingExecutionStrategy : ExecutionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class
/// with default retry settings inherited from <see cref="ExecutionStrategy"/>.
/// </summary>
/// <param name="dependencies">Parameter object containing service dependencies.</param>
public SqliteRetryingExecutionStrategy(ExecutionStrategyDependencies dependencies)
: this(dependencies, DefaultMaxRetryCount, DefaultMaxDelay)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class.
/// </summary>
/// <param name="dependencies">Parameter object containing service dependencies.</param>
/// <param name="maxRetryCount">The maximum number of retry attempts.</param>
/// <param name="maxRetryDelay">The maximum delay between retries.</param>
public SqliteRetryingExecutionStrategy(
ExecutionStrategyDependencies dependencies,
int maxRetryCount,
TimeSpan maxRetryDelay)
: base(dependencies, maxRetryCount, maxRetryDelay)
{
}
/// <inheritdoc />
protected override bool ShouldRetryOn(Exception exception)
{
// EF Core wraps provider exceptions, so walk the inner-exception chain.
for (Exception? current = exception; current is not null; current = current.InnerException)
{
if (current is SqliteException sqlite && sqlite.IsBusyOrLocked())
{
return true;
}
}
return false;
}
}
@@ -184,11 +184,17 @@ internal sealed class SqliteEFCoreDistributedLockingMechanism<T> : IDistributedL
throw new ArgumentException($"LockObject with id={LockId} does not exist.");
}
}
catch (SqliteException ex) when (ex.IsBusyOrLocked())
catch (SqliteException ex) when (IsBusyOrLocked(ex))
{
throw new DistributedWriteLockTimeoutException(LockId);
}
});
}
private static bool IsBusyOrLocked(SqliteException ex) =>
ex.SqliteErrorCode
is raw.SQLITE_BUSY
or raw.SQLITE_LOCKED
or raw.SQLITE_LOCKED_SHAREDCACHE;
}
}
@@ -1,26 +0,0 @@
using Microsoft.Data.Sqlite;
using SQLitePCL;
namespace Umbraco.Cms.Persistence.EFCore;
/// <summary>
/// SQLite-specific exception helpers for code running on the EF Core persistence stack.
/// </summary>
/// <remarks>
/// A parallel helper exists at <c>Umbraco.Cms.Persistence.Sqlite.Services.SqliteExceptionExtensions</c>
/// for the NPoco stack. Both stacks are independent (neither references the other) so the small
/// duplication is intentional — keeps the layering clean.
/// </remarks>
public static class SqliteExceptionExtensions
{
/// <summary>
/// Determines if the SQLite exception is a BUSY or LOCKED error.
/// </summary>
/// <param name="ex">The SQLite exception to check.</param>
/// <returns><c>true</c> if the error is BUSY, LOCKED, or LOCKED_SHAREDCACHE; otherwise <c>false</c>.</returns>
public static bool IsBusyOrLocked(this SqliteException ex) =>
ex.SqliteErrorCode
is raw.SQLITE_BUSY
or raw.SQLITE_LOCKED
or raw.SQLITE_LOCKED_SHAREDCACHE;
}
@@ -21,7 +21,7 @@
var backOfficeAssetsPath = BackOfficePathGenerator.BackOfficeAssetsPath;
var loginLogoImageAlternative = Url.RouteUrl(BackOfficeGraphicsController.LoginLogoAlternativeRouteName, new {Version= "1"});
}<!doctype html>
<html lang="en">
<html lang="@GlobalSettings.Value.DefaultUILanguage">
<head>
<meta charset="UTF-8" />
@@ -61,7 +61,7 @@
<p>Here are the <a href="https://www.enable-javascript.com/" target="_blank" rel="noopener" style="text-decoration: underline;">instructions how to enable JavaScript in your web browser</a>.</p>
</div>
</noscript>
<umb-app lang="@GlobalSettings.Value.DefaultUILanguage" @(SecuritySettings.Value.KeepUserLoggedIn ? "keep-user-logged-in" : "")></umb-app>
<umb-app @(SecuritySettings.Value.KeepUserLoggedIn ? "keep-user-logged-in" : "")></umb-app>
@if (isDebug)
{
@@ -35,7 +35,7 @@
}
<!DOCTYPE html>
<html lang="en">
<html lang="@GlobalSettings.Value.DefaultUILanguage">
<head>
<meta charset="UTF-8"/>
<base href="@backOfficePath.EnsureEndsWith('/')" />
@@ -83,7 +83,6 @@
</noscript>
<umb-auth
lang="@GlobalSettings.Value.DefaultUILanguage"
return-url="@backOfficePath"
logo-image="@loginLogoImage"
logo-image-alternative="@loginLogoImageAlternative"
@@ -16,16 +16,21 @@
</ItemGroup>
<!--
The Razor editor in modern Visual Studio and the C# extension for VS Code use the Razor source generator
The Razor editor in VS2026 and the C# extension for VS Code uses the Razor source generator
for IDE functionality. We need to add some things to make sure it works correctly, but we
only do them for design time builds, so that we don't impact regular builds or CI.
We also have an escape hatch in case it does cause issues, users can set EnableCohostEditorCompatibility=false
We also have an escape hatch in case it does cause issues, users can set the appropriate property
in their project file to disable this.
CompilerVisibleProperty is surfaced to generators via AnalyzerConfigOptionsProvider, not as a source-generator input file,
so it doesn't enter the hintName-collision codepath that AdditionalFiles does. Keeping it at evaluation time is safe.
-->
<ItemGroup Condition="'$(DesignTimeBuild)' == 'true' and '$(EnableCohostEditorCompatibility)' != 'false'">
<!--
We have to make sure the source generator can see the .cshtml files, so make them AdditionalFiles.
-->
<AdditionalFiles Include="**\*.cshtml" />
<!--
Make sure the source generator knows where the project is, so it can compute target paths.
-->
<CompilerVisibleProperty Include="MSBuildProjectDirectory" />
</ItemGroup>
</Project>
@@ -49,39 +49,4 @@
<ContentWithTargetPath Include="@(_UmbracoFolderFiles)" Exclude="@(ContentWithTargetPath)" TargetPath="%(Identity)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Target>
<!--
The Razor source generator needs .cshtml files in @(AdditionalFiles). The Razor SDK adds them
via @(RazorGenerate), but only inside a target that runs during the build — so during cohost
design-time builds they may not be present yet, which is what PR #21861 worked around.
Doing the include at evaluation time (as PR #21861 did) causes duplicates with the SDK during
dotnet watch / hot reload design-time builds: the SDK adds the same .cshtml under a different
item Identity (slash form / relative vs absolute) and the generator then sees two inputs that
derive the same hintName, which crashes it with CS8785 (see issue #22773).
Run as a target before CoreCompile (hot-reload path) and CompileDesignTime (IDE design-time path)
so the SDK's contribution is visible in both cases. Then add only the .cshtml files that are not already
present. Both sides are normalized to %(FullPath) so items with different Identity forms still compare equal.
Set EnableCohostEditorCompatibility=false in a project to opt out entirely.
-->
<Target Name="_UmbracoEnsureRazorAdditionalFilesForCohostEditor"
BeforeTargets="CoreCompile;CompileDesignTime"
Condition="'$(DesignTimeBuild)' == 'true' and '$(EnableCohostEditorCompatibility)' != 'false'">
<ItemGroup>
<_UmbracoCshtmlCandidate Include="**\*.cshtml" />
<_UmbracoCshtmlCandidateFull Include="@(_UmbracoCshtmlCandidate->'%(FullPath)')" />
<_UmbracoExistingAdditionalCshtmlFull
Include="@(AdditionalFiles->'%(FullPath)')"
Condition="'%(Extension)' == '.cshtml'" />
<_UmbracoCshtmlMissingFromAdditional
Include="@(_UmbracoCshtmlCandidateFull)"
Exclude="@(_UmbracoExistingAdditionalCshtmlFull)" />
<AdditionalFiles Include="@(_UmbracoCshtmlMissingFromAdditional)" />
</ItemGroup>
</Target>
</Project>
-2
View File
@@ -305,8 +305,6 @@ public class MyEntityCacheRefresher : CacheRefresherBase<MyEntityCacheRefresher>
- `Attempt.Succeed(value)` / `Attempt.Fail<T>()`
- `Attempt<Content, ContentEditingOperationStatus>` - typed result with status
> Writing or reviewing a query with a `WHERE IN` on a runtime-sized collection? See "Avoiding the SQL Server 2100-parameter limit" in `/src/Umbraco.Infrastructure/CLAUDE.md` — that's where the full helper list (`Constants.Sql.MaxParameterCount`, `InGroupsOf`, NPoco's `FetchByGroups`) and the decision rules live.
### Configuration
Configuration models in `/Configuration/Models`:
@@ -26,21 +26,9 @@ public interface IRepositoryCacheVersionAccessor
/// Notifies of a version change on a given cache key.
/// </summary>
/// <param name="cacheKey">Key of the changed version.</param>
[Obsolete("Use version that takes newVersion, scheduled for removal in V19")]
void VersionChanged(string cacheKey)
{ }
/// <summary>
/// Notifies of a version change on a given cache key, providing the new version so internal caches
/// can be updated in-place without a database round-trip.
/// </summary>
/// <param name="cacheKey">Key of the changed version.</param>
/// <param name="newVersion">The new version GUID that was just written to the database.</param>
void VersionChanged(string cacheKey, Guid newVersion)
{
VersionChanged(cacheKey);
}
/// <summary>
/// Notifies the accessor that caches have been synchronized.
/// </summary>
@@ -1,12 +0,0 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Defines an asynchronous handler for a <typeparamref name="TNotification" /> that should be invoked when notifications are dispatched in a distributed cache scope (e.g. to trigger a distributed cache refresher).
/// </summary>
/// <typeparam name="TNotification">The type of the notification.</typeparam>
public interface IDistributedCacheAsyncNotificationHandler<in TNotification> : INotificationAsyncHandler<TNotification>, IDistributedCacheNotificationHandler
where TNotification : INotification
{ }
@@ -23,14 +23,5 @@ public sealed class LanguageDeletedDistributedCacheNotificationHandler : Deleted
/// <inheritdoc />
protected override void Handle(IEnumerable<ILanguage> entities, IDictionary<string, object?> state)
{
_distributedCache.RemoveLanguageCache(entities);
// User groups cache their allowed language ids, so a deleted language must be evicted from
// them too - otherwise a stale, now-missing id lingers on the cached user group and breaks
// reads that resolve those ids. This is a deliberately coarse refresh of the entire user group
// and user caches (RefreshAll also clears IUser): we can't know which groups reference the
// language without a query, and language deletion is rare enough that a full refresh is fine.
_distributedCache.RefreshAllUserGroupCache();
}
=> _distributedCache.RemoveLanguageCache(entities);
}
+1 -10
View File
@@ -368,17 +368,8 @@ public class ObjectCacheAppCache : IAppPolicyCache, IDisposable
}
// Ensure key is removed from set when evicted from cache
return options.RegisterPostEvictionCallback((key, _, reason, _) =>
return options.RegisterPostEvictionCallback((key, _, _, _) =>
{
// Removed and Replaced evictions don't need pruning here: the Remove/Clear call sites already
// prune the tracking set synchronously under the write lock, and a Replaced key still has a
// live entry (the synchronous Set re-added it). Pruning here instead runs on a background
// thread and races with that re-add, dropping a key whose entry is still cached. (#23064)
if (reason is EvictionReason.Removed or EvictionReason.Replaced)
{
return;
}
try
{
if (_locker.TryEnterWriteLock(_writeLockTimeout) is false)
@@ -345,15 +345,15 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshNode))
{
Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result;
_documentUrlService.UpdateUrlSegmentCacheAsync(key).GetAwaiter().GetResult();
_documentUrlAliasService.UpdateAliasCacheAsync(key).GetAwaiter().GetResult();
_documentUrlService.CreateOrUpdateUrlSegmentsAsync(key).GetAwaiter().GetResult();
_documentUrlAliasService.CreateOrUpdateAliasesAsync(key).GetAwaiter().GetResult();
}
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch))
{
Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result;
_documentUrlService.UpdateUrlSegmentCacheWithDescendantsAsync(key).GetAwaiter().GetResult();
_documentUrlAliasService.UpdateAliasCacheWithDescendantsAsync(key).GetAwaiter().GetResult();
_documentUrlService.CreateOrUpdateUrlSegmentsWithDescendantsAsync(key).GetAwaiter().GetResult();
_documentUrlAliasService.CreateOrUpdateAliasesWithDescendantsAsync(key).GetAwaiter().GetResult();
}
}
@@ -1,6 +1,5 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Collections;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
@@ -15,7 +14,6 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
private readonly ILogger<RepositoryCacheVersionService> _logger;
private readonly IRepositoryCacheVersionAccessor _repositoryCacheVersionAccessor;
private readonly ConcurrentDictionary<string, Guid> _cacheVersions = new();
private readonly ConcurrentDictionary<Guid, ConcurrentHashSet<string>> _writtenKeysByScope = new();
/// <summary>
/// Initializes a new instance of the <see cref="RepositoryCacheVersionService" /> class.
@@ -46,6 +44,7 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
var cacheKey = GetCacheKey<TEntity>();
// The cache version accessor will take a read lock if the version is not in request cache, so we don't need to take one here.
RepositoryCacheVersion? databaseVersion = await _repositoryCacheVersionAccessor.GetAsync(cacheKey);
if (databaseVersion?.Version is null)
@@ -85,23 +84,18 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
public async Task SetCacheUpdatedAsync<TEntity>()
where TEntity : class
{
string cacheKey = GetCacheKey<TEntity>();
ConcurrentHashSet<string>? writtenKeys = GetOrRegisterScopeWrittenKeys();
if (writtenKeys?.TryAdd(cacheKey) is false)
{
_logger.LogDebug("Cache version for {EntityType} already written in this scope, skipping", typeof(TEntity).Name);
return;
}
using ICoreScope scope = _scopeProvider.CreateCoreScope();
// We have to take a write lock to ensure the cache is not being read while we update the version.
scope.WriteLock(Constants.Locks.CacheVersion);
var cacheKey = GetCacheKey<TEntity>();
var newVersion = Guid.NewGuid();
_logger.LogDebug("Setting cache for {EntityType} to version {Version}", typeof(TEntity).Name, newVersion);
await _repositoryCacheVersionRepository.SaveAsync(new RepositoryCacheVersion { Identifier = cacheKey, Version = newVersion.ToString() });
_cacheVersions[cacheKey] = newVersion;
_repositoryCacheVersionAccessor.VersionChanged(cacheKey, newVersion);
_repositoryCacheVersionAccessor.VersionChanged(cacheKey);
scope.Complete();
}
@@ -110,6 +104,7 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
public async Task SetCachesSyncedAsync()
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
scope.ReadLock(Constants.Locks.CacheVersion);
// We always sync all caches versions, so it's safe to assume all caches are synced at this point.
IEnumerable<RepositoryCacheVersion> cacheVersions = await _repositoryCacheVersionRepository.GetAllAsync();
@@ -136,22 +131,4 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
internal string GetCacheKey<TEntity>()
where TEntity : class =>
typeof(TEntity).FullName ?? typeof(TEntity).Name;
private ConcurrentHashSet<string>? GetOrRegisterScopeWrittenKeys()
{
IScopeContext? context = _scopeProvider.Context;
if (context is null)
{
return null;
}
Guid contextId = context.InstanceId;
ConcurrentHashSet<string> writtenKeys = _writtenKeysByScope.GetOrAdd(contextId, _ => new ConcurrentHashSet<string>());
context.Enlist(
$"RepositoryCacheVersionService_{contextId}",
completed => _writtenKeysByScope.TryRemove(contextId, out _));
return writtenKeys;
}
}
@@ -36,7 +36,6 @@ public interface IConfigManipulator
/// </summary>
/// <param name="disable">The value to save.</param>
/// <returns></returns>
[Obsolete("This method is no longer used by Umbraco. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
Task SaveDisableRedirectUrlTrackingAsync(bool disable);
/// <summary>
@@ -16,11 +16,6 @@ public class ContentSettings
/// </summary>
internal const bool StaticResolveUrlsFromTextString = false;
/// <summary>
/// The default value for whether sorting children by a field fires per-item notifications.
/// </summary>
internal const bool StaticSortChildrenByFieldFiresNotifications = false;
/// <summary>
/// The default preview badge markup template.
/// </summary>
@@ -115,18 +110,6 @@ public class ContentSettings
[DefaultValue(StaticResolveUrlsFromTextString)]
public bool ResolveUrlsFromTextString { get; set; } = StaticResolveUrlsFromTextString;
/// <summary>
/// Gets or sets a value indicating whether sorting the children of a node by a field fires
/// per-item save/sort notifications (and therefore webhooks).
/// </summary>
/// <remarks>
/// Defaults to <c>false</c>: the children are reordered with a single set-based update and a branch
/// cache refresh, without per-item notifications. Set to <c>true</c> to restore per-item notifications
/// (and webhooks), accepting the additional performance cost on nodes with many children.
/// </remarks>
[DefaultValue(StaticSortChildrenByFieldFiresNotifications)]
public bool SortChildrenByFieldFiresNotifications { get; set; } = StaticSortChildrenByFieldFiresNotifications;
/// <summary>
/// Gets or sets a value for the collection of error pages.
/// </summary>
@@ -30,17 +30,6 @@ public class DatabaseServerMessengerSettings
/// </summary>
internal const string StaticTimeBetweenPruneOperations = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// The default timeout for a single synchronization operation.
/// </summary>
internal const string StaticSyncTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// Gets the default timeout for a single synchronization operation, for use as a fallback when an invalid
/// <see cref="SyncTimeout" /> is configured.
/// </summary>
public static readonly TimeSpan DefaultSyncTimeout = TimeSpan.Parse(StaticSyncTimeout);
/// <summary>
/// Gets or sets a value for the maximum number of instructions that can be processed at startup; otherwise the server
/// cold-boots (rebuilds its caches).
@@ -66,13 +55,4 @@ public class DatabaseServerMessengerSettings
/// </summary>
[DefaultValue(StaticTimeBetweenPruneOperations)]
public TimeSpan TimeBetweenPruneOperations { get; set; } = TimeSpan.Parse(StaticTimeBetweenPruneOperations);
/// <summary>
/// Gets or sets the maximum time to wait for a single synchronization operation to complete before it is
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single sync,
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
/// </summary>
[DefaultValue(StaticSyncTimeout)]
public TimeSpan SyncTimeout { get; set; } = DefaultSyncTimeout;
}
@@ -20,17 +20,6 @@ public class DatabaseServerRegistrarSettings
/// </summary>
internal const string StaticStaleServerTimeout = "00:02:00";
/// <summary>
/// The default timeout for a single server touch operation.
/// </summary>
internal const string StaticTouchTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// Gets the default timeout for a single server touch operation, for use as a fallback when an invalid
/// <see cref="TouchTimeout" /> is configured.
/// </summary>
public static readonly TimeSpan DefaultTouchTimeout = TimeSpan.Parse(StaticTouchTimeout);
/// <summary>
/// Gets or sets a value for the amount of time to wait between calls to the database on the background thread.
/// </summary>
@@ -42,13 +31,4 @@ public class DatabaseServerRegistrarSettings
/// </summary>
[DefaultValue(StaticStaleServerTimeout)]
public TimeSpan StaleServerTimeout { get; set; } = TimeSpan.Parse(StaticStaleServerTimeout);
/// <summary>
/// Gets or sets the maximum time to wait for a single server touch operation to complete before it is
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single touch,
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
/// </summary>
[DefaultValue(StaticTouchTimeout)]
public TimeSpan TouchTimeout { get; set; } = DefaultTouchTimeout;
}
@@ -20,12 +20,5 @@ public class IndexingSettings
/// <summary>
/// Gets or sets a value for how many items to index at a time.
/// </summary>
/// <remarks>
/// This is the primary lever for the peak memory used while (re)building an index: a full page of
/// content and its property data is held in memory at once, so lowering this value reduces rebuild
/// memory at the cost of more, smaller batches. Lower it on very large sites that hit memory pressure
/// during a rebuild.
/// </remarks>
[DefaultValue(StaticBatchSize)]
public int BatchSize { get; set; } = StaticBatchSize;
}
@@ -32,11 +32,6 @@ public class LoggingSettings
/// </summary>
internal const string StaticFileNameFormatArguments = "MachineName";
/// <summary>
/// The default mode for enriching log events with a session identifier.
/// </summary>
internal const SessionIdLoggingMode StaticSessionIdLogging = SessionIdLoggingMode.SessionId;
/// <summary>
/// Gets or sets a value for the maximum age of a log file.
/// </summary>
@@ -75,16 +70,4 @@ public class LoggingSettings
/// </remarks>
[DefaultValue(StaticFileNameFormatArguments)]
public string FileNameFormatArguments { get; set; } = StaticFileNameFormatArguments;
/// <summary>
/// Gets or sets a value determining how log events are enriched with a session identifier.
/// </summary>
/// <remarks>
/// Defaults to <see cref="SessionIdLoggingMode.SessionId" /> for backward compatibility. Set to
/// <see cref="SessionIdLoggingMode.CookieHash" /> or <see cref="SessionIdLoggingMode.None" /> to avoid the
/// blocking session-store load that resolving the actual session id incurs per request when the session is
/// backed by an <c>IDistributedCache</c>.
/// </remarks>
[DefaultValue(StaticSessionIdLogging)]
public SessionIdLoggingMode SessionIdLogging { get; set; } = StaticSessionIdLogging;
}
@@ -1,33 +0,0 @@
using System.ComponentModel;
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Settings for scheduled publishing.
/// </summary>
[UmbracoOptions(Constants.Configuration.ConfigScheduledPublishing)]
public class ScheduledPublishingSettings
{
private const string StaticPeriod = "00:01:00";
private const bool StaticAlignToClock = false; // TODO (V19): Switch this to true.
/// <summary>
/// Gets or sets a value for how often scheduled publishing runs.
/// </summary>
[DefaultValue(StaticPeriod)]
public TimeSpan Period { get; set; } = TimeSpan.Parse(StaticPeriod);
/// <summary>
/// Gets or sets a value indicating whether scheduled publishing runs are aligned to clock boundaries
/// derived from <see cref="Period" /> (for example, on the minute, or every N seconds), rather than drifting
/// based on when the previous run completed.
/// </summary>
/// <remarks>
/// When enabled, <see cref="Period" /> must be a whole number of seconds that divides evenly into one hour
/// (for example 10, 12, 15, 20, 30 or 60 seconds) so that boundaries land on consistent clock times.
/// Boundaries are anchored to <strong>UTC</strong>, not the server's local time zone; for sub-minute and
/// whole-minute periods this is indistinguishable from local time at the second level.
/// </remarks>
[DefaultValue(StaticAlignToClock)]
public bool AlignToClock { get; set; } = StaticAlignToClock;
}
@@ -1,29 +0,0 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Determines how request logging enriches log events with a session identifier.
/// </summary>
public enum SessionIdLoggingMode
{
/// <summary>
/// Do not enrich log events with a session identifier.
/// </summary>
None = 0,
/// <summary>
/// Enrich log events with the actual ASP.NET Core session id. This is the default and matches the
/// historical behaviour, but reading the session id forces the session to be loaded from its store, which
/// is a blocking round-trip per request when the session is backed by an <c>IDistributedCache</c>.
/// </summary>
SessionId,
/// <summary>
/// Enrich log events with a one-way hash of the session cookie value. This provides the same per-session
/// correlation as <see cref="SessionId" /> without loading the session from its store, so it never incurs
/// a distributed-cache round-trip.
/// </summary>
CookieHash,
}
@@ -16,7 +16,6 @@ public class UnattendedSettings
private const bool StaticInstallUnattended = false;
private const bool StaticUpgradeUnattended = false;
private const TelemetryLevel StaticTelemetryLevel = TelemetryLevel.Detailed;
private const string StaticMigrationClaimTimeout = "02:00:00";
/// <summary>
/// Gets or sets a value indicating whether unattended installs are enabled.
@@ -46,17 +45,6 @@ public class UnattendedSettings
/// </remarks>
public bool PackageMigrationsUnattended { get; set; } = true;
/// <summary>
/// Gets or sets the maximum time a migration leadership claim is considered valid before
/// another server may take over. Protects against a leader crashing mid-migration.
/// </summary>
/// <remarks>
/// Only relevant in load-balanced deployments with <see cref="UpgradeUnattended"/> enabled.
/// Default is 2 hours, which should exceed the longest reasonable migration run time.
/// </remarks>
[DefaultValue(StaticMigrationClaimTimeout)]
public TimeSpan MigrationClaimTimeout { get; set; } = TimeSpan.Parse(StaticMigrationClaimTimeout);
/// <summary>
/// Gets or sets a value to use for creating a user with a name for Unattended Installs
/// </summary>
@@ -1,43 +0,0 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Options;
namespace Umbraco.Cms.Core.Configuration.Models.Validation;
/// <summary>
/// Validator for configuration represented as <see cref="ScheduledPublishingSettings" />.
/// </summary>
public class ScheduledPublishingSettingsValidator : ConfigurationValidatorBase, IValidateOptions<ScheduledPublishingSettings>
{
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, ScheduledPublishingSettings options)
{
if (options.Period <= TimeSpan.Zero)
{
return ValidateOptionsResult.Fail(
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be greater than zero.");
}
if (options.AlignToClock && IsCleanDivisorOfAnHour(options.Period) == false)
{
return ValidateOptionsResult.Fail(
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be a whole number of seconds that divides evenly into one hour (3600 seconds) when {Constants.Configuration.ConfigScheduledPublishing}:AlignToClock is enabled, e.g. 10, 12, 15, 20, 30 or 60 seconds.");
}
return ValidateOptionsResult.Success;
}
private static bool IsCleanDivisorOfAnHour(TimeSpan period)
{
var totalSeconds = period.TotalSeconds;
// Must be a positive, whole number of seconds (no sub-second component).
if (totalSeconds <= 0 || totalSeconds != Math.Floor(totalSeconds))
{
return false;
}
return 3600 % (long)totalSeconds == 0;
}
}
@@ -291,11 +291,6 @@ public static partial class Constants
/// </summary>
public const string ConfigDistributedJobs = ConfigPrefix + "DistributedJobs";
/// <summary>
/// The configuration key for scheduled publishing settings.
/// </summary>
public const string ConfigScheduledPublishing = ConfigPrefix + "ScheduledPublishing";
/// <summary>
/// The configuration key for backoffice token cookie settings.
/// </summary>
@@ -36,14 +36,6 @@ public static partial class Constants
/// The key used to store the Umbraco pre-migrations upgrade plan state.
/// </summary>
public const string UmbracoUpgradePlanPremigrationsKey = KeyValuePrefix + UmbracoUpgradePlanPremigrationsName;
/// <summary>
/// The key used to coordinate migration leadership across servers in a load-balanced
/// environment. The value is either empty (no active leader) or
/// <c>"{machineIdentifier}|{claimedAtUtc:O}"</c> when a server holds the claim,
/// where <c>machineIdentifier</c> is the value returned by <see cref="Umbraco.Cms.Core.Factories.IMachineInfoFactory.GetMachineIdentifier"/>.
/// </summary>
public const string UpgradeLockKey = "Umbraco.Core.Upgrader.Lock";
}
/// <summary>
@@ -1,15 +0,0 @@
namespace Umbraco.Cms.Core.DependencyInjection;
/// <summary>
/// Marker interface indicating that Umbraco itself has enabled ASP.NET Core output caching
/// (via Website template caching or Delivery API caching configuration).
/// Used to gate Umbraco's automatic registration of the output cache middleware so that
/// applications calling <c>services.AddOutputCache(...)</c> for their own purposes do not
/// inadvertently trigger a duplicate <c>UseOutputCache()</c> registration.
/// </summary>
public interface IUmbracoManagedOutputCacheMarker { }
/// <summary>
/// Marker class implementation for <see cref="IUmbracoManagedOutputCacheMarker"/>.
/// </summary>
public sealed class UmbracoManagedOutputCacheMarker : IUmbracoManagedOutputCacheMarker { }
@@ -57,7 +57,6 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IValidateOptions<RequestHandlerSettings>, RequestHandlerSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<UnattendedSettings>, UnattendedSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<SecuritySettings>, SecuritySettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<ScheduledPublishingSettings>, ScheduledPublishingSettingsValidator>();
// Register configuration sections.
// TODO (V18): Remove the registrations of UserPasswordConfigurationSettings and MemberPasswordConfigurationSettings.
@@ -103,7 +102,6 @@ public static partial class UmbracoBuilderExtensions
.AddUmbracoOptions<CacheSettings>()
.AddUmbracoOptions<SystemDateMigrationSettings>()
.AddUmbracoOptions<DistributedJobSettings>()
.AddUmbracoOptions<ScheduledPublishingSettings>(options => options.ValidateOnStart())
.AddUmbracoOptions<BackOfficeTokenCookieSettings>()
.AddUmbracoOptions<WebsiteSettings>()
.AddUmbracoOptions<SignalRSettings>();
@@ -458,7 +458,6 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<IDocumentUrlAliasService, DocumentUrlAliasService>();
Services.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, DocumentUrlAliasServiceInitializerNotificationHandler>();
Services.AddNotificationAsyncHandler<ContentTypeChangedNotification, DocumentUrlServiceContentTypeChangedNotificationHandler>();
Services.AddNotificationAsyncHandler<ContentTreeChangeNotification, DocumentUrlServiceContentTreeChangeNotificationHandler>();
}
}
}
@@ -405,8 +405,7 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -454,8 +454,7 @@
<key alias="httpsCheckConfigurationRectifyNotPossible">Mae gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i 'false' yn eich ffeil appSettings.json. Unwaith y byddwch yn cyrchu'r wefan hon gan ddefnyddio'r cynllun HTTPS, dylid gosod hwnnw i 'true'.</key>
<key alias="httpsCheckConfigurationCheckResult">Mae'r gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i '%0%' yn eich ffeil appSettings.json, mae eich cwcis %1% wedi'u marcio'n ddiogel.</key>
<key alias="umbracoApplicationUrlCheckResultTrue">Mae gosodiad yr ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod i <strong>%0%</strong>.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod, felly bydd URL y rhaglen yn cael ei ganfod yn awtomatig o geisiadau sy'n dod i mewn. Argymhellir ei osod yn benodol.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod ac mae canfod URL y rhaglen yn awtomatig wedi'i analluogi (mae 'Umbraco:CMS:WebRouting:ApplicationUrlDetection' yn 'None'). Ni fydd nodweddion sydd angen URL absoliwt, fel e-byst ailosod cyfrinair a gwahoddiadau, yn gweithio. Gosodwch URL y rhaglen yn benodol, neu galluogwch ganfod yn awtomatig.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod.</key>
<key alias="smtpMailSettingsNotFound">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp'.</key>
<key alias="smtpMailSettingsHostNotConfigured">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp:Host'.</key>
<key alias="smtpMailSettingsConnectionFail">Methwyd cyrraedd y gweinydd SMTP a ffurfweddwyd gyda gwesteiwr '%0%' a phorth '%1%'. Gwiriwch i sicrhau bod y gosodiadau SMTP yn y ffurfweddiad 'Umbraco:CMS:Global:Smtp' yn gywir.</key>
@@ -463,8 +463,7 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -452,8 +452,7 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
<key alias="clickJackingCheckHeaderFound">
<![CDATA[The header or meta-tag <strong>X-Frame-Options</strong> used to control whether a site can be IFRAMEd by another was found.]]></key>
<key alias="clickJackingCheckHeaderNotFound">
@@ -403,8 +403,7 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -2230,9 +2230,9 @@ public static class PublishedContentExtensions
// with a non-existing published node, will get cache misses and call the DB
// making it a very slow operation.
// INavigationQueryService.TryGetChildrenKeys returns keys already ordered by SortOrder
// and FilterAvailable preserves enumeration order, so no further OrderBy is needed.
return publishedStatusFilteringService.FilterAvailable(childrenKeys, culture);
return publishedStatusFilteringService
.FilterAvailable(childrenKeys, culture)
.OrderBy(x => x.SortOrder);
}
private static IEnumerable<IPublishedContent> EnumerateDescendantsOrSelfInternal(
@@ -730,10 +730,6 @@ public static partial class StringExtensions
/// </summary>
/// <param name="fileName">The file name to convert.</param>
/// <returns>A friendly name with the extension stripped, underscores and dashes converted to spaces, and title case applied.</returns>
/// <remarks>
/// Mirrored client-side in <c>src/Umbraco.Web.UI.Client/src/packages/media/media/utils/to-friendly-name.function.ts</c>;
/// keep the two implementations in sync.
/// </remarks>
public static string ToFriendlyName(this string fileName)
{
// strip the file extension
@@ -44,34 +44,28 @@ public class UmbracoApplicationUrlCheck : HealthCheck
private HealthCheckStatus CheckUmbracoApplicationUrl()
{
WebRoutingSettings settings = _webRoutingSettings.CurrentValue;
var url = settings.UmbracoApplicationUrl;
var url = _webRoutingSettings.CurrentValue.UmbracoApplicationUrl;
string resultMessage;
StatusResultType resultType;
var success = false;
if (url.IsNullOrWhiteSpace() is false)
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", [url]);
resultType = StatusResultType.Success;
}
else if (settings.ApplicationUrlDetection == ApplicationUrlDetection.None)
{
// No explicit URL and auto-detection is disabled, so the application URL can never be established.
// Features that require an absolute URL (e.g. password reset and invitation emails) will not work.
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultError");
resultType = StatusResultType.Error;
}
else
if (url.IsNullOrWhiteSpace())
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
resultType = StatusResultType.Warning;
}
else
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", new[] { url });
resultType = StatusResultType.Success;
success = true;
}
return new HealthCheckStatus(resultMessage)
{
ResultType = resultType,
ReadMoreLink = resultType == StatusResultType.Success
ReadMoreLink = success
? null
: Constants.HealthChecks.DocumentationLinks.Security.UmbracoApplicationUrlCheck,
};
+32 -63
View File
@@ -36,14 +36,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
/// <summary>
/// Gets the dictionary of shadow nodes tracking file and directory changes.
/// </summary>
/// <remarks>
/// Uses <see cref="StringComparer.OrdinalIgnoreCase"/> so the shadow exposes case-insensitive
/// path semantics (matching Windows file system behavior) while preserving the original case
/// of paths. Preserving case is required for <see cref="Complete"/>: the stored key is also
/// used to locate the shadow file via <c>_sfs.GetFullPath</c>, which on case-sensitive
/// file systems (e.g. Linux) must match the case the file was actually written with.
/// </remarks>
private Dictionary<string, ShadowNode> Nodes => _nodes ??= new Dictionary<string, ShadowNode>(StringComparer.OrdinalIgnoreCase);
private Dictionary<string, ShadowNode> Nodes => _nodes ??= new Dictionary<string, ShadowNode>();
/// <inheritdoc />
public IEnumerable<string> GetDirectories(string path)
@@ -73,7 +66,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
var normPath = NormPath(path);
if (recursive)
{
Nodes[normPath] = new ShadowNode(true, true, normPath);
Nodes[normPath] = new ShadowNode(true, true);
var remove = Nodes.Where(x => IsDescendant(normPath, x.Key)).ToList();
foreach (KeyValuePair<string, ShadowNode> kvp in remove)
{
@@ -91,7 +84,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Directory is not empty.");
}
Nodes[normPath] = new ShadowNode(true, true, normPath);
Nodes[path] = new ShadowNode(true, true);
var remove = Nodes.Where(x => IsChild(normPath, x.Key)).ToList();
foreach (KeyValuePair<string, ShadowNode> kvp in remove)
{
@@ -138,7 +131,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
if (sd.IsDelete)
{
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
Nodes[dirPath] = new ShadowNode(false, true);
}
}
else
@@ -153,13 +146,12 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
Nodes[dirPath] = new ShadowNode(false, true);
}
}
var canonicalPath = sf?.CanonicalPath ?? path;
_sfs.AddFile(canonicalPath, stream, overrideIfExists);
Nodes[normPath] = new ShadowNode(false, false, canonicalPath);
_sfs.AddFile(path, stream, overrideIfExists);
Nodes[normPath] = new ShadowNode(false, false);
}
/// <inheritdoc />
@@ -186,7 +178,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
{
if (Nodes.TryGetValue(NormPath(path), out ShadowNode? sf))
{
return sf.IsDir || sf.IsDelete ? Stream.Null : _sfs.OpenFile(sf.CanonicalPath);
return sf.IsDir || sf.IsDelete ? Stream.Null : _sfs.OpenFile(path);
}
return Inner.OpenFile(path);
@@ -200,8 +192,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
return;
}
var normPath = NormPath(path);
Nodes[normPath] = new ShadowNode(true, false, normPath);
Nodes[NormPath(path)] = new ShadowNode(true, false);
}
/// <inheritdoc />
@@ -235,7 +226,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
if (sd.IsDelete)
{
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
Nodes[dirPath] = new ShadowNode(false, true);
}
}
else
@@ -250,15 +241,13 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
Nodes[dirPath] = new ShadowNode(false, true);
}
}
var sourceCanonical = sf?.CanonicalPath ?? normSource;
var targetCanonical = tf?.CanonicalPath ?? normTarget;
_sfs.MoveFile(sourceCanonical, targetCanonical, overrideIfExists);
Nodes[normSource] = new ShadowNode(true, false, sourceCanonical);
Nodes[normTarget] = new ShadowNode(false, false, targetCanonical);
_sfs.MoveFile(normSource, normTarget, overrideIfExists);
Nodes[normSource] = new ShadowNode(true, false);
Nodes[normTarget] = new ShadowNode(false, false);
}
/// <inheritdoc />
@@ -280,7 +269,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
{
if (Nodes.TryGetValue(NormPath(path), out ShadowNode? sf))
{
return sf.IsDir || sf.IsDelete ? string.Empty : _sfs.GetFullPath(sf.CanonicalPath);
return sf.IsDir || sf.IsDelete ? string.Empty : _sfs.GetFullPath(path);
}
return Inner.GetFullPath(path);
@@ -302,7 +291,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
return _sfs.GetLastModified(sf.CanonicalPath);
return _sfs.GetLastModified(path);
}
/// <inheritdoc />
@@ -318,7 +307,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
return _sfs.GetCreated(sf.CanonicalPath);
return _sfs.GetCreated(path);
}
/// <inheritdoc />
@@ -334,7 +323,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
return _sfs.GetSize(sf.CanonicalPath);
return _sfs.GetSize(path);
}
/// <inheritdoc />
@@ -359,7 +348,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
if (sd.IsDelete)
{
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
Nodes[dirPath] = new ShadowNode(false, true);
}
}
else
@@ -374,13 +363,12 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
Nodes[dirPath] = new ShadowNode(false, true);
}
}
var canonicalPath = sf?.CanonicalPath ?? path;
_sfs.AddFile(canonicalPath, physicalPath, overrideIfExists, copy);
Nodes[normPath] = new ShadowNode(false, false, canonicalPath);
_sfs.AddFile(path, physicalPath, overrideIfExists, copy);
Nodes[normPath] = new ShadowNode(false, false);
}
/// <summary>
@@ -405,11 +393,11 @@ internal sealed partial class ShadowFileSystem : IFileSystem
{
if (Inner.CanAddPhysical)
{
Inner.AddFile(kvp.Key, _sfs.GetFullPath(kvp.Value.CanonicalPath)); // overwrite, move
Inner.AddFile(kvp.Key, _sfs.GetFullPath(kvp.Key)); // overwrite, move
}
else
{
using (Stream stream = _sfs.OpenFile(kvp.Value.CanonicalPath))
using (Stream stream = _sfs.OpenFile(kvp.Key))
{
Inner.AddFile(kvp.Key, stream, true);
}
@@ -453,15 +441,11 @@ internal sealed partial class ShadowFileSystem : IFileSystem
}
/// <summary>
/// Normalizes a path's directory separators to forward slashes.
/// Normalizes a path to lowercase with forward slashes.
/// </summary>
/// <param name="path">The path to normalize.</param>
/// <returns>The normalized path.</returns>
/// <remarks>
/// Case is preserved. Case-insensitive matching is handled by <see cref="Nodes"/>'s
/// <see cref="StringComparer.OrdinalIgnoreCase"/> comparer.
/// </remarks>
private static string NormPath(string path) => path.Replace("\\", "/");
private static string NormPath(string path) => path.ToLowerInvariant().Replace("\\", "/");
/// <summary>
/// Determines whether the input path is a direct child of the specified path.
@@ -472,7 +456,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
/// <remarks>Values can be "" (root), "foo", "foo/bar"...</remarks>
private static bool IsChild(string path, string input)
{
if (input.StartsWith(path, StringComparison.OrdinalIgnoreCase) == false || input.Length < path.Length + 2)
if (input.StartsWith(path) == false || input.Length < path.Length + 2)
{
return false;
}
@@ -482,7 +466,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
return false;
}
var pos = input.IndexOf('/', path.Length + 1);
var pos = input.IndexOf("/", path.Length + 1, StringComparison.OrdinalIgnoreCase);
return pos < 0;
}
@@ -494,7 +478,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
/// <returns><c>true</c> if input is a descendant of path; otherwise, <c>false</c>.</returns>
private static bool IsDescendant(string path, string input)
{
if (input.StartsWith(path, StringComparison.OrdinalIgnoreCase) == false || input.Length < path.Length + 2)
if (input.StartsWith(path) == false || input.Length < path.Length + 2)
{
return false;
}
@@ -511,14 +495,12 @@ internal sealed partial class ShadowFileSystem : IFileSystem
{
foreach (var file in Inner.GetFiles(path))
{
var normFile = NormPath(file);
Nodes[normFile] = new ShadowNode(true, false, normFile);
Nodes[NormPath(file)] = new ShadowNode(true, false);
}
foreach (var dir in Inner.GetDirectories(path))
{
var normDir = NormPath(dir);
Nodes[normDir] = new ShadowNode(true, true, normDir);
Nodes[NormPath(dir)] = new ShadowNode(true, true);
if (recurse)
{
Delete(dir, true);
@@ -630,12 +612,10 @@ internal sealed partial class ShadowFileSystem : IFileSystem
/// </summary>
/// <param name="isDelete">Whether this node represents a deletion.</param>
/// <param name="isdir">Whether this node represents a directory.</param>
/// <param name="canonicalPath">The original-case path tracked by this node.</param>
public ShadowNode(bool isDelete, bool isdir, string canonicalPath)
public ShadowNode(bool isDelete, bool isdir)
{
IsDelete = isDelete;
IsDir = isdir;
CanonicalPath = canonicalPath;
}
/// <summary>
@@ -648,17 +628,6 @@ internal sealed partial class ShadowFileSystem : IFileSystem
/// </summary>
public bool IsDir { get; }
/// <summary>
/// Gets the original-case path tracked by this node. For existing-file nodes this is
/// the path used the first time the file was staged in the current shadow scope.
/// </summary>
/// <remarks>
/// All operations against the inner shadow file system (<c>_sfs</c>) must use this
/// path so that re-staging the same logical path with a different case still reaches
/// the same on-disk file on case-sensitive file systems (e.g. Linux).
/// </remarks>
public string CanonicalPath { get; }
/// <summary>
/// Gets a value indicating whether this node represents an existing item (not deleted).
/// </summary>
@@ -1,22 +0,0 @@
namespace Umbraco.Cms.Core.Models.ContentEditing;
/// <summary>
/// Represents a system field that a node's children can be sorted by.
/// </summary>
public enum ContentSortField
{
/// <summary>
/// Sort by the node's name.
/// </summary>
Name,
/// <summary>
/// Sort by the date the node was created.
/// </summary>
CreateDate,
/// <summary>
/// Sort by the date the node was last updated.
/// </summary>
UpdateDate,
}
@@ -454,24 +454,7 @@ public static class ContentRepositoryExtensions
/// Clears all publish culture information from the content item.
/// </summary>
/// <param name="content">The content item to clear publish information from.</param>
public static void ClearPublishInfos(this IContent content)
{
if (content.PublishCultureInfos is null)
{
return;
}
// Pass each published culture through ClearPublishInfo([culture]) to ensure correct change tracking.
var cultures = content.PublishCultureInfos.Values.Select(c => c.Culture).ToArray();
foreach (var culture in cultures)
{
content.ClearPublishInfo(culture);
}
// Following #22799 the explicit calls to `ClearPublishInfo` for each culture cause the unpublish in all cultures.
// `PublishCultureInfos` is set to null purely to retain previous behaviour at a property level.
content.PublishCultureInfos = null;
}
public static void ClearPublishInfos(this IContent content) => content.PublishCultureInfos = null;
/// <summary>
/// Returns false if the culture is already unpublished
@@ -8,25 +8,7 @@ namespace Umbraco.Cms.Core.Models.Navigation;
/// </summary>
public sealed class NavigationNode
{
private static readonly Comparison<(Guid Key, int SortOrder)> _sortBySortOrder =
static (a, b) => a.SortOrder.CompareTo(b.SortOrder);
private readonly ConcurrentHashSet<Guid> _children;
/// <summary>
/// Cached snapshot of <see cref="Children"/> ordered by each child's <c>SortOrder</c>.
/// </summary>
/// <remarks>
/// Built lazily by <see cref="GetOrderedChildren"/> on first access and invalidated
/// (set to <c>null</c>) by <see cref="AddChild"/> / <see cref="RemoveChild"/> /
/// <see cref="InvalidateOrderedChildren"/>. Reads are lock-free on the fast path; the
/// build and invalidation paths take <see cref="_orderedChildrenLock"/> so concurrent
/// first-access threads agree on a single canonical array and an in-flight build
/// cannot finish after a concurrent invalidation has cleared it.
/// </remarks>
private Guid[]? _orderedChildren;
private readonly Lock _orderedChildrenLock = new();
private ConcurrentHashSet<Guid> _children;
/// <summary>
/// Gets the unique key of this navigation node.
@@ -71,17 +53,6 @@ public sealed class NavigationNode
/// Updates the sort order of this node.
/// </summary>
/// <param name="newSortOrder">The new sort order value.</param>
/// <remarks>
/// The parent node's cached ordered-children list (if any) is now stale because it sorts
/// by child <c>SortOrder</c>. Callers that hold a reference to the parent should call
/// <see cref="InvalidateOrderedChildren"/> on it; <see cref="NavigationNode"/> does not
/// hold a reference to its parent <see cref="NavigationNode"/> so cannot invalidate it
/// itself.
/// </remarks>
// TODO (V19): Make internal. The contract requires the caller to invalidate the parent's
// ordered-children cache (InvalidateOrderedChildren is internal, so external callers cannot
// satisfy that contract and would silently observe stale ordering on subsequent reads).
// Internal callers in ContentNavigationServiceBase already do the invalidation correctly.
public void UpdateSortOrder(int newSortOrder) => SortOrder = newSortOrder;
/// <summary>
@@ -103,8 +74,6 @@ public sealed class NavigationNode
child.SortOrder = _children.Count;
_children.Add(childKey);
InvalidateOrderedChildren();
}
/// <summary>
@@ -122,91 +91,5 @@ public sealed class NavigationNode
_children.Remove(childKey);
child.Parent = null;
InvalidateOrderedChildren();
}
/// <summary>
/// Returns this node's children ordered by <c>SortOrder</c>.
/// </summary>
/// <param name="navigationStructure">The navigation structure dictionary containing all nodes; needed to look up each child's current <c>SortOrder</c>.</param>
/// <returns>An immutable, sort-order-presorted snapshot of the children. The result is cached and reused across calls until the children set or a child's <c>SortOrder</c> is mutated.</returns>
/// <remarks>
/// Lock-free fast path: a non-null cached array is returned without acquiring the lock.
/// If the cache is empty, <see cref="BuildOrderedChildren"/> is called under the lock to
/// build (with double-checked re-read) and store the canonical array.
/// </remarks>
internal IReadOnlyList<Guid> GetOrderedChildren(ConcurrentDictionary<Guid, NavigationNode> navigationStructure)
{
// Volatile.Read provides the acquire fence that pairs with the release fence on the
// lock-protected stores in BuildOrderedChildren / InvalidateOrderedChildren. On weak
// memory architectures (e.g. ARM64) a plain read can observe writes out of order with
// the lock release, so without this barrier a reader could in principle see a torn or
// unpublished reference; on x86/x64 the TSO model already gives acquire semantics so
// this compiles to a normal load. Matches the lock-free read idiom in System.Lazy<T>
// and LazyInitializer.EnsureInitialized.
Guid[]? cached = Volatile.Read(ref _orderedChildren);
if (cached is not null)
{
return cached;
}
return BuildOrderedChildren(navigationStructure);
}
/// <summary>
/// Invalidates the cached ordered-children snapshot.
/// </summary>
/// <remarks>
/// Called by <see cref="AddChild"/> and <see cref="RemoveChild"/> automatically. Must be
/// called externally when a child's <c>SortOrder</c> changes (the parent's cache sorts by
/// child <c>SortOrder</c> and so is stale after such an update).
/// </remarks>
internal void InvalidateOrderedChildren()
{
lock (_orderedChildrenLock)
{
_orderedChildren = null;
}
}
private Guid[] BuildOrderedChildren(ConcurrentDictionary<Guid, NavigationNode> navigationStructure)
{
lock (_orderedChildrenLock)
{
// Double-check under the lock — another thread may have built the cache while we
// were waiting to acquire it.
Guid[]? cached = _orderedChildren;
if (cached is not null)
{
return cached;
}
if (_children.Count == 0)
{
_orderedChildren = [];
return _orderedChildren;
}
var sorted = new List<(Guid Key, int SortOrder)>(_children.Count);
foreach (Guid childKey in _children)
{
if (navigationStructure.TryGetValue(childKey, out NavigationNode? childNode))
{
sorted.Add((childKey, childNode.SortOrder));
}
}
sorted.Sort(_sortBySortOrder);
var result = new Guid[sorted.Count];
for (var i = 0; i < sorted.Count; i++)
{
result[i] = sorted[i].Key;
}
_orderedChildren = result;
return result;
}
}
}
@@ -16,19 +16,6 @@ public interface IContentRepository<in TId, TEntity> : IReadWriteQueryRepository
/// </summary>
int RecycleBinId { get; }
/// <summary>
/// Updates the sort order of the specified nodes so that each node's sort order matches its
/// position in the supplied (already ordered) collection, in a single set-based update.
/// </summary>
/// <param name="orderedNodeIds">The node identifiers in their desired order.</param>
/// <remarks>
/// This persists the sort order directly and does not load the entities or fire any notifications;
/// callers are responsible for any required cache refresh and auditing.
/// </remarks>
// TODO (V19): Remove the default implementation.
void UpdateSortOrder(IReadOnlyList<int> orderedNodeIds)
=> throw new NotImplementedException();
/// <summary>
/// Gets versions.
/// </summary>
@@ -28,27 +28,6 @@ public interface IDocumentCacheService
/// <returns>The published content, or <c>null</c> if not found.</returns>
Task<IPublishedContent?> GetByIdAsync(int id, bool? preview = null);
/// <summary>
/// Attempts to retrieve a content item from the in-memory converted-content cache without
/// touching the distributed cache or the database.
/// </summary>
/// <param name="key">The unique key of the content.</param>
/// <param name="preview">Whether to consider unpublished content.</param>
/// <param name="content">When this method returns, contains the cached published content if a hit was made; otherwise <c>null</c>.</param>
/// <returns><c>true</c> if the content was served from the in-memory cache; <c>false</c> if a slower retrieval (HybridCache or database) is required.</returns>
/// <remarks>
/// Synchronous fast-path used by sync consumers (e.g. <c>IPublishedContentCache.GetById(bool, Guid)</c>)
/// to avoid setting up the async state machine on the dominant warm-cache case. On a miss
/// the caller falls back to the existing async path. The default implementation always
/// returns <c>false</c> so the caller takes the async path.
/// </remarks>
// TODO (V19): Remove the default implementation.
bool TryGetCached(Guid key, bool preview, out IPublishedContent? content)
{
content = null;
return false;
}
/// <summary>
/// Seeds the cache with initial content data.
/// </summary>
@@ -26,26 +26,6 @@ public interface IMediaCacheService
/// <returns>The published media content, or <c>null</c> if not found.</returns>
Task<IPublishedContent?> GetByIdAsync(int id);
/// <summary>
/// Attempts to retrieve a media item from the in-memory converted-content cache without
/// touching the distributed cache or the database.
/// </summary>
/// <param name="key">The unique key of the media.</param>
/// <param name="content">When this method returns, contains the cached published media if a hit was made; otherwise <c>null</c>.</param>
/// <returns><c>true</c> if the media was served from the in-memory cache; <c>false</c> if a slower retrieval (HybridCache or database) is required.</returns>
/// <remarks>
/// Synchronous fast-path used by sync consumers (e.g. <c>IPublishedMediaCache.GetById(bool, Guid)</c>)
/// to avoid setting up the async state machine on the dominant warm-cache case. On a miss
/// the caller falls back to the existing async path. The default implementation always
/// returns <c>false</c> so the caller takes the async path.
/// </remarks>
// TODO (V19): Remove the default implementation.
bool TryGetCached(Guid key, out IPublishedContent? content)
{
content = null;
return false;
}
/// <summary>
/// Determines whether media with the specified identifier exists in the cache.
/// </summary>
@@ -105,15 +105,7 @@ internal sealed class ContentEditingService
/// <inheritdoc />
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(ContentCreateModel createModel, Guid userKey)
{
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
{
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
}
return await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, await GetCulturesToValidate(createModel.Variants.Select(variant => variant.Culture), userKey));
}
=> await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, await GetCulturesToValidate(createModel.Variants.Select(variant => variant.Culture), userKey));
private async Task<IEnumerable<string?>?> GetCulturesToValidate(IEnumerable<string?>? cultures, Guid userKey)
{
@@ -340,15 +332,6 @@ internal sealed class ContentEditingService
Guid userKey)
=> await HandleSortAsync(parentKey, sortingModels, userKey);
/// <inheritdoc />
public async Task<ContentEditingOperationStatus> SortByFieldAsync(
Guid? parentKey,
ContentSortField field,
Direction direction,
string? culture,
Guid userKey)
=> await HandleSortByFieldAsync(parentKey, field, direction, culture, userKey);
private async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCulturesAndPropertiesAsync(
ContentEditingModelBase contentEditingModelBase,
Guid contentTypeKey,
@@ -401,8 +384,8 @@ internal sealed class ContentEditingService
protected override OperationResult? Delete(IContent content, int userId) => ContentService.Delete(content, userId);
/// <inheritdoc />
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: ordering);
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: null);
/// <inheritdoc />
protected override ContentEditingOperationStatus Sort(IEnumerable<IContent> items, int userId)
@@ -411,13 +394,6 @@ internal sealed class ContentEditingService
return OperationResultToOperationStatus(result);
}
/// <inheritdoc />
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
{
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
return OperationResultToOperationStatus(result);
}
private async Task<ContentEditingOperationStatus> Save(IContent content, Guid userKey)
{
try
@@ -458,10 +458,6 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
{
// these are the only result states currently expected from the invoked IContentService operations
OperationResultType.Success => ContentEditingOperationStatus.Success,
// a no-op (e.g. sorting children when nothing needs reordering) is a successful outcome, not an error
OperationResultType.NoOperation => ContentEditingOperationStatus.Success,
OperationResultType.FailedCancelledByEvent => ContentEditingOperationStatus.CancelledByNotification,
OperationResultType.FailedCannot => ContentEditingOperationStatus.CannotDeleteWhenReferenced,
@@ -623,25 +619,6 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
return filteredContentTypes.Any();
}
/// <summary>
/// Validates that content of the requested type is allowed to be created under the requested parent, applying the
/// same "allowed at root", "allowed as child" and content type filter rules that are enforced when the content is
/// actually created. This allows the validation endpoints to be consistent with creation.
/// </summary>
/// <param name="createModel">The content creation model.</param>
/// <returns>The operation status; <see cref="ContentEditingOperationStatus.Success"/> when creation is allowed.</returns>
protected async Task<ContentEditingOperationStatus> ValidateCreationAllowedAsync(ContentCreationModelBase createModel)
{
TContentType? contentType = ContentTypeService.Get(createModel.ContentTypeKey);
if (contentType is null)
{
return ContentEditingOperationStatus.ContentTypeNotFound;
}
(int? _, ContentEditingOperationStatus operationStatus) = await TryGetAndValidateParentIdAsync(createModel.ParentKey, contentType);
return operationStatus;
}
private void UpdateNames(ContentEditingModelBase contentEditingModelBase, TContent content, TContentType contentType)
{
if (contentType.VariesByCulture())
@@ -86,10 +86,9 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
/// <param name="parentId">The parent identifier.</param>
/// <param name="pageIndex">The zero-based page index.</param>
/// <param name="pageSize">The page size.</param>
/// <param name="ordering">The ordering to apply, or <c>null</c> to use the default (sort order).</param>
/// <param name="total">The total number of children.</param>
/// <returns>The paged children.</returns>
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total);
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total);
/// <summary>
/// Handles the sorting operation asynchronously.
@@ -112,7 +111,16 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
return ContentEditingOperationStatus.NotFound;
}
List<TContent> children = LoadAllChildren(contentId.Value, ordering: null);
const int pageSize = 500;
var pageNumber = 0;
IEnumerable<TContent> page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out var total);
var children = new List<TContent>((int)total);
children.AddRange(page);
while (pageNumber * pageSize < total)
{
page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out _);
children.AddRange(page);
}
try
{
@@ -130,102 +138,4 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
return ContentEditingOperationStatus.SortingInvalid;
}
}
/// <summary>
/// Handles sorting a parent's children by a system field asynchronously.
/// </summary>
/// <param name="parentKey">The optional parent key.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
/// <param name="userKey">The user key performing the operation.</param>
/// <returns>The operation status.</returns>
protected async Task<ContentEditingOperationStatus> HandleSortByFieldAsync(
Guid? parentKey,
ContentSortField field,
Direction direction,
string? culture,
Guid userKey)
{
var contentId = parentKey.HasValue
? ContentService.GetById(parentKey.Value)?.Id
: Constants.System.Root;
if (contentId.HasValue is false)
{
return ContentEditingOperationStatus.NotFound;
}
Ordering ordering = BuildOrdering(field, direction, culture);
// The database does the ordering (matching the list view and the order shown in the sort UI).
if (ContentSettings.SortChildrenByFieldFiresNotifications)
{
// Opt-in path: load the children and persist via the standard sort, firing per-item
// save/sort notifications (and therefore webhooks), at the cost of loading every child.
List<TContent> orderedChildren = LoadAllChildren(contentId.Value, ordering);
if (orderedChildren.Count == 0)
{
return ContentEditingOperationStatus.Success;
}
return Sort(orderedChildren, await GetUserIdAsync(userKey));
}
// Default path: persist the resulting order with a single set-based update and a branch cache
// refresh, without loading every child or firing per-item notifications.
List<int> orderedChildIds = LoadOrderedChildIds(contentId.Value, ordering);
if (orderedChildIds.Count == 0)
{
// Nothing to sort - the order is trivially correct.
return ContentEditingOperationStatus.Success;
}
return SortChildrenInBulk(contentId.Value, orderedChildIds, await GetUserIdAsync(userKey));
}
/// <summary>
/// Persists the supplied (already ordered) child identifiers as the new sort order, without loading
/// the children or firing per-item notifications.
/// </summary>
/// <param name="parentId">The parent identifier, or the root identifier for root-level sorting.</param>
/// <param name="orderedChildIds">The child identifiers in their desired order.</param>
/// <param name="userId">The user performing the operation.</param>
/// <returns>The operation status.</returns>
protected abstract ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId);
private List<int> LoadOrderedChildIds(int contentId, Ordering ordering)
=> LoadAllChildren(contentId, ordering, child => child.Id);
private List<TContent> LoadAllChildren(int contentId, Ordering? ordering)
=> LoadAllChildren(contentId, ordering, child => child);
// Pages through all children, projecting each page with the selector so callers that only need a
// lightweight value (e.g. the id) don't retain every loaded child.
private List<TResult> LoadAllChildren<TResult>(int contentId, Ordering? ordering, Func<TContent, TResult> selector)
{
const int pageSize = 500;
var pageNumber = 0;
IEnumerable<TContent> page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out var total);
var results = new List<TResult>((int)total);
results.AddRange(page.Select(selector));
while (pageNumber * pageSize < total)
{
page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out _);
results.AddRange(page.Select(selector));
}
return results;
}
private static Ordering BuildOrdering(ContentSortField field, Direction direction, string? culture)
=> field switch
{
// Name is variant - the culture selects the variant name to order by (invariant content and media
// ignore it). Create and update dates are node-level, so the culture does not apply.
ContentSortField.Name => Ordering.By("name", direction, culture),
ContentSortField.CreateDate => Ordering.By("createDate", direction),
ContentSortField.UpdateDate => Ordering.By("updateDate", direction),
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort field."),
};
}
+7 -60
View File
@@ -1670,7 +1670,9 @@ public class ContentService : RepositoryService, IContentService
{
// Determine cultures publishing/unpublishing which will be based on previous calls to content.PublishCulture and ClearPublishInfo
culturesUnpublishing = content.GetCulturesUnpublishing();
culturesPublishing = GetCulturesPublishing(content);
culturesPublishing = variesByCulture
? content.PublishCultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList()
: null;
// ensure that the document can be published, and publish handling events, business rules, etc
publishResult = StrategyCanPublish(
@@ -1741,12 +1743,6 @@ public class ContentService : RepositoryService, IContentService
// won't happen in a branch
if (unpublishing)
{
if (culturesUnpublishing is null)
{
culturesUnpublishing = content.GetCulturesUnpublishing();
culturesPublishing = GetCulturesPublishing(content);
}
IContent? newest = GetById(content.Id); // ensure we have the newest version - in scope
if (content.VersionId != newest?.VersionId)
{
@@ -1809,13 +1805,12 @@ public class ContentService : RepositoryService, IContentService
var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesUnpublishing);
Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs);
PublishResultType? publishResultType = publishResult?.Result ?? unpublishResult?.Result;
if (publishResultType == null)
if (publishResult == null)
{
throw new PanicException("publishResultType == null - should not happen");
throw new PanicException("publishResult == null - should not happen");
}
switch (publishResultType)
switch (publishResult.Result)
{
case PublishResultType.FailedPublishMandatoryCultureMissing:
// Occurs when a mandatory culture was unpublished (which means we tried publishing the document without a mandatory culture)
@@ -2433,11 +2428,6 @@ public class ContentService : RepositoryService, IContentService
return result;
}
private IReadOnlyList<string>? GetCulturesPublishing(IContent content)
=> content.ContentType.VariesByCulture()
? content.PublishCultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList()
: null;
#endregion
#region Delete
@@ -3137,13 +3127,7 @@ public class ContentService : RepositoryService, IContentService
{
scope.WriteLock(Constants.Locks.ContentTree);
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
// partially-loaded content (e.g. loaded with loadTemplates: false or without property data),
// and saving those directly would wipe the template and property data (#23120).
// GetByIds returns items in the requested order, preserving the caller's ordering that drives the sort.
IContent[] reloaded = GetByIds(itemsA.Select(x => x.Id).ToArray()).ToArray();
OperationResult ret = Sort(scope, reloaded, userId, evtMsgs);
OperationResult ret = Sort(scope, itemsA, userId, evtMsgs);
scope.Complete();
return ret;
}
@@ -3181,43 +3165,6 @@ public class ContentService : RepositoryService, IContentService
}
}
/// <inheritdoc />
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
if (orderedChildIds.Count == 0)
{
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
}
using ICoreScope scope = ScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.ContentTree);
_documentRepository.UpdateSortOrder(orderedChildIds);
// Sort order lives in umbracoNode; neither the published cache nor the content repository cache keeps
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
if (parentId == Constants.System.Root)
{
IContent[] roots = GetByIds(orderedChildIds).ToArray();
scope.Notifications.Publish(new ContentTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
}
else
{
IContent? parent = GetById(parentId);
if (parent is not null)
{
scope.Notifications.Publish(new ContentTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
}
}
Audit(AuditType.Sort, userId, parentId);
scope.Complete();
return OperationResult.Succeed(evtMsgs);
}
private OperationResult Sort(ICoreScope scope, IContent[] itemsA, int userId, EventMessages eventMessages)
{
var sortingNotification = new ContentSortingNotification(itemsA, eventMessages);
@@ -305,40 +305,13 @@ public class DocumentUrlAliasService : IDocumentUrlAliasService
scope.Complete();
}
/// <inheritdoc/>
public async Task UpdateAliasCacheAsync(Guid documentKey)
{
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
await CreateOrUpdateAliasesInternalAsync(documentKey, forceSkipDatabaseWrite: true);
scope.Complete();
}
/// <inheritdoc/>
public async Task UpdateAliasCacheWithDescendantsAsync(Guid documentKey)
{
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
var documentKeys = new List<Guid> { documentKey };
if (_documentNavigationQueryService.TryGetDescendantsKeys(documentKey, out IEnumerable<Guid> descendantKeys))
{
documentKeys.AddRange(descendantKeys);
}
foreach (Guid key in documentKeys)
{
await CreateOrUpdateAliasesInternalAsync(key, forceSkipDatabaseWrite: true);
}
scope.Complete();
}
/// <summary>
/// Internal implementation that processes a single document without creating its own scope.
/// Caller must ensure a scope is active. A write lock on <see cref="Constants.Locks.DocumentUrlAliases"/>
/// is required whenever this method may perform database writes — i.e. on all server roles except
/// <see cref="ServerRole.Subscriber"/>, where persistence is skipped and the write lock is not taken.
/// </summary>
private async Task CreateOrUpdateAliasesInternalAsync(Guid documentKey, bool forceSkipDatabaseWrite = false)
private async Task CreateOrUpdateAliasesInternalAsync(Guid documentKey)
{
IContent? document = _contentService.GetById(documentKey);
if (document is null || document.Trashed || document.Blueprint)
@@ -356,7 +329,7 @@ public class DocumentUrlAliasService : IDocumentUrlAliasService
// Save to database (handles insert/update/delete via diff) and add to cache.
// On subscribers we skip the persistence — the publisher has already written the aliases — but the
// in-memory cache is still refreshed via the deferred enlistments so routing keeps working locally.
bool skipDatabaseWrites = forceSkipDatabaseWrite || SkipDatabaseWrites();
bool skipDatabaseWrites = SkipDatabaseWrites();
if (aliases.Count > 0)
{
if (skipDatabaseWrites is false)
@@ -154,7 +154,7 @@ public class DocumentUrlService : IDocumentUrlService
IPublishStatusQueryService publishStatusQueryService,
IDomainCacheService domainCacheService)
#pragma warning disable CS0618 // Type or member is obsolete
: this(
:this(
logger,
documentUrlRepository,
documentRepository,
@@ -604,35 +604,7 @@ public class DocumentUrlService : IDocumentUrlService
}
/// <inheritdoc/>
public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable<IContent> documents)
=> await CreateOrUpdateUrlSegmentsInternalAsync(documents, skipDatabaseWrite: false);
/// <inheritdoc/>
public async Task UpdateUrlSegmentCacheAsync(Guid key)
{
IContent? content = _contentService.GetById(key);
if (content is not null)
{
await CreateOrUpdateUrlSegmentsInternalAsync(content.Yield(), skipDatabaseWrite: true);
}
}
/// <inheritdoc/>
public async Task UpdateUrlSegmentCacheWithDescendantsAsync(Guid key)
{
var id = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Document).Result;
IContent? item = _contentService.GetById(id);
if (item is null)
{
_logger.LogDebug("Skipping URL segment cache update for document with key {DocumentKey} — document not found.", key);
return;
}
IEnumerable<IContent> descendants = _contentService.GetPagedDescendants(id, 0, int.MaxValue, out _);
await CreateOrUpdateUrlSegmentsInternalAsync(new List<IContent>(descendants) { item }, skipDatabaseWrite: true);
}
private async Task CreateOrUpdateUrlSegmentsInternalAsync(IEnumerable<IContent> documentsEnumerable, bool skipDatabaseWrite)
public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable<IContent> documentsEnumerable)
{
IEnumerable<IContent> documents = documentsEnumerable as IContent[] ?? documentsEnumerable.ToArray();
if (documents.Any() is false)
@@ -692,7 +664,7 @@ public class DocumentUrlService : IDocumentUrlService
}
}
if (!skipDatabaseWrite && toSave.Count > 0 && SkipDatabaseWrites() is false)
if (toSave.Count > 0 && SkipDatabaseWrites() is false)
{
scope.WriteLock(Constants.Locks.DocumentUrls);
_documentUrlRepository.Save(toSave);
@@ -1,65 +0,0 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Services;
/// <summary>
/// Handles <see cref="ContentTreeChangeNotification"/> to persist URL segments and aliases to the database
/// on the originating server. This fires post-commit (during scope disposal) before the cache instruction
/// is delivered to other servers, ensuring URL data is in the database before any server processes the instruction.
/// </summary>
public class DocumentUrlServiceContentTreeChangeNotificationHandler
: INotificationAsyncHandler<ContentTreeChangeNotification>
{
private readonly IDocumentUrlService _documentUrlService;
private readonly IDocumentUrlAliasService _documentUrlAliasService;
/// <summary>
/// Initializes a new instance of the <see cref="DocumentUrlServiceContentTreeChangeNotificationHandler"/> class.
/// </summary>
public DocumentUrlServiceContentTreeChangeNotificationHandler(
IDocumentUrlService documentUrlService,
IDocumentUrlAliasService documentUrlAliasService)
{
_documentUrlService = documentUrlService;
_documentUrlAliasService = documentUrlAliasService;
}
/// <inheritdoc/>
public async Task HandleAsync(ContentTreeChangeNotification notification, CancellationToken cancellationToken)
{
if (_documentUrlService.IsInitialized is false)
{
return;
}
var refreshNodeItems = new List<IContent>();
foreach (TreeChange<IContent> change in notification.Changes)
{
if (change.ChangeTypes.HasType(TreeChangeTypes.RefreshNode))
{
refreshNodeItems.Add(change.Item);
}
if (change.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch))
{
await _documentUrlService.CreateOrUpdateUrlSegmentsWithDescendantsAsync(change.Item.Key);
await _documentUrlAliasService.CreateOrUpdateAliasesWithDescendantsAsync(change.Item.Key);
}
}
if (refreshNodeItems.Count > 0)
{
await _documentUrlService.CreateOrUpdateUrlSegmentsAsync(refreshNodeItems);
foreach (IContent item in refreshNodeItems)
{
await _documentUrlAliasService.CreateOrUpdateAliasesAsync(item.Key);
}
}
}
}
@@ -95,18 +95,6 @@ public interface IContentEditingService
/// <returns>The operation status indicating success or failure.</returns>
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
/// <summary>
/// Sorts the children of a parent by a system field.
/// </summary>
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
/// <param name="userKey">The unique identifier of the user performing the action.</param>
/// <returns>The operation status indicating success or failure.</returns>
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, string? culture, Guid userKey)
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
/// <summary>
/// Deletes a content item whether it is in the recycle bin or not.
/// </summary>
@@ -542,22 +542,6 @@ public interface IContentService : IContentServiceBase<IContent>
/// <returns>The operation result.</returns>
OperationResult Sort(IEnumerable<int>? ids, int userId = Constants.Security.SuperUserId);
/// <summary>
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
/// as the new sort order, in a single set-based update.
/// </summary>
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
/// <param name="orderedChildIds">The child document identifiers, in the desired order.</param>
/// <param name="userId">The identifier of the user performing the action.</param>
/// <returns>The operation result.</returns>
/// <remarks>
/// Unlike <see cref="Sort(IEnumerable{int}?, int)" />, this does not load the children or fire per-item
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
/// </remarks>
// TODO (V19): Remove the default implementation.
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
=> throw new NotImplementedException();
#endregion
#region Publish Document
@@ -60,20 +60,4 @@ public interface IDocumentUrlAliasService
/// </summary>
/// <returns><c>true</c> if there are any aliases in the cache; otherwise, <c>false</c>.</returns>
bool HasAny();
/// <summary>
/// Updates the in-memory alias cache for a single document without writing to the database.
/// </summary>
/// <param name="documentKey">The document key.</param>
// TODO (V19): Remove default implementation when external implementations have had time to adopt.
Task UpdateAliasCacheAsync(Guid documentKey)
=> CreateOrUpdateAliasesAsync(documentKey);
/// <summary>
/// Updates the in-memory alias cache for a document and its descendants without writing to the database.
/// </summary>
/// <param name="documentKey">The document key.</param>
// TODO (V19): Remove default implementation when external implementations have had time to adopt.
Task UpdateAliasCacheWithDescendantsAsync(Guid documentKey)
=> CreateOrUpdateAliasesWithDescendantsAsync(documentKey);
}
@@ -100,20 +100,4 @@ public interface IDocumentUrlService
/// Gets a value indicating whether any URLs have been cached.
/// </summary>
bool HasAny();
/// <summary>
/// Updates the in-memory URL segment cache for a single document without writing to the database.
/// </summary>
/// <param name="key">The document key.</param>
// TODO (V19): Remove default implementation when external implementations have had time to adopt.
Task UpdateUrlSegmentCacheAsync(Guid key)
=> CreateOrUpdateUrlSegmentsAsync(key);
/// <summary>
/// Updates the in-memory URL segment cache for a document and its descendants without writing to the database.
/// </summary>
/// <param name="key">The document key.</param>
// TODO (V19): Remove default implementation when external implementations have had time to adopt.
Task UpdateUrlSegmentCacheWithDescendantsAsync(Guid key)
=> CreateOrUpdateUrlSegmentsWithDescendantsAsync(key);
}
@@ -118,18 +118,6 @@ public interface IMediaEditingService
/// </returns>
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
/// <summary>
/// Sorts the children of a parent by a system field.
/// </summary>
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="userKey">The unique identifier of the user performing the operation.</param>
/// <returns>The operation status indicating the operation outcome.</returns>
/// <remarks>Media items never vary by culture, so children are always ordered by the invariant name.</remarks>
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
/// <summary>
/// Permanently deletes a media item from the recycle bin.
/// </summary>
@@ -359,22 +359,6 @@ public interface IMediaService : IContentServiceBase<IMedia>
/// <returns>True if sorting succeeded, otherwise False</returns>
bool Sort(IEnumerable<IMedia> items, int userId = Constants.Security.SuperUserId);
/// <summary>
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
/// as the new sort order, in a single set-based update.
/// </summary>
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
/// <param name="orderedChildIds">The child media identifiers, in the desired order.</param>
/// <param name="userId">The identifier of the user performing the action.</param>
/// <returns>The operation result.</returns>
/// <remarks>
/// Unlike <see cref="Sort(IEnumerable{IMedia}, int)" />, this does not load the children or fire per-item
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
/// </remarks>
// TODO (V19): Remove the default implementation.
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
=> throw new NotImplementedException();
/// <summary>
/// Creates an <see cref="IMedia" /> object using the alias of the <see cref="IMediaType" />
/// that this Media should based on.
@@ -83,15 +83,7 @@ internal sealed class MediaEditingService
/// <inheritdoc />
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(MediaCreateModel createModel)
{
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
{
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
}
return await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
}
=> await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
/// <inheritdoc />
public async Task<Attempt<MediaCreateResult, ContentEditingOperationStatus>> CreateAsync(MediaCreateModel createModel, Guid userKey)
@@ -173,12 +165,6 @@ internal sealed class MediaEditingService
public async Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey)
=> await HandleSortAsync(parentKey, sortingModels, userKey);
/// <inheritdoc />
public async Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
// Media never varies by culture, so children are always ordered by the invariant name.
=> await HandleSortByFieldAsync(parentKey, field, direction, culture: null, userKey);
/// <inheritdoc />
protected override IMedia New(string? name, int parentId, IMediaType mediaType)
=> new Models.Media(name, parentId, mediaType);
@@ -201,8 +187,8 @@ internal sealed class MediaEditingService
=> ContentService.Delete(media, userId).Result;
/// <inheritdoc />
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, filter: null, ordering: ordering);
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total);
/// <inheritdoc />
protected override ContentEditingOperationStatus Sort(IEnumerable<IMedia> items, int userId)
@@ -213,13 +199,6 @@ internal sealed class MediaEditingService
: ContentEditingOperationStatus.CancelledByNotification;
}
/// <inheritdoc />
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
{
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
return OperationResultToOperationStatus(result);
}
/// <summary>
/// Saves a media item to the repository.
/// </summary>
-46
View File
@@ -1414,15 +1414,6 @@ namespace Umbraco.Cms.Core.Services
{
scope.WriteLock(Constants.Locks.MediaTree);
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
// partially-loaded media (e.g. without property data), and saving those directly would
// wipe the property data (#23120). Preserve the caller's ordering, which drives the sort.
var reloadedById = GetByIds(itemsA.Select(x => x.Id)).ToDictionary(x => x.Id);
itemsA = itemsA
.Select(x => reloadedById.TryGetValue(x.Id, out IMedia? media) ? media : null)
.WhereNotNull()
.ToArray();
var savingNotification = new MediaSavingNotification(itemsA, messages);
if (scope.Notifications.PublishCancelable(savingNotification))
{
@@ -1461,43 +1452,6 @@ namespace Umbraco.Cms.Core.Services
}
/// <inheritdoc />
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
if (orderedChildIds.Count == 0)
{
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
}
using ICoreScope scope = ScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.MediaTree);
_mediaRepository.UpdateSortOrder(orderedChildIds);
// Sort order lives in umbracoNode; neither the published cache nor the media repository cache keeps
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
if (parentId == Constants.System.Root)
{
IMedia[] roots = GetByIds(orderedChildIds).ToArray();
scope.Notifications.Publish(new MediaTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
}
else
{
IMedia? parent = GetById(parentId);
if (parent is not null)
{
scope.Notifications.Publish(new MediaTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
}
}
Audit(AuditType.Sort, userId, parentId);
scope.Complete();
return OperationResult.Succeed(evtMsgs);
}
/// <summary>
/// Checks the data integrity of the media tree and optionally fixes detected issues.
/// </summary>
@@ -30,48 +30,11 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
/// <summary>
/// Bundles a navigation structure dictionary and its root keys into a single reference so that
/// <see cref="HandleRebuildAsync"/> can swap both atomically with one <see cref="Interlocked.Exchange{T}"/>
/// call and readers always observe a consistent pair. Also carries the per-snapshot
/// descendants cache populated by <see cref="TryGetDescendantsKeysFromStructure"/>.
/// call and readers always observe a consistent pair.
/// </summary>
private sealed record NavigationSnapshot(
ConcurrentDictionary<Guid, NavigationNode> Structure,
HashSet<Guid> Roots)
{
private long _generation;
/// <summary>
/// Cache of descendants <c>Guid[]</c> keyed by parent and an optional content-type
/// filter. Populated lazily by <see cref="TryGetDescendantsKeysFromStructure"/> and
/// cleared by <see cref="Invalidate"/> on any structural mutation.
/// </summary>
/// <remarks>
/// The composite key allows both <c>TryGetDescendantsKeys</c> (content-type =
/// <c>null</c>) and <c>TryGetDescendantsKeysOfType</c> (content-type = the resolved
/// <c>Guid</c>) to share one cache without their results contaminating each other.
/// Realistic per-parent fan-out is bounded by the "allowed types" content model
/// (typically 1-5 types per parent), and the cache is populated only for queries
/// that actually run, so memory grows with the templates exercised rather than the
/// theoretical product of (parents × content types).
/// </remarks>
public ConcurrentDictionary<(Guid Parent, Guid? ContentType), Guid[]> DescendantsCache { get; } = new();
/// <summary>
/// A monotonic counter incremented on every mutation. Used by readers to detect a
/// concurrent mutation that occurred during their compute, so they can avoid writing
/// a now-stale result back to <see cref="DescendantsCache"/>.
/// </summary>
public long Generation => Interlocked.Read(ref _generation);
/// <summary>
/// Clears the descendants cache and bumps the generation. Call after any mutation to
/// this snapshot's <see cref="Structure"/> or <see cref="Roots"/>.
/// </summary>
public void Invalidate()
{
Interlocked.Increment(ref _generation);
DescendantsCache.Clear();
}
}
HashSet<Guid> Roots);
private NavigationSnapshot _navigation = new(new(), []);
private NavigationSnapshot _recycleBinNavigation = new(new(), []);
@@ -201,12 +164,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
/// </param>
/// <returns><c>true</c> if the parent node exists in the structure; otherwise, <c>false</c>.</returns>
public bool TryGetDescendantsKeys(Guid parentKey, out IEnumerable<Guid> descendantsKeys)
{
// Snapshot to a local so cache lookups, the structure walk, and the generation check
// all see the same NavigationSnapshot instance even if a rebuild swaps it in mid-call.
NavigationSnapshot snapshot = _navigation;
return TryGetDescendantsKeysFromStructure(snapshot.Structure, parentKey, out descendantsKeys, contentTypeKey: null, cachingSnapshot: snapshot);
}
=> TryGetDescendantsKeysFromStructure(_navigation.Structure, parentKey, out descendantsKeys);
/// <summary>
/// Attempts to get all descendant node keys of a specific content type under a parent node.
@@ -224,11 +182,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
{
if (TryGetContentTypeKey(contentTypeAlias, out Guid? contentTypeKey))
{
// Snapshot to a local so cache lookups, the structure walk, and the generation
// check all see the same NavigationSnapshot instance even if a rebuild swaps it
// in mid-call.
NavigationSnapshot snapshot = _navigation;
return TryGetDescendantsKeysFromStructure(snapshot.Structure, parentKey, out descendantsKeys, contentTypeKey, cachingSnapshot: snapshot);
return TryGetDescendantsKeysFromStructure(_navigation.Structure, parentKey, out descendantsKeys, contentTypeKey);
}
// Content type alias doesn't exist
@@ -343,10 +297,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
/// </param>
/// <returns><c>true</c> if the parent node exists in the recycle bin; otherwise, <c>false</c>.</returns>
public bool TryGetDescendantsKeysInBin(Guid parentKey, out IEnumerable<Guid> descendantsKeys)
{
NavigationSnapshot snapshot = _recycleBinNavigation;
return TryGetDescendantsKeysFromStructure(snapshot.Structure, parentKey, out descendantsKeys, contentTypeKey: null, cachingSnapshot: snapshot);
}
=> TryGetDescendantsKeysFromStructure(_recycleBinNavigation.Structure, parentKey, out descendantsKeys);
/// <summary>
/// Attempts to get all ancestor node keys of a child node in the recycle bin navigation structure.
@@ -424,14 +375,8 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
// Reset the SortOrder based on its new position in the bin
nodeToRemove.UpdateSortOrder(_recycleBinNavigation.Structure.Count);
var moved = _recycleBinNavigation.Structure.TryAdd(nodeToRemove.Key, nodeToRemove) &&
_navigation.Structure.TryRemove(key, out _);
// Both snapshots' descendant lists are now potentially stale.
_navigation.Invalidate();
_recycleBinNavigation.Invalidate();
return moved;
return _recycleBinNavigation.Structure.TryAdd(nodeToRemove.Key, nodeToRemove) &&
_navigation.Structure.TryRemove(key, out _);
}
/// <summary>
@@ -473,7 +418,6 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
parentNode?.AddChild(_navigation.Structure, key);
_navigation.Invalidate();
return true;
}
@@ -524,7 +468,6 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
// Set the new parent for the node (if parent node is null - the node is moved to root)
targetParentNode?.AddChild(_navigation.Structure, key);
_navigation.Invalidate();
return true;
}
@@ -545,18 +488,6 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
node.UpdateSortOrder(newSortOrder);
// The parent's cached ordered-children snapshot sorts by child SortOrder and is now
// stale — invalidate so the next read rebuilds against the new value.
if (node.Parent is not null
&& _navigation.Structure.TryGetValue(node.Parent.Value, out NavigationNode? parentNode))
{
parentNode.InvalidateOrderedChildren();
}
// Descendants lists are sort-order-presorted (depth-first using each parent's
// ordered children), so re-ordering a child re-orders any cached ancestor descendants.
_navigation.Invalidate();
return true;
}
@@ -579,9 +510,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
RemoveDescendantsRecursively(nodeToRemove);
var removed = _recycleBinNavigation.Structure.TryRemove(key, out _);
_recycleBinNavigation.Invalidate();
return removed;
return _recycleBinNavigation.Structure.TryRemove(key, out _);
}
/// <summary>
@@ -616,14 +545,8 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
// Restore the node and its descendants from the recycle bin to the main structure
RestoreNodeAndDescendantsRecursively(nodeToRestore);
var restored = _navigation.Structure.TryAdd(nodeToRestore.Key, nodeToRestore) &&
_recycleBinNavigation.Structure.TryRemove(key, out _);
// Both snapshots' descendant lists are now potentially stale.
_navigation.Invalidate();
_recycleBinNavigation.Invalidate();
return restored;
return _navigation.Structure.TryAdd(nodeToRestore.Key, nodeToRestore) &&
_recycleBinNavigation.Structure.TryRemove(key, out _);
}
/// <summary>
@@ -732,9 +655,10 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
ConcurrentDictionary<Guid, NavigationNode> structure,
Guid parentKey,
out IEnumerable<Guid> descendantsKeys,
Guid? contentTypeKey = null,
NavigationSnapshot? cachingSnapshot = null)
Guid? contentTypeKey = null)
{
var descendants = new List<Guid>();
if (structure.TryGetValue(parentKey, out NavigationNode? parentNode) is false)
{
// Parent doesn't exist
@@ -742,50 +666,9 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
return false;
}
// Both unfiltered and content-type-filtered queries are cached, distinguished by the
// optional contentTypeKey in the composite key. Realistic per-parent fan-out is bounded
// by the "allowed types" model (a few types per parent), and entries are populated
// lazily for queries that actually run — so memory tracks the templates exercised, not
// the theoretical product of (parents × types).
var useCache = cachingSnapshot is not null;
#pragma warning disable IDE0008 // Use explicit type (in this case using var improves the readability of the tuple key).
var cacheKey = (parentKey, contentTypeKey);
#pragma warning restore IDE0008 // Use explicit type
if (useCache && cachingSnapshot!.DescendantsCache.TryGetValue(cacheKey, out Guid[]? cached))
{
descendantsKeys = cached;
return true;
}
// Capture the snapshot's mutation generation BEFORE walking. If a mutation invalidates
// between here and the cache write, the result we computed may be stale relative to
// the now-current Structure; we still hand it to the caller (it was correct at the
// moment we read), but skip the cache write so future readers don't see stale data.
var startGeneration = useCache ? cachingSnapshot!.Generation : 0;
var descendants = new List<Guid>();
GetDescendantsRecursively(structure, parentNode, descendants, contentTypeKey);
if (useCache)
{
Guid[] result = [.. descendants];
// Only install if no mutation happened during compute, and skip caching empty
// results — they're cheap to recompute and caching them bloats the dictionary with
// one entry per (parent, type) pair queried with no measurable benefit.
if (result.Length > 0 && cachingSnapshot!.Generation == startGeneration)
{
cachingSnapshot.DescendantsCache[cacheKey] = result;
}
descendantsKeys = result;
}
else
{
descendantsKeys = descendants;
}
descendantsKeys = descendants;
return true;
}
@@ -976,15 +859,6 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
return [];
}
// Unfiltered case uses the cached snapshot maintained on the node — returns the same
// sorted Guid[] across calls until the children set or a child's SortOrder changes.
if (contentTypeKey.HasValue is false)
{
return node.GetOrderedChildren(structure);
}
// Filtered-by-content-type case stays uncached: it would need a composite (node, type)
// key to memoise, and the call site is rare enough not to be worth it.
var childrenWithSortOrder = new List<(Guid ChildNodeKey, int SortOrder)>(node.Children.Count);
foreach (Guid childNodeKey in node.Children)
{
@@ -993,7 +867,8 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
continue;
}
if (childNode.ContentTypeKey != contentTypeKey.Value)
// Apply contentTypeKey filter
if (contentTypeKey.HasValue && childNode.ContentTypeKey != contentTypeKey.Value)
{
continue;
}
@@ -144,14 +144,14 @@ public PublishStatusService(
{
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
ISet<string> publishedCultures = await _publishStatusRepository.GetPublishStatusAsync(documentKey, cancellationToken);
UpdatePublishedCultures(documentKey, publishedCultures);
_publishedCultures[documentKey] = publishedCultures;
scope.Complete();
}
/// <inheritdoc/>
public Task RemoveAsync(Guid documentKey, CancellationToken cancellationToken)
{
RemovePublishedCultures(documentKey);
_publishedCultures.TryRemove(documentKey, out _);
return Task.CompletedTask;
}
@@ -166,23 +166,8 @@ public PublishStatusService(
}
foreach ((Guid documentKey, ISet<string> publishedCultures) in publishStatus)
{
UpdatePublishedCultures(documentKey, publishedCultures);
}
}
private void UpdatePublishedCultures(Guid documentKey, ISet<string> publishedCultures)
{
if (publishedCultures.Count > 0)
{
_publishedCultures[documentKey] = publishedCultures;
}
else
{
RemovePublishedCultures(documentKey);
}
}
private void RemovePublishedCultures(Guid documentKey)
=> _publishedCultures.TryRemove(documentKey, out _);
}
@@ -56,17 +56,14 @@ internal sealed class PublishedContentStatusFilteringService : IPublishedContent
_publishStatusQueryService.IsDocumentPublished(key, culture)
&& _publishStatusQueryService.HasPublishedAncestorPath(key, culture));
// Returned lazily so consumers like .FirstOrDefault() / .Take(n) can short-circuit
// without materialising the full result. Callers that need to enumerate the result
// more than once should buffer it themselves (.ToList() / .ToArray()).
return WhereIsInvariantOrHasCultureOrRequestedAllCultures(candidateKeys, culture, preview);
return WhereIsInvariantOrHasCultureOrRequestedAllCultures(candidateKeys, culture, preview).ToArray();
}
/// <inheritdoc />
public IEnumerable<IPublishedContent> Unfiltered(IEnumerable<Guid> candidateKeys)
{
var preview = _previewService.IsInPreview();
return candidateKeys.Select(key => _publishedContentCache.GetById(preview, key)).WhereNotNull();
return candidateKeys.Select(key => _publishedContentCache.GetById(preview, key)).WhereNotNull().ToArray();
}
/// <summary>
@@ -24,15 +24,10 @@ internal sealed class PublishedMediaStatusFilteringService : IPublishedMediaStat
=> _publishedMediaCache = publishedMediaCache;
/// <inheritdoc />
/// <remarks>
/// Returned lazily so consumers like .FirstOrDefault() / .Take(n) can short-circuit without
/// materialising the full result. Callers that need to enumerate the result more than once
/// should buffer it themselves (.ToList() / .ToArray()).
/// </remarks>
public IEnumerable<IPublishedContent> FilterAvailable(IEnumerable<Guid> candidateKeys, string? culture)
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull();
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull().ToArray();
/// <inheritdoc />
public IEnumerable<IPublishedContent> Unfiltered(IEnumerable<Guid> candidateKeys)
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull();
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull().ToArray();
}
-6
View File
@@ -43,8 +43,6 @@ public static class UdiEntityTypeHelper
return Constants.UdiEntityType.DataTypeContainer;
case UmbracoObjectTypes.MemberType:
return Constants.UdiEntityType.MemberType;
case UmbracoObjectTypes.MemberTypeContainer:
return Constants.UdiEntityType.MemberTypeContainer;
case UmbracoObjectTypes.MemberGroup:
return Constants.UdiEntityType.MemberGroup;
case UmbracoObjectTypes.RelationType:
@@ -77,8 +75,6 @@ public static class UdiEntityTypeHelper
return UmbracoObjectTypes.Document;
case Constants.UdiEntityType.DocumentBlueprint:
return UmbracoObjectTypes.DocumentBlueprint;
case Constants.UdiEntityType.DocumentBlueprintContainer:
return UmbracoObjectTypes.DocumentBlueprintContainer;
case Constants.UdiEntityType.Media:
return UmbracoObjectTypes.Media;
case Constants.UdiEntityType.Member:
@@ -99,8 +95,6 @@ public static class UdiEntityTypeHelper
return UmbracoObjectTypes.DataTypeContainer;
case Constants.UdiEntityType.MemberType:
return UmbracoObjectTypes.MemberType;
case Constants.UdiEntityType.MemberTypeContainer:
return UmbracoObjectTypes.MemberTypeContainer;
case Constants.UdiEntityType.MemberGroup:
return UmbracoObjectTypes.MemberGroup;
case Constants.UdiEntityType.RelationType:
@@ -1,3 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Configuration;
@@ -9,48 +14,37 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs
public class DelayCalculator
{
/// <summary>
/// Determines the delay before the first run of a recurring task, using a <see cref="TimeProvider" /> for the current time.
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
/// configuration for the first run time is available.
/// </summary>
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
/// <param name="logger">The logger.</param>
/// <param name="timeProvider">The time provider used to determine the current time.</param>
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
/// <returns>
/// The delay before first running the recurring task.
/// </returns>
public static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, TimeProvider timeProvider, TimeSpan defaultDelay)
=> GetDelay(firstRunTime, cronTabParser, logger, timeProvider.GetLocalNow().DateTime, defaultDelay);
/// <summary>
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optional configuration for the first run time is available.
/// </summary>
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
/// <param name="logger">The logger.</param>
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
/// <returns>
/// The delay before first running the recurring task.
/// </returns>
[Obsolete("Use the overload accepting TimeProvider. Scheduled for removal in Umbraco 19.")]
public static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, TimeSpan defaultDelay)
=> GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay);
/// <returns>The delay before first running the recurring task.</returns>
public static TimeSpan GetDelay(
string firstRunTime,
ICronTabParser cronTabParser,
ILogger logger,
TimeSpan defaultDelay) => GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay);
/// <summary>
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optional configuration for the first run time is available.
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
/// configuration for the first run time is available.
/// </summary>
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
/// <param name="logger">The logger.</param>
/// <param name="now">The current datetime.</param>
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
/// <returns>
/// The delay before first running the recurring task.
/// </returns>
/// <remarks>
/// Internal to expose for unit tests.
/// </remarks>
internal static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, DateTime now, TimeSpan defaultDelay)
/// <returns>The delay before first running the recurring task.</returns>
/// <remarks>Internal to expose for unit tests.</remarks>
internal static TimeSpan GetDelay(
string firstRunTime,
ICronTabParser cronTabParser,
ILogger logger,
DateTime now,
TimeSpan defaultDelay)
{
// If first run time not set, start with just small delay after application start.
if (string.IsNullOrEmpty(firstRunTime))
@@ -62,14 +56,12 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs
if (!cronTabParser.IsValidCronTab(firstRunTime))
{
logger.LogWarning("Could not parse {FirstRunTime} as a crontab expression. Defaulting to default delay for hosted service start.", firstRunTime);
return defaultDelay;
}
// Otherwise start at scheduled time according to cron expression, unless within the default delay period.
DateTime firstRunOccurrence = cronTabParser.GetNextOccurrence(firstRunTime, now);
TimeSpan delay = firstRunOccurrence - now;
DateTime firstRunOccurance = cronTabParser.GetNextOccurrence(firstRunTime, now);
TimeSpan delay = firstRunOccurance - now;
return delay < defaultDelay
? defaultDelay
: delay;
@@ -97,7 +97,7 @@ public class DistributedBackgroundJobHostedService : BackgroundService
{
try
{
await RunRunnableJob(stoppingToken);
await RunRunnableJob();
}
catch (Exception exception)
{
@@ -117,7 +117,7 @@ public class DistributedBackgroundJobHostedService : BackgroundService
}
}
private async Task RunRunnableJob(CancellationToken stoppingToken)
private async Task RunRunnableJob()
{
IDistributedBackgroundJob? job = await _distributedJobService.TryTakeRunnableAsync();
@@ -129,7 +129,7 @@ public class DistributedBackgroundJobHostedService : BackgroundService
try
{
await job.ExecuteAsync(stoppingToken);
await job.ExecuteAsync();
}
catch (Exception ex)
{
@@ -1,4 +1,4 @@
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// A background job that will be executed by an available server. With a single server setup this will always be the same.
@@ -16,30 +16,8 @@ public interface IDistributedBackgroundJob
/// </summary>
TimeSpan Period { get; }
/// <summary>
/// Gets a value indicating whether the job's runs should be aligned to clock boundaries derived from <see cref="Period" />.
/// </summary>
/// <remarks>
/// When <c>true</c>, the job becomes runnable on the next clock boundary that is a multiple of <see cref="Period" />
/// (measured from a fixed <strong>UTC</strong> origin, so boundaries fall on round clock times such as on the minute
/// or every N seconds) rather than at <c>LastRun + Period</c>.
/// For predictable boundaries <see cref="Period" /> should divide evenly into one hour.
/// The scheduler may cache this value when it first evaluates registered jobs; changing it at runtime may require an application restart.
/// Defaults to <c>false</c>, preserving the original drift-from-completion behaviour.
/// </remarks>
bool AlignToClock => false;
/// <summary>
/// Run the job.
/// </summary>
Task ExecuteAsync();
/// <summary>
/// Run the job with a cancellation token that signals when the host is shutting down.
/// </summary>
/// <remarks>
/// The default implementation delegates to <see cref="ExecuteAsync()"/>.
/// Override this method to respond to graceful shutdown.
/// </remarks>
Task ExecuteAsync(CancellationToken cancellationToken) => ExecuteAsync();
}
@@ -1,98 +1,38 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// A recurring background job.
/// A recurring background job
/// </summary>
public interface IRecurringBackgroundJob
{
/// <summary>
/// The default delay to use for recurring tasks for the first run after application start-up if no alternative is configured.
/// </summary>
[Obsolete("Use RecurringBackgroundJobBase.DefaultDelay instead. Scheduled for removal in Umbraco 19.")]
static readonly TimeSpan DefaultDelay = RecurringBackgroundJobBase.DefaultDelay;
/// <summary>
/// The default server roles that recurring background jobs run on.
/// </summary>
[Obsolete("Use RecurringBackgroundJobBase.DefaultServerRoles instead. Scheduled for removal in Umbraco 19.")]
static readonly ServerRole[] DefaultServerRoles = RecurringBackgroundJobBase.DefaultServerRoles;
static readonly TimeSpan DefaultDelay = System.TimeSpan.FromMinutes(3);
static readonly ServerRole[] DefaultServerRoles = new[] { ServerRole.Single, ServerRole.SchedulingPublisher };
/// <summary>
/// Timespan representing how often the task should recur.
/// </summary>
/// <value>
/// The period.
/// </value>
/// <remarks>
/// Set to <see cref="Timeout.InfiniteTimeSpan" /> to (temporarily) disable automatic scheduling and turn the job into a manually triggered one (via <see cref="IRecurringBackgroundJobTrigger{TJob}" />). To change the period at runtime, subclasses of <see cref="RecurringBackgroundJobBase" /> assign the protected setter on <see cref="RecurringBackgroundJobBase.Period" /> (which auto-raises <see cref="PeriodChanged" />); direct implementors of this interface must raise <see cref="PeriodChanged" /> themselves after updating the backing value.
/// </remarks>
TimeSpan Period { get; }
/// <summary>
/// Timespan representing the initial delay after application start-up before the first run of the task occurs.
/// Timespan representing the initial delay after application start-up before the first run of the task
/// occurs.
/// </summary>
/// <value>
/// The delay.
/// </value>
/// <remarks>
/// Set to <see cref="Timeout.InfiniteTimeSpan" /> to skip the automatic first run entirely; the first execution then only occurs when manually triggered via <see cref="IRecurringBackgroundJobTrigger{TJob}" />.
/// </remarks>
TimeSpan Delay => RecurringBackgroundJobBase.DefaultDelay; // TODO (V19): Remove the default implementation
TimeSpan Delay { get => DefaultDelay; }
/// <summary>
/// Timespan to wait before re-evaluating execution conditions when an execution is ignored (e.g. runtime not ready, wrong server role or not main domain).
/// Gets the server roles for which this recurring background job is intended.
/// </summary>
/// <value>
/// The ignored delay.
/// </value>
/// <remarks>
/// This back-off prevents tight looping when <see cref="Period" /> is short (or <see cref="TimeSpan.Zero" />) and an execution is skipped without invoking <see cref="RunJobAsync(CancellationToken)" />.
/// Set to <see cref="Timeout.InfiniteTimeSpan" /> to disable the job for the remaining application lifecycle once an ignored condition is encountered — useful when the condition is known not to change (e.g. a server role that will not be promoted on this instance). To change the ignored delay at runtime, subclasses of <see cref="RecurringBackgroundJobBase" /> assign the protected setter on <see cref="RecurringBackgroundJobBase.IgnoredDelay" /> (which auto-raises <see cref="IgnoredDelayChanged" />); direct implementors of this interface must raise <see cref="IgnoredDelayChanged" /> themselves after updating the backing value.
/// </remarks>
TimeSpan IgnoredDelay => RecurringBackgroundJobBase.DefaultIgnoredDelay; // TODO (V19): Remove the default implementation
ServerRole[] ServerRoles { get => DefaultServerRoles; }
event EventHandler PeriodChanged;
/// <summary>
/// Gets the server roles the task executes on.
/// Executes the logic associated with the recurring background job asynchronously.
/// </summary>
/// <value>
/// The server roles.
/// </value>
ServerRole[] ServerRoles => RecurringBackgroundJobBase.DefaultServerRoles; // TODO (V19): Remove the default implementation
/// <summary>
/// This event should be raised when the <see cref="Period" /> property changes to notify the background job manager to update the schedule for this job.
/// </summary>
event EventHandler PeriodChanged; // TODO (V19): Change to `event EventHandler? PeriodChanged;` so implementations can use field-like event syntax without manual backing-delegate accessors.
/// <summary>
/// This event should be raised when the <see cref="IgnoredDelay" /> property changes (e.g. from <see cref="Timeout.InfiniteTimeSpan" /> back to a finite value) to interrupt any in-progress ignored back-off and re-read the new value.
/// </summary>
event EventHandler IgnoredDelayChanged
{
add { }
remove { }
} // TODO (V19): Remove the default implementation and change to `event EventHandler? IgnoredDelayChanged;` so implementations can use field-like event syntax without manual backing-delegate accessors.
/// <summary>
/// Runs the background job.
/// </summary>
/// <returns>
/// A task representing the asynchronous operation.
/// </returns>
[Obsolete("Use RunJobAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
/// <returns>A <see cref="System.Threading.Tasks.Task"/> that represents the asynchronous execution of the background job.</returns>
Task RunJobAsync();
/// <summary>
/// Runs the background job with cancellation support.
/// </summary>
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A task representing the asynchronous operation.
/// </returns>
Task RunJobAsync(CancellationToken cancellationToken)
#pragma warning disable CS0618 // Type or member is obsolete
=> RunJobAsync(); // TODO (V19): Remove the default implementation when RunJobAsync() is removed
#pragma warning restore CS0618 // Type or member is obsolete
}
@@ -1,45 +0,0 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Infrastructure.HostedServices;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// Provides methods to signal a specific recurring background job to execute immediately.
/// </summary>
/// <typeparam name="TJob">The type of the recurring background job to trigger, as registered via <see cref="ServiceCollectionExtensions.AddRecurringBackgroundJob{TJob}(IServiceCollection)" />.</typeparam>
public interface IRecurringBackgroundJobTrigger<TJob>
where TJob : class, ITriggerableRecurringBackgroundJob
{
/// <summary>
/// Signals the background loop to execute immediately.
/// After the triggered execution, the original schedule is kept.
/// </summary>
/// <returns>
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
/// </returns>
/// <seealso cref="NextExecutionStrategy.None" />
bool TriggerExecution();
/// <summary>
/// Signals the background loop to execute immediately, with the specified strategy for determining the next execution after the triggered one completes.
/// </summary>
/// <param name="strategy">Controls the delay after the triggered execution.</param>
/// <returns>
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
/// </returns>
bool TriggerExecution(NextExecutionStrategy strategy);
/// <summary>
/// Signals the background loop to execute immediately.
/// After the triggered execution, the next execution is scheduled after the specified delay (measured from execution start; execution time is subtracted to prevent drift).
/// </summary>
/// <param name="nextDelay">The target interval from execution start to the next execution. Execution time is subtracted to prevent drift.</param>
/// <returns>
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
/// </returns>
bool TriggerExecution(TimeSpan nextDelay);
}
@@ -1,11 +0,0 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// Marker interface for recurring background jobs that support being triggered manually.
/// Only jobs implementing this interface can be triggered via <see cref="IRecurringBackgroundJobTrigger{TJob}" />.
/// </summary>
public interface ITriggerableRecurringBackgroundJob : IRecurringBackgroundJob
{ }
@@ -2,9 +2,7 @@
// See LICENSE for more details.
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
@@ -24,10 +22,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
public string Name => "ScheduledPublishingJob";
/// <inheritdoc />
public TimeSpan Period => _scheduledPublishingSettings.CurrentValue.Period;
/// <inheritdoc />
public bool AlignToClock => _scheduledPublishingSettings.CurrentValue.AlignToClock;
public TimeSpan Period => TimeSpan.FromMinutes(1);
private readonly IContentService _contentService;
@@ -36,7 +31,6 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
private readonly TimeProvider _timeProvider;
private readonly IServerMessenger _serverMessenger;
private readonly IUmbracoContextFactory _umbracoContextFactory;
private readonly IOptionsMonitor<ScheduledPublishingSettings> _scheduledPublishingSettings;
/// <summary>
/// Initializes a new instance of the <see cref="ScheduledPublishingJob" /> class.
@@ -47,8 +41,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
ILogger<ScheduledPublishingJob> logger,
IServerMessenger serverMessenger,
ICoreScopeProvider scopeProvider,
TimeProvider timeProvider,
IOptionsMonitor<ScheduledPublishingSettings> scheduledPublishingSettings)
TimeProvider timeProvider)
{
_contentService = contentService;
_umbracoContextFactory = umbracoContextFactory;
@@ -56,7 +49,6 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
_serverMessenger = serverMessenger;
_scopeProvider = scopeProvider;
_timeProvider = timeProvider;
_scheduledPublishingSettings = scheduledPublishingSettings;
}
/// <inheritdoc />
@@ -1,5 +1,7 @@
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Core.Telemetry;
@@ -10,18 +12,33 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
/// <summary>
/// Represents a background job that collects and reports information about the current Umbraco site, typically for analytics, diagnostics, or telemetry purposes.
/// </summary>
public class ReportSiteJob : RecurringBackgroundJobBase
public class ReportSiteJob : IRecurringBackgroundJob
{
/// <summary>
/// Gets the period at which the report site job runs.
/// </summary>
public TimeSpan Period => TimeSpan.FromDays(1);
/// <summary>
/// Gets the time interval to wait between executions of the <see cref="ReportSiteJob"/>.
/// The delay is set to 5 minutes.
/// </summary>
public override TimeSpan Delay => TimeSpan.FromMinutes(5);
public TimeSpan Delay => TimeSpan.FromMinutes(5);
/// <summary>
/// Gets an array containing all possible values of the <see cref="ServerRole"/> enumeration.
/// </summary>
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
public ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
/// <summary>
/// Event that is triggered when the reporting period for the site job is changed.
/// </summary>
/// <remarks>No-op event as the period never changes on this job</remarks>
public event EventHandler PeriodChanged
{
add { }
remove { }
}
private readonly ILogger<ReportSiteJob> _logger;
private readonly ITelemetryService _telemetryService;
@@ -40,7 +57,6 @@ public class ReportSiteJob : RecurringBackgroundJobBase
ITelemetryService telemetryService,
IJsonSerializer jsonSerializer,
IHttpClientFactory httpClientFactory)
: base(TimeSpan.FromDays(1))
{
_logger = logger;
_telemetryService = telemetryService;
@@ -51,11 +67,8 @@ public class ReportSiteJob : RecurringBackgroundJobBase
/// <summary>
/// Executes the background job that sends the anonymous site ID to the telemetry service.
/// </summary>
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A task that represents the asynchronous operation.
/// </returns>
public override async Task RunJobAsync(CancellationToken cancellationToken)
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task RunJobAsync()
{
TelemetryReportData? telemetryReportData = await _telemetryService.GetTelemetryReportDataAsync().ConfigureAwait(false);
if (telemetryReportData is null)
@@ -87,7 +100,7 @@ public class ReportSiteJob : RecurringBackgroundJobBase
// Make a HTTP Post to telemetry service
// https://telemetry.umbraco.com/installs/
// Fire & Forget, do not need to know if its a 200, 500 etc
using (await httpClient.SendAsync(request, cancellationToken))
using (await httpClient.SendAsync(request))
{ }
}
catch
@@ -3,7 +3,9 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
@@ -11,22 +13,31 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
/// <summary>
/// Implements periodic database instruction processing as a hosted service.
/// </summary>
public class InstructionProcessJob : RecurringBackgroundJobBase
public class InstructionProcessJob : IRecurringBackgroundJob
{
/// <summary>
/// Gets the interval between executions of the instruction process job.
/// </summary>
public TimeSpan Period { get; }
/// <summary>
/// Gets the delay time before the job is executed. The delay is fixed at one minute.
/// </summary>
public override TimeSpan Delay => TimeSpan.FromMinutes(1);
public TimeSpan Delay { get => TimeSpan.FromMinutes(1); }
/// <summary>
/// Gets an array containing all possible values of the <see cref="ServerRole"/> enumeration.
/// </summary>
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
public ServerRole[] ServerRoles { get => Enum.GetValues<ServerRole>(); }
/// <summary>
/// Event that is raised when the execution period of the <see cref="InstructionProcessJob"/> is changed.
/// </summary>
/// <remarks>No-op event as the period never changes on this job</remarks>
public event EventHandler PeriodChanged { add { } remove { } }
private readonly ILogger<InstructionProcessJob> _logger;
private readonly IServerMessenger _messenger;
private readonly TimeSpan _syncTimeout;
private Task? _inFlightSync;
/// <summary>
/// Initializes a new instance of the <see cref="InstructionProcessJob" /> class.
@@ -38,82 +49,29 @@ public class InstructionProcessJob : RecurringBackgroundJobBase
IServerMessenger messenger,
ILogger<InstructionProcessJob> logger,
IOptions<GlobalSettings> globalSettings)
: base(globalSettings.Value.DatabaseServerMessenger.TimeBetweenSyncOperations)
{
_messenger = messenger;
_logger = logger;
_syncTimeout = ValidateSyncTimeout(globalSettings.Value.DatabaseServerMessenger.SyncTimeout);
}
// A non-positive timeout would make every sync "time out" immediately (or throw from WaitAsync for a
// negative value), so guard against misconfiguration and fall back to the default. Timeout.InfiniteTimeSpan
// is allowed as an explicit opt-out that restores the unbounded wait.
private TimeSpan ValidateSyncTimeout(TimeSpan configuredSyncTimeout)
{
if (configuredSyncTimeout > TimeSpan.Zero || configuredSyncTimeout == Timeout.InfiniteTimeSpan)
{
return configuredSyncTimeout;
}
_logger.LogWarning(
"Configured DatabaseServerMessenger.SyncTimeout of {ConfiguredSyncTimeout} is not valid; it must be positive (or Timeout.InfiniteTimeSpan to disable the timeout). Falling back to {DefaultSyncTimeout}.",
configuredSyncTimeout,
DatabaseServerMessengerSettings.DefaultSyncTimeout);
return DatabaseServerMessengerSettings.DefaultSyncTimeout;
Period = globalSettings.Value.DatabaseServerMessenger.TimeBetweenSyncOperations;
}
/// <summary>
/// Executes the instruction processing job asynchronously by synchronizing messages using the messenger service.
/// Logs an error if the synchronization fails or stalls, but always completes the task so polling continues.
/// Logs an error if the synchronization fails, but always completes the task.
/// </summary>
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A task representing the asynchronous operation.
/// </returns>
public override async Task RunJobAsync(CancellationToken cancellationToken)
/// <returns>A completed task representing the asynchronous operation.</returns>
public Task RunJobAsync()
{
// If a previous sync is still running (e.g. blocked on a hung database connection after a timeout),
// skip starting another. This bounds us to a single in-flight call instead of accumulating blocked
// thread-pool threads, and logs the stall once rather than on every interval until it recovers.
if (_inFlightSync is { IsCompleted: false })
{
return;
}
// IServerMessenger.Sync() is synchronous and cannot observe the cancellation token, so a hung database
// connection would otherwise block this job's recurring loop indefinitely and silently stop cache
// polling until the process is recycled. Offload it to the thread pool and bound the wait so the loop
// survives and keeps polling; the in-flight call keeps running until its connection faults (bounded by
// the database command/connection timeout, not by SyncTimeout), after which syncing resumes without a recycle.
//
// The loop is already started under ExecutionContext.SuppressFlow() (see RecurringBackgroundJobHostedService.StartAsync),
// which is what makes offloading the scope-creating Sync() to Task.Run safe for the static ambient scope stack.
var syncTask = Task.Run(_messenger.Sync, cancellationToken);
_inFlightSync = syncTask;
// Observe the task's eventual fault on every exit path (timeout, shutdown cancellation, or a late
// failure once we have stopped awaiting it) so it never surfaces as an UnobservedTaskException.
_ = syncTask.ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
try
{
await syncTask.WaitAsync(_syncTimeout, cancellationToken);
_logger.LogDebug("Synchronized cache instructions.");
_messenger.Sync();
}
catch (TimeoutException)
{
_logger.LogError(
"Cache instruction sync did not complete within {SyncTimeout} and may be stalled on a hung database connection. Cache updates are paused on this server until the stalled connection recovers.",
_syncTimeout);
}
catch (Exception e) when (e is not OperationCanceledException)
catch (Exception e)
{
_logger.LogError(e, "Failed (will repeat).");
}
return Task.CompletedTask;
}
}

Some files were not shown because too many files have changed in this diff Show More