Compare commits
133
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b836b44343 | ||
|
|
d8f4342a86 | ||
|
|
022439065f | ||
|
|
065e567f11 | ||
|
|
58a1c15626 | ||
|
|
05f8158e4a | ||
|
|
ca7dcd5150 | ||
|
|
ceef53d624 | ||
|
|
5bb53172aa | ||
|
|
aa9473131b | ||
|
|
35a3a2455c | ||
|
|
ab8b8b48d4 | ||
|
|
feb1689848 | ||
|
|
925d6bc430 | ||
|
|
acfaf23e43 | ||
|
|
7c1b907410 | ||
|
|
564ca0384b | ||
|
|
91381604dd | ||
|
|
c566dd0a71 | ||
|
|
ea78147657 | ||
|
|
0a5189e54a | ||
|
|
102e4aa80b | ||
|
|
0bec947b8b | ||
|
|
d0e7ef0169 | ||
|
|
bf0270b244 | ||
|
|
dc77f37129 | ||
|
|
dbecec3451 | ||
|
|
d92e6bbeff | ||
|
|
e56ddcc3f9 | ||
|
|
fb8c3b19ce | ||
|
|
7a7aadffe5 | ||
|
|
0ac6e8500a | ||
|
|
4c35c0c2e9 | ||
|
|
de47e0b1f7 | ||
|
|
969ae87798 | ||
|
|
828e359666 | ||
|
|
a5b7e0dac1 | ||
|
|
86abc3528d | ||
|
|
c45b12ec58 | ||
|
|
22c4bc7835 | ||
|
|
1e82376420 | ||
|
|
aa854da3f4 | ||
|
|
28cdbe5317 | ||
|
|
3dbd4baefe | ||
|
|
fa5dd209c1 | ||
|
|
4cc4acee62 | ||
|
|
62663d9573 | ||
|
|
b582f9d2ef | ||
|
|
ad90db8b38 | ||
|
|
8e3b821a55 | ||
|
|
8ac989c4e3 | ||
|
|
3913a61b74 | ||
|
|
90bedcd42e | ||
|
|
c54189aa90 | ||
|
|
88ec0a248f | ||
|
|
3e22733081 | ||
|
|
232077e820 | ||
|
|
943d1eeccd | ||
|
|
38d73b3a41 | ||
|
|
5c0cb154f5 | ||
|
|
4a621a13bc | ||
|
|
bc2048573c | ||
|
|
424209ac06 | ||
|
|
5cf577bf57 | ||
|
|
ed4b207fe7 | ||
|
|
40e027d0ea | ||
|
|
a6f6bdf8bc | ||
|
|
f1bc1db6ce | ||
|
|
c4d5b89fc5 | ||
|
|
7597a8ad40 | ||
|
|
7b75324172 | ||
|
|
172a3be5ac | ||
|
|
4c1fde9e0c | ||
|
|
808cba2747 | ||
|
|
da0117f240 | ||
|
|
61d3e4c53d | ||
|
|
51d70877d1 | ||
|
|
2dcfe68208 | ||
|
|
ce06c4ba4d | ||
|
|
a91de6e677 | ||
|
|
faf3824a0a | ||
|
|
58ed9899be | ||
|
|
bc7bd9a32a | ||
|
|
e06a583f1a | ||
|
|
61cc37ad1d | ||
|
|
dc7b34eb58 | ||
|
|
dfe98ffa3b | ||
|
|
6b8e8935fd | ||
|
|
1c058a32d9 | ||
|
|
12f838277c | ||
|
|
5d76706553 | ||
|
|
1fdcb835bc | ||
|
|
6b3bdb59b7 | ||
|
|
a54769758b | ||
|
|
609b74b475 | ||
|
|
82b2991a18 | ||
|
|
721cf53d40 | ||
|
|
f8ba3d8cfc | ||
|
|
82f7830d26 | ||
|
|
8d5826c61f | ||
|
|
5a73f63cfd | ||
|
|
e463cd3a0c | ||
|
|
2901be793a | ||
|
|
f4592111fa | ||
|
|
12c699d5bd | ||
|
|
1637d9b158 | ||
|
|
cd4521bd77 | ||
|
|
80e2764eda | ||
|
|
d9bb17de2a | ||
|
|
72fdf281fd | ||
|
|
b24c9ba8ac | ||
|
|
2377e9a555 | ||
|
|
0f438c551c | ||
|
|
abfa8cb144 | ||
|
|
5dd8378c57 | ||
|
|
8e7440580a | ||
|
|
358d435948 | ||
|
|
dcf1595e74 | ||
|
|
0add0f5b18 | ||
|
|
e07f188bd4 | ||
|
|
55fec1dc2a | ||
|
|
dfe93c5639 | ||
|
|
22a7a9577b | ||
|
|
c784858b32 | ||
|
|
5ae17ace6a | ||
|
|
cd476ab6ed | ||
|
|
4b66c114c4 | ||
|
|
0bdb1bb1ed | ||
|
|
a434ad7b33 | ||
|
|
1214771847 | ||
|
|
396497a921 | ||
|
|
ff565b95e0 | ||
|
|
93a1f82b05 |
@@ -0,0 +1,135 @@
|
||||
---
|
||||
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.
|
||||
@@ -435,6 +435,14 @@ 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
|
||||
@@ -531,6 +539,16 @@ 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
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
<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.7.1" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.7.1" />
|
||||
<PackageVersion Include="Examine" Version="3.8.0" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.8.0" />
|
||||
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageVersion Include="JsonPatch.Net" Version="3.3.0" />
|
||||
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
|
||||
@@ -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>
|
||||
|
||||
+32
-98
@@ -825,74 +825,31 @@ 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:
|
||||
- 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"
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm
|
||||
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
|
||||
- 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
|
||||
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/
|
||||
- job: PublishTestHelpersNpm
|
||||
displayName: Push TestHelpers to pre-release feed (npm)
|
||||
steps:
|
||||
- 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"
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm-testhelpers
|
||||
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
|
||||
- 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
|
||||
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/
|
||||
|
||||
- stage: Deploy_NuGet
|
||||
displayName: NuGet release
|
||||
@@ -941,53 +898,30 @@ 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:
|
||||
- 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
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm
|
||||
registry: https://registry.npmjs.org/
|
||||
customEndpoint: "NPM - Umbraco Backoffice"
|
||||
- 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
|
||||
displayName: Push to npm
|
||||
npmTag: $(npmDistTag)
|
||||
- job: PublishTestHelpers
|
||||
displayName: Push Test Helpers to NPM
|
||||
steps:
|
||||
- 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
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm-testhelpers
|
||||
registry: https://registry.npmjs.org/
|
||||
customEndpoint: "NPM - Umbraco Backoffice"
|
||||
- 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
|
||||
displayName: Push test helpers to npm
|
||||
npmTag: $(npmDistTag)
|
||||
|
||||
- stage: Upload_API_Docs
|
||||
pool:
|
||||
|
||||
@@ -5,10 +5,10 @@ trigger: none
|
||||
|
||||
schedules:
|
||||
- cron: '0 3 * * *'
|
||||
displayName: Daily 3AM build (main)
|
||||
displayName: Daily 3AM build (v17/dev)
|
||||
branches:
|
||||
include:
|
||||
- main
|
||||
- v17/dev
|
||||
|
||||
parameters:
|
||||
- name: skipIntegrationTests
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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 }}
|
||||
+6
-3
@@ -53,11 +53,14 @@ public class SearchDataTypeItemController : DatatypeItemControllerBase
|
||||
return Ok(new PagedModel<DataTypeItemResponseModel> { Total = searchResult.Total });
|
||||
}
|
||||
|
||||
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
|
||||
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
|
||||
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(keys);
|
||||
IEnumerable<IDataType> orderedDataTypes = OrderByRequestedIds(dataTypes, keys);
|
||||
|
||||
var result = new PagedModel<DataTypeItemResponseModel>
|
||||
{
|
||||
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(dataTypes),
|
||||
Total = searchResult.Total
|
||||
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(orderedDataTypes),
|
||||
Total = searchResult.Total,
|
||||
};
|
||||
|
||||
return Ok(result);
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -54,11 +54,14 @@ public class SearchMediaTypeItemController : MediaTypeItemControllerBase
|
||||
return Task.FromResult<IActionResult>(Ok(new PagedModel<MediaTypeItemResponseModel> { Total = searchResult.Total }));
|
||||
}
|
||||
|
||||
IEnumerable<IMediaType> mediaTypes = _mediaTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray().EmptyNull());
|
||||
Guid[] keys = searchResult.Items.Select(item => item.Key).ToArray();
|
||||
IEnumerable<IMediaType> mediaTypes = _mediaTypeService.GetMany(keys.EmptyNull());
|
||||
IEnumerable<IMediaType> orderedMediaTypes = OrderByRequestedIds(mediaTypes, keys);
|
||||
|
||||
var result = new PagedModel<MediaTypeItemResponseModel>
|
||||
{
|
||||
Items = _mapper.MapEnumerable<IMediaType, MediaTypeItemResponseModel>(mediaTypes),
|
||||
Total = searchResult.Total
|
||||
Items = _mapper.MapEnumerable<IMediaType, MediaTypeItemResponseModel>(orderedMediaTypes),
|
||||
Total = searchResult.Total,
|
||||
};
|
||||
|
||||
return Task.FromResult<IActionResult>(Ok(result));
|
||||
|
||||
+14
-3
@@ -32,6 +32,14 @@ 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)]
|
||||
@@ -45,11 +53,14 @@ public class SearchMemberTypeItemController : MemberTypeItemControllerBase
|
||||
return Task.FromResult<IActionResult>(Ok(new PagedModel<MemberTypeItemResponseModel> { Total = searchResult.Total }));
|
||||
}
|
||||
|
||||
IEnumerable<IMemberType> memberTypes = _memberTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray());
|
||||
Guid[] keys = searchResult.Items.Select(item => item.Key).ToArray();
|
||||
IEnumerable<IMemberType> memberTypes = _memberTypeService.GetMany(keys);
|
||||
IEnumerable<IMemberType> orderedMemberTypes = OrderByRequestedIds(memberTypes, keys);
|
||||
|
||||
var result = new PagedModel<MemberTypeItemResponseModel>
|
||||
{
|
||||
Items = _mapper.MapEnumerable<IMemberType, MemberTypeItemResponseModel>(memberTypes),
|
||||
Total = searchResult.Total
|
||||
Items = _mapper.MapEnumerable<IMemberType, MemberTypeItemResponseModel>(orderedMemberTypes),
|
||||
Total = searchResult.Total,
|
||||
};
|
||||
|
||||
return Task.FromResult<IActionResult>(Ok(result));
|
||||
|
||||
+16
-45
@@ -8,67 +8,38 @@ using Umbraco.Cms.Core.Security;
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for setting the redirect URL tracking status.
|
||||
/// Controller for setting the redirect URL tracking status. Retained for backwards compatibility only;
|
||||
/// the endpoint no longer modifies any configuration.
|
||||
/// </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">The back office security accessor.</param>
|
||||
/// <param name="configManipulator">The configuration manipulator.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Ignored. Retained for binary compatibility.</param>
|
||||
/// <param name="configManipulator">Ignored. Retained for binary compatibility.</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>
|
||||
/// Sets the redirect URL tracking status.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token for the HTTP request.</param>
|
||||
/// <param name="status">The redirect status to set.</param>
|
||||
/// <returns>An OK result if successful.</returns>
|
||||
/// <param name="status">The redirect status (ignored).</param>
|
||||
/// <returns>An OK result.</returns>
|
||||
[HttpPost("status")]
|
||||
[EndpointSummary("Sets the redirect URL tracking status.")]
|
||||
[EndpointDescription("Updates the redirect URL tracking configuration according to the provided 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.")]
|
||||
[MapToApiVersion("1.0")]
|
||||
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();
|
||||
}
|
||||
[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());
|
||||
}
|
||||
|
||||
+14
-3
@@ -32,6 +32,14 @@ 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)]
|
||||
@@ -45,11 +53,14 @@ public class SearchTemplateItemController : TemplateItemControllerBase
|
||||
return Ok(new PagedModel<TemplateItemResponseModel> { Total = searchResult.Total });
|
||||
}
|
||||
|
||||
IEnumerable<ITemplate> templates = await _templateService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
|
||||
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
|
||||
IEnumerable<ITemplate> templates = await _templateService.GetAllAsync(keys);
|
||||
IEnumerable<ITemplate> orderedTemplates = OrderByRequestedIds(templates, keys);
|
||||
|
||||
var result = new PagedModel<TemplateItemResponseModel>
|
||||
{
|
||||
Items = _mapper.MapEnumerable<ITemplate, TemplateItemResponseModel>(templates),
|
||||
Total = searchResult.Total
|
||||
Items = _mapper.MapEnumerable<ITemplate, TemplateItemResponseModel>(orderedTemplates),
|
||||
Total = searchResult.Total,
|
||||
};
|
||||
|
||||
return Ok(result);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
|
||||
@@ -68,6 +69,10 @@ 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,5 +1,8 @@
|
||||
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;
|
||||
@@ -16,6 +19,7 @@ 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.
|
||||
@@ -25,18 +29,46 @@ 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)
|
||||
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>>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -72,7 +104,6 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
dataType.Key = requestModel.Id.Value;
|
||||
}
|
||||
|
||||
|
||||
return Attempt.SucceedWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.Success, dataType);
|
||||
}
|
||||
|
||||
@@ -82,7 +113,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
{
|
||||
try
|
||||
{
|
||||
var parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
|
||||
EntityContainer? parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
|
||||
|
||||
return parent is null
|
||||
? Attempt.FailWithStatus(DataTypeOperationStatus.ParentNotFound, 0)
|
||||
@@ -97,6 +128,7 @@ 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))
|
||||
@@ -104,7 +136,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
return Task.FromResult(Attempt.FailWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.PropertyEditorNotFound, new DataType(new VoidEditor(_dataValueEditorFactory), _configurationEditorJsonSerializer) ));
|
||||
}
|
||||
|
||||
IDataType dataType = (IDataType)current.DeepClone();
|
||||
var dataType = (IDataType)current.DeepClone();
|
||||
|
||||
IDictionary<string, object> configurationData = MapConfigurationData(requestModel, editor);
|
||||
dataType.Name = requestModel.Name;
|
||||
@@ -119,12 +151,26 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
|
||||
private ValueStorageType GetEditorValueStorageType(IDataEditor editor, IDictionary<string, object> configurationData)
|
||||
{
|
||||
var configurationObject = editor.GetConfigurationEditor()
|
||||
.ToConfigurationObject(configurationData, _configurationEditorJsonSerializer);
|
||||
|
||||
if (configurationObject is IConfigureValueType configureValueType)
|
||||
// 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
|
||||
{
|
||||
return ValueTypes.ToStorageType(configureValueType.ValueType);
|
||||
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);
|
||||
}
|
||||
|
||||
var valueType = editor.GetValueEditor().ValueType;
|
||||
|
||||
+549
-2
@@ -10888,6 +10888,150 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
@@ -11282,6 +11426,113 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
@@ -19158,6 +19409,150 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
@@ -19408,6 +19803,113 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
@@ -27860,8 +28362,8 @@
|
||||
"tags": [
|
||||
"Redirect Management"
|
||||
],
|
||||
"summary": "Sets the redirect URL tracking status.",
|
||||
"description": "Updates the redirect URL tracking configuration according to the provided status.",
|
||||
"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.",
|
||||
"operationId": "PostRedirectManagementStatus",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -27907,6 +28409,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated": true,
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
@@ -39855,6 +40358,14 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ContentSortFieldModel": {
|
||||
"enum": [
|
||||
"Name",
|
||||
"CreateDate",
|
||||
"UpdateDate"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"CopyDataTypeRequestModel": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -50161,6 +50672,42 @@
|
||||
},
|
||||
"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"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
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; }
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
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; }
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
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 bool _disposedValue;
|
||||
private volatile bool _disposedValue;
|
||||
|
||||
public InMemoryModelFactory(
|
||||
Lazy<UmbracoServices> umbracoServices,
|
||||
@@ -280,25 +280,34 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
|
||||
}
|
||||
}
|
||||
|
||||
// don't use an upgradeable lock here because only 1 thread at a time could enter it
|
||||
try
|
||||
// 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)
|
||||
{
|
||||
_locker.EnterReadLock();
|
||||
if (_hasModels)
|
||||
{
|
||||
return _infos;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_locker.IsReadLockHeld)
|
||||
{
|
||||
_locker.ExitReadLock();
|
||||
}
|
||||
return _infos;
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -359,6 +368,12 @@ 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,8 +15,9 @@ 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
|
||||
2. **Migration Provider Setup** - Configures DbContext to use SQLite (incl. transient-error retry)
|
||||
3. **Migrations** - SQLite-specific migration files for OpenIddict tables
|
||||
4. **Retrying Execution Strategy** - Retries transient SQLite lock errors on EF Core operations
|
||||
|
||||
### Folder Structure
|
||||
|
||||
@@ -30,7 +31,8 @@ Umbraco.Cms.Persistence.EFCore.Sqlite/
|
||||
│ └── UmbracoDbContextModelSnapshot.cs # Current model state
|
||||
├── EFCoreSqliteComposer.cs # DI registration
|
||||
├── SqliteMigrationProvider.cs # IMigrationProvider impl
|
||||
└── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
|
||||
├── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
|
||||
└── SqliteRetryingExecutionStrategy.cs # IExecutionStrategy for transient lock errors
|
||||
```
|
||||
|
||||
### Relationship with Parent Project
|
||||
@@ -65,7 +67,19 @@ Registers `IMigrationProvider` and `IMigrationProviderSetup` for SQLite.
|
||||
|
||||
### SqliteMigrationProviderSetup (line 11-14)
|
||||
|
||||
Configures `DbContextOptionsBuilder` with `UseSqlite` and migrations assembly.
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
@@ -122,7 +136,8 @@ All tables prefixed with `umbraco`:
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SqliteMigrationProvider.cs` | Migration execution |
|
||||
| `SqliteMigrationProviderSetup.cs` | DbContext configuration |
|
||||
| `SqliteMigrationProviderSetup.cs` | DbContext configuration (UseSqlite + retry strategy) |
|
||||
| `SqliteRetryingExecutionStrategy.cs` | Retry on transient SQLite BUSY/LOCKED errors |
|
||||
| `EFCoreSqliteComposer.cs` | DI registration |
|
||||
| `Migrations/*.cs` | Migration files |
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Persistence.EFCore.Migrations;
|
||||
|
||||
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
|
||||
@@ -15,6 +14,15 @@ public class SqliteMigrationProviderSetup : IMigrationProviderSetup
|
||||
/// <inheritdoc />
|
||||
public void Setup(DbContextOptionsBuilder builder, string? connectionString)
|
||||
{
|
||||
builder.UseSqlite(connectionString, x => x.MigrationsAssembly(GetType().Assembly.FullName));
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+1
-7
@@ -184,17 +184,11 @@ internal sealed class SqliteEFCoreDistributedLockingMechanism<T> : IDistributedL
|
||||
throw new ArgumentException($"LockObject with id={LockId} does not exist.");
|
||||
}
|
||||
}
|
||||
catch (SqliteException ex) when (IsBusyOrLocked(ex))
|
||||
catch (SqliteException ex) when (ex.IsBusyOrLocked())
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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;
|
||||
}
|
||||
@@ -305,6 +305,8 @@ 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`:
|
||||
|
||||
+10
-1
@@ -23,5 +23,14 @@ public sealed class LanguageDeletedDistributedCacheNotificationHandler : Deleted
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Handle(IEnumerable<ILanguage> entities, IDictionary<string, object?> state)
|
||||
=> _distributedCache.RemoveLanguageCache(entities);
|
||||
{
|
||||
_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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,8 +368,17 @@ public class ObjectCacheAppCache : IAppPolicyCache, IDisposable
|
||||
}
|
||||
|
||||
// Ensure key is removed from set when evicted from cache
|
||||
return options.RegisterPostEvictionCallback((key, _, _, _) =>
|
||||
return options.RegisterPostEvictionCallback((key, _, reason, _) =>
|
||||
{
|
||||
// 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)
|
||||
|
||||
@@ -36,6 +36,7 @@ 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,6 +16,11 @@ 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>
|
||||
@@ -110,6 +115,18 @@ 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,6 +30,17 @@ 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).
|
||||
@@ -55,4 +66,13 @@ 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,6 +20,17 @@ 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>
|
||||
@@ -31,4 +42,13 @@ 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,5 +20,12 @@ 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,6 +32,11 @@ 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>
|
||||
@@ -70,4 +75,16 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// 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,
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
// 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,6 +291,11 @@ 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>
|
||||
|
||||
@@ -57,6 +57,7 @@ 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.
|
||||
@@ -102,6 +103,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
.AddUmbracoOptions<CacheSettings>()
|
||||
.AddUmbracoOptions<SystemDateMigrationSettings>()
|
||||
.AddUmbracoOptions<DistributedJobSettings>()
|
||||
.AddUmbracoOptions<ScheduledPublishingSettings>(options => options.ValidateOnStart())
|
||||
.AddUmbracoOptions<BackOfficeTokenCookieSettings>()
|
||||
.AddUmbracoOptions<WebsiteSettings>()
|
||||
.AddUmbracoOptions<SignalRSettings>();
|
||||
|
||||
@@ -730,6 +730,10 @@ 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
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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,
|
||||
}
|
||||
@@ -16,6 +16,19 @@ 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>
|
||||
|
||||
@@ -105,7 +105,15 @@ internal sealed class ContentEditingService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(ContentCreateModel createModel, Guid userKey)
|
||||
=> await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, await GetCulturesToValidate(createModel.Variants.Select(variant => variant.Culture), 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));
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<string?>?> GetCulturesToValidate(IEnumerable<string?>? cultures, Guid userKey)
|
||||
{
|
||||
@@ -332,6 +340,15 @@ 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,
|
||||
@@ -384,8 +401,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, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: null);
|
||||
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);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus Sort(IEnumerable<IContent> items, int userId)
|
||||
@@ -394,6 +411,13 @@ 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,6 +458,10 @@ 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,
|
||||
|
||||
@@ -619,6 +623,25 @@ 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,9 +86,10 @@ 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, out long total);
|
||||
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total);
|
||||
|
||||
/// <summary>
|
||||
/// Handles the sorting operation asynchronously.
|
||||
@@ -111,16 +112,7 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
|
||||
return ContentEditingOperationStatus.NotFound;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
List<TContent> children = LoadAllChildren(contentId.Value, ordering: null);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -138,4 +130,102 @@ 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."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3137,7 +3137,13 @@ public class ContentService : RepositoryService, IContentService
|
||||
{
|
||||
scope.WriteLock(Constants.Locks.ContentTree);
|
||||
|
||||
OperationResult ret = Sort(scope, itemsA, userId, evtMsgs);
|
||||
// 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);
|
||||
scope.Complete();
|
||||
return ret;
|
||||
}
|
||||
@@ -3175,6 +3181,43 @@ 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);
|
||||
|
||||
@@ -95,6 +95,18 @@ 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,6 +542,22 @@ 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
|
||||
|
||||
@@ -118,6 +118,18 @@ 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,6 +359,22 @@ 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,7 +83,15 @@ internal sealed class MediaEditingService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(MediaCreateModel createModel)
|
||||
=> await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
|
||||
{
|
||||
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
|
||||
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
|
||||
{
|
||||
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
|
||||
}
|
||||
|
||||
return await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<MediaCreateResult, ContentEditingOperationStatus>> CreateAsync(MediaCreateModel createModel, Guid userKey)
|
||||
@@ -165,6 +173,12 @@ 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);
|
||||
@@ -187,8 +201,8 @@ internal sealed class MediaEditingService
|
||||
=> ContentService.Delete(media, userId).Result;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total);
|
||||
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);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus Sort(IEnumerable<IMedia> items, int userId)
|
||||
@@ -199,6 +213,13 @@ 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>
|
||||
|
||||
@@ -1414,6 +1414,15 @@ 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))
|
||||
{
|
||||
@@ -1452,6 +1461,43 @@ 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>
|
||||
|
||||
@@ -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,6 +16,19 @@ 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>
|
||||
|
||||
+10
-2
@@ -2,7 +2,9 @@
|
||||
// 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;
|
||||
@@ -22,7 +24,10 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
public string Name => "ScheduledPublishingJob";
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan Period => TimeSpan.FromMinutes(1);
|
||||
public TimeSpan Period => _scheduledPublishingSettings.CurrentValue.Period;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool AlignToClock => _scheduledPublishingSettings.CurrentValue.AlignToClock;
|
||||
|
||||
|
||||
private readonly IContentService _contentService;
|
||||
@@ -31,6 +36,7 @@ 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.
|
||||
@@ -41,7 +47,8 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
ILogger<ScheduledPublishingJob> logger,
|
||||
IServerMessenger serverMessenger,
|
||||
ICoreScopeProvider scopeProvider,
|
||||
TimeProvider timeProvider)
|
||||
TimeProvider timeProvider,
|
||||
IOptionsMonitor<ScheduledPublishingSettings> scheduledPublishingSettings)
|
||||
{
|
||||
_contentService = contentService;
|
||||
_umbracoContextFactory = umbracoContextFactory;
|
||||
@@ -49,6 +56,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
_serverMessenger = serverMessenger;
|
||||
_scopeProvider = scopeProvider;
|
||||
_timeProvider = timeProvider;
|
||||
_scheduledPublishingSettings = scheduledPublishingSettings;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
+60
-8
@@ -4,7 +4,6 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
|
||||
@@ -26,6 +25,8 @@ public class InstructionProcessJob : RecurringBackgroundJobBase
|
||||
|
||||
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.
|
||||
@@ -41,27 +42,78 @@ public class InstructionProcessJob : RecurringBackgroundJobBase
|
||||
{
|
||||
_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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the instruction processing job asynchronously by synchronizing messages using the messenger service.
|
||||
/// Logs an error if the synchronization fails, but always completes the task.
|
||||
/// Logs an error if the synchronization fails or stalls, but always completes the task so polling continues.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
/// <returns>
|
||||
/// A completed task representing the asynchronous operation.
|
||||
/// A task representing the asynchronous operation.
|
||||
/// </returns>
|
||||
public override Task RunJobAsync(CancellationToken cancellationToken)
|
||||
public override async Task RunJobAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// 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
|
||||
{
|
||||
_messenger.Sync();
|
||||
await syncTask.WaitAsync(_syncTimeout, cancellationToken);
|
||||
_logger.LogDebug("Synchronized cache instructions.");
|
||||
}
|
||||
catch (Exception e)
|
||||
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)
|
||||
{
|
||||
_logger.LogError(e, "Failed (will repeat).");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
+58
-7
@@ -33,6 +33,8 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
private readonly IServerRoleAccessor _serverRoleAccessor;
|
||||
private readonly IDisposable? _onChangeRegistration;
|
||||
private GlobalSettings _globalSettings;
|
||||
private TimeSpan _touchTimeout;
|
||||
private Task? _inFlightTouch;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TouchServerJob" /> class.
|
||||
@@ -55,11 +57,13 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings.CurrentValue;
|
||||
_serverRoleAccessor = serverRoleAccessor;
|
||||
_touchTimeout = ValidateTouchTimeout(globalSettings.CurrentValue.DatabaseServerRegistrar.TouchTimeout);
|
||||
|
||||
_onChangeRegistration = globalSettings.OnChange(x =>
|
||||
{
|
||||
_globalSettings = x;
|
||||
Period = x.DatabaseServerRegistrar.WaitTimeBetweenCalls;
|
||||
_touchTimeout = ValidateTouchTimeout(x.DatabaseServerRegistrar.TouchTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,14 +75,23 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
/// <returns>
|
||||
/// A completed task when the job has finished running.
|
||||
/// </returns>
|
||||
public override Task RunJobAsync(CancellationToken cancellationToken)
|
||||
public override async Task RunJobAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// If the IServerRoleAccessor has been changed away from ElectedServerRoleAccessor this task no longer makes sense,
|
||||
// since all it's used for is to allow the ElectedServerRoleAccessor
|
||||
// to figure out what role a given server has, so we just stop this task.
|
||||
if (_serverRoleAccessor is not ElectedServerRoleAccessor)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
return;
|
||||
}
|
||||
|
||||
// If a previous touch 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 (each contending for the servers lock), and logs the stall once rather than on
|
||||
// every interval until it recovers.
|
||||
if (_inFlightTouch is { IsCompleted: false })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString();
|
||||
@@ -99,18 +112,56 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
_logger.LogDebug("Registering server with application URL {ServerAddress}.", serverAddress);
|
||||
}
|
||||
|
||||
// IServerRegistrationService.TouchServer() runs a synchronous database write and cannot observe the
|
||||
// cancellation token, so a hung connection would otherwise block this job's recurring loop indefinitely
|
||||
// and silently stop server-registration heartbeats until the process is recycled. Offload it to the
|
||||
// thread pool and bound the wait so the loop survives and keeps touching.
|
||||
// (See InstructionProcessJob for the same pattern and the ExecutionContext.SuppressFlow rationale.)
|
||||
TimeSpan staleServerTimeout = _globalSettings.DatabaseServerRegistrar.StaleServerTimeout;
|
||||
var touchTask = Task.Run(() => _serverRegistrationService.TouchServer(serverAddress, staleServerTimeout), cancellationToken);
|
||||
_inFlightTouch = touchTask;
|
||||
|
||||
// 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.
|
||||
_ = touchTask.ContinueWith(
|
||||
static t => _ = t.Exception,
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
|
||||
try
|
||||
{
|
||||
_serverRegistrationService.TouchServer(
|
||||
serverAddress,
|
||||
_globalSettings.DatabaseServerRegistrar.StaleServerTimeout);
|
||||
await touchTask.WaitAsync(_touchTimeout, cancellationToken);
|
||||
_logger.LogDebug("Touched server registration for {ServerAddress}.", serverAddress);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Touching the server registration did not complete within {TouchTimeout} and may be stalled on a hung database connection. Server registration is paused on this server until the stalled connection recovers.",
|
||||
_touchTimeout);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update server record in database.");
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
// A non-positive timeout would make every touch "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 ValidateTouchTimeout(TimeSpan configuredTouchTimeout)
|
||||
{
|
||||
if (configuredTouchTimeout > TimeSpan.Zero || configuredTouchTimeout == Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
return configuredTouchTimeout;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Configured DatabaseServerRegistrar.TouchTimeout of {ConfiguredTouchTimeout} is not valid; it must be positive (or Timeout.InfiniteTimeSpan to disable the timeout). Falling back to {DefaultTouchTimeout}.",
|
||||
configuredTouchTimeout,
|
||||
DatabaseServerRegistrarSettings.DefaultTouchTimeout);
|
||||
|
||||
return DatabaseServerRegistrarSettings.DefaultTouchTimeout;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -384,6 +384,57 @@ using (ICoreScope scope = ScopeProvider.CreateCoreScope())
|
||||
3. **Lazy loading outside scope** - NPoco relationships must load within scope
|
||||
4. **Large migrations** - Split into multiple steps if > 1000 lines
|
||||
5. **Repository logic in services** - Keep repos thin, logic in services
|
||||
6. **Unbatched `WHERE IN` on user-sized collections** - See "Avoiding the SQL Server 2100-parameter limit" below
|
||||
|
||||
### Avoiding the SQL Server 2100-parameter limit
|
||||
|
||||
SQL Server caps a single statement at 2100 parameters. When an `IN` clause is built from a collection sized by user data, that cap can be hit — and the symptom is a runtime `SqlException` (error 8003) on customer installs that nobody hit in dev.
|
||||
|
||||
**The constant and helpers**:
|
||||
- `Constants.Sql.MaxParameterCount = 2000` (in `Umbraco.Core`, `Constants-Sql.cs`) — the ceiling we target (2100 minus headroom for joined predicates already in the SQL).
|
||||
- `IEnumerable<T>.InGroupsOf(groupSize)` (in `Umbraco.Core`, `Extensions/EnumerableExtensions.cs`) — extension method to batch a collection.
|
||||
- `Database.FetchByGroups<TResult, TSource>(source, groupSize, sqlFactory)` (in `Umbraco.Infrastructure`, `Persistence/NPocoDatabaseExtensions.cs`) — NPoco helper that batches a fetch.
|
||||
|
||||
**The safe patterns** (use one of these any time the collection size is user-driven):
|
||||
|
||||
```csharp
|
||||
// Pattern 1: batch a DeleteMany / Execute / Fetch by looping.
|
||||
foreach (IEnumerable<int> group in ids.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Database.DeleteMany<FooDto>().Where(x => group.Contains(x.Id)).Execute();
|
||||
}
|
||||
|
||||
// Pattern 2: batched fetch with NPoco helper.
|
||||
List<FooDto> dtos = Database.FetchByGroups<FooDto, int>(
|
||||
ids,
|
||||
Constants.Sql.MaxParameterCount,
|
||||
batch => Sql().Select<FooDto>().From<FooDto>().WhereIn<FooDto>(x => x.Id, batch));
|
||||
|
||||
// Pattern 3: reserve headroom for other parameters in the same statement.
|
||||
foreach (IEnumerable<int> group in entityIds.InGroupsOf(Constants.Sql.MaxParameterCount - userGroupIds.Length))
|
||||
{
|
||||
// statement uses entityIds + userGroupIds, so subtract the other predicate's parameter count from the budget
|
||||
}
|
||||
```
|
||||
|
||||
**Decision rule when writing or reviewing a `WHERE IN`-style query**:
|
||||
|
||||
Look at what drives the size of the collection feeding the `IN`. Ask: *could this realistically exceed 2000 on a large install?* Risky drivers — batch any query backed by these:
|
||||
- All content / media / member nodes (or descendants of a deep tree).
|
||||
- A product of two scaling dimensions, e.g. `documents × languages`, `properties × versions`, `relations × endpoints`.
|
||||
- Configuration-tunable batch sizes (`CacheSettings.DocumentSeedBatchSize`, `NuCacheSettings.SqlPageSize`, etc.). The default may be safe but the customer can raise it.
|
||||
- Anything that scans property data, version history, relations, or audit logs across many nodes.
|
||||
|
||||
Safe drivers — don't bother batching:
|
||||
- Languages / content types / member groups / user groups — bounded by install configuration, typically <100.
|
||||
- "Per single content item" collections — properties on one document, versions of one document, tokens for one external login.
|
||||
- IDs supplied directly by a user action through the UI (picker selections, bulk actions on a page of results).
|
||||
|
||||
If you're not sure, batch — the cost is one loop and an `IEnumerable<T>` allocation per batch; the cost of being wrong is a SqlException on a customer's biggest site.
|
||||
|
||||
**For new public APIs** that take an `IEnumerable<int>`/`IEnumerable<Guid>` and feed it into a query, batch internally even if no current caller is large — package authors and future callers will not know about the 2000-limit ceiling.
|
||||
|
||||
**Don't** rely on `if (ids.Length > MaxParameterCount) throw` as a substitute for batching. Throwing only moves the problem; the caller has no obvious way to recover and will most likely just fail in production.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ internal sealed class JsonConfigManipulator : IConfigManipulator
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
[Obsolete("This method is no longer used by Umbraco. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
|
||||
public async Task SaveDisableRedirectUrlTrackingAsync(bool disable)
|
||||
=> await CreateOrUpdateConfigValueAsync(DisableRedirectUrlTrackingPath, disable);
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.AddNotificationHandler<ExternalMemberCacheRefresherNotification, ExternalMemberIndexingNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<LanguageCacheRefresherNotification, LanguageIndexingNotificationHandler>();
|
||||
|
||||
builder.AddNotificationHandler<UmbracoRequestBeginNotification, RebuildOnStartupHandler>();
|
||||
builder.AddNotificationAsyncHandler<UmbracoApplicationStartedNotification, RebuildOnStartedHandler>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -170,13 +170,7 @@ public class ContentIndexPopulator : IndexPopulator<IUmbracoContentIndex>
|
||||
{
|
||||
content = _contentService.GetPagedDescendants(contentParentId, pageIndex, pageSize, out _).ToArray();
|
||||
|
||||
var valueSets = _contentValueSetBuilder.GetValueSets(content).ToArray();
|
||||
|
||||
// ReSharper disable once PossibleMultipleEnumeration
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(valueSets);
|
||||
}
|
||||
ValueSetIndexer.IndexItems(indexes, _contentValueSetBuilder.GetValueSets(content));
|
||||
|
||||
pageIndex++;
|
||||
}
|
||||
@@ -216,12 +210,7 @@ public class ContentIndexPopulator : IndexPopulator<IUmbracoContentIndex>
|
||||
}
|
||||
}
|
||||
|
||||
var valueSets = _contentValueSetBuilder.GetValueSets(indexableContent.ToArray()).ToArray();
|
||||
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(valueSets);
|
||||
}
|
||||
ValueSetIndexer.IndexItems(indexes, _contentValueSetBuilder.GetValueSets(indexableContent.ToArray()));
|
||||
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
@@ -49,13 +49,7 @@ internal sealed class DeliveryApiContentIndexPopulator : IndexPopulator
|
||||
_deliveryApiContentIndexHelper.EnumerateApplicableDescendantsForContentIndex(
|
||||
Constants.System.Root,
|
||||
descendants =>
|
||||
{
|
||||
ValueSet[] valueSets = _deliveryContentIndexValueSetBuilder.GetValueSets(descendants).ToArray();
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(valueSets);
|
||||
}
|
||||
});
|
||||
ValueSetIndexer.IndexItems(indexes, _deliveryContentIndexValueSetBuilder.GetValueSets(descendants)));
|
||||
}
|
||||
|
||||
public override bool IsRegistered(IIndex index)
|
||||
|
||||
@@ -107,11 +107,7 @@ public class MediaIndexPopulator : IndexPopulator<IUmbracoContentIndex>
|
||||
{
|
||||
media = _mediaService.GetPagedDescendants(mediaParentId, pageIndex, _indexingSettings.BatchSize, out _).ToArray();
|
||||
|
||||
// ReSharper disable once PossibleMultipleEnumeration
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(_mediaValueSetBuilder.GetValueSets(media));
|
||||
}
|
||||
ValueSetIndexer.IndexItems(indexes, _mediaValueSetBuilder.GetValueSets(media));
|
||||
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
@@ -41,11 +41,7 @@ public class MemberIndexPopulator : IndexPopulator<IUmbracoMemberIndex>
|
||||
{
|
||||
members = _memberService.GetAll(pageIndex, pageSize, out _).ToArray();
|
||||
|
||||
// ReSharper disable once PossibleMultipleEnumeration
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(_valueSetBuilder.GetValueSets(members));
|
||||
}
|
||||
ValueSetIndexer.IndexItems(indexes, _valueSetBuilder.GetValueSets(members));
|
||||
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Examine;
|
||||
|
||||
/// <summary>
|
||||
/// Handles how the indexes are rebuilt after startup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Once the application has fully started this rebuilds the Examine indexes if they are empty.
|
||||
/// If it is a cold boot, they are all rebuilt.
|
||||
/// </remarks>
|
||||
public sealed class RebuildOnStartedHandler : INotificationAsyncHandler<UmbracoApplicationStartedNotification>
|
||||
{
|
||||
// The notification is published again on restart, but the indexes only need to be
|
||||
// considered for rebuilding once per application lifetime.
|
||||
private static int _hasRun;
|
||||
|
||||
private readonly ISyncBootStateAccessor _syncBootStateAccessor;
|
||||
private readonly IIndexRebuilder _indexRebuilder;
|
||||
private readonly IRuntimeState _runtimeState;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Umbraco.Cms.Infrastructure.Examine.RebuildOnStartedHandler"/> class, responsible for handling index rebuilds during application startup.
|
||||
/// </summary>
|
||||
/// <param name="syncBootStateAccessor">Provides access to the application's synchronous boot state, used to determine if the system is ready for index rebuilding.</param>
|
||||
/// <param name="indexRebuilder">The service responsible for rebuilding Examine indexes.</param>
|
||||
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
|
||||
public RebuildOnStartedHandler(
|
||||
ISyncBootStateAccessor syncBootStateAccessor,
|
||||
IIndexRebuilder indexRebuilder,
|
||||
IRuntimeState runtimeState)
|
||||
{
|
||||
_syncBootStateAccessor = syncBootStateAccessor;
|
||||
_indexRebuilder = indexRebuilder;
|
||||
_runtimeState = runtimeState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Once the application has fully started, schedule an index rebuild for any empty indexes (or all if it's a cold boot).
|
||||
/// </summary>
|
||||
/// <param name="notification">The notification.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public async Task HandleAsync(UmbracoApplicationStartedNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_runtimeState.Level != RuntimeLevel.Run)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _hasRun, 1, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SyncBootState bootState = _syncBootStateAccessor.GetSyncBootState();
|
||||
|
||||
// if it's not a cold boot, only rebuild empty ones
|
||||
await _indexRebuilder.RebuildIndexesAsync(
|
||||
bootState != SyncBootState.ColdBoot,
|
||||
TimeSpan.FromMinutes(1));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ namespace Umbraco.Cms.Infrastructure.Examine;
|
||||
/// On the first HTTP request this will rebuild the Examine indexes if they are empty.
|
||||
/// If it is a cold boot, they are all rebuilt.
|
||||
/// </remarks>
|
||||
[Obsolete("Superseded by RebuildOnStartedHandler. Scheduled for removal in Umbraco 19.")]
|
||||
public sealed class RebuildOnStartupHandler : INotificationHandler<UmbracoRequestBeginNotification>
|
||||
{
|
||||
// These must be static because notification handlers are transient.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using Examine;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Examine;
|
||||
|
||||
/// <summary>
|
||||
/// Writes a batch of <see cref="ValueSet" />s to one or more indexes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When a single index is registered the value sets are streamed straight through, so a lazily-built
|
||||
/// sequence is enumerated once and never fully materialised in memory — keeping the common single-index
|
||||
/// rebuild's peak memory down. When multiple indexes are registered the sequence is materialised once and
|
||||
/// reused, so the (potentially expensive) value sets are not rebuilt per index.
|
||||
/// </remarks>
|
||||
internal static class ValueSetIndexer
|
||||
{
|
||||
public static void IndexItems(IReadOnlyList<IIndex> indexes, IEnumerable<ValueSet> valueSets)
|
||||
{
|
||||
switch (indexes.Count)
|
||||
{
|
||||
case 0:
|
||||
return;
|
||||
case 1:
|
||||
indexes[0].IndexItems(valueSets);
|
||||
return;
|
||||
default:
|
||||
ValueSet[] materialized = valueSets as ValueSet[] ?? valueSets.ToArray();
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(materialized);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,12 +131,15 @@ public class PackageMigrationRunner
|
||||
=> RunPackagePlansAsync(plansToRun).GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// Runs the all specified package migration plans and publishes a <see cref="MigrationPlansExecutedNotification" />
|
||||
/// if all are successful.
|
||||
/// Runs all the specified package migration plans and publishes a <see cref="MigrationPlansExecutedNotification" />.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All plans are run to completion even if one fails, so that one package's failure does not block another's.
|
||||
/// A failed plan is reported via <see cref="ExecutedMigrationPlan.Successful" /> on the returned result rather
|
||||
/// than by throwing; callers must inspect the results to detect a failure.
|
||||
/// </remarks>
|
||||
/// <param name="plansToRun"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="Exception">If any plan fails it will throw an exception.</exception>
|
||||
public async Task<IEnumerable<ExecutedMigrationPlan>> RunPackagePlansAsync(IEnumerable<string> plansToRun)
|
||||
{
|
||||
List<ExecutedMigrationPlan> results = new();
|
||||
|
||||
@@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Exceptions;
|
||||
using Umbraco.Cms.Core.Logging;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.Migrations;
|
||||
using Umbraco.Cms.Infrastructure.Migrations.Install;
|
||||
using Umbraco.Cms.Infrastructure.Migrations.Upgrade;
|
||||
using Umbraco.Cms.Infrastructure.Runtime;
|
||||
@@ -163,7 +164,23 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
|
||||
|
||||
try
|
||||
{
|
||||
await _packageMigrationRunner.RunPackagePlansAsync(pendingMigrations);
|
||||
IEnumerable<ExecutedMigrationPlan> executedPlans =
|
||||
await _packageMigrationRunner.RunPackagePlansAsync(pendingMigrations);
|
||||
|
||||
// Failed plans are reported via the result, not by throwing (the runner deliberately runs all plans to
|
||||
// completion so one package's failure doesn't block another's). Surface them as a boot failure here so the
|
||||
// failure is observable, mirroring the core upgrade path - otherwise the migration stays pending and the
|
||||
// runtime re-derives Upgrading on every boot, leaving the site stuck on the maintenance page.
|
||||
// All failures are reported together.
|
||||
var failedPlans = executedPlans.Where(plan => plan.Successful is false).ToList();
|
||||
if (failedPlans.Count > 0)
|
||||
{
|
||||
SetRuntimeError(CreatePackageMigrationError(failedPlans));
|
||||
notification.UnattendedUpgradeResult =
|
||||
RuntimeUnattendedUpgradeNotification.UpgradeResult.HasErrors;
|
||||
return;
|
||||
}
|
||||
|
||||
notification.UnattendedUpgradeResult = RuntimeUnattendedUpgradeNotification.UpgradeResult.PackageMigrationComplete;
|
||||
|
||||
// Migration plans may have changed published content, so refresh the distributed cache to ensure consistency on first request.
|
||||
@@ -200,6 +217,22 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
|
||||
}
|
||||
}
|
||||
|
||||
private static Exception CreatePackageMigrationError(IReadOnlyList<ExecutedMigrationPlan> failedPlans)
|
||||
{
|
||||
static Exception ToException(ExecutedMigrationPlan plan)
|
||||
=> plan.Exception ?? new UnattendedInstallException(
|
||||
$"An error occurred while running the unattended package migration '{plan.Plan.Name}'.");
|
||||
|
||||
if (failedPlans.Count == 1)
|
||||
{
|
||||
return ToException(failedPlans[0]);
|
||||
}
|
||||
|
||||
return new AggregateException(
|
||||
$"{failedPlans.Count} unattended package migrations failed: {string.Join(", ", failedPlans.Select(plan => plan.Plan.Name))}.",
|
||||
failedPlans.Select(ToException));
|
||||
}
|
||||
|
||||
private void SetRuntimeError(Exception exception)
|
||||
=> _runtimeState.Configure(
|
||||
RuntimeLevel.BootFailed,
|
||||
|
||||
@@ -52,8 +52,8 @@ internal sealed class DatabaseDataCreator
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "Find all logs that are from the namespace 'Umbraco.Core'",
|
||||
Query = "StartsWith(SourceContext, 'Umbraco.Core')",
|
||||
Name = "Find all logs that are within the namespace 'Umbraco.Cms'",
|
||||
Query = "StartsWith(SourceContext, 'Umbraco.Cms')",
|
||||
},
|
||||
new()
|
||||
{
|
||||
|
||||
+30
@@ -1297,6 +1297,36 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement
|
||||
/// </summary>
|
||||
public abstract int RecycleBinId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpdateSortOrder(IReadOnlyList<int> orderedNodeIds)
|
||||
{
|
||||
if (orderedNodeIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var nodeTable = SqlSyntax.GetQuotedTableName(NodeDto.TableName);
|
||||
var idColumn = SqlSyntax.GetQuotedColumnName(NodeDto.IdColumnName);
|
||||
var sortOrderColumn = SqlSyntax.GetQuotedColumnName(NodeDto.SortOrderColumnName);
|
||||
|
||||
// Each node's new sort order is its position in the ordered collection.
|
||||
var ordered = orderedNodeIds
|
||||
.Select((id, sortOrder) => new KeyValuePair<int, int>(id, sortOrder))
|
||||
.ToList();
|
||||
|
||||
// Two parameters per node (id + sort order), so batch to stay within the SQL Server parameter limit.
|
||||
foreach (IEnumerable<KeyValuePair<int, int>> group in ordered.InGroupsOf(Constants.Sql.MaxParameterCount / 2))
|
||||
{
|
||||
List<KeyValuePair<int, int>> groupList = group.ToList();
|
||||
var args = groupList.SelectMany(pair => new object[] { pair.Key, pair.Value }).ToArray();
|
||||
var whenClauses = string.Join(" ", groupList.Select((_, i) => $"WHEN @{i * 2} THEN @{(i * 2) + 1}"));
|
||||
var inClause = string.Join(", ", groupList.Select((_, i) => $"@{i * 2}"));
|
||||
|
||||
var sql = $"UPDATE {nodeTable} SET {sortOrderColumn} = CASE {idColumn} {whenClauses} END WHERE {idColumn} IN ({inClause})";
|
||||
Database.Execute(sql, args);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all entities that are currently in the recycle bin.
|
||||
/// </summary>
|
||||
|
||||
+15
-5
@@ -249,14 +249,24 @@ internal sealed class RedirectUrlRepository : EntityRepositoryBase<Guid, IRedire
|
||||
|
||||
protected override IEnumerable<IRedirectUrl> PerformGetAll(params Guid[]? ids)
|
||||
{
|
||||
if (ids?.Length > Constants.Sql.MaxParameterCount)
|
||||
if (ids is null || ids.Length == 0)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"This repository does not support more than {Constants.Sql.MaxParameterCount} ids.");
|
||||
return Database.Fetch<RedirectUrlDto>(GetBaseQuery(false))
|
||||
.WhereNotNull()
|
||||
.Select(Map)
|
||||
.WhereNotNull();
|
||||
}
|
||||
|
||||
// Batch the WhereIn fetch so we never exceed SQL Server's 2100 parameter limit.
|
||||
// EntityRepositoryBase.GetMany already groups IDs, but we keep the batching here as
|
||||
// a defensive measure for safety and consistency at the repository boundary.
|
||||
var dtos = new List<RedirectUrlDto>(ids.Length);
|
||||
foreach (IEnumerable<Guid> group in ids.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Sql<ISqlContext> sql = GetBaseQuery(false).WhereIn<RedirectUrlDto>(x => x.Id, group);
|
||||
dtos.AddRange(Database.Fetch<RedirectUrlDto>(sql));
|
||||
}
|
||||
|
||||
Sql<ISqlContext> sql = GetBaseQuery(false).WhereIn<RedirectUrlDto>(x => x.Id, ids);
|
||||
List<RedirectUrlDto> dtos = Database.Fetch<RedirectUrlDto>(sql);
|
||||
return dtos.WhereNotNull().Select(Map).WhereNotNull();
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -74,6 +74,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement
|
||||
string[] columns = [
|
||||
sx.ColumnWithAlias("x", "otherId", "nodeId"),
|
||||
sx.ColumnWithAlias("n", "uniqueId", "nodeKey"),
|
||||
sx.ColumnWithAlias("n", "text", "nodeName"),
|
||||
sx.ColumnWithAlias("n", "nodeObjectType", "nodeObjectType"),
|
||||
sx.ColumnWithAlias("d", "published", "nodePublished"),
|
||||
sx.ColumnWithAlias("ctn", "uniqueId", "contentTypeKey"),
|
||||
|
||||
@@ -20,6 +20,10 @@ public class DistributedJobService : IDistributedJobService
|
||||
private readonly ILogger<DistributedJobService> _logger;
|
||||
private readonly DistributedJobSettings _settings;
|
||||
|
||||
// Which jobs align to the clock is a startup configuration concern (changing it requires a restart), so it is
|
||||
// captured once in the constructor rather than re-evaluated on every poll.
|
||||
private readonly HashSet<string> _clockAlignedJobNames;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DistributedJobService"/> class.
|
||||
/// </summary>
|
||||
@@ -58,6 +62,10 @@ public class DistributedJobService : IDistributedJobService
|
||||
_distributedBackgroundJobs = distributedBackgroundJobs;
|
||||
_logger = logger;
|
||||
_settings = settings.Value;
|
||||
_clockAlignedJobNames = _distributedBackgroundJobs
|
||||
.Where(x => x.AlignToClock)
|
||||
.Select(x => x.Name)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -67,9 +75,12 @@ public class DistributedJobService : IDistributedJobService
|
||||
|
||||
scope.EagerWriteLock(Constants.Locks.DistributedJobs);
|
||||
|
||||
DateTime utcNow = DateTime.UtcNow;
|
||||
|
||||
IEnumerable<DistributedBackgroundJobModel> jobs = _distributedJobRepository.GetAll();
|
||||
DistributedBackgroundJobModel? job = jobs.FirstOrDefault(x => x.LastRun < DateTime.UtcNow - x.Period
|
||||
&& (x.IsRunning is false || x.LastAttemptedRun < DateTime.UtcNow - x.Period - _settings.MaximumExecutionTime));
|
||||
DistributedBackgroundJobModel? job = jobs.FirstOrDefault(x =>
|
||||
IsDue(x, utcNow, _clockAlignedJobNames.Contains(x.Name))
|
||||
&& (x.IsRunning is false || x.LastAttemptedRun < utcNow - x.Period - _settings.MaximumExecutionTime));
|
||||
|
||||
if (job is null)
|
||||
{
|
||||
@@ -97,6 +108,39 @@ public class DistributedJobService : IDistributedJobService
|
||||
return distributedJob;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a job is due to run.
|
||||
/// </summary>
|
||||
/// <param name="job">The job state.</param>
|
||||
/// <param name="utcNow">The current UTC time.</param>
|
||||
/// <param name="aligned">
|
||||
/// Whether the job's runs are aligned to clock boundaries (see <see cref="IDistributedBackgroundJob.AlignToClock" />).
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// For non-aligned jobs the period counts from the previous run's completion (<c>LastRun + Period</c>, drifting).
|
||||
/// For aligned jobs the job is due once a clock boundary — a multiple of the period measured from a fixed UTC
|
||||
/// origin, so boundaries fall on round clock times such as on the minute — has fallen strictly after the previous
|
||||
/// run's completion. Boundaries are in UTC, not the server's local time zone. This is overrun-safe: if a run takes
|
||||
/// longer than the period, the boundary it would have targeted has already passed, so the missed boundary is
|
||||
/// skipped rather than triggering back-to-back runs.
|
||||
/// </remarks>
|
||||
internal static bool IsDue(DistributedBackgroundJobModel job, DateTime utcNow, bool aligned)
|
||||
{
|
||||
if (aligned == false || job.Period <= TimeSpan.Zero)
|
||||
{
|
||||
return job.LastRun < utcNow - job.Period;
|
||||
}
|
||||
|
||||
long periodTicks = job.Period.Ticks;
|
||||
|
||||
// Floor the current UTC time to the most recent clock boundary. Ticks count from a fixed origin (0001-01-01), and
|
||||
// a day divides evenly by any clean sub-hour period, so boundaries fall on round clock times (e.g. each :10s).
|
||||
long ticksSinceBoundary = utcNow.Ticks % periodTicks;
|
||||
long currentBoundaryTicks = utcNow.Ticks - ticksSinceBoundary;
|
||||
|
||||
return currentBoundaryTicks > job.LastRun.Ticks;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task FinishAsync(string jobName)
|
||||
{
|
||||
@@ -136,11 +180,25 @@ public class DistributedJobService : IDistributedJobService
|
||||
return;
|
||||
}
|
||||
|
||||
// Clock-aligned jobs only hit their boundaries as tightly as the poll interval allows. If the poll interval
|
||||
// is longer than the job's period, boundaries between polls are silently missed.
|
||||
foreach (IDistributedBackgroundJob job in _distributedBackgroundJobs)
|
||||
{
|
||||
if (job.AlignToClock && job.Period < _settings.Period)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Distributed background job '{JobName}' aligns to the clock with a period of {Period}, but the distributed job poll interval is longer ({PollInterval}). Clock boundaries shorter than the poll interval will be missed; set Umbraco:CMS:DistributedJobs:Period to be no longer than the job period.",
|
||||
job.Name,
|
||||
job.Period,
|
||||
_settings.Period);
|
||||
}
|
||||
}
|
||||
|
||||
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
||||
scope.WriteLock(Constants.Locks.DistributedJobs);
|
||||
|
||||
DistributedBackgroundJobModel[] existingJobs = _distributedJobRepository.GetAll().ToArray();
|
||||
var existingJobsByName = existingJobs.ToDictionary(x => x.Name);
|
||||
Dictionary<string, DistributedBackgroundJobModel> existingJobsByName = existingJobs.ToDictionary(x => x.Name);
|
||||
|
||||
// Collect all changes first, then execute - minimizes time spent in the critical section
|
||||
var jobsToAdd = new List<DistributedBackgroundJobModel>();
|
||||
|
||||
@@ -122,11 +122,30 @@ internal sealed class IndexedEntitySearchService : IIndexedEntitySearchService
|
||||
.Where(key => key != Guid.Empty)
|
||||
.ToArray();
|
||||
|
||||
// EntityService.GetAll returns entities in database (not Lucene score) order, which
|
||||
// would discard the relevance ranking. Re-order to match the search result sequence.
|
||||
IEnumerable<IEntitySlim> orderedItems;
|
||||
if (keys.Length > 0)
|
||||
{
|
||||
var keyOrder = new Dictionary<Guid, int>(keys.Length);
|
||||
for (var i = 0; i < keys.Length; i++)
|
||||
{
|
||||
keyOrder.TryAdd(keys[i], i);
|
||||
}
|
||||
|
||||
orderedItems = _entityService
|
||||
.GetAll(objectType, keys)
|
||||
.OrderBy(entity => keyOrder.TryGetValue(entity.Key, out var index) ? index : int.MaxValue)
|
||||
.ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
orderedItems = [];
|
||||
}
|
||||
|
||||
return Task.FromResult(new PagedModel<IEntitySlim>
|
||||
{
|
||||
Items = keys.Any()
|
||||
? _entityService.GetAll(objectType, keys)
|
||||
: Enumerable.Empty<IEntitySlim>(),
|
||||
Items = orderedItems,
|
||||
Total = totalFound
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,30 +47,21 @@ public class LogViewerRepository : LogViewerRepositoryBase
|
||||
|
||||
var filesForCurrentDay = Directory.GetFiles(_loggingConfiguration.LogDirectory, filesToFind);
|
||||
|
||||
// Foreach file we find - open it
|
||||
// Foreach file we find - open it. Any failure reading a single file (open error,
|
||||
// unrecoverable parse error, etc.) should not prevent the remaining files for the
|
||||
// day or date range from being read.
|
||||
foreach (var filePath in filesForCurrentDay)
|
||||
{
|
||||
// Open log file & add contents to the log collection
|
||||
// Which we then use LINQ to page over
|
||||
using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
try
|
||||
{
|
||||
using (var stream = new StreamReader(fs))
|
||||
{
|
||||
var reader = new LogEventReader(stream);
|
||||
while (TryRead(reader, out LogEvent? evt))
|
||||
{
|
||||
// We may get a null if log line is malformed
|
||||
if (evt == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (logFilter.TakeLogEvent(evt))
|
||||
{
|
||||
logs.Add(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
ReadLogFile(filePath, logFilter, logs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Skipped log file {FilePath} after a file-level error; the file may be inaccessible or unreadable.",
|
||||
filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +79,63 @@ public class LogViewerRepository : LogViewerRepositoryBase
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private void ReadLogFile(string filePath, ILogFilter logFilter, List<LogEvent> logs)
|
||||
{
|
||||
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var stream = new StreamReader(fs);
|
||||
var reader = new LogEventReader(stream);
|
||||
|
||||
var errorCount = 0;
|
||||
Exception? firstError = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
LogEvent? evt;
|
||||
try
|
||||
{
|
||||
if (!reader.TryRead(out evt))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is Newtonsoft.Json.JsonException or InvalidDataException)
|
||||
{
|
||||
// Serilog.Formatting.Compact.Reader uses Newtonsoft.Json internally and surfaces
|
||||
// its exceptions (Umbraco's own serialization is on System.Text.Json, but that
|
||||
// doesn't apply here — we have to catch what the reader actually throws).
|
||||
// JsonException covers parse failures (e.g. an unterminated string in a truncated
|
||||
// entry); InvalidDataException covers structurally-valid JSON that isn't a valid
|
||||
// Serilog Compact event. Either way the offending line has been consumed from the
|
||||
// underlying StreamReader and the next TryRead call advances. Anything else
|
||||
// (IOException, decoder failures, etc.) is propagated to the file-level catch in
|
||||
// GetLogs so we don't risk a tight loop or silently swallow a more serious failure.
|
||||
errorCount++;
|
||||
firstError ??= ex;
|
||||
continue;
|
||||
}
|
||||
|
||||
// LogEventReader may return true with a null event for a benign skip.
|
||||
if (evt is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (logFilter.TakeLogEvent(evt))
|
||||
{
|
||||
logs.Add(evt);
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCount > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
firstError,
|
||||
"Encountered {ErrorCount} unreadable line(s) while reading log file {FilePath}. The file may contain partially-written or corrupt entries; affected lines were skipped.",
|
||||
errorCount,
|
||||
filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, string?> MapLogMessageProperties(IReadOnlyDictionary<string, LogEventPropertyValue>? properties)
|
||||
{
|
||||
var result = new Dictionary<string, string?>();
|
||||
@@ -121,21 +169,4 @@ public class LogViewerRepository : LogViewerRepositoryBase
|
||||
}
|
||||
|
||||
private static string GetSearchPattern(DateTime day) => $"*{day:yyyyMMdd}*.json";
|
||||
|
||||
private bool TryRead(LogEventReader reader, out LogEvent? evt)
|
||||
{
|
||||
try
|
||||
{
|
||||
return reader.TryRead(out evt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// As we are reading/streaming one line at a time in the JSON file
|
||||
// Thus we can not report the line number, as it will always be 1
|
||||
_logger.LogError(ex, "Unable to parse a line in the JSON log file");
|
||||
|
||||
evt = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ public static class UmbracoBuilderExtensions
|
||||
builder.AddNotificationHandler<ContentTypeChangedNotification, DeferredCacheRebuildNotificationHandler>();
|
||||
builder.AddNotificationHandler<MediaTypeChangedNotification, DeferredCacheRebuildNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, SeedingNotificationHandler>();
|
||||
builder.AddNotificationHandler<UmbracoApplicationStartingNotification, DomainCacheSeedingNotificationHandler>();
|
||||
builder.AddCacheSeeding();
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="IRuntimeState"/> used by the cache startup notification handlers.
|
||||
/// </summary>
|
||||
internal static class RuntimeStateExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true when startup cache seeding should be skipped because the site is not yet serving
|
||||
/// front-end content, i.e. it is installing (or below) or upgrading with the maintenance page shown.
|
||||
/// </summary>
|
||||
/// <param name="state">The runtime state.</param>
|
||||
/// <param name="globalSettings">The global settings.</param>
|
||||
public static bool ShouldSkipStartupSeeding(this IRuntimeState state, GlobalSettings globalSettings)
|
||||
=> state.Level <= RuntimeLevel.Install
|
||||
|| (state.Level == RuntimeLevel.Upgrade && globalSettings.ShowMaintenancePageWhenInUpgradeState);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.HybridCache.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
|
||||
|
||||
internal sealed class DomainCacheSeedingNotificationHandler : INotificationHandler<UmbracoApplicationStartingNotification>
|
||||
{
|
||||
private readonly IDomainCacheService _domainCacheService;
|
||||
private readonly IRuntimeState _runtimeState;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public DomainCacheSeedingNotificationHandler(IDomainCacheService domainCacheService, IRuntimeState runtimeState, IOptions<GlobalSettings> globalSettings)
|
||||
{
|
||||
_domainCacheService = domainCacheService;
|
||||
_runtimeState = runtimeState;
|
||||
_globalSettings = globalSettings.Value;
|
||||
}
|
||||
|
||||
public void Handle(UmbracoApplicationStartingNotification notification)
|
||||
{
|
||||
if (_runtimeState.ShouldSkipStartupSeeding(_globalSettings))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Force eager population of the lazily-loaded domain cache.
|
||||
_domainCacheService.GetAll(includeWildcards: true);
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -1,11 +1,10 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.HybridCache.Services;
|
||||
using Umbraco.Cms.Infrastructure.HybridCache.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
|
||||
|
||||
@@ -29,7 +28,7 @@ internal sealed class SeedingNotificationHandler : INotificationAsyncHandler<Umb
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
if (_runtimeState.Level <= RuntimeLevel.Install || (_runtimeState.Level == RuntimeLevel.Upgrade && _globalSettings.ShowMaintenancePageWhenInUpgradeState))
|
||||
if (_runtimeState.ShouldSkipStartupSeeding(_globalSettings))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -207,21 +207,28 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<ContentCacheNode>> GetContentSourcesAsync(IEnumerable<Guid> keys, bool preview = false)
|
||||
{
|
||||
Sql<ISqlContext>? sql = SqlContentSourcesSelect()
|
||||
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
|
||||
.WhereIn<NodeDto>(x => x.UniqueId, keys)
|
||||
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
|
||||
// Batch the WHERE IN to stay within SQL Server's parameter limit.
|
||||
// The configurable document seed batch size is applied upstream; this method only enforces MaxParameterCount.
|
||||
Guid[] keysArray = keys as Guid[] ?? keys.ToArray();
|
||||
var dtos = new List<ContentSourceDto>(keysArray.Length);
|
||||
foreach (IEnumerable<Guid> group in keysArray.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Sql<ISqlContext>? sql = SqlContentSourcesSelect()
|
||||
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
|
||||
.WhereIn<NodeDto>(x => x.UniqueId, group)
|
||||
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
|
||||
|
||||
List<ContentSourceDto> dtos = await Database.FetchAsync<ContentSourceDto>(sql);
|
||||
dtos.AddRange(await Database.FetchAsync<ContentSourceDto>(sql));
|
||||
}
|
||||
|
||||
dtos = dtos
|
||||
var filtered = dtos
|
||||
.Where(x => x is not null)
|
||||
.Where(x => preview || ((x.PubDataRaw is not null || x.PubData is not null) && (!x.Published || x.PubName is not null)))
|
||||
.ToList();
|
||||
|
||||
IContentCacheDataSerializer serializer =
|
||||
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document);
|
||||
return dtos
|
||||
return filtered
|
||||
.Select(x => CreateContentNodeKit(x, serializer, preview))
|
||||
.OfType<ContentCacheNode>();
|
||||
}
|
||||
@@ -379,20 +386,27 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<ContentCacheNode>> GetMediaSourcesAsync(IEnumerable<Guid> keys)
|
||||
{
|
||||
Sql<ISqlContext>? sql = SqlMediaSourcesSelect()
|
||||
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media))
|
||||
.WhereIn<NodeDto>(x => x.UniqueId, keys)
|
||||
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
|
||||
// Batch the WHERE IN by Constants.Sql.MaxParameterCount so callers configuring
|
||||
// CacheSettings.MediaSeedBatchSize above that limit do not hit SQL Server's 2100 parameter limit.
|
||||
Guid[] keysArray = keys as Guid[] ?? keys.ToArray();
|
||||
var dtos = new List<ContentSourceDto>(keysArray.Length);
|
||||
foreach (IEnumerable<Guid> group in keysArray.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Sql<ISqlContext>? sql = SqlMediaSourcesSelect()
|
||||
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media))
|
||||
.WhereIn<NodeDto>(x => x.UniqueId, group)
|
||||
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
|
||||
|
||||
List<ContentSourceDto> dtos = await Database.FetchAsync<ContentSourceDto>(sql);
|
||||
dtos.AddRange(await Database.FetchAsync<ContentSourceDto>(sql));
|
||||
}
|
||||
|
||||
dtos = dtos
|
||||
var filtered = dtos
|
||||
.Where(x => x is not null)
|
||||
.ToList();
|
||||
|
||||
IContentCacheDataSerializer serializer =
|
||||
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Media);
|
||||
return dtos
|
||||
return filtered
|
||||
.Select(x => CreateMediaNodeKit(x, serializer));
|
||||
}
|
||||
|
||||
@@ -578,107 +592,135 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
|
||||
/// </summary>
|
||||
private List<CacheRebuildDocumentDto> GetDocumentMetadataForNodes(List<int> nodeIds)
|
||||
{
|
||||
// Query content metadata with both edit and published version info
|
||||
// Query content metadata with both edit and published version info.
|
||||
// Uses nested join pattern to ensure we only get the published ContentVersion
|
||||
// (where a DocumentVersionDto with Published=true exists)
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<NodeDto>(
|
||||
x => x.NodeId,
|
||||
x => x.UniqueId,
|
||||
x => x.Text,
|
||||
x => x.Path,
|
||||
x => x.Level,
|
||||
x => x.ParentId,
|
||||
x => x.SortOrder,
|
||||
x => x.CreateDate,
|
||||
x => Alias(x.UserId, "CreatorId"))
|
||||
.AndSelect<ContentDto>(x => x.ContentTypeId)
|
||||
.AndSelect<DocumentDto>(x => x.Published)
|
||||
.AndSelect<ContentVersionDto>(
|
||||
x => Alias(x.Id, "EditVersionId"),
|
||||
x => Alias(x.Text, "EditName"),
|
||||
x => Alias(x.VersionDate, "EditVersionDate"),
|
||||
x => Alias(x.UserId, "EditWriterId"))
|
||||
.AndSelect<ContentVersionDto>(
|
||||
"pcv",
|
||||
x => Alias(x.Id, "PublishedVersionId"),
|
||||
x => Alias(x.Text, "PublishedName"),
|
||||
x => Alias(x.VersionDate, "PublishedVersionDate"),
|
||||
x => Alias(x.UserId, "PublishedWriterId"))
|
||||
.From<NodeDto>()
|
||||
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
|
||||
.InnerJoin<DocumentDto>().On<NodeDto, DocumentDto>((n, d) => n.NodeId == d.NodeId)
|
||||
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
|
||||
// (where a DocumentVersionDto with Published=true exists).
|
||||
// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
|
||||
var results = new List<CacheRebuildDocumentDto>(nodeIds.Count);
|
||||
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<NodeDto>(
|
||||
x => x.NodeId,
|
||||
x => x.UniqueId,
|
||||
x => x.Text,
|
||||
x => x.Path,
|
||||
x => x.Level,
|
||||
x => x.ParentId,
|
||||
x => x.SortOrder,
|
||||
x => x.CreateDate,
|
||||
x => Alias(x.UserId, "CreatorId"))
|
||||
.AndSelect<ContentDto>(x => x.ContentTypeId)
|
||||
.AndSelect<DocumentDto>(x => x.Published)
|
||||
.AndSelect<ContentVersionDto>(
|
||||
x => Alias(x.Id, "EditVersionId"),
|
||||
x => Alias(x.Text, "EditName"),
|
||||
x => Alias(x.VersionDate, "EditVersionDate"),
|
||||
x => Alias(x.UserId, "EditWriterId"))
|
||||
.AndSelect<ContentVersionDto>(
|
||||
"pcv",
|
||||
x => Alias(x.Id, "PublishedVersionId"),
|
||||
x => Alias(x.Text, "PublishedName"),
|
||||
x => Alias(x.VersionDate, "PublishedVersionDate"),
|
||||
x => Alias(x.UserId, "PublishedWriterId"))
|
||||
.From<NodeDto>()
|
||||
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
|
||||
.InnerJoin<DocumentDto>().On<NodeDto, DocumentDto>((n, d) => n.NodeId == d.NodeId)
|
||||
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
|
||||
|
||||
// Nested join: ContentVersionDto "pcv" INNER JOIN DocumentVersionDto "pdv" ON published=true
|
||||
// This ensures pcv only includes rows where there's a published DocumentVersion
|
||||
.LeftJoin<ContentVersionDto>(
|
||||
j => j.InnerJoin<DocumentVersionDto>("pdv")
|
||||
.On<ContentVersionDto, DocumentVersionDto>(
|
||||
(left, right) => left.Id == right.Id && right.Published == true, "pcv", "pdv"),
|
||||
"pcv")
|
||||
// Nested join: ContentVersionDto "pcv" INNER JOIN DocumentVersionDto "pdv" ON published=true.
|
||||
// This ensures pcv only includes rows where there's a published DocumentVersion.
|
||||
.LeftJoin<ContentVersionDto>(
|
||||
j => j.InnerJoin<DocumentVersionDto>("pdv")
|
||||
.On<ContentVersionDto, DocumentVersionDto>(
|
||||
(left, right) => left.Id == right.Id && right.Published == true, "pcv", "pdv"),
|
||||
"pcv")
|
||||
|
||||
.On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId, aliasRight: "pcv")
|
||||
.WhereIn<NodeDto>(x => x.NodeId, nodeIds);
|
||||
.On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId, aliasRight: "pcv")
|
||||
.WhereIn<NodeDto>(x => x.NodeId, group);
|
||||
|
||||
return Database.Fetch<CacheRebuildDocumentDto>(sql);
|
||||
results.AddRange(Database.Fetch<CacheRebuildDocumentDto>(sql));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets property data for the specified node IDs using efficient JOIN on nodeId.
|
||||
/// This avoids the expensive WHERE IN on versionId that causes index scans.
|
||||
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
|
||||
/// </summary>
|
||||
private List<CacheRebuildPropertyDto> GetPropertyDataForNodes(List<int> nodeIds)
|
||||
{
|
||||
// JOIN through nodeId → versionId path for efficient query plan
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<PropertyDataDto>(
|
||||
x => x.VersionId,
|
||||
x => x.LanguageId,
|
||||
x => x.Segment,
|
||||
x => x.IntegerValue,
|
||||
x => x.DecimalValue,
|
||||
x => x.DateValue,
|
||||
x => x.VarcharValue,
|
||||
x => x.TextValue)
|
||||
.AndSelect<PropertyTypeDto>(x => Alias(x.Alias, "PropertyAlias"))
|
||||
.From<PropertyDataDto>()
|
||||
.InnerJoin<PropertyTypeDto>().On<PropertyDataDto, PropertyTypeDto>((pd, pt) => pd.PropertyTypeId == pt.Id)
|
||||
.InnerJoin<ContentVersionDto>().On<PropertyDataDto, ContentVersionDto>((pd, cv) => pd.VersionId == cv.Id)
|
||||
.WhereIn<ContentVersionDto>(x => x.NodeId, nodeIds);
|
||||
var results = new List<CacheRebuildPropertyDto>();
|
||||
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
// JOIN through nodeId → versionId path for efficient query plan.
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<PropertyDataDto>(
|
||||
x => x.VersionId,
|
||||
x => x.LanguageId,
|
||||
x => x.Segment,
|
||||
x => x.IntegerValue,
|
||||
x => x.DecimalValue,
|
||||
x => x.DateValue,
|
||||
x => x.VarcharValue,
|
||||
x => x.TextValue)
|
||||
.AndSelect<PropertyTypeDto>(x => Alias(x.Alias, "PropertyAlias"))
|
||||
.From<PropertyDataDto>()
|
||||
.InnerJoin<PropertyTypeDto>().On<PropertyDataDto, PropertyTypeDto>((pd, pt) => pd.PropertyTypeId == pt.Id)
|
||||
.InnerJoin<ContentVersionDto>().On<PropertyDataDto, ContentVersionDto>((pd, cv) => pd.VersionId == cv.Id)
|
||||
.WhereIn<ContentVersionDto>(x => x.NodeId, group);
|
||||
|
||||
return Database.Fetch<CacheRebuildPropertyDto>(sql);
|
||||
results.AddRange(Database.Fetch<CacheRebuildPropertyDto>(sql));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets culture variation data for the specified node IDs.
|
||||
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
|
||||
/// </summary>
|
||||
private List<CacheRebuildCultureDto> GetCultureDataForNodes(List<int> nodeIds)
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<ContentVersionCultureVariationDto>(x => x.VersionId, x => x.Name, x => x.UpdateDate)
|
||||
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
|
||||
.From<ContentVersionCultureVariationDto>()
|
||||
.InnerJoin<LanguageDto>().On<ContentVersionCultureVariationDto, LanguageDto>((cv, l) => cv.LanguageId == l.Id)
|
||||
.InnerJoin<ContentVersionDto>().On<ContentVersionCultureVariationDto, ContentVersionDto>((ccv, cv) => ccv.VersionId == cv.Id)
|
||||
.WhereIn<ContentVersionDto>(x => x.NodeId, nodeIds);
|
||||
var results = new List<CacheRebuildCultureDto>();
|
||||
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<ContentVersionCultureVariationDto>(x => x.VersionId, x => x.Name, x => x.UpdateDate)
|
||||
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
|
||||
.From<ContentVersionCultureVariationDto>()
|
||||
.InnerJoin<LanguageDto>().On<ContentVersionCultureVariationDto, LanguageDto>((cv, l) => cv.LanguageId == l.Id)
|
||||
.InnerJoin<ContentVersionDto>().On<ContentVersionCultureVariationDto, ContentVersionDto>((ccv, cv) => ccv.VersionId == cv.Id)
|
||||
.WhereIn<ContentVersionDto>(x => x.NodeId, group);
|
||||
|
||||
return Database.Fetch<CacheRebuildCultureDto>(sql);
|
||||
results.AddRange(Database.Fetch<CacheRebuildCultureDto>(sql));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets document culture variation data (edited status per culture) for the specified node IDs.
|
||||
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
|
||||
/// </summary>
|
||||
private List<CacheRebuildDocumentCultureDto> GetDocumentCultureDataForNodes(List<int> nodeIds)
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<DocumentCultureVariationDto>(x => x.NodeId, x => x.Edited)
|
||||
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
|
||||
.From<DocumentCultureVariationDto>()
|
||||
.InnerJoin<LanguageDto>().On<DocumentCultureVariationDto, LanguageDto>((dcv, l) => dcv.LanguageId == l.Id)
|
||||
.WhereIn<DocumentCultureVariationDto>(x => x.NodeId, nodeIds);
|
||||
var results = new List<CacheRebuildDocumentCultureDto>();
|
||||
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<DocumentCultureVariationDto>(x => x.NodeId, x => x.Edited)
|
||||
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
|
||||
.From<DocumentCultureVariationDto>()
|
||||
.InnerJoin<LanguageDto>().On<DocumentCultureVariationDto, LanguageDto>((dcv, l) => dcv.LanguageId == l.Id)
|
||||
.WhereIn<DocumentCultureVariationDto>(x => x.NodeId, group);
|
||||
|
||||
return Database.Fetch<CacheRebuildDocumentCultureDto>(sql);
|
||||
results.AddRange(Database.Fetch<CacheRebuildDocumentCultureDto>(sql));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1207,31 +1249,38 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
|
||||
|
||||
/// <summary>
|
||||
/// Gets content metadata for the specified node IDs using efficient JOIN. Used for media and members.
|
||||
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
|
||||
/// </summary>
|
||||
private List<CacheRebuildContentDto> GetContentMetadataForNodes(List<int> nodeIds)
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<NodeDto>(
|
||||
x => x.NodeId,
|
||||
x => x.UniqueId,
|
||||
x => x.Text,
|
||||
x => x.Path,
|
||||
x => x.Level,
|
||||
x => x.ParentId,
|
||||
x => x.SortOrder,
|
||||
x => x.CreateDate,
|
||||
x => Alias(x.UserId, "CreatorId"))
|
||||
.AndSelect<ContentDto>(x => x.ContentTypeId)
|
||||
.AndSelect<ContentVersionDto>(
|
||||
x => Alias(x.Id, "VersionId"),
|
||||
x => Alias(x.VersionDate, "VersionDate"),
|
||||
x => Alias(x.UserId, "WriterId"))
|
||||
.From<NodeDto>()
|
||||
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
|
||||
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
|
||||
.WhereIn<NodeDto>(x => x.NodeId, nodeIds);
|
||||
var results = new List<CacheRebuildContentDto>(nodeIds.Count);
|
||||
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<NodeDto>(
|
||||
x => x.NodeId,
|
||||
x => x.UniqueId,
|
||||
x => x.Text,
|
||||
x => x.Path,
|
||||
x => x.Level,
|
||||
x => x.ParentId,
|
||||
x => x.SortOrder,
|
||||
x => x.CreateDate,
|
||||
x => Alias(x.UserId, "CreatorId"))
|
||||
.AndSelect<ContentDto>(x => x.ContentTypeId)
|
||||
.AndSelect<ContentVersionDto>(
|
||||
x => Alias(x.Id, "VersionId"),
|
||||
x => Alias(x.VersionDate, "VersionDate"),
|
||||
x => Alias(x.UserId, "WriterId"))
|
||||
.From<NodeDto>()
|
||||
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
|
||||
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
|
||||
.WhereIn<NodeDto>(x => x.NodeId, group);
|
||||
|
||||
return Database.Fetch<CacheRebuildContentDto>(sql);
|
||||
results.AddRange(Database.Fetch<CacheRebuildContentDto>(sql));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -38,6 +38,20 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
|
||||
private readonly ConcurrentDictionary<string, IPublishedContent> _publishedContentCache = [];
|
||||
|
||||
// Monotonic counter bumped whenever the in-memory cache (L0/L1) is invalidated or refreshed.
|
||||
// GetNodeAsync captures it before reading the backing store and re-checks it before writing
|
||||
// back, so a snapshot read before a concurrent publish/refresh is never written over the
|
||||
// refreshed entry — preventing the stale-set clobber that otherwise persists until a full clear.
|
||||
//
|
||||
// Deliberately a single global counter, not per-key: any invalidation invalidates every in-flight
|
||||
// read-through. The only cost is an occasional skipped cache population when a read-through for one
|
||||
// key overlaps an unrelated publish — a re-miss on the next request, never stale data. A per-key
|
||||
// scheme would avoid that but needs a global epoch for bulk clears plus an exact per-key bump on
|
||||
// every mutated cache key, which is easy to get wrong and would silently reintroduce the clobber.
|
||||
// Global is correctness-robust; only revisit if read-through churn under heavy concurrent
|
||||
// publishing ever shows up in profiling.
|
||||
private long _cacheGeneration;
|
||||
|
||||
private HashSet<Guid> SeedKeys
|
||||
{
|
||||
get
|
||||
@@ -129,15 +143,28 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
}
|
||||
|
||||
(bool exists, ContentCacheNode? contentCacheNode) = await _hybridCache.TryGetValueAsync<ContentCacheNode?>(cacheKey, CancellationToken.None);
|
||||
|
||||
// A value found in the backing store is already current, so it can always populate the caches
|
||||
// below; only a value built from the read-through DB fetch needs the generation guard.
|
||||
bool snapshotIsCurrent = true;
|
||||
if (exists is false)
|
||||
{
|
||||
// Capture the cache generation before reading the backing store. If a concurrent publish or
|
||||
// invalidation bumps the generation while we read and build below, the snapshot we hold is
|
||||
// stale and must not be written back over the refreshed entries (the clobber that leaves
|
||||
// memory permanently stale until a full clear).
|
||||
long generation = Interlocked.Read(ref _cacheGeneration);
|
||||
|
||||
bool ancestorCheckFailed;
|
||||
(contentCacheNode, ancestorCheckFailed) = await GetContentCacheNodeFromRepo();
|
||||
|
||||
snapshotIsCurrent = IsCacheGenerationCurrent(generation);
|
||||
|
||||
// Only cache the result if the ancestor check didn't fail.
|
||||
// When content exists in DB but the ancestor check fails, this could be a transient
|
||||
// race condition during cache rebuild. Caching null would poison the distributed cache.
|
||||
if (ancestorCheckFailed is false)
|
||||
// Skip the write when the generation moved — a refresh has superseded this snapshot.
|
||||
if (ancestorCheckFailed is false && snapshotIsCurrent)
|
||||
{
|
||||
await _hybridCache.SetAsync(
|
||||
cacheKey,
|
||||
@@ -153,7 +180,10 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
}
|
||||
|
||||
IPublishedContent? result = _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview).CreateModel(_publishedModelFactory);
|
||||
if (result is not null)
|
||||
|
||||
// Only populate the L0 cache when our snapshot is still current; otherwise a concurrent
|
||||
// refresh has already written fresher content and we must not overwrite it with this one.
|
||||
if (result is not null && snapshotIsCurrent)
|
||||
{
|
||||
_publishedContentCache[cacheKey] = result;
|
||||
}
|
||||
@@ -185,6 +215,13 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
|
||||
private bool GetPreview() => _previewService.IsInPreview();
|
||||
|
||||
// Bumped after every in-memory cache invalidation/refresh so in-flight read-through snapshots
|
||||
// (see GetNodeAsync) can detect they have been superseded and skip writing back stale content.
|
||||
private void InvalidateMemoryCacheGeneration() => Interlocked.Increment(ref _cacheGeneration);
|
||||
|
||||
private bool IsCacheGenerationCurrent(long capturedGeneration)
|
||||
=> Interlocked.Read(ref _cacheGeneration) == capturedGeneration;
|
||||
|
||||
public IEnumerable<IPublishedContent> GetByContentType(IPublishedContentType contentType)
|
||||
{
|
||||
using ICoreScope scope = _scopeProvider.CreateCoreScope();
|
||||
@@ -198,6 +235,10 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
|
||||
public async Task ClearMemoryCacheAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Bump first so any read-through that read the backing store before this clear is rejected
|
||||
// when it tries to write back, even while the reseed below is still running.
|
||||
InvalidateMemoryCacheGeneration();
|
||||
|
||||
_publishedContentCache.Clear();
|
||||
await _hybridCache.RemoveByTagAsync(Constants.Cache.Tags.Content, cancellationToken);
|
||||
|
||||
@@ -227,11 +268,13 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
var cacheKey = GetCacheKey(publishedNode.Key, false);
|
||||
await _hybridCache.SetAsync(cacheKey, publishedNode, GetEntryOptions(publishedNode.Key, false), GenerateTags(publishedNode));
|
||||
_publishedContentCache.Remove(cacheKey, out _);
|
||||
InvalidateMemoryCacheGeneration();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Either no published node in the database cache, or the ancestor path is no longer published —
|
||||
// remove any stale published entry from the local memory cache.
|
||||
// remove any stale published entry from the local memory cache. ClearPublishedCacheAsync
|
||||
// bumps the generation itself, so this path is already covered.
|
||||
await ClearPublishedCacheAsync(key);
|
||||
}
|
||||
|
||||
@@ -423,12 +466,17 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
ClearConvertedContentCache(contentTypeIdsAsArray);
|
||||
}
|
||||
|
||||
public void ClearConvertedContentCache() => _publishedContentCache.Clear();
|
||||
public void ClearConvertedContentCache()
|
||||
{
|
||||
_publishedContentCache.Clear();
|
||||
InvalidateMemoryCacheGeneration();
|
||||
}
|
||||
|
||||
public void ClearConvertedContentCache(IReadOnlyCollection<int> contentTypeIds)
|
||||
{
|
||||
var ids = contentTypeIds as int[] ?? contentTypeIds.ToArray();
|
||||
_publishedContentCache.RemoveAll(content => ids.Contains(content.Value.ContentType.Id));
|
||||
InvalidateMemoryCacheGeneration();
|
||||
}
|
||||
|
||||
private async Task ClearPublishedCacheAsync(Guid key)
|
||||
@@ -436,6 +484,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
var cacheKey = GetCacheKey(key, false);
|
||||
await _hybridCache.RemoveAsync(cacheKey);
|
||||
_publishedContentCache.Remove(cacheKey, out _);
|
||||
InvalidateMemoryCacheGeneration();
|
||||
}
|
||||
|
||||
private static string ContentTypeIdTag(int contentTypeId)
|
||||
|
||||
@@ -34,6 +34,20 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, IPublishedContent> _publishedContentCache = [];
|
||||
|
||||
// Monotonic counter bumped whenever the in-memory cache (L0/L1) is invalidated or refreshed.
|
||||
// GetNodeAsync captures it before reading the backing store and re-checks it before writing
|
||||
// back, so a snapshot read before a concurrent refresh is never written over the refreshed
|
||||
// entry — preventing the stale-set clobber that otherwise persists until a full clear.
|
||||
//
|
||||
// Deliberately a single global counter, not per-key: any invalidation invalidates every in-flight
|
||||
// read-through. The only cost is an occasional skipped cache population when a read-through for one
|
||||
// key overlaps an unrelated refresh — a re-miss on the next request, never stale data. A per-key
|
||||
// scheme would avoid that but needs a global epoch for bulk clears plus an exact per-key bump on
|
||||
// every mutated cache key, which is easy to get wrong and would silently reintroduce the clobber.
|
||||
// Global is correctness-robust; only revisit if read-through churn under heavy concurrent
|
||||
// refreshing ever shows up in profiling.
|
||||
private long _cacheGeneration;
|
||||
|
||||
private HashSet<Guid>? _seedKeys;
|
||||
private HashSet<Guid> SeedKeys
|
||||
{
|
||||
@@ -124,11 +138,24 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
|
||||
string cacheKey = GetCacheKey(key);
|
||||
(bool exists, ContentCacheNode? contentCacheNode) = await _hybridCache.TryGetValueAsync<ContentCacheNode?>(cacheKey, CancellationToken.None);
|
||||
|
||||
// A value found in the backing store is already current, so it can always populate the caches
|
||||
// below; only a value built from the read-through DB fetch needs the generation guard.
|
||||
bool snapshotIsCurrent = true;
|
||||
if (exists is false)
|
||||
{
|
||||
// Capture the cache generation before reading the backing store. If a concurrent refresh or
|
||||
// invalidation bumps the generation while we read and build below, the snapshot we hold is
|
||||
// stale and must not be written back over the refreshed entries (the clobber that leaves
|
||||
// memory permanently stale until a full clear).
|
||||
long generation = Interlocked.Read(ref _cacheGeneration);
|
||||
|
||||
contentCacheNode = await GetContentCacheNodeFromRepo();
|
||||
snapshotIsCurrent = IsCacheGenerationCurrent(generation);
|
||||
|
||||
// We don't want to cache removed items, this may cause issues if the L2 serializer changes.
|
||||
if (contentCacheNode is not null)
|
||||
// Skip the write when the generation moved — a refresh has superseded this snapshot.
|
||||
if (contentCacheNode is not null && snapshotIsCurrent)
|
||||
{
|
||||
await _hybridCache.SetAsync(
|
||||
cacheKey,
|
||||
@@ -144,7 +171,10 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
}
|
||||
|
||||
IPublishedContent? result = _publishedContentFactory.ToIPublishedMedia(contentCacheNode).CreateModel(_publishedModelFactory);
|
||||
if (result is not null)
|
||||
|
||||
// Only populate the L0 cache when our snapshot is still current; otherwise a concurrent
|
||||
// refresh has already written fresher content and we must not overwrite it with this one.
|
||||
if (result is not null && snapshotIsCurrent)
|
||||
{
|
||||
_publishedContentCache[key] = result;
|
||||
}
|
||||
@@ -160,6 +190,13 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
}
|
||||
}
|
||||
|
||||
// Bumped after every in-memory cache invalidation/refresh so in-flight read-through snapshots
|
||||
// (see GetNodeAsync) can detect they have been superseded and skip writing back stale content.
|
||||
private void InvalidateMemoryCacheGeneration() => Interlocked.Increment(ref _cacheGeneration);
|
||||
|
||||
private bool IsCacheGenerationCurrent(long capturedGeneration)
|
||||
=> Interlocked.Read(ref _cacheGeneration) == capturedGeneration;
|
||||
|
||||
public async Task<bool> HasContentByIdAsync(int id)
|
||||
{
|
||||
Attempt<Guid> keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Media);
|
||||
@@ -186,6 +223,7 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
var cacheNode = _cacheNodeFactory.ToContentCacheNode(media);
|
||||
await _databaseCacheRepository.RefreshMediaAsync(cacheNode);
|
||||
_publishedContentCache.Remove(media.Key, out _);
|
||||
InvalidateMemoryCacheGeneration();
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
@@ -263,9 +301,12 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
{
|
||||
await _hybridCache.SetAsync(GetCacheKey(publishedNode.Key), publishedNode, GetEntryOptions(publishedNode.Key));
|
||||
_publishedContentCache.Remove(key, out _);
|
||||
InvalidateMemoryCacheGeneration();
|
||||
}
|
||||
else
|
||||
{
|
||||
// RemoveFromMemoryCacheAsync → ClearPublishedCacheAsync bumps the generation itself,
|
||||
// so this path is already covered.
|
||||
await RemoveFromMemoryCacheAsync(key);
|
||||
}
|
||||
|
||||
@@ -274,6 +315,10 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
|
||||
public async Task ClearMemoryCacheAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Bump first so any read-through that read the backing store before this clear is rejected
|
||||
// when it tries to write back, even while the reseed below is still running.
|
||||
InvalidateMemoryCacheGeneration();
|
||||
|
||||
_publishedContentCache.Clear();
|
||||
await _hybridCache.RemoveByTagAsync(Constants.Cache.Tags.Media, cancellationToken);
|
||||
|
||||
@@ -295,12 +340,17 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
ClearConvertedContentCache(mediaTypeIdsAsArray);
|
||||
}
|
||||
|
||||
public void ClearConvertedContentCache() => _publishedContentCache.Clear();
|
||||
public void ClearConvertedContentCache()
|
||||
{
|
||||
_publishedContentCache.Clear();
|
||||
InvalidateMemoryCacheGeneration();
|
||||
}
|
||||
|
||||
public void ClearConvertedContentCache(IReadOnlyCollection<int> mediaTypeIds)
|
||||
{
|
||||
var ids = mediaTypeIds as int[] ?? mediaTypeIds.ToArray();
|
||||
_publishedContentCache.RemoveAll(content => ids.Contains(content.Value.ContentType.Id));
|
||||
InvalidateMemoryCacheGeneration();
|
||||
}
|
||||
|
||||
public void Rebuild(IReadOnlyCollection<int> contentTypeIds)
|
||||
@@ -357,6 +407,7 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
{
|
||||
await _hybridCache.RemoveAsync(GetCacheKey(key));
|
||||
_publishedContentCache.Remove(key, out _);
|
||||
InvalidateMemoryCacheGeneration();
|
||||
}
|
||||
|
||||
private static string MediaTypeIdTag(int mediaTypeId)
|
||||
|
||||
@@ -77,6 +77,8 @@ public class UmbracoApplicationBuilder : IUmbracoApplicationBuilder, IUmbracoEnd
|
||||
// Only use backoffice rewrites if backoffice is enabled
|
||||
if (ApplicationServices.GetService<IBackOfficeEnabledMarker>() is not null)
|
||||
{
|
||||
// Must run before the rewriter so the cache-bust hash is still present on the request path.
|
||||
AppBuilder.UseUmbracoBackOfficeCacheHeaders();
|
||||
AppBuilder.UseUmbracoBackOfficeRewrites();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,120 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Net;
|
||||
using Umbraco.Cms.Core.Web;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Web.Common.AspNetCore;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the current session identifier and reads, writes and clears session values using the
|
||||
/// ASP.NET Core <see cref="ISession" /> exposed on the current <see cref="HttpContext" />.
|
||||
/// </summary>
|
||||
internal sealed class AspNetCoreSessionManager : ISessionIdResolver, ISessionManager
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly IOptions<SessionOptions> _sessionOptions;
|
||||
private readonly IOptionsMonitor<LoggingSettings> _loggingSettings;
|
||||
|
||||
public AspNetCoreSessionManager(IHttpContextAccessor httpContextAccessor) =>
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
|
||||
public string? SessionId
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AspNetCoreSessionManager" /> class.
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">Provides access to the current <see cref="HttpContext" />.</param>
|
||||
/// <param name="sessionOptions">The configured session options, used to determine the session cookie name.</param>
|
||||
/// <param name="loggingSettings">The logging settings, used to determine how the session id is resolved for log enrichment.</param>
|
||||
public AspNetCoreSessionManager(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IOptions<SessionOptions> sessionOptions,
|
||||
IOptionsMonitor<LoggingSettings> loggingSettings)
|
||||
{
|
||||
get
|
||||
{
|
||||
HttpContext? httpContext = _httpContextAccessor.HttpContext;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_sessionOptions = sessionOptions;
|
||||
_loggingSettings = loggingSettings;
|
||||
}
|
||||
|
||||
return IsSessionsAvailable
|
||||
? httpContext?.Session.Id
|
||||
: "0";
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// The resolved value depends on <see cref="LoggingSettings.SessionIdLogging" />: the actual session id
|
||||
/// (default), a one-way hash of the session cookie, or nothing.
|
||||
/// </remarks>
|
||||
public string? SessionId =>
|
||||
_loggingSettings.CurrentValue.SessionIdLogging switch
|
||||
{
|
||||
SessionIdLoggingMode.None => null,
|
||||
SessionIdLoggingMode.CookieHash => ResolveSessionCookieHash(),
|
||||
_ => ResolveSessionId(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the actual ASP.NET Core session id, but only when an established session cookie is present.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reading Session.Id forces a synchronous, blocking load from the session store. When sessions are
|
||||
/// backed by IDistributedCache (e.g. load-balanced setups), that is a network round-trip incurred on
|
||||
/// every request that resolves the id for logging - even anonymous requests that never use session.
|
||||
/// Only an established session sends back the session cookie, so its absence means there is nothing
|
||||
/// meaningful to load (see #23082).
|
||||
/// </remarks>
|
||||
private string? ResolveSessionId()
|
||||
{
|
||||
if (IsSessionsAvailable is false)
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
HttpContext? httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext is null || TryGetSessionCookieValue(httpContext, out _) is false)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return httpContext.Session.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If session isn't enabled this will throw an exception so we check
|
||||
/// Resolves a one-way hash of the session cookie value, which correlates requests to the same session
|
||||
/// without loading the session from its store.
|
||||
/// </summary>
|
||||
private bool IsSessionsAvailable => !(_httpContextAccessor.HttpContext?.Features.Get<ISessionFeature>()?.Session is null);
|
||||
private string? ResolveSessionCookieHash()
|
||||
{
|
||||
HttpContext? httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext is null || TryGetSessionCookieValue(httpContext, out var cookieValue) is false)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Never log the raw cookie value - it is effectively a bearer token for the session. A one-way hash
|
||||
// preserves per-session correlation without exposing the cookie and without loading the session.
|
||||
return cookieValue!.GenerateHash<SHA256>();
|
||||
}
|
||||
|
||||
private bool TryGetSessionCookieValue(HttpContext httpContext, out string? value)
|
||||
{
|
||||
var sessionCookieName = _sessionOptions.Value.Cookie.Name;
|
||||
if (sessionCookieName is null)
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return httpContext.Request.Cookies.TryGetValue(sessionCookieName, out value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether session is available for the current request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Accessing <see cref="HttpContext.Session" /> throws an <see cref="InvalidOperationException" /> when the
|
||||
/// session middleware has not been configured (i.e. <c>UseSession</c> was not called), so this is checked
|
||||
/// before reading from or writing to the session.
|
||||
/// </remarks>
|
||||
private bool IsSessionsAvailable => _httpContextAccessor.HttpContext?.Features.Get<ISessionFeature>()?.Session is not null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? GetSessionValue(string key)
|
||||
{
|
||||
if (!IsSessionsAvailable)
|
||||
@@ -39,6 +125,7 @@ internal sealed class AspNetCoreSessionManager : ISessionIdResolver, ISessionMan
|
||||
return _httpContextAccessor.HttpContext?.Session.GetString(key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetSessionValue(string key, string value)
|
||||
{
|
||||
if (!IsSessionsAvailable)
|
||||
@@ -49,6 +136,7 @@ internal sealed class AspNetCoreSessionManager : ISessionIdResolver, ISessionMan
|
||||
_httpContextAccessor.HttpContext?.Session.SetString(key, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearSessionValue(string key)
|
||||
{
|
||||
if (!IsSessionsAvailable)
|
||||
|
||||
@@ -63,7 +63,8 @@ Umbraco.Web.Common/
|
||||
│ └── UmbracoPublishedContentCultureProvider.cs
|
||||
├── Middleware/
|
||||
│ ├── BootFailedMiddleware.cs # Startup failure handling (81 lines)
|
||||
│ └── PreviewAuthenticationMiddleware.cs # Preview mode auth (84 lines)
|
||||
│ ├── PreviewAuthenticationMiddleware.cs # Preview mode auth (84 lines)
|
||||
│ └── UmbracoBackOfficeCacheHeadersMiddleware.cs # Cache-Control on cache-busted backoffice asset path
|
||||
├── Routing/
|
||||
│ ├── IAreaRoutes.cs # Area routing interface
|
||||
│ ├── IRoutableDocumentFilter.cs # Content routing filter
|
||||
@@ -256,6 +257,8 @@ ASP.NET Core Identity sign-in manager for members.
|
||||
|
||||
### Middleware
|
||||
|
||||
**Convention**: middleware lives in `Middleware/` as a class implementing `IMiddleware`, registered as a singleton next to its dependencies' registration (generic middleware in `AddWebComponents`; feature-specific middleware where the feature's services are added, e.g. backoffice middleware in `AddBackOfficeCore`), and wired into the pipeline via `app.UseMiddleware<TMiddleware>()`. Companion `IApplicationBuilder` extension methods are thin one-line `UseMiddleware<T>()` wrappers — inline `builder.Use(async …)` lambdas bypass DI and are harder to test; `CspNonceExtensions` and `Web.UI/WebApplicationExtensions` are tiny pre-existing exceptions, not a precedent for new work.
|
||||
|
||||
**BootFailedMiddleware** (lines 17-81):
|
||||
- Intercepts requests when `RuntimeLevel == BootFailed`
|
||||
- Debug mode: Rethrows exception for stack trace
|
||||
@@ -266,6 +269,11 @@ ASP.NET Core Identity sign-in manager for members.
|
||||
- Skips client-side requests and backoffice paths
|
||||
- Uses `IPreviewService.TryGetPreviewClaimsIdentityAsync()`
|
||||
|
||||
**UmbracoBackOfficeCacheHeadersMiddleware**:
|
||||
- Sets `Cache-Control: public, max-age=31536000, immutable` on responses under the cache-busted backoffice asset prefix (`/umbraco/backoffice/<hash>/…`); `no-cache` in debug mode
|
||||
- Runs before `UseUmbracoBackOfficeRewrites` so the original (hash-bearing) path can be matched
|
||||
- Non-destructive: uses `Response.OnStarting` + `ContainsKey` guard so any consumer override wins
|
||||
|
||||
---
|
||||
|
||||
## 4. Routing
|
||||
|
||||
@@ -229,6 +229,19 @@ public static class ApplicationBuilderExtensions
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers <see cref="UmbracoBackOfficeCacheHeadersMiddleware"/> to set the default
|
||||
/// <c>Cache-Control</c> header on responses served from the cache-busted BackOffice assets path.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See <see cref="UmbracoBackOfficeCacheHeadersMiddleware"/> for behaviour, debug-mode semantics,
|
||||
/// and the precedence rules for consumer overrides. Must be registered before
|
||||
/// <see cref="UseUmbracoBackOfficeRewrites"/> so that the original request path (still containing
|
||||
/// the cache-bust hash) can be matched.
|
||||
/// </remarks>
|
||||
public static IApplicationBuilder UseUmbracoBackOfficeCacheHeaders(this IApplicationBuilder builder)
|
||||
=> builder.UseMiddleware<UmbracoBackOfficeCacheHeadersMiddleware>();
|
||||
|
||||
/// <summary>
|
||||
/// Configure a virtual path with IApplicationBuilder.UseRewriter for BackOffice assets to allow cache-busting using the url
|
||||
/// /umbraco/backoffice/!cache-busting-id!/assets/index.js => /umbraco/backoffice/assets/index.js.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using Umbraco.Cms.Web.Common.Hosting;
|
||||
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
|
||||
|
||||
namespace Umbraco.Cms.Web.Common.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the default <c>Cache-Control</c> response header on requests served from the cache-busted
|
||||
/// BackOffice assets path (<c>/umbraco/backoffice/<hash>/...</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The path prefix contains a deployment-wide hash derived from the Umbraco version
|
||||
/// (see <see cref="IBackOfficePathGenerator.BackOfficeCacheBustHash"/>). Because the URL itself
|
||||
/// changes whenever the version changes, all responses served under that prefix are safe to mark
|
||||
/// as <c>immutable</c> with a long <c>max-age</c>, regardless of whether the on-disk filename
|
||||
/// contains a content hash.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// In debug mode the underlying built assets may change while the app is running (typically
|
||||
/// from a developer rebuilding the backoffice without restarting the host). The header is
|
||||
/// therefore set to <c>no-cache</c>, which still allows the browser to store the response
|
||||
/// but forces an <c>If-None-Match</c> revalidation on the next request — yielding fast 304s
|
||||
/// when nothing has changed and full 200s when the file on disk has been rebuilt.
|
||||
/// <c>no-store</c> would force a full re-download on every request, which is unnecessary.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This middleware is non-destructive to consumer customisation:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// The header is only set when no <c>Cache-Control</c> value is already present on the
|
||||
/// response, so synchronous overrides written upstream (including
|
||||
/// <c>StaticFileOptions.OnPrepareResponse</c>) take precedence.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// The header is set via <c>HttpResponse.OnStarting</c>; consumer callbacks registered
|
||||
/// later in the pipeline fire first (LIFO) and can therefore override the default.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// Non-2xx responses (e.g. 404) are not marked as immutable to avoid long-lived caching
|
||||
/// of error responses.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Must run before <see cref="Umbraco.Extensions.ApplicationBuilderExtensions.UseUmbracoBackOfficeRewrites"/>
|
||||
/// so the original request path (still containing the cache-bust hash) can be matched.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <seealso cref="Microsoft.AspNetCore.Http.IMiddleware" />
|
||||
public class UmbracoBackOfficeCacheHeadersMiddleware : IMiddleware
|
||||
{
|
||||
private readonly string _prefix;
|
||||
private readonly string _headerValue;
|
||||
|
||||
public UmbracoBackOfficeCacheHeadersMiddleware(
|
||||
IBackOfficePathGenerator backOfficePathGenerator,
|
||||
IHostingEnvironment hostingEnvironment)
|
||||
{
|
||||
// Normalise to a single leading slash, no trailing slash — defensive against any
|
||||
// future change in IBackOfficePathGenerator's output shape.
|
||||
_prefix = "/" + backOfficePathGenerator.BackOfficeAssetsPath.TrimStart('/').TrimEnd('/');
|
||||
_headerValue = hostingEnvironment.IsDebugMode
|
||||
? "no-cache"
|
||||
: "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
|
||||
{
|
||||
if (IsCacheableAssetRequest(context.Request))
|
||||
{
|
||||
context.Response.OnStarting(static state =>
|
||||
{
|
||||
(HttpResponse response, string value) = ((HttpResponse, string))state;
|
||||
if (ShouldSetCacheControl(response))
|
||||
{
|
||||
response.Headers[HeaderNames.CacheControl] = value;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}, (context.Response, _headerValue));
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
|
||||
// Only GET/HEAD: POST/PUT/DELETE responses aren't cacheable in the immutable sense and
|
||||
// OPTIONS is used for CORS preflight, where a long cache lifetime would prevent the
|
||||
// browser from re-issuing preflights when needed.
|
||||
private bool IsCacheableAssetRequest(HttpRequest request)
|
||||
=> (HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method))
|
||||
&& request.Path.StartsWithSegments(_prefix, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Include 304 alongside 2xx: intermediate caches (CDNs, proxies) use the Cache-Control on
|
||||
// the 304 response to update freshness for the cached body.
|
||||
private static bool ShouldSetCacheControl(HttpResponse response)
|
||||
=> response.StatusCode is (>= 200 and < 300) or 304
|
||||
&& !response.Headers.ContainsKey(HeaderNames.CacheControl);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { manifests as propertyEditorManifests } from '../src/packages/property-e
|
||||
import { manifests as publishCacheManifests } from '../src/packages/publish-cache/umbraco-package';
|
||||
import { manifests as relationsManifests } from '../src/packages/relations/umbraco-package';
|
||||
import { manifests as rteManifests } from '../src/packages/rte/umbraco-package';
|
||||
import { manifests as searchManifests } from '../src/packages/core/search/manifests';
|
||||
import { manifests as segmentManifests } from '../src/packages/segment/umbraco-package';
|
||||
import { manifests as settingsManifests } from '../src/packages/settings/umbraco-package';
|
||||
import { manifests as staticFileManifests } from '../src/packages/static-file/umbraco-package';
|
||||
@@ -188,25 +189,25 @@ export const parameters = {
|
||||
},
|
||||
},
|
||||
backgrounds: {
|
||||
options: {
|
||||
greyish: {
|
||||
options: {
|
||||
greyish: {
|
||||
name: 'Greyish',
|
||||
value: '#F3F3F5',
|
||||
},
|
||||
|
||||
white: {
|
||||
white: {
|
||||
name: 'White',
|
||||
value: '#ffffff',
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
setCustomElements(customElementManifests);
|
||||
export const tags = ['autodocs'];
|
||||
|
||||
export const initialGlobals = {
|
||||
backgrounds: {
|
||||
value: 'greyish'
|
||||
}
|
||||
backgrounds: {
|
||||
value: 'greyish'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createImportMap } from '../importmap/index.js';
|
||||
|
||||
const excludeTheseMaps = [
|
||||
'@umbraco-cms/backoffice/models',
|
||||
'@umbraco-cms/backoffice/markdown-editor',
|
||||
'@umbraco-cms/backoffice/markdown-editor', // Excluded because it loads Monaco Editor which fails to load workers in the test environment
|
||||
'@umbraco-cms/backoffice/external/',
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { createImportMap } from '../importmap/index.js';
|
||||
|
||||
const ILLEGAL_CORE_IMPORTS_THRESHOLD = 5;
|
||||
const SELF_IMPORTS_THRESHOLD = 0;
|
||||
const BIDIRECTIONAL_IMPORTS_THRESHOLD = 15;
|
||||
const CORE_MODULES_BIDIRECTIONAL_IMPORTS_THRESHOLD = 16;
|
||||
const PACKAGES_MODULES_BIDIRECTIONAL_IMPORTS_THRESHOLD = 14;
|
||||
|
||||
const clientProjectRoot = path.resolve(import.meta.dirname, '../../');
|
||||
const modulePrefix = '@umbraco-cms/backoffice/';
|
||||
@@ -187,13 +188,13 @@ function reportSelfImportsFromModules() {
|
||||
console.log(`\n\n`);
|
||||
}
|
||||
|
||||
function reportBidirectionalModuleImports() {
|
||||
console.error(`🔍 Scanning all modules for bidirectional imports...`);
|
||||
function reportBidirectionalModuleImports(modules, label, threshold) {
|
||||
console.error(`🔍 Scanning all ${label} modules for bidirectional imports...`);
|
||||
console.log(`\n`);
|
||||
|
||||
let entries = [];
|
||||
|
||||
packageModules.forEach(([alias, path]) => {
|
||||
modules.forEach(([alias, path]) => {
|
||||
const importsInModule = getUmbracoModuleImportsInModule(alias);
|
||||
|
||||
// Check imports for all the modules
|
||||
@@ -216,16 +217,12 @@ function reportBidirectionalModuleImports() {
|
||||
console.error(`🚨 ${moduleA} and ${moduleB} are importing each other`);
|
||||
});
|
||||
|
||||
if (total > BIDIRECTIONAL_IMPORTS_THRESHOLD) {
|
||||
throw new Error(
|
||||
`Bidirectional imports found in ${total} modules. ${total - BIDIRECTIONAL_IMPORTS_THRESHOLD} more than the threshold.`,
|
||||
);
|
||||
if (total > threshold) {
|
||||
throw new Error(`Bidirectional imports found in ${total} modules. ${total - threshold} more than the threshold.`);
|
||||
} else if (total === 0) {
|
||||
console.log(`✅ Success! No bidirectional imports found.`);
|
||||
} else {
|
||||
console.log(
|
||||
`✅ Success! Still (${total}) under the threshold of ${BIDIRECTIONAL_IMPORTS_THRESHOLD} bidirectional imports.`,
|
||||
);
|
||||
console.log(`✅ Success! Still (${total}) under the threshold of ${threshold} bidirectional imports.`);
|
||||
}
|
||||
|
||||
console.log(`\n\n`);
|
||||
@@ -234,7 +231,8 @@ function reportBidirectionalModuleImports() {
|
||||
function report() {
|
||||
reportIllegalImportsFromCore();
|
||||
reportSelfImportsFromModules();
|
||||
reportBidirectionalModuleImports();
|
||||
reportBidirectionalModuleImports(coreModules, 'Core', CORE_MODULES_BIDIRECTIONAL_IMPORTS_THRESHOLD);
|
||||
reportBidirectionalModuleImports(packageModules, 'Packages', PACKAGES_MODULES_BIDIRECTIONAL_IMPORTS_THRESHOLD);
|
||||
}
|
||||
|
||||
report();
|
||||
|
||||
@@ -651,3 +651,60 @@ if (state.held?.some((l) => l.name === 'umb:token-refresh')) {
|
||||
Note: there is a TOCTOU gap between `query()` and `request()`. If the lock releases between the two calls, `request()` acquires and releases immediately — this is harmless.
|
||||
|
||||
|
||||
### Routing (`umb-router-slot` + dynamic routes)
|
||||
|
||||
When a view owns an `umb-router-slot` and computes its routes from observable data (e.g. workspace/design editors), three behaviours of the slot must be respected. Getting any one of them wrong leaves the view stuck on a path it cannot recover from.
|
||||
|
||||
**Guard the slot until routes are populated**
|
||||
|
||||
If `umb-router-slot` mounts with `routes = undefined` (or an early/empty array), it fires `init`/`change` against whatever the URL currently is, settles on that local path, and does **not** re-match the URL when the routes array is replaced later. Always wrap the slot:
|
||||
|
||||
```typescript
|
||||
// ❌ Slot mounts with undefined routes, locks in the wrong active path
|
||||
return html`<umb-router-slot .routes=${this._routes}></umb-router-slot>`;
|
||||
|
||||
// ✅ Slot only mounts once real routes are in hand
|
||||
return html`
|
||||
${this._routes
|
||||
? html`<umb-router-slot .routes=${this._routes}></umb-router-slot>`
|
||||
: nothing}
|
||||
`;
|
||||
```
|
||||
|
||||
`umb-workspace-editor` (`packages/core/workspace/components/workspace-editor/workspace-editor.element.ts`) uses this guard; views that build their own router-slot must do the same.
|
||||
|
||||
**Don't compute routes against not-yet-loaded data**
|
||||
|
||||
Helpers like `UmbContentTypeContainerStructureHelper.childContainers` ship with `[]` as their initial value, so the first observer callback fires synchronously with empty data. If `#createRoutes()` runs at that point, the slot sees a wrong route set first. Await the structure load before wiring the helper:
|
||||
|
||||
```typescript
|
||||
this.consumeContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT, async (workspaceContext) => {
|
||||
this.#workspaceContext = workspaceContext;
|
||||
if (!workspaceContext) return;
|
||||
|
||||
// Block route generation until real containers are loaded
|
||||
await workspaceContext.structure.whenLoaded();
|
||||
|
||||
this.#tabsStructureHelper.setStructureManager(workspaceContext.structure);
|
||||
this.#observeRootGroups();
|
||||
});
|
||||
```
|
||||
|
||||
**`redirectTo` doesn't fire on the initial route attachment**
|
||||
|
||||
The router-slot library only applies `redirectTo` on navigation events, not when routes are first attached. A `path: ''` route with `redirectTo: 'foo'` will leave the slot sitting on the empty local path forever. Use **route duplication** instead — copy the target route onto the empty path:
|
||||
|
||||
```typescript
|
||||
// ❌ Redirect never fires when the slot mounts late (e.g. inside a modal workspace)
|
||||
routes.push({ path: '', pathMatch: 'full', redirectTo: 'tab/settings' });
|
||||
|
||||
// ✅ Duplicate the landing route directly under the empty path
|
||||
const defaultRoute = routes[0]; // or whichever is the landing route
|
||||
routes.push({ ...defaultRoute, path: '' });
|
||||
```
|
||||
|
||||
`umb-workspace-editor` uses this pattern — see the `// Duplicate first workspace and use it for the empty path scenario.` block in `workspace-editor.element.ts`.
|
||||
|
||||
Do **not** add `pathMatch: 'full'` to the duplicated empty-path route. The modal sub-router appends modal paths (e.g. `/add-property/-1/container-root`) to the current active local path. With `path: ''` matching prefix-wise (regex `/^/`), the main route stays matched and the modal-router can resolve the appended segment. With `pathMatch: 'full'`, the empty-path route only matches an exactly-empty URL — modal URLs fall through to the catch-all, the route component unmounts, the modal registration is torn down, and the modal never opens.
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { UmbMockDocumentBlueprintModel } from '../../mock-data-set.types.js';
|
||||
import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
import type { DocumentVariantResponseModel } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
|
||||
type UmbDocumentVariantState = DocumentVariantResponseModel['state'];
|
||||
|
||||
export const data: Array<UmbMockDocumentBlueprintModel> = [
|
||||
{
|
||||
@@ -14,7 +16,7 @@ export const data: Array<UmbMockDocumentBlueprintModel> = [
|
||||
name: 'The Simplest Document Blueprint',
|
||||
variants: [
|
||||
{
|
||||
state: DocumentVariantStateModel.DRAFT,
|
||||
state: 'Draft' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:32:24.957009',
|
||||
culture: 'en-us',
|
||||
segment: null,
|
||||
@@ -48,7 +50,7 @@ export const data: Array<UmbMockDocumentBlueprintModel> = [
|
||||
name: 'A Forbidden Document Blueprint',
|
||||
variants: [
|
||||
{
|
||||
state: DocumentVariantStateModel.DRAFT,
|
||||
state: 'Draft' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:32:24.957009',
|
||||
culture: 'en-US',
|
||||
segment: null,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { UmbMockDocumentModel } from '../../mock-data-set.types.js';
|
||||
import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
import type { DocumentVariantResponseModel } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
|
||||
type UmbDocumentVariantState = DocumentVariantResponseModel['state'];
|
||||
|
||||
export const data: Array<UmbMockDocumentModel> = [
|
||||
{
|
||||
@@ -18,7 +20,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
isTrashed: false,
|
||||
variants: [
|
||||
{
|
||||
state: DocumentVariantStateModel.DRAFT,
|
||||
state: 'Draft' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:32:24.957009',
|
||||
culture: 'en-US',
|
||||
segment: null,
|
||||
@@ -637,7 +639,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED,
|
||||
state: 'Published' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:31:51.354764',
|
||||
culture: 'en-US',
|
||||
segment: null,
|
||||
@@ -648,7 +650,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED,
|
||||
state: 'Published' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:31:51.354764',
|
||||
culture: 'da-dk',
|
||||
segment: null,
|
||||
@@ -749,7 +751,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED,
|
||||
state: 'Published' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:31:51.354764',
|
||||
culture: 'en-US',
|
||||
segment: null,
|
||||
@@ -760,7 +762,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED,
|
||||
state: 'Published' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:31:51.354764',
|
||||
culture: 'da',
|
||||
segment: null,
|
||||
@@ -771,7 +773,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED,
|
||||
state: 'Published' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:31:51.354764',
|
||||
culture: 'da',
|
||||
segment: 'vip',
|
||||
@@ -782,7 +784,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED,
|
||||
state: 'Published' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:31:51.354764',
|
||||
culture: null,
|
||||
segment: 'vip-invariant',
|
||||
@@ -793,7 +795,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED,
|
||||
state: 'Published' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:31:51.354764',
|
||||
culture: null,
|
||||
segment: 'generic',
|
||||
@@ -804,7 +806,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED,
|
||||
state: 'Published' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:31:51.354764',
|
||||
culture: 'no-no',
|
||||
segment: null,
|
||||
@@ -815,7 +817,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED_PENDING_CHANGES,
|
||||
state: 'PublishedPendingChanges' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:31:51.354764',
|
||||
culture: 'es-es',
|
||||
segment: null,
|
||||
@@ -826,7 +828,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
state: DocumentVariantStateModel.NOT_CREATED,
|
||||
state: 'NotCreated' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:31:51.354764',
|
||||
culture: 'pl-pl',
|
||||
segment: null,
|
||||
@@ -913,7 +915,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
state: DocumentVariantStateModel.DRAFT,
|
||||
state: 'Draft' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:32:24.957009',
|
||||
culture: 'en-US',
|
||||
segment: null,
|
||||
@@ -943,7 +945,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
isTrashed: false,
|
||||
variants: [
|
||||
{
|
||||
state: DocumentVariantStateModel.DRAFT,
|
||||
state: 'Draft' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:32:24.957009',
|
||||
culture: 'en-US',
|
||||
segment: null,
|
||||
@@ -988,7 +990,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
isTrashed: false,
|
||||
variants: [
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED,
|
||||
state: 'Published' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:32:24.957009',
|
||||
culture: 'en-US',
|
||||
segment: null,
|
||||
@@ -1276,7 +1278,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED,
|
||||
state: 'Published' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:31:51.354764',
|
||||
culture: 'en-US',
|
||||
segment: null,
|
||||
@@ -1287,7 +1289,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED,
|
||||
state: 'Published' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:31:51.354764',
|
||||
culture: 'da-dk',
|
||||
segment: null,
|
||||
@@ -1316,7 +1318,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
isTrashed: false,
|
||||
variants: [
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED,
|
||||
state: 'Published' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:32:24.957009',
|
||||
culture: 'en-US',
|
||||
segment: null,
|
||||
@@ -1327,7 +1329,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED,
|
||||
state: 'Published' as UmbDocumentVariantState,
|
||||
publishDate: '2023-02-06T15:32:24.957009',
|
||||
culture: 'da-dk',
|
||||
segment: null,
|
||||
|
||||
@@ -21,8 +21,8 @@ export const savedSearches: Array<SavedLogSearchResponseModel> = [
|
||||
query: 'Has(Duration) and Duration > 1000',
|
||||
},
|
||||
{
|
||||
name: "Find all logs that are from the namespace 'Umbraco.Core'",
|
||||
query: "StartsWith(SourceContext, 'Umbraco.Core')",
|
||||
name: "Find all logs that are within the namespace 'Umbraco.Cms'",
|
||||
query: "StartsWith(SourceContext, 'Umbraco.Cms')",
|
||||
},
|
||||
{
|
||||
name: 'Find all logs that use a specific log message template',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { UmbMockDocumentModel } from '../../mock-data-set.types.js';
|
||||
import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
import {
|
||||
INVARIANT_DOCUMENT_TYPE_ID,
|
||||
INVARIANT_DOCUMENT_TYPE_WITH_CULTURE_VARIANT_COMPOSITION_ID,
|
||||
@@ -7,6 +6,9 @@ import {
|
||||
SEGMENT_VARIANT_DOCUMENT_TYPE_ID,
|
||||
VARIANT_DOCUMENT_TYPE_ID,
|
||||
} from './document-type.data.js';
|
||||
import type { DocumentVariantResponseModel } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
|
||||
type UmbDocumentVariantState = DocumentVariantResponseModel['state'];
|
||||
|
||||
export const data: Array<UmbMockDocumentModel> = [
|
||||
{
|
||||
@@ -25,7 +27,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
template: null,
|
||||
variants: [
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED,
|
||||
state: 'Published' as UmbDocumentVariantState,
|
||||
publishDate: '2024-01-15T10:05:00.000Z',
|
||||
culture: null,
|
||||
segment: null,
|
||||
@@ -63,7 +65,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
template: null,
|
||||
variants: [
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED,
|
||||
state: 'Published' as UmbDocumentVariantState,
|
||||
publishDate: '2024-01-15T10:05:00.000Z',
|
||||
culture: 'en-US',
|
||||
segment: null,
|
||||
@@ -74,7 +76,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
state: DocumentVariantStateModel.DRAFT,
|
||||
state: 'Draft' as UmbDocumentVariantState,
|
||||
publishDate: null,
|
||||
culture: 'da',
|
||||
segment: null,
|
||||
@@ -126,7 +128,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
template: null,
|
||||
variants: [
|
||||
{
|
||||
state: DocumentVariantStateModel.PUBLISHED,
|
||||
state: 'Published' as UmbDocumentVariantState,
|
||||
publishDate: '2024-01-15T10:05:00.000Z',
|
||||
culture: null,
|
||||
segment: null,
|
||||
@@ -137,7 +139,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
state: DocumentVariantStateModel.DRAFT,
|
||||
state: 'Draft' as UmbDocumentVariantState,
|
||||
publishDate: null,
|
||||
culture: null,
|
||||
segment: 's1',
|
||||
@@ -189,7 +191,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
template: null,
|
||||
variants: [
|
||||
{
|
||||
state: DocumentVariantStateModel.DRAFT,
|
||||
state: 'Draft' as UmbDocumentVariantState,
|
||||
publishDate: null,
|
||||
culture: null,
|
||||
segment: null,
|
||||
@@ -234,7 +236,7 @@ export const data: Array<UmbMockDocumentModel> = [
|
||||
template: null,
|
||||
variants: [
|
||||
{
|
||||
state: DocumentVariantStateModel.DRAFT,
|
||||
state: 'Draft' as UmbDocumentVariantState,
|
||||
publishDate: null,
|
||||
culture: null,
|
||||
segment: null,
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
import type { UmbMockDocumentModel } from '../../mock-data-set.types.js';
|
||||
import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
import type { DocumentVariantResponseModel } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
|
||||
// Map string state to enum
|
||||
/**
|
||||
*
|
||||
* @param state
|
||||
*/
|
||||
function mapState(state: string): DocumentVariantStateModel {
|
||||
switch (state) {
|
||||
case 'Published':
|
||||
return DocumentVariantStateModel.PUBLISHED;
|
||||
case 'Draft':
|
||||
return DocumentVariantStateModel.DRAFT;
|
||||
case 'NotCreated':
|
||||
return DocumentVariantStateModel.NOT_CREATED;
|
||||
case 'PublishedPendingChanges':
|
||||
return DocumentVariantStateModel.PUBLISHED_PENDING_CHANGES;
|
||||
default:
|
||||
return DocumentVariantStateModel.DRAFT;
|
||||
}
|
||||
}
|
||||
type UmbDocumentVariantState = DocumentVariantResponseModel['state'];
|
||||
|
||||
const rawData = [
|
||||
{
|
||||
@@ -2570,6 +2552,6 @@ export const data: Array<UmbMockDocumentModel> = rawData.map((doc) => ({
|
||||
...doc,
|
||||
variants: doc.variants.map((v) => ({
|
||||
...v,
|
||||
state: mapState(v.state),
|
||||
state: v.state as UmbDocumentVariantState,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -21,8 +21,8 @@ export const savedSearches: Array<SavedLogSearchResponseModel> = [
|
||||
query: 'Has(Duration) and Duration > 1000',
|
||||
},
|
||||
{
|
||||
name: "Find all logs that are from the namespace 'Umbraco.Core'",
|
||||
query: "StartsWith(SourceContext, 'Umbraco.Core')",
|
||||
name: "Find all logs that are within the namespace 'Umbraco.Cms'",
|
||||
query: "StartsWith(SourceContext, 'Umbraco.Cms')",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user