Compare commits
139
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b87d519bf2 | ||
|
|
f255fd7bff | ||
|
|
7527de7c56 | ||
|
|
df12a3e467 | ||
|
|
ba29b91301 | ||
|
|
336bffe4c4 | ||
|
|
426e516c61 | ||
|
|
c718a3ce12 | ||
|
|
3aa87fec96 | ||
|
|
6c5873047b | ||
|
|
58b047bf7e | ||
|
|
ae4ac2a4b9 | ||
|
|
17e73eee28 | ||
|
|
8ab68b574f | ||
|
|
626f0a9ee1 | ||
|
|
cd406fba43 | ||
|
|
caeb354064 | ||
|
|
dec99737b7 | ||
|
|
bbebb07e8c | ||
|
|
f2dc9e7031 | ||
|
|
5e1aabcce6 | ||
|
|
f08bd93793 | ||
|
|
79e7b95253 | ||
|
|
d76daf5b4e | ||
|
|
0c021bedec | ||
|
|
0af0c47f69 | ||
|
|
b49a0905d6 | ||
|
|
435c4abb42 | ||
|
|
3de31c4a19 | ||
|
|
91837ebd4d | ||
|
|
dbcc982251 | ||
|
|
d911505a00 | ||
|
|
86176f7461 | ||
|
|
107cfbf9f6 | ||
|
|
dffd60edf6 | ||
|
|
3ab9d7c492 | ||
|
|
f1eaf604e8 | ||
|
|
1045ad7ae2 | ||
|
|
44a42352cb | ||
|
|
b70e2ae7bc | ||
|
|
2f09fd4ca0 | ||
|
|
1725de6a9c | ||
|
|
1a316255c8 | ||
|
|
183c85e560 | ||
|
|
1792cfe6f2 | ||
|
|
28fb93f792 | ||
|
|
9adf5307e3 | ||
|
|
65b85fd5d2 | ||
|
|
00f0d2340e | ||
|
|
518adf51b0 | ||
|
|
ef1f760847 | ||
|
|
5b3ab2ea2a | ||
|
|
9dc49df369 | ||
|
|
70d1a05a4e | ||
|
|
818019dc22 | ||
|
|
a42cdc6656 | ||
|
|
c64f431a23 | ||
|
|
94336588af | ||
|
|
d78eb98109 | ||
|
|
dc1ac9fb8d | ||
|
|
8484bab495 | ||
|
|
3de27358d5 | ||
|
|
d3d0e40fd3 | ||
|
|
25941fd749 | ||
|
|
a6e6585d42 | ||
|
|
0afa6f30fe | ||
|
|
25d382b1c0 | ||
|
|
e4e6e04091 | ||
|
|
63fe8cfd55 | ||
|
|
c507f43912 | ||
|
|
4fc3a56c8f | ||
|
|
d58d5b246b | ||
|
|
ed8246390c | ||
|
|
46c402dda9 | ||
|
|
27263c56ea | ||
|
|
afd885f358 | ||
|
|
307e5d5c1b | ||
|
|
0248dcc020 | ||
|
|
a9a370c357 | ||
|
|
92c5d3f6ed | ||
|
|
927eacf5c1 | ||
|
|
bbf5760c2d | ||
|
|
fc87b3efda | ||
|
|
a1359abeb9 | ||
|
|
4d27e1972d | ||
|
|
67f0eb5e4a | ||
|
|
6f0007df85 | ||
|
|
eb217d671a | ||
|
|
722ca0476a | ||
|
|
94603b6918 | ||
|
|
948eefb624 | ||
|
|
64e6a7cf8a | ||
|
|
a06c4aa4c0 | ||
|
|
13a20c8189 | ||
|
|
4693546e34 | ||
|
|
21cfb0b27d | ||
|
|
f2f19ba5a7 | ||
|
|
b7a05c005e | ||
|
|
8f72b079c5 | ||
|
|
626a78085f | ||
|
|
80127d4647 | ||
|
|
a98a6aa390 | ||
|
|
30977bd0ad | ||
|
|
48a9fb1c38 | ||
|
|
a4594a3166 | ||
|
|
3b5d95abaa | ||
|
|
2955911420 | ||
|
|
760f6454f4 | ||
|
|
0f19dc3716 | ||
|
|
fbe355fdd0 | ||
|
|
ad9735f5c5 | ||
|
|
d603d0c820 | ||
|
|
c63e2668d6 | ||
|
|
87e12b9ee2 | ||
|
|
6ad2a17c09 | ||
|
|
773ce35e6a | ||
|
|
742f0b1e2a | ||
|
|
f8da54b79d | ||
|
|
3b11b237cb | ||
|
|
ba5ec202f6 | ||
|
|
37bfe65b77 | ||
|
|
244ea6c34e | ||
|
|
294b24d20d | ||
|
|
ec8f2ccf8e | ||
|
|
45da578e97 | ||
|
|
015df79ef2 | ||
|
|
3b1956d35b | ||
|
|
e5bd1954a7 | ||
|
|
a62afe418d | ||
|
|
0eb0313db1 | ||
|
|
5f19354a53 | ||
|
|
aa2e0a338f | ||
|
|
81e6d8ec74 | ||
|
|
956c4dc830 | ||
|
|
cbe7e8a533 | ||
|
|
9883fb5b42 | ||
|
|
3250e69444 | ||
|
|
2c7e026721 | ||
|
|
8c721dcbde |
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: umb-bump-version
|
||||
description: Bump the Umbraco CMS version across all required files. Use when the user asks to bump, update, or set the version number — e.g., "bump version to 17.3.4", "set version to 18.0.0-rc", "update version". Accepts the target version as an argument.
|
||||
argument-hint: <version> (e.g., 17.3.4, 18.0.0-rc)
|
||||
---
|
||||
|
||||
# Bump Version - Umbraco CMS
|
||||
|
||||
Updates the Umbraco CMS version string across all files that track it.
|
||||
|
||||
**Do NOT use AskUserQuestion if a version argument is provided. Only ask if `$ARGUMENTS` is empty or cannot be parsed as a version.**
|
||||
|
||||
## Arguments
|
||||
|
||||
- `$ARGUMENTS` - Required: the target version string (e.g., `17.3.4`, `18.0.0-rc`)
|
||||
|
||||
## Files to Update
|
||||
|
||||
The following 5 files must be updated with the new version:
|
||||
|
||||
| # | File | Field |
|
||||
|---|------|-------|
|
||||
| 1 | `version.json` | `"version"` |
|
||||
| 2 | `src/Umbraco.Web.UI.Client/package.json` | `"version"` |
|
||||
| 3 | `src/Umbraco.Web.UI.Client/package-lock.json` | top-level `"version"` AND `packages[""].version` |
|
||||
| 4 | `tests/Umbraco.Tests.AcceptanceTest/package.json` | `"version"` |
|
||||
| 5 | `tests/Umbraco.Tests.AcceptanceTest/package-lock.json` | top-level `"version"` AND `packages[""].version` |
|
||||
|
||||
**Note**: For major version bumps (e.g., 17.x to 18.x), `src/Umbraco.Web.UI.Login/package.json` has a caret-ranged dependency on `@umbraco-cms/backoffice` (e.g., `^17.2.0`) that will need manual updating. This skill does not handle that — major bumps involve many other changes beyond version strings.
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Parse and Validate the Version
|
||||
|
||||
Extract the version from `$ARGUMENTS`. It must be a valid semver-like string (e.g., `17.3.4`, `18.0.0-rc`, `17.4.0-preview.1`). If no version is provided or it cannot be parsed, ask the user for the target version.
|
||||
|
||||
### 2. Read the Current Version
|
||||
|
||||
Read `version.json` and extract the current `"version"` value. If the current version already equals the target version, report that the version is already set and stop — do not edit, stage, or commit anything.
|
||||
|
||||
Otherwise, display both versions:
|
||||
|
||||
```
|
||||
Bumping version: {current} -> {target}
|
||||
```
|
||||
|
||||
### 3. Update All Files
|
||||
|
||||
Update each of the 5 files listed above, replacing the old version with the new version. For each file:
|
||||
|
||||
- **`version.json`**: Replace the `"version"` value.
|
||||
- **`package.json` files**: Replace the `"version"` value (near the top of the file).
|
||||
- **`package-lock.json` files**: Replace BOTH the top-level `"version"` value AND the `"version"` inside the `"packages": { "": { ... } }` block. These are always in the first ~10 lines of the file.
|
||||
|
||||
Use targeted edits — do NOT rewrite entire files. Be precise to avoid changing version strings in dependency entries.
|
||||
|
||||
### 4. Verify
|
||||
|
||||
After all edits, grep for the target version value across the 5 files to confirm all updates landed correctly:
|
||||
|
||||
```bash
|
||||
grep -n "\"version\": \"{version}\"" version.json src/Umbraco.Web.UI.Client/package.json src/Umbraco.Web.UI.Client/package-lock.json tests/Umbraco.Tests.AcceptanceTest/package.json tests/Umbraco.Tests.AcceptanceTest/package-lock.json
|
||||
```
|
||||
|
||||
Expect exactly 7 matches (one per `package.json` and `version.json`, two per `package-lock.json`).
|
||||
|
||||
### 5. Stage and Commit
|
||||
|
||||
Stage only the 5 changed files:
|
||||
|
||||
```bash
|
||||
git add version.json src/Umbraco.Web.UI.Client/package.json src/Umbraco.Web.UI.Client/package-lock.json tests/Umbraco.Tests.AcceptanceTest/package.json tests/Umbraco.Tests.AcceptanceTest/package-lock.json
|
||||
```
|
||||
|
||||
Then commit with the message `Bump version to {version}.` — replacing `{version}` with the target version:
|
||||
|
||||
```bash
|
||||
git commit -m "Bump version to {version}."
|
||||
```
|
||||
|
||||
### 6. Report
|
||||
|
||||
Output a summary:
|
||||
|
||||
```
|
||||
Version bumped to {version} in:
|
||||
- version.json
|
||||
- src/Umbraco.Web.UI.Client/package.json
|
||||
- src/Umbraco.Web.UI.Client/package-lock.json
|
||||
- tests/Umbraco.Tests.AcceptanceTest/package.json
|
||||
- tests/Umbraco.Tests.AcceptanceTest/package-lock.json
|
||||
|
||||
Changes staged and committed.
|
||||
```
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Job
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
- name: Build And Deploy
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Job
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build And Deploy
|
||||
id: builddeploy
|
||||
uses: Azure/static-web-apps-deploy@v1
|
||||
|
||||
@@ -1,28 +1,52 @@
|
||||
name: Claude PR Review
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, ready_for_review]
|
||||
pull_request:
|
||||
types: [opened, ready_for_review, reopened]
|
||||
# NOTE: `pull_request_target` would let this workflow review fork PRs
|
||||
# (with access to secrets), but the action currently fails during OIDC
|
||||
# token exchange with "401 Unauthorized - Invalid OIDC token" on that
|
||||
# event. PR #579 added `pull_request_target` routing to the action, but
|
||||
# Anthropic's `/github-app-token-exchange` endpoint appears not to
|
||||
# accept the token claims produced by that event. Re-enable once the
|
||||
# upstream issue is resolved.
|
||||
# See: https://github.com/anthropics/claude-code-action/issues/347
|
||||
# https://github.com/anthropics/claude-code-action/issues/621
|
||||
# pull_request_target:
|
||||
# types: [opened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
review:
|
||||
if: github.event.pull_request.draft == false
|
||||
# Skip fork PRs: secrets are not exposed on `pull_request` events from
|
||||
# forks, so the action would fail with a red check. Remove this clause
|
||||
# once upstream fork support lands (tracked in
|
||||
# https://github.com/anthropics/claude-code-action/issues/939) and we
|
||||
# can re-enable the `pull_request_target` trigger above.
|
||||
if: >-
|
||||
github.event.pull_request.draft == false
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_03 }}
|
||||
base_branch: "main"
|
||||
|
||||
# Enable progress tracking
|
||||
track_progress: true
|
||||
|
||||
# Debug (set to true to show full output in logs, false to hide it and only post comments on the PR)
|
||||
show_full_output: false
|
||||
|
||||
additional_permissions: "actions: read"
|
||||
claude_args: "--model claude-sonnet-4-6 --allowedTools 'Bash(gh:*),Bash(git:*)'"
|
||||
prompt: |
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# We use the setup-dotnet action to set up .NET Core, otherwise the CodeQL CLI will not work with preview versions.
|
||||
- name: Setup .NET from global.json
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Issue Deduplication
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [ opened ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: 'Issue number to analyze for duplicates'
|
||||
required: true
|
||||
type: number
|
||||
|
||||
jobs:
|
||||
deduplicate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check for duplicate issues
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
prompt: |
|
||||
Analyze this new issue and check if it's a duplicate of existing issues in the repository.
|
||||
|
||||
Issue: #${{ github.event.issue.number || inputs.issue_number }}
|
||||
Repository: ${{ github.repository }}
|
||||
|
||||
Your task:
|
||||
1. Use mcp__github__get_issue to get details of the current issue (#${{ github.event.issue.number || inputs.issue_number }})
|
||||
2. Search for similar existing issues using mcp__github__search_issues with relevant keywords from the issue title and body
|
||||
3. Compare the new issue with existing ones to identify potential duplicates
|
||||
|
||||
Criteria for duplicates:
|
||||
- Same bug or error being reported
|
||||
- Same feature request (even if worded differently)
|
||||
- Same question being asked
|
||||
- Issues describing the same root problem
|
||||
|
||||
If you find duplicates:
|
||||
- Add a comment on the new issue linking to the original issue(s)
|
||||
- Apply the "duplicate" and "state/needs-investigation" labels to the new issue
|
||||
- Be polite and explain why it's a duplicate
|
||||
- Suggest the user follow the original issue for updates
|
||||
|
||||
If it's NOT a duplicate:
|
||||
- Don't add any comments
|
||||
- You may apply appropriate topic labels based on the issue content
|
||||
|
||||
Use these tools:
|
||||
- mcp__github__get_issue: Get issue details
|
||||
- mcp__github__search_issues: Search for similar issues
|
||||
- mcp__github__list_issues: List recent issues if needed
|
||||
- mcp__github__add_issue_comment: Add a comment if duplicate found
|
||||
- mcp__github__update_issue: Add labels
|
||||
|
||||
Be thorough but efficient. Focus on finding true duplicates, not just similar issues.
|
||||
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_03 }}
|
||||
|
||||
# Issues are opened by community members without write access, so the
|
||||
# default OIDC token exchange fails with "User does not have write
|
||||
# access on this repository". Pass `github_token` explicitly and set
|
||||
# `allowed_non_write_users` to bypass that check. Safe here because
|
||||
# `permissions:` and `--allowedTools` below are tightly scoped to
|
||||
# issue operations only.
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
|
||||
# Surface full SDK output (including tool calls and permission denials)
|
||||
# to diagnose why Claude sometimes only partially completes (e.g. labels
|
||||
# an issue but skips the comment). Safe to leave on — no secrets in output.
|
||||
show_full_output: true
|
||||
|
||||
claude_args: |
|
||||
--model claude-haiku-4-5 --allowedTools "mcp__github__get_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__add_issue_comment,mcp__github__update_issue,mcp__github__get_issue_comments"
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
run:
|
||||
working-directory: src/Umbraco.Web.UI.Client
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
run:
|
||||
working-directory: src/Umbraco.Web.UI.Client
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
+5
-2
@@ -52,7 +52,9 @@ tools/docfx/
|
||||
/build/csharp-docs/_site/
|
||||
|
||||
# Local config
|
||||
.claude/settings.local.json
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
!.claude/settings.json
|
||||
.env.local
|
||||
|
||||
# Build
|
||||
@@ -70,7 +72,8 @@ tools/docfx/
|
||||
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/assets
|
||||
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/js
|
||||
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/lib
|
||||
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/views
|
||||
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/views/*
|
||||
!/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/views/errors
|
||||
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/login
|
||||
|
||||
# Environment specific data
|
||||
|
||||
@@ -227,9 +227,11 @@ Project ownership is distributed across teams. Check individual project director
|
||||
|
||||
1. **Layered Architecture with Dependency Inversion**
|
||||
- Core defines contracts (interfaces)
|
||||
- Infrastructure implements contracts
|
||||
- Infrastructure implements contracts that need Infrastructure-owned machinery
|
||||
- Web/APIs consume implementations via DI
|
||||
|
||||
**Where service implementations live**: Services whose dependencies are satisfiable from Core interfaces alone (repositories, scope, config, other Core services) live in `Umbraco.Core/Services/` — this covers the majority of domain services (`MemberService`, `ContentService`, `MediaService`, `ContentTypeService`, `EntityService`, `AuditService`, `ExternalMemberService`, etc.). Service implementations only live in `Umbraco.Infrastructure/Services/Implement/` when they genuinely need Infrastructure concerns — Examine indexes (`ContentSearchService`, `MediaSearchService`, `IndexedEntitySearchService`), log files (`LogViewerRepository`), packaging internals (`PackagingService`), webhook firing (`WebhookFiringService`), distributed-job coordination (`DistributedJobService`). When adding a new service, default to Core and only move to Infrastructure if a concrete dependency forces it.
|
||||
|
||||
2. **Interface-First Design**
|
||||
- All services defined as interfaces in Core
|
||||
- Enables testing, polymorphism, extensibility
|
||||
@@ -561,6 +563,8 @@ For detailed information about individual projects, see their CLAUDE.md files:
|
||||
- **API Infrastructure**: `/src/Umbraco.Cms.Api.Common/CLAUDE.md` - OpenAPI, authentication, serialization
|
||||
- **Backoffice Frontend**: `/src/Umbraco.Web.UI.Client/CLAUDE.md` - Lit web components, extension system, auth client
|
||||
|
||||
**Important**: When working on backoffice client code (anything under `src/Umbraco.Web.UI.Client/`), read `/src/Umbraco.Web.UI.Client/CLAUDE.md` first. It contains action-specific checklists (deprecation, testing, security, etc.) that are not duplicated here.
|
||||
|
||||
### Getting Help
|
||||
|
||||
- **Official Docs**: https://docs.umbraco.com/
|
||||
|
||||
+29
-26
@@ -13,32 +13,32 @@
|
||||
</ItemGroup>
|
||||
<!-- Microsoft packages -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.4.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.5.0" />
|
||||
<PackageVersion Include="System.Linq.Async" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
<!-- Umbraco packages -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Umbraco.JsonSchema.Extensions" Version="0.3.0" />
|
||||
<PackageVersion Include="Umbraco.JsonSchema.Extensions" Version="0.4.0" />
|
||||
</ItemGroup>
|
||||
<!-- Third-party packages -->
|
||||
<ItemGroup>
|
||||
@@ -50,7 +50,7 @@
|
||||
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageVersion Include="JsonPatch.Net" Version="3.3.0" />
|
||||
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
|
||||
<PackageVersion Include="MailKit" Version="4.15.1" />
|
||||
<PackageVersion Include="MailKit" Version="4.16.0" />
|
||||
<PackageVersion Include="Markdig" Version="0.45.0" />
|
||||
<PackageVersion Include="Markdown" Version="2.2.1" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.4" />
|
||||
@@ -59,9 +59,9 @@
|
||||
<PackageVersion Include="ncrontab" Version="3.4.0" />
|
||||
<PackageVersion Include="NPoco" Version="6.2.0" />
|
||||
<PackageVersion Include="NPoco.SqlServer" Version="6.2.0" />
|
||||
<PackageVersion Include="OpenIddict.Abstractions" Version="7.2.0" />
|
||||
<PackageVersion Include="OpenIddict.AspNetCore" Version="7.2.0" />
|
||||
<PackageVersion Include="OpenIddict.EntityFrameworkCore" Version="7.2.0" />
|
||||
<PackageVersion Include="OpenIddict.Abstractions" Version="7.4.0" />
|
||||
<PackageVersion Include="OpenIddict.AspNetCore" Version="7.4.0" />
|
||||
<PackageVersion Include="OpenIddict.EntityFrameworkCore" Version="7.4.0" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.1" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Process" Version="3.0.0" />
|
||||
@@ -77,7 +77,7 @@
|
||||
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<PackageVersion Include="SixLabors.ImageSharp.Web" Version="3.2.0" />
|
||||
<!-- When updating this version, also update templates/UmbracoExtension/Umbraco.Extension.csproj -->
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.4" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
</ItemGroup>
|
||||
<!-- Transitive pinned versions (only required because our direct dependencies have vulnerable versions of transitive dependencies) -->
|
||||
<ItemGroup>
|
||||
@@ -88,5 +88,8 @@
|
||||
<!-- Markdown references vulnerable version of the following: -->
|
||||
<!-- TODO (V19): Remove these pinned dependencies when the Markdown dependency is removed. -->
|
||||
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||
<!-- Examine (via Microsoft.AspNetCore.DataProtection 8.0.4) references a vulnerable version of the following: -->
|
||||
<!-- 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>
|
||||
+29
-15
@@ -188,16 +188,9 @@ stages:
|
||||
parameters:
|
||||
nodeVersion: ${{ variables.nodeVersion }}
|
||||
npm_config_cache: ${{ variables.npm_config_cache }}
|
||||
- bash: |
|
||||
echo "##[command]Install nbgv"
|
||||
dotnet tool install --tool-path . nbgv
|
||||
echo "##[command]Running nbgv get-version"
|
||||
PACKAGE_VERSION=$(nbgv get-version -v NpmPackageVersion)
|
||||
echo "##[command]Running npm version"
|
||||
echo "##[debug]Version: $PACKAGE_VERSION"
|
||||
cd tests/Umbraco.Tests.AcceptanceTest
|
||||
npm version $PACKAGE_VERSION --allow-same-version --no-git-tag-version
|
||||
displayName: Set NPM Version
|
||||
- template: templates/set-npm-version.yml
|
||||
parameters:
|
||||
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
|
||||
- bash: |
|
||||
echo "##[command]Running npm pack"
|
||||
mkdir $(Build.ArtifactStagingDirectory)/npm-testhelpers
|
||||
@@ -904,12 +897,27 @@ stages:
|
||||
- stage: Deploy_NuGet
|
||||
displayName: NuGet release
|
||||
dependsOn: Deploy_MyGet
|
||||
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.nuGetDeploy}}))
|
||||
# Run only when Deploy_MyGet actually ran (succeeded or failed) — not when it was skipped due to an upstream test failure.
|
||||
# Inspect Deploy_MyGet's direct result rather than succeeded()/failed(), which are transitive across the full ancestor graph.
|
||||
# Approval is required every run via the WaitForApproval job below.
|
||||
condition: and(in(dependencies.Deploy_MyGet.result, 'Succeeded', 'SucceededWithIssues', 'Failed'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.nuGetDeploy}}))
|
||||
jobs:
|
||||
- job:
|
||||
- job: WaitForApproval
|
||||
displayName: Wait for manual approval
|
||||
pool: server
|
||||
timeoutInMinutes: 4320 # 3 days
|
||||
steps:
|
||||
- task: ManualValidation@0
|
||||
displayName: Manual approval to push to NuGet
|
||||
inputs:
|
||||
notifyUsers: ''
|
||||
instructions: 'Approve to push the NuGet release.'
|
||||
onTimeout: 'reject'
|
||||
- job: Push
|
||||
displayName: Push to NuGet
|
||||
dependsOn: WaitForApproval
|
||||
pool:
|
||||
vmImage: "windows-latest" # NuGetCommand@2 is no longer supported on Ubuntu 24.04 so we'll use windows until an alternative is available.
|
||||
displayName: Push to NuGet
|
||||
steps:
|
||||
- checkout: none
|
||||
- task: DownloadPipelineArtifact@2
|
||||
@@ -927,7 +935,10 @@ stages:
|
||||
|
||||
- stage: Deploy_Npm
|
||||
displayName: Npm release
|
||||
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.npmDeploy}}))
|
||||
# Inspect Deploy_NuGet.result directly so a MyGet failure (which is in the transitive ancestor graph)
|
||||
# doesn't cascade-skip this stage via succeeded(). Deploy_NuGet must itself have succeeded — a NuGet
|
||||
# failure deliberately blocks the npm release.
|
||||
condition: and(in(dependencies.Deploy_NuGet.result, 'Succeeded', 'SucceededWithIssues'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.npmDeploy}}))
|
||||
dependsOn:
|
||||
- Deploy_NuGet
|
||||
jobs:
|
||||
@@ -988,7 +999,10 @@ stages:
|
||||
- Build
|
||||
- Build_Docs
|
||||
- Deploy_NuGet
|
||||
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.uploadApiDocs}}))
|
||||
# Build_Docs must have produced artifacts (we won't upload anything otherwise) and Deploy_NuGet must
|
||||
# have succeeded — a NuGet failure deliberately blocks the docs upload. Direct result checks avoid
|
||||
# transitive succeeded()/failed() which would cascade-skip on a MyGet failure.
|
||||
condition: and(in(dependencies.Build_Docs.result, 'Succeeded', 'SucceededWithIssues'), in(dependencies.Deploy_NuGet.result, 'Succeeded', 'SucceededWithIssues'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.uploadApiDocs}}))
|
||||
jobs:
|
||||
- job:
|
||||
displayName: Upload C# Docs
|
||||
|
||||
@@ -117,7 +117,7 @@ stages:
|
||||
- stage: Integration
|
||||
displayName: Integration Tests
|
||||
dependsOn: Build
|
||||
condition: ${{ eq(parameters.skipIntegrationTests, false) }}
|
||||
condition: and(succeeded(), ${{ eq(parameters.skipIntegrationTests, false) }})
|
||||
jobs:
|
||||
# Integration Tests (SQLite)
|
||||
- job:
|
||||
@@ -319,8 +319,8 @@ stages:
|
||||
|
||||
- stage: DefaultConfigE2E
|
||||
displayName: Default Config E2E Tests
|
||||
dependsOn: Integration
|
||||
condition: always()
|
||||
dependsOn: [Build, Integration]
|
||||
condition: in(dependencies.Build.result, 'Succeeded', 'SucceededWithIssues')
|
||||
variables:
|
||||
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
|
||||
# Enable console logging in Release mode
|
||||
@@ -500,8 +500,8 @@ stages:
|
||||
|
||||
- stage: AdditionalConfigE2E
|
||||
displayName: Additional Config E2E Tests
|
||||
dependsOn: DefaultConfigE2E
|
||||
condition: always()
|
||||
dependsOn: [Build, DefaultConfigE2E]
|
||||
condition: in(dependencies.Build.result, 'Succeeded', 'SucceededWithIssues')
|
||||
variables:
|
||||
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
|
||||
ASPNETCORE_URLS: https://localhost:44331
|
||||
|
||||
@@ -6,16 +6,9 @@ steps:
|
||||
versionSource: 'fromFile'
|
||||
versionFilePath: src/Umbraco.Web.UI.Client/.nvmrc
|
||||
|
||||
- bash: |
|
||||
echo "##[command]Install nbgv"
|
||||
dotnet tool install --tool-path . nbgv
|
||||
echo "##[command]Running nbgv get-version"
|
||||
PACKAGE_VERSION=$(nbgv get-version -v NpmPackageVersion)
|
||||
echo "##[command]Running npm version"
|
||||
echo "##[debug]Version: $PACKAGE_VERSION"
|
||||
cd src/Umbraco.Web.UI.Client
|
||||
npm version $PACKAGE_VERSION --allow-same-version --no-git-tag-version
|
||||
displayName: Set NPM Version
|
||||
- template: set-npm-version.yml
|
||||
parameters:
|
||||
workingDirectory: src/Umbraco.Web.UI.Client
|
||||
|
||||
- task: Cache@2
|
||||
displayName: Cache node_modules
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
parameters:
|
||||
- name: workingDirectory
|
||||
type: string
|
||||
|
||||
steps:
|
||||
- bash: |
|
||||
echo "##[command]Install nbgv"
|
||||
dotnet tool install --tool-path . nbgv
|
||||
echo "##[command]Running nbgv get-version"
|
||||
PACKAGE_VERSION=$(nbgv get-version -v NpmPackageVersion)
|
||||
echo "##[command]Running npm version"
|
||||
echo "##[debug]Version: $PACKAGE_VERSION"
|
||||
cd ${{ parameters.workingDirectory }}
|
||||
npm version $PACKAGE_VERSION --allow-same-version --no-git-tag-version
|
||||
displayName: Set NPM Version
|
||||
@@ -0,0 +1,244 @@
|
||||
# Research: IDistributedBackgroundJob Write Lock Timeout in Load-Balanced Setup
|
||||
|
||||
**Issue**: [#22113](https://github.com/umbraco/Umbraco-CMS/issues/22113)
|
||||
**Error**: `Failed to acquire write lock for id: -347`
|
||||
**Lock -347**: `Constants.Locks.DistributedJobs` (all distributed background jobs)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The root cause is most likely **SQL Server page-level lock contention** on the `umbracoLock` table, caused by long-running content operations (inside the user's distributed job) holding REPEATABLEREAD locks on one row (e.g., `-333` ContentTree) which block write access to *all other rows on the same data page* (including `-347` DistributedJobs).
|
||||
|
||||
This is exacerbated by:
|
||||
1. **Nested scope transaction sharing** - the user's outer scope holds the transaction (and all locks) open for the entire job duration
|
||||
2. **Small table, single page** - all ~18 lock rows fit on one 8KB SQL Server data page
|
||||
3. **5-second write lock timeout** - the default is too short when contention exists
|
||||
4. **Backoffice activity** adding further lock pressure on the same table
|
||||
|
||||
---
|
||||
|
||||
## Detailed Analysis
|
||||
|
||||
### The Lock Table Problem
|
||||
|
||||
The `umbracoLock` table has approximately 18 rows (IDs -331 through -348). In SQL Server, a standard data page is 8KB. These 18 small rows (each just `id INT`, `name NVARCHAR`, `value INT`) **all fit on a single data page**.
|
||||
|
||||
SQL Server's lock granularity decisions:
|
||||
- For small tables, the query optimizer may choose **page-level locks** instead of row-level locks
|
||||
- The `WITH (REPEATABLEREAD)` table hint in the locking SQL means locks are held until the **end of the transaction**
|
||||
- Without an explicit `ROWLOCK` hint, SQL Server decides the granularity
|
||||
|
||||
**Read lock SQL** (from `SqlServerDistributedLockingMechanism.cs:147`):
|
||||
```sql
|
||||
SELECT value FROM umbracoLock WITH (REPEATABLEREAD) WHERE id=@id
|
||||
```
|
||||
|
||||
**Write lock SQL** (from `SqlServerDistributedLockingMechanism.cs:182-183`):
|
||||
```sql
|
||||
UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = (CASE WHEN (value=1) THEN -1 ELSE 1 END) WHERE id=@id
|
||||
```
|
||||
|
||||
Neither uses a `ROWLOCK` hint, so SQL Server is free to use page-level locking.
|
||||
|
||||
### The Reproduction Scenario
|
||||
|
||||
Here's the exact sequence that causes the error:
|
||||
|
||||
**Server A** (running the user's distributed job):
|
||||
|
||||
1. `DistributedBackgroundJobHostedService` calls `TryTakeRunnableAsync()`
|
||||
2. `TryTakeRunnableAsync` acquires `EagerWriteLock(-347)`, marks the "Clean Up Your Room" job as running, commits scope, **releases lock -347** -- this is fine
|
||||
3. The user's `ExecuteAsync()` runs:
|
||||
```csharp
|
||||
using ICoreScope scope = _scopeProvider.CreateCoreScope(); // ROOT scope, starts transaction
|
||||
|
||||
_contentService.CountChildren(...) // Creates NESTED scope, acquires ReadLock(-333)
|
||||
_contentService.RecycleBinSmells() // Creates NESTED scope, acquires ReadLock(-333)
|
||||
_contentService.EmptyRecycleBin(...) // Creates NESTED scope, acquires WriteLock(-333)
|
||||
|
||||
scope.Complete(); // Transaction commits HERE, all locks released HERE
|
||||
```
|
||||
|
||||
4. **Critical**: All nested scopes share the root scope's database/transaction (confirmed in `Scope.cs:350-360`). The `ReadLock(-333)` acquired by `CountChildren` is held until the ROOT scope disposes. If `EmptyRecycleBin` takes 30+ seconds (many items), the locks on row -333 are held for 30+ seconds.
|
||||
|
||||
5. With page-level locking, the shared (S) lock on row -333's **page** also covers row -347. This S lock blocks any exclusive (X) lock requests on the same page.
|
||||
|
||||
**Server B** (polling for jobs every 5 seconds):
|
||||
|
||||
6. `TryTakeRunnableAsync()` tries `EagerWriteLock(-347)`:
|
||||
```sql
|
||||
SET LOCK_TIMEOUT 5000;
|
||||
UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = ... WHERE id=-347
|
||||
```
|
||||
7. This UPDATE needs an exclusive (X) lock on row -347. But the page containing -347 has a shared (S) lock held by Server A's long-running transaction.
|
||||
8. Server B **blocks for 5 seconds**, then gets SQL error 1222 (lock timeout)
|
||||
9. This becomes: `DistributedWriteLockTimeoutException` → **"Failed to acquire write lock for id: -347"**
|
||||
|
||||
### Why Backoffice Login Triggers It
|
||||
|
||||
When users log into the backoffice and interact with content:
|
||||
|
||||
- **Listing content**: `ContentService.GetById/GetChildren` → `ReadLock(-333)`
|
||||
- **Saving content**: `ContentService.Save` → `WriteLock(-333)`
|
||||
- **Deleting content**: `ContentService.Delete/MoveToRecycleBin` → `WriteLock(-333)`
|
||||
- **Publishing**: `ContentService.Publish` → `WriteLock(-333)`
|
||||
|
||||
Each of these acquires locks on the `umbracoLock` table. In load-balanced setups, backoffice web requests on *any server* add page-level lock contention on the same data page as -347. The more backoffice activity, the higher the probability that some transaction is holding a page lock that blocks -347 acquisition.
|
||||
|
||||
### Why It "Disables the Server Until Restart"
|
||||
|
||||
The `DistributedBackgroundJobHostedService` catches exceptions and continues (line 80). However:
|
||||
|
||||
1. Every 5 seconds, `TryTakeRunnableAsync` fails with the lock timeout
|
||||
2. The error is logged each time, creating a flood of error logs
|
||||
3. **No distributed jobs run on the affected server** because `TryTakeRunnableAsync` always times out
|
||||
4. The user's custom job that's causing the contention (on the other server) eventually finishes, but by then the pattern of contention from backoffice operations may sustain the problem
|
||||
5. The server appears "disabled" because its distributed job processing is effectively blocked
|
||||
|
||||
The server doesn't truly need a restart to recover, but the sustained contention from backoffice operations can make it *appear* permanently broken. A restart clears all in-flight transactions and ambient scopes, resolving the immediate contention.
|
||||
|
||||
---
|
||||
|
||||
## Contributing Factors
|
||||
|
||||
### 1. No `ROWLOCK` Hint
|
||||
|
||||
The distributed locking SQL uses `WITH (REPEATABLEREAD)` but not `WITH (ROWLOCK, REPEATABLEREAD)`. Adding `ROWLOCK` would force SQL Server to use row-level locks, preventing cross-row contention on the same page.
|
||||
|
||||
**File**: `src/Umbraco.Cms.Persistence.SqlServer/Services/SqlServerDistributedLockingMechanism.cs`
|
||||
- Line 147 (read lock): `SELECT value FROM umbracoLock WITH (REPEATABLEREAD) WHERE id=@id`
|
||||
- Line 182-183 (write lock): `UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = ... WHERE id=@id`
|
||||
|
||||
### 2. Short Default Write Lock Timeout
|
||||
|
||||
**File**: `src/Umbraco.Core/Configuration/Models/GlobalSettings.cs`
|
||||
|
||||
The default write lock timeout is **5 seconds** (`DistributedLockingWriteLockDefaultTimeout`). In a load-balanced setup with active backoffice use, this is easily exceeded during page-level lock contention.
|
||||
|
||||
### 3. User's Outer Scope Prolongs Lock Duration
|
||||
|
||||
The user's code wraps multiple ContentService calls in a single scope:
|
||||
|
||||
```csharp
|
||||
using ICoreScope scope = _scopeProvider.CreateCoreScope();
|
||||
_contentService.CountChildren(...); // ReadLock(-333) acquired, held by root transaction
|
||||
_contentService.RecycleBinSmells(); // ReadLock(-333)
|
||||
_contentService.EmptyRecycleBin(...); // WriteLock(-333), potentially slow
|
||||
scope.Complete(); // ALL locks released here
|
||||
```
|
||||
|
||||
The nested scopes created by ContentService methods all share the root scope's transaction (`Scope.cs:350-360`). This means the ReadLock from `CountChildren` is held for the entire duration of `EmptyRecycleBin`.
|
||||
|
||||
### 4. `Task.Run` in User Code
|
||||
|
||||
The user wraps their code in `Task.Run()`:
|
||||
```csharp
|
||||
public Task ExecuteAsync()
|
||||
{
|
||||
return Task.Run(() => { ... });
|
||||
}
|
||||
```
|
||||
|
||||
While this doesn't directly cause the lock issue, `Task.Run` moves execution to a thread pool thread. This is unnecessary (the hosted service already runs on a background thread) and could cause issues with scope ambient context if the async context doesn't flow properly.
|
||||
|
||||
---
|
||||
|
||||
## Potential Fixes
|
||||
|
||||
### Fix 1: Add `ROWLOCK` Hint (Framework Fix - Recommended)
|
||||
|
||||
Add `ROWLOCK` to the SQL statements in `SqlServerDistributedLockingMechanism`:
|
||||
|
||||
```sql
|
||||
-- Read lock
|
||||
SELECT value FROM umbracoLock WITH (ROWLOCK, REPEATABLEREAD) WHERE id=@id
|
||||
|
||||
-- Write lock
|
||||
UPDATE umbracoLock WITH (ROWLOCK, REPEATABLEREAD) SET value = ... WHERE id=@id
|
||||
```
|
||||
|
||||
This forces SQL Server to use row-level locks, preventing cross-row contention within the same page. Row-level locks on id=-333 would NOT block row-level locks on id=-347.
|
||||
|
||||
**Impact**: Minimal. Row-level locks are slightly more expensive in memory (lock manager overhead) but the umbracoLock table is tiny. This is the standard best practice for small lookup tables where row independence is required.
|
||||
|
||||
The same fix should also be applied to the EF Core SQL Server locking mechanism:
|
||||
- `src/Umbraco.Cms.Persistence.EFCore/Locking/SqlServerEFCoreDistributedLockingMechanism.cs`
|
||||
|
||||
### Fix 2: Separate Lock Tables (Framework Fix - More Invasive)
|
||||
|
||||
Move distributed job locks to a separate table (`umbracoDistributedJobLock`) so they can never share a page with content tree locks. This is more invasive but eliminates the problem entirely regardless of SQL Server lock granularity decisions.
|
||||
|
||||
### Fix 3: Increase Write Lock Timeout (User Workaround)
|
||||
|
||||
```json
|
||||
{
|
||||
"Umbraco": {
|
||||
"CMS": {
|
||||
"Global": {
|
||||
"DistributedLockingWriteLockDefaultTimeout": "00:00:30"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Increasing to 30 seconds gives more time for the contending transaction to complete. This is a workaround, not a fix - it trades timeout frequency for longer blocking delays.
|
||||
|
||||
### Fix 4: User Code Improvement (User Workaround)
|
||||
|
||||
The user should avoid wrapping multiple ContentService calls in a single outer scope. Each ContentService method already manages its own scope:
|
||||
|
||||
```csharp
|
||||
public Task ExecuteAsync()
|
||||
{
|
||||
// NO outer scope needed - each ContentService method creates its own scope
|
||||
int numberOfThingsInBin = _contentService.CountChildren(Constants.System.RecycleBinContent);
|
||||
_logger.LogInformation("You have {Count} items to clean", numberOfThingsInBin);
|
||||
|
||||
if (_contentService.RecycleBinSmells())
|
||||
{
|
||||
_contentService.EmptyRecycleBin(userId: -1);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
```
|
||||
|
||||
This reduces lock hold duration because each ContentService call acquires and releases its locks independently. The `CountChildren` ReadLock(-333) is released before `EmptyRecycleBin` starts.
|
||||
|
||||
Also: remove the `Task.Run` wrapper - it's unnecessary since the hosted service already runs on a background thread.
|
||||
|
||||
---
|
||||
|
||||
## Key Code References
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/Umbraco.Infrastructure/BackgroundJobs/DistributedBackgroundJobHostedService.cs` | Timer loop, calls TryTake → Execute → Finish |
|
||||
| `src/Umbraco.Infrastructure/Services/Implement/DistributedJobService.cs` | Acquires WriteLock(-347) in TryTakeRunnableAsync (line 68) and FinishAsync (line 105) |
|
||||
| `src/Umbraco.Cms.Persistence.SqlServer/Services/SqlServerDistributedLockingMechanism.cs` | SQL Server lock SQL (lines 147, 182-183) - missing ROWLOCK hint |
|
||||
| `src/Umbraco.Core/Persistence/Constants-Locks.cs` | Lock ID definitions (-331 through -348) |
|
||||
| `src/Umbraco.Infrastructure/Scoping/Scope.cs:350-360` | Nested scopes share parent's Database/transaction |
|
||||
| `src/Umbraco.Core/Services/ContentService.cs` | EmptyRecycleBin acquires WriteLock(-333), CountChildren/RecycleBinSmells acquire ReadLock(-333) |
|
||||
| `src/Umbraco.Core/Configuration/Models/GlobalSettings.cs` | Default lock timeout: 5 seconds for writes |
|
||||
|
||||
---
|
||||
|
||||
## Verification Steps
|
||||
|
||||
To confirm this hypothesis:
|
||||
|
||||
1. **SQL Server Activity Monitor**: During reproduction, check for page-level locks on the `umbracoLock` table using `sys.dm_tran_locks`:
|
||||
```sql
|
||||
SELECT * FROM sys.dm_tran_locks
|
||||
WHERE resource_database_id = DB_ID()
|
||||
AND resource_associated_entity_id = OBJECT_ID('umbracoLock')
|
||||
ORDER BY request_mode, resource_type
|
||||
```
|
||||
|
||||
2. **Check lock granularity**: Look for `resource_type = 'PAGE'` entries, which would confirm page-level locking.
|
||||
|
||||
3. **Test with ROWLOCK**: Temporarily modify the SQL to include `ROWLOCK` hint and verify the issue disappears.
|
||||
|
||||
4. **Test without outer scope**: Have the user remove the wrapping `CreateCoreScope()` call and verify the issue is mitigated (shorter individual lock durations).
|
||||
@@ -0,0 +1,271 @@
|
||||
# Memory Leak Analysis — Umbraco CMS v17
|
||||
|
||||
**Date**: 2026-03-03
|
||||
**Branch**: `main`
|
||||
**Scope**: All production projects under `src/`
|
||||
**Methodology**: Static analysis — grep-based pattern matching across ~1,000 C# source files
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Seven potential memory management issues were identified. None represent an unbounded memory growth path that would cause noticeable degradation or an `OutOfMemoryException` on a typical site running for days or weeks. The most accurate characterisation of the meaningful findings is **reduced `ArrayPool` efficiency** rather than classical memory leaks — the GC reclaims all affected memory eventually, but pooled buffers are not returned promptly.
|
||||
|
||||
The single highest-value fix is a one-line addition to `DatabaseServerMessenger.Dispose()`. Two findings around `JsonDocument` disposal are worth addressing for correctness, particularly on multi-server deployments. The remaining findings have negligible practical impact.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### Finding 1 — `CancellationTokenSource` Not Disposed
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **File** | `src/Umbraco.Infrastructure/Sync/DatabaseServerMessenger.cs` |
|
||||
| **Lines** | 24 (creation), 339–349 (Dispose) |
|
||||
| **Confidence** | High |
|
||||
| **Practical Impact** | Negligible |
|
||||
|
||||
`DatabaseServerMessenger` implements `IDisposable`, but its `Dispose(bool)` method omits disposal of `_cancellationTokenSource`:
|
||||
|
||||
```csharp
|
||||
// Line 24 — created
|
||||
private readonly CancellationTokenSource _cancellationTokenSource = new();
|
||||
|
||||
// Lines 339–349 — _syncIdle is disposed; _cancellationTokenSource is not
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposedValue)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_syncIdle.Dispose();
|
||||
// ← _cancellationTokenSource.Dispose() is missing
|
||||
}
|
||||
_disposedValue = true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`CancellationTokenSource` internally holds a native `SafeWaitHandle` (a Win32 event object) that should be released via `Dispose()`. Because this class is a singleton, exactly **one** handle is leaked for the lifetime of the process — the GC finaliser will never reclaim it. The practical memory cost is a few hundred bytes and one OS handle, which is immeasurable in a normal server process.
|
||||
|
||||
**Real-world impact over several days**: None observable. This is a correctness issue rather than a practical one.
|
||||
|
||||
**Recommended fix**: Add `_cancellationTokenSource.Dispose();` inside the `if (disposing)` block at line 345. This is a single-line change.
|
||||
|
||||
---
|
||||
|
||||
### Finding 2 — `JsonDocument` Not Disposed in Cache Sync Loop
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **File** | `src/Umbraco.Infrastructure/Services/CacheInstructionService.cs` |
|
||||
| **Lines** | 287, 293, 315–334 |
|
||||
| **Confidence** | High |
|
||||
| **Practical Impact** | Low (single server) / Low–Medium (multi-server) |
|
||||
|
||||
`TryDeserializeInstructions` allocates a `JsonDocument` — which rents a buffer from `ArrayPool<byte>` — and returns it via an `out` parameter. The caller uses the document's `RootElement` once, then allows the variable to go out of scope without calling `Dispose()`:
|
||||
|
||||
```csharp
|
||||
// Line 287 — JsonDocument created inside TryDeserializeInstructions
|
||||
if (TryDeserializeInstructions(instruction, out JsonDocument? jsonInstructions) is false
|
||||
&& jsonInstructions is null)
|
||||
{
|
||||
lastId = instruction.Id;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Line 293 — last use; jsonInstructions goes out of scope without Dispose()
|
||||
List<RefreshInstruction> instructionBatch = GetAllInstructions(jsonInstructions?.RootElement);
|
||||
```
|
||||
|
||||
`JsonDocument` has no finaliser. When the GC collects an un-disposed instance, the rented `ArrayPool` buffer is collected as ordinary heap memory rather than being returned to the pool. This reduces pool hit rates and increases allocation pressure.
|
||||
|
||||
This codepath runs inside the multi-server cache instruction sync loop. On a **single-server** deployment the loop processes only local (skipped) instructions and almost never reaches `TryDeserializeInstructions`. On a **multi-server load-balanced** deployment with active content publishing, this can fire many times per minute.
|
||||
|
||||
**Real-world impact over several days**: Negligible on single-server. On a busy multi-server site, slightly elevated Gen 0 GC frequency from reduced `ArrayPool` reuse. Memory does not grow unboundedly.
|
||||
|
||||
**Recommended fix**: Wrap the `JsonDocument` in a `using` declaration at the call site:
|
||||
```csharp
|
||||
using JsonDocument? jsonInstructions = TryDeserializeInstructions(instruction);
|
||||
if (jsonInstructions is null) { lastId = instruction.Id; continue; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Finding 3 — `JsonDocument` Cached Without Disposal on Eviction
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **File** | `src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/JsonValueConverter.cs` |
|
||||
| **Lines** | 52–68 |
|
||||
| **Confidence** | Medium |
|
||||
| **Practical Impact** | Low |
|
||||
|
||||
`ConvertSourceToIntermediate` returns a `JsonDocument` that the published content cache stores at `PropertyCacheLevel.Element` (cached per content element, per variant):
|
||||
|
||||
```csharp
|
||||
public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType)
|
||||
=> PropertyCacheLevel.Element;
|
||||
|
||||
public override object? ConvertSourceToIntermediate(...)
|
||||
{
|
||||
// ...
|
||||
return JsonDocument.Parse(sourceString); // rented ArrayPool buffer not returned on eviction
|
||||
}
|
||||
```
|
||||
|
||||
The cache holds values as `object?` and evicts them by releasing references. Because there is no eviction callback that calls `Dispose()`, the rented buffer for each `JsonDocument` is abandoned rather than returned to the pool.
|
||||
|
||||
This affects every content node with a JSON property type (block lists, media pickers, nested content, etc.). On a site with mostly-static content the cached `JsonDocument` population is bounded and stable. On a site with frequent content changes causing cache churn, pool hit rates are lower and allocation pressure is higher.
|
||||
|
||||
**Real-world impact over several days**: Low. Memory does not grow unboundedly — the GC collects evicted documents. The observable effect, if any, would be marginally higher Gen 0 collection frequency on high-churn sites. This is unlikely to be measurable on a typical site.
|
||||
|
||||
**Recommended fix**: This requires a non-trivial design change — either wrapping returned values in a disposable owner type with cache eviction callbacks, or switching the internal representation away from the pooled `JsonDocument` type.
|
||||
|
||||
---
|
||||
|
||||
### Finding 4 — `CryptoStream` and `ICryptoTransform` Not Disposed
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **File** | `src/Umbraco.Infrastructure/Security/MemberPasswordHasher.cs` |
|
||||
| **Lines** | 161–171 |
|
||||
| **Confidence** | Medium |
|
||||
| **Practical Impact** | Negligible |
|
||||
|
||||
In a legacy password decryption helper, `MemoryStream` is correctly wrapped in `using`, but `CryptoStream` and `ICryptoTransform` are not:
|
||||
|
||||
```csharp
|
||||
private static string DecryptLegacyPassword(string encryptedPassword, SymmetricAlgorithm algorithm)
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
ICryptoTransform cryptoTransform = algorithm.CreateDecryptor(); // not disposed
|
||||
var cryptoStream = new CryptoStream(memoryStream, cryptoTransform, CryptoStreamMode.Write); // not disposed
|
||||
var buf = Convert.FromBase64String(encryptedPassword);
|
||||
cryptoStream.Write(buf, 0, 32);
|
||||
cryptoStream.FlushFinalBlock();
|
||||
return Encoding.Unicode.GetString(memoryStream.ToArray());
|
||||
}
|
||||
```
|
||||
|
||||
Both types implement `IDisposable` and hold internal transform state buffers. However, this method is only invoked for accounts with Umbraco ≤ 8 encrypted password hashes — a codepath that is exercised only during migrations from legacy installations and is effectively never called on a v17 site.
|
||||
|
||||
**Real-world impact over several days**: None observable. The objects are small and collected promptly by the GC.
|
||||
|
||||
**Recommended fix**: Add `using` declarations for both `cryptoTransform` and `cryptoStream` for correctness.
|
||||
|
||||
---
|
||||
|
||||
### Finding 5 — Static Event Subscription Without Unsubscription (Development Mode Only)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **File** | `src/Umbraco.Cms.DevelopmentMode.Backoffice/InMemoryAuto/InMemoryAssemblyLoadContextManager.cs` |
|
||||
| **Lines** | 10–11 |
|
||||
| **Confidence** | High (pattern) |
|
||||
| **Practical Impact** | None in production |
|
||||
|
||||
The class subscribes to a static event in its constructor but implements no `IDisposable` to unsubscribe:
|
||||
|
||||
```csharp
|
||||
public InMemoryAssemblyLoadContextManager() =>
|
||||
AssemblyLoadContext.Default.Resolving += OnResolvingDefaultAssemblyLoadContext;
|
||||
// No corresponding -= and no IDisposable
|
||||
```
|
||||
|
||||
The class is registered as a singleton (`AddSingleton<InMemoryAssemblyLoadContextManager>()`), so its lifetime matches the process and the omission is benign in normal operation. The static event would prevent GC if the DI container released its reference (e.g. during repeated host rebuilding in integration tests). This component is only active when `ModelsMode` is `InMemoryAuto` and `RuntimeMode` is `BackofficeDevelopment` — it is never loaded in production.
|
||||
|
||||
**Real-world impact over several days**: None in production. Negligible in development.
|
||||
|
||||
**Recommended fix**: Implement `IDisposable` and unsubscribe in `Dispose()` for correctness and test isolation.
|
||||
|
||||
---
|
||||
|
||||
### Finding 6 — Static `HttpClient` Bypasses `IHttpClientFactory`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **File** | `src/Umbraco.Core/Media/EmbedProviders/OEmbedProviderBase.cs` |
|
||||
| **Lines** | 13, 88–92 |
|
||||
| **Confidence** | Low (not a true memory leak) |
|
||||
| **Practical Impact** | Negligible (memory); Low (DNS staleness) |
|
||||
|
||||
A static `HttpClient?` field is lazily initialised without using `IHttpClientFactory`:
|
||||
|
||||
```csharp
|
||||
private static HttpClient? _httpClient;
|
||||
|
||||
if (_httpClient == null)
|
||||
{
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd(...);
|
||||
}
|
||||
```
|
||||
|
||||
`HttpClient` is designed to be long-lived and reused, so the static pattern does not cause a memory leak. The practical concern is that DNS changes are not respected (no `PooledConnectionLifetime` on the underlying handler), which could cause stale connections on sites where OEmbed providers change their infrastructure. This is not a memory concern.
|
||||
|
||||
**Real-world impact over several days**: No memory impact. Potential for stale DNS on OEmbed requests after several days if a provider changes their IP.
|
||||
|
||||
**Recommended fix**: Inject `IHttpClientFactory` and use a named or typed client.
|
||||
|
||||
---
|
||||
|
||||
### Finding 7 — Unbounded Static Regex Cache
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **File** | `src/Umbraco.Core/Services/OEmbedService.cs` |
|
||||
| **Lines** | 15, 68–69 |
|
||||
| **Confidence** | Low |
|
||||
| **Practical Impact** | Negligible |
|
||||
|
||||
Compiled `Regex` objects are cached in a static `ConcurrentDictionary` with no eviction:
|
||||
|
||||
```csharp
|
||||
private static readonly ConcurrentDictionary<string, Regex> RegexCache = new();
|
||||
|
||||
private static Regex GetOrCreateRegex(string pattern)
|
||||
=> RegexCache.GetOrAdd(pattern, p => new Regex(p, RegexOptions.IgnoreCase | RegexOptions.Compiled));
|
||||
```
|
||||
|
||||
The dictionary is bounded by the number of unique URL scheme patterns across registered OEmbed providers, which is typically around 15–20 entries. Compiled `Regex` objects are intentionally long-lived. This is not a memory leak under normal usage; it would only become one if patterns were generated dynamically from user input at runtime (which they are not).
|
||||
|
||||
**Real-world impact over several days**: None observable.
|
||||
|
||||
**Recommended fix**: No action needed under current usage patterns. Add a size cap if the pattern set ever becomes dynamic.
|
||||
|
||||
---
|
||||
|
||||
## Items Investigated and Cleared
|
||||
|
||||
The following patterns were examined and found to be correctly implemented:
|
||||
|
||||
| Class / Area | Pattern Checked | Result |
|
||||
|---|---|---|
|
||||
| `DatabaseServerMessenger._syncIdle` | `ManualResetEvent` disposal | ✓ Disposed at line 345 |
|
||||
| `RecurringHostedServiceBase._timer` | `System.Threading.Timer` disposal | ✓ Disposed via `_timer?.Dispose()` |
|
||||
| `DistributedBackgroundJobHostedService` | `PeriodicTimer` disposal | ✓ Wrapped in `using` |
|
||||
| `RetryDbConnection` | `StateChange` event handler | ✓ Unsubscribed in `Dispose(bool)` |
|
||||
| `UmbracoIdentityUser` | `ObservableCollection.CollectionChanged` | ✓ Cleaned up in property setters |
|
||||
| `Content` / `ContentBase` / `ContentTypeBase` | `CollectionChanged` handlers | ✓ Use `ClearCollectionChangedEvents()` before reassignment |
|
||||
| `FileRepository` / `PartialViewRepository` | `MemoryStream` returned from `GetContentStream` | ✓ All call sites wrap result in `using` |
|
||||
| `JsonConfigManipulator` | `FileStream` disposal | ✓ Wrapped in `await using` |
|
||||
| `QueuedHostedService` | `ExecutionContext.SuppressFlow()` | ✓ Wrapped in `using` |
|
||||
| Background job DI registrations | Captive dependency (scoped-in-singleton) | ✓ No violations found |
|
||||
|
||||
---
|
||||
|
||||
## Priority and Effort Summary
|
||||
|
||||
| Priority | Finding | Fix Effort |
|
||||
|---|---|---|
|
||||
| **Fix** | Finding 1: `CancellationTokenSource` not disposed | 1 line |
|
||||
| **Fix** | Finding 2: `JsonDocument` not disposed in sync loop | ~3 lines |
|
||||
| **Fix** | Finding 4: `CryptoStream` not disposed | 2 lines |
|
||||
| **Fix** | Finding 5: Static event leak (dev-only) | `IDisposable` implementation |
|
||||
| **Consider** | Finding 3: `JsonDocument` cached without disposal | Design change required |
|
||||
| **Consider** | Finding 6: Static `HttpClient` | Inject `IHttpClientFactory` |
|
||||
| **Monitor** | Finding 7: Static `Regex` cache | No action unless patterns become dynamic |
|
||||
|
||||
Findings 1, 2, and 4 are low-effort correctness fixes that follow established .NET resource management idioms. Finding 3 is a legitimate design smell that warrants a separate investigation into how the published content cache handles disposable cached values.
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IDeliveryApiOutputCacheRequestFilter"/> that prevents caching
|
||||
/// for preview mode requests and requests without public access.
|
||||
/// </summary>
|
||||
public class DefaultDeliveryApiOutputCacheRequestFilter : IDeliveryApiOutputCacheRequestFilter
|
||||
{
|
||||
private readonly IRequestPreviewService _requestPreviewService;
|
||||
private readonly IApiAccessService _apiAccessService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultDeliveryApiOutputCacheRequestFilter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="requestPreviewService">The preview service.</param>
|
||||
/// <param name="apiAccessService">The API access service.</param>
|
||||
public DefaultDeliveryApiOutputCacheRequestFilter(IRequestPreviewService requestPreviewService, IApiAccessService apiAccessService)
|
||||
{
|
||||
_requestPreviewService = requestPreviewService;
|
||||
_apiAccessService = apiAccessService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual bool IsCacheable(HttpContext context)
|
||||
=> IsPreview() is false && HasPublicAccess();
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual bool IsCacheable(HttpContext context, IPublishedContent content) => true;
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if the current request is a preview request; <c>false</c> if the request
|
||||
/// is not a preview and may be cached.
|
||||
/// </summary>
|
||||
protected virtual bool IsPreview()
|
||||
=> _requestPreviewService.IsPreview();
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if the current request has public access; <c>false</c> if the request
|
||||
/// is not publicly accessible and should not be cached.
|
||||
/// </summary>
|
||||
protected virtual bool HasPublicAccess()
|
||||
=> _apiAccessService.HasPublicAccess();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Tags cached pages for delivery API output caching with their content type alias, enabling eviction by content type.
|
||||
/// </summary>
|
||||
internal sealed class DeliveryApiContentTypeOutputCacheTagProvider : IDeliveryApiOutputCacheTagProvider
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<string> GetTags(IPublishedContent content)
|
||||
{
|
||||
yield return Constants.DeliveryApi.OutputCache.ContentTypeTagPrefix + content.ContentType.Alias;
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.Changes;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Web.Common.Caching;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="ContentCacheRefresherNotification"/> to evict Delivery API output cache entries
|
||||
/// when content is published, unpublished, moved, or deleted. Also evicts responses for content
|
||||
/// that references the changed content via picker properties (umbDocument relations).
|
||||
/// </summary>
|
||||
internal sealed class DeliveryApiDocumentOutputCacheEvictionHandler
|
||||
: RelationOutputCacheEvictionHandlerBase, INotificationAsyncHandler<ContentCacheRefresherNotification>
|
||||
{
|
||||
private readonly IEnumerable<IDeliveryApiOutputCacheEvictionProvider> _evictionProviders;
|
||||
private readonly ILogger<DeliveryApiDocumentOutputCacheEvictionHandler> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeliveryApiDocumentOutputCacheEvictionHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="outputCacheStore">The output cache store for evicting cached responses.</param>
|
||||
/// <param name="relationService">The relation service for querying entity references.</param>
|
||||
/// <param name="idKeyMap">The ID/key mapping service for converting between integer IDs and GUIDs.</param>
|
||||
/// <param name="evictionProviders">Custom eviction providers for additional tag-based eviction.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public DeliveryApiDocumentOutputCacheEvictionHandler(
|
||||
IOutputCacheStore outputCacheStore,
|
||||
IRelationService relationService,
|
||||
IIdKeyMap idKeyMap,
|
||||
IEnumerable<IDeliveryApiOutputCacheEvictionProvider> evictionProviders,
|
||||
ILogger<DeliveryApiDocumentOutputCacheEvictionHandler> logger)
|
||||
: base(outputCacheStore, relationService, idKeyMap)
|
||||
{
|
||||
_evictionProviders = evictionProviders;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task HandleAsync(ContentCacheRefresherNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (notification.MessageType != MessageType.RefreshByPayload
|
||||
|| notification.MessageObject is not ContentCacheRefresher.JsonPayload[] payloads)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var changedEntityIds = new List<int>();
|
||||
|
||||
foreach (ContentCacheRefresher.JsonPayload payload in payloads)
|
||||
{
|
||||
if (payload.Blueprint)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await EvictForPayloadAsync(payload, cancellationToken);
|
||||
changedEntityIds.Add(payload.Id);
|
||||
}
|
||||
|
||||
// Evict content that references the changed content via picker properties.
|
||||
await EvictRelatedContentAsync(
|
||||
changedEntityIds,
|
||||
Constants.Conventions.RelationTypes.RelatedDocumentAlias,
|
||||
Constants.DeliveryApi.OutputCache.ContentTagPrefix,
|
||||
_logger,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EvictForPayloadAsync(ContentCacheRefresher.JsonPayload payload, CancellationToken cancellationToken)
|
||||
{
|
||||
if (payload.ChangeTypes.HasFlag(TreeChangeTypes.RefreshAll))
|
||||
{
|
||||
// Evict all Delivery API responses — media responses may reference content via picker properties.
|
||||
_logger.LogDebug("Content refresh all — evicting all Delivery API output cache entries.");
|
||||
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllTag, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.Key.HasValue is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Guid contentKey = payload.Key.Value;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("Evicting Delivery API output cache for content {ContentKey}.", contentKey);
|
||||
}
|
||||
|
||||
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.ContentTagPrefix + contentKey, cancellationToken);
|
||||
|
||||
if (payload.ChangeTypes.HasFlag(TreeChangeTypes.RefreshBranch))
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("Evicting Delivery API output cache for descendants of {ContentKey}.", contentKey);
|
||||
}
|
||||
|
||||
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AncestorTagPrefix + contentKey, cancellationToken);
|
||||
}
|
||||
|
||||
await InvokeCustomEvictionProvidersAsync(payload, contentKey, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task InvokeCustomEvictionProvidersAsync(ContentCacheRefresher.JsonPayload payload, Guid contentKey, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = new OutputCacheContentChangedContext(
|
||||
payload.Id,
|
||||
contentKey,
|
||||
payload.PublishedCultures ?? [],
|
||||
payload.UnpublishedCultures ?? []);
|
||||
|
||||
foreach (IDeliveryApiOutputCacheEvictionProvider provider in _evictionProviders)
|
||||
{
|
||||
IEnumerable<string> additionalTags = await provider.GetAdditionalEvictionTagsAsync(context, cancellationToken);
|
||||
foreach (var tag in additionalTags)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("Evicting Delivery API output cache tag {Tag} via custom provider.", tag);
|
||||
}
|
||||
|
||||
await OutputCacheStore.EvictByTagAsync(tag, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.Changes;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Web.Common.Caching;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="MediaCacheRefresherNotification"/> to evict Delivery API output cache entries
|
||||
/// when media is created, updated, or deleted. Also evicts content responses that reference
|
||||
/// the changed media via picker properties (umbMedia relations).
|
||||
/// </summary>
|
||||
internal sealed class DeliveryApiMediaOutputCacheEvictionHandler
|
||||
: RelationOutputCacheEvictionHandlerBase, INotificationAsyncHandler<MediaCacheRefresherNotification>
|
||||
{
|
||||
private readonly ILogger<DeliveryApiMediaOutputCacheEvictionHandler> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeliveryApiMediaOutputCacheEvictionHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="outputCacheStore">The output cache store for evicting cached responses.</param>
|
||||
/// <param name="relationService">The relation service for querying entity references.</param>
|
||||
/// <param name="idKeyMap">The ID/key mapping service for converting between integer IDs and GUIDs.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public DeliveryApiMediaOutputCacheEvictionHandler(
|
||||
IOutputCacheStore outputCacheStore,
|
||||
IRelationService relationService,
|
||||
IIdKeyMap idKeyMap,
|
||||
ILogger<DeliveryApiMediaOutputCacheEvictionHandler> logger)
|
||||
: base(outputCacheStore, relationService, idKeyMap)
|
||||
=> _logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task HandleAsync(MediaCacheRefresherNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (notification.MessageType != MessageType.RefreshByPayload
|
||||
|| notification.MessageObject is not MediaCacheRefresher.JsonPayload[] payloads)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (MediaCacheRefresher.JsonPayload payload in payloads)
|
||||
{
|
||||
if (payload.ChangeTypes.HasFlag(TreeChangeTypes.RefreshAll))
|
||||
{
|
||||
// Evict all Delivery API responses — content responses may include referenced media,
|
||||
// so evicting only media entries would leave stale media references in content responses.
|
||||
_logger.LogDebug("Media refresh all — evicting all Delivery API output cache entries.");
|
||||
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllTag, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.Key.HasValue is false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("Evicting Delivery API output cache for media {MediaKey}.", payload.Key.Value);
|
||||
}
|
||||
|
||||
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.MediaTagPrefix + payload.Key.Value, cancellationToken);
|
||||
}
|
||||
|
||||
// Evict content that references the changed media via picker properties.
|
||||
await EvictRelatedContentAsync(
|
||||
payloads.Select(p => p.Id),
|
||||
Constants.Conventions.RelationTypes.RelatedMediaAlias,
|
||||
Constants.DeliveryApi.OutputCache.ContentTagPrefix,
|
||||
_logger,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Web.Common.Caching;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="MemberCacheRefresherNotification"/> to evict Delivery API output cache entries
|
||||
/// for content that references the changed member via picker properties (umbMember relations).
|
||||
/// </summary>
|
||||
internal sealed class DeliveryApiMemberOutputCacheEvictionHandler
|
||||
: RelationOutputCacheEvictionHandlerBase, INotificationAsyncHandler<MemberCacheRefresherNotification>
|
||||
{
|
||||
private readonly ILogger<DeliveryApiMemberOutputCacheEvictionHandler> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeliveryApiMemberOutputCacheEvictionHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="outputCacheStore">The output cache store for evicting cached responses.</param>
|
||||
/// <param name="relationService">The relation service for querying entity references.</param>
|
||||
/// <param name="idKeyMap">The ID/key mapping service for converting between integer IDs and GUIDs.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public DeliveryApiMemberOutputCacheEvictionHandler(
|
||||
IOutputCacheStore outputCacheStore,
|
||||
IRelationService relationService,
|
||||
IIdKeyMap idKeyMap,
|
||||
ILogger<DeliveryApiMemberOutputCacheEvictionHandler> logger)
|
||||
: base(outputCacheStore, relationService, idKeyMap)
|
||||
=> _logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task HandleAsync(MemberCacheRefresherNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (notification.MessageType != MessageType.RefreshByPayload
|
||||
|| notification.MessageObject is not MemberCacheRefresher.JsonPayload[] payloads)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Evict content that references the changed members via picker properties.
|
||||
await EvictRelatedContentAsync(
|
||||
payloads.Select(p => p.Id),
|
||||
Constants.Conventions.RelationTypes.RelatedMemberAlias,
|
||||
Constants.DeliveryApi.OutputCache.ContentTagPrefix,
|
||||
_logger,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.Services.Navigation;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Output cache policy for Delivery API content endpoints.
|
||||
/// </summary>
|
||||
internal sealed class DeliveryApiOutputCacheContentPolicy : DeliveryApiOutputCachePolicyBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeliveryApiOutputCacheContentPolicy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="defaultDuration">The default cache duration from configuration.</param>
|
||||
/// <param name="defaultVaryByHeaders">The default vary-by headers for content requests.</param>
|
||||
public DeliveryApiOutputCacheContentPolicy(TimeSpan defaultDuration, StringValues defaultVaryByHeaders)
|
||||
: base(defaultDuration, defaultVaryByHeaders)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ResolvedItemsKey => DeliveryApiOutputCacheKeys.ResolvedContentItemsKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ItemTagPrefix => Constants.DeliveryApi.OutputCache.ContentTagPrefix;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string AllItemsTag => Constants.DeliveryApi.OutputCache.AllContentTag;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void AddItemTags(OutputCacheContext context, IPublishedContent item, IServiceProvider services)
|
||||
{
|
||||
// Tag with ancestor keys for branch eviction.
|
||||
IDocumentNavigationQueryService navigationService = services.GetRequiredService<IDocumentNavigationQueryService>();
|
||||
if (navigationService.TryGetAncestorsKeys(item.Key, out IEnumerable<Guid> ancestorKeys))
|
||||
{
|
||||
foreach (Guid ancestorKey in ancestorKeys)
|
||||
{
|
||||
context.Tags.Add(Constants.DeliveryApi.OutputCache.AncestorTagPrefix + ancestorKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Keys used to pass resolved content and media items from controllers to the output cache policy
|
||||
/// via <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/>.
|
||||
/// </summary>
|
||||
internal static class DeliveryApiOutputCacheKeys
|
||||
{
|
||||
/// <summary>
|
||||
/// Key for storing resolved content items in <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/>.
|
||||
/// </summary>
|
||||
public const string ResolvedContentItemsKey = "Umbraco.DeliveryApi.OutputCache.ResolvedContentItems";
|
||||
|
||||
/// <summary>
|
||||
/// Key for storing resolved media items in <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/>.
|
||||
/// </summary>
|
||||
public const string ResolvedMediaItemsKey = "Umbraco.DeliveryApi.OutputCache.ResolvedMediaItems";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IDeliveryApiOutputCacheManager"/> that delegates
|
||||
/// to the ASP.NET Core <see cref="IOutputCacheStore"/>.
|
||||
/// </summary>
|
||||
internal sealed class DeliveryApiOutputCacheManager : IDeliveryApiOutputCacheManager
|
||||
{
|
||||
private readonly IOutputCacheStore _outputCacheStore;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeliveryApiOutputCacheManager"/> class.
|
||||
/// </summary>
|
||||
/// <param name="outputCacheStore">The ASP.NET Core output cache store.</param>
|
||||
public DeliveryApiOutputCacheManager(IOutputCacheStore outputCacheStore)
|
||||
=> _outputCacheStore = outputCacheStore;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EvictContentAsync(Guid contentKey, CancellationToken cancellationToken = default)
|
||||
=> await _outputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.ContentTagPrefix + contentKey, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EvictMediaAsync(Guid mediaKey, CancellationToken cancellationToken = default)
|
||||
=> await _outputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.MediaTagPrefix + mediaKey, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EvictByTagAsync(string tag, CancellationToken cancellationToken = default)
|
||||
=> await _outputCacheStore.EvictByTagAsync(tag, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EvictAllContentAsync(CancellationToken cancellationToken = default)
|
||||
=> await _outputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllContentTag, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EvictAllMediaAsync(CancellationToken cancellationToken = default)
|
||||
=> await _outputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllMediaTag, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EvictAllAsync(CancellationToken cancellationToken = default)
|
||||
=> await _outputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllTag, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Umbraco.Cms.Core;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Output cache policy for Delivery API media endpoints.
|
||||
/// </summary>
|
||||
internal sealed class DeliveryApiOutputCacheMediaPolicy : DeliveryApiOutputCachePolicyBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeliveryApiOutputCacheMediaPolicy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="defaultDuration">The default cache duration from configuration.</param>
|
||||
/// <param name="defaultVaryByHeaders">The default vary-by headers for media requests.</param>
|
||||
public DeliveryApiOutputCacheMediaPolicy(TimeSpan defaultDuration, StringValues defaultVaryByHeaders)
|
||||
: base(defaultDuration, defaultVaryByHeaders)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ResolvedItemsKey => DeliveryApiOutputCacheKeys.ResolvedMediaItemsKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ItemTagPrefix => Constants.DeliveryApi.OutputCache.MediaTagPrefix;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string AllItemsTag => Constants.DeliveryApi.OutputCache.AllMediaTag;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
internal sealed class DeliveryApiOutputCachePolicy : IOutputCachePolicy
|
||||
{
|
||||
private readonly TimeSpan _duration;
|
||||
private readonly StringValues _varyByHeaderNames;
|
||||
|
||||
public DeliveryApiOutputCachePolicy(TimeSpan duration, StringValues varyByHeaderNames)
|
||||
{
|
||||
_duration = duration;
|
||||
_varyByHeaderNames = varyByHeaderNames;
|
||||
}
|
||||
|
||||
ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
IRequestPreviewService requestPreviewService = context
|
||||
.HttpContext
|
||||
.RequestServices
|
||||
.GetRequiredService<IRequestPreviewService>();
|
||||
|
||||
IApiAccessService apiAccessService = context
|
||||
.HttpContext
|
||||
.RequestServices
|
||||
.GetRequiredService<IApiAccessService>();
|
||||
|
||||
context.EnableOutputCaching = requestPreviewService.IsPreview() is false && apiAccessService.HasPublicAccess();
|
||||
context.ResponseExpirationTimeSpan = _duration;
|
||||
context.CacheVaryByRules.HeaderNames = _varyByHeaderNames;
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Base output cache policy for Delivery API endpoints. Handles request filtering, vary-by rules,
|
||||
/// and tagging. Subclasses specify the resolved-items key, tag prefix, and "all" tag that
|
||||
/// distinguish content from media.
|
||||
/// </summary>
|
||||
internal abstract class DeliveryApiOutputCachePolicyBase : IOutputCachePolicy
|
||||
{
|
||||
private readonly TimeSpan _defaultDuration;
|
||||
private readonly StringValues _defaultVaryByHeaders;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeliveryApiOutputCachePolicyBase"/> class.
|
||||
/// </summary>
|
||||
/// <param name="defaultDuration">The default cache duration from configuration.</param>
|
||||
/// <param name="defaultVaryByHeaders">The default vary-by headers for this endpoint type.</param>
|
||||
protected DeliveryApiOutputCachePolicyBase(TimeSpan defaultDuration, StringValues defaultVaryByHeaders)
|
||||
{
|
||||
_defaultDuration = defaultDuration;
|
||||
_defaultVaryByHeaders = defaultVaryByHeaders;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/> key used to retrieve
|
||||
/// resolved <see cref="IPublishedContent"/> items stashed by the controller.
|
||||
/// </summary>
|
||||
protected abstract string ResolvedItemsKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tag prefix for individual item eviction (e.g. <c>umb-dapi-content-</c>).
|
||||
/// </summary>
|
||||
protected abstract string ItemTagPrefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the "all items" tag for bulk eviction (e.g. <c>umb-dapi-content-all</c>).
|
||||
/// </summary>
|
||||
protected abstract string AllItemsTag { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds additional per-item tags to the output cache context. Called once per resolved item
|
||||
/// during <c>ServeResponseAsync</c>. The default implementation does nothing.
|
||||
/// </summary>
|
||||
/// <param name="context">The output cache context.</param>
|
||||
/// <param name="item">The published content or media item.</param>
|
||||
/// <param name="services">The request service provider.</param>
|
||||
protected virtual void AddItemTags(OutputCacheContext context, IPublishedContent item, IServiceProvider services)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
IServiceProvider services = context.HttpContext.RequestServices;
|
||||
ILogger logger = services.GetRequiredService<ILoggerFactory>().CreateLogger(GetType());
|
||||
|
||||
IDeliveryApiOutputCacheRequestFilter requestFilter = services.GetRequiredService<IDeliveryApiOutputCacheRequestFilter>();
|
||||
if (requestFilter.IsCacheable(context.HttpContext) is false)
|
||||
{
|
||||
context.EnableOutputCaching = false;
|
||||
logger.LogDebug("Request filter returned not cacheable — skipping output cache.");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
context.EnableOutputCaching = true;
|
||||
context.AllowCacheLookup = true;
|
||||
context.AllowCacheStorage = true;
|
||||
context.AllowLocking = true;
|
||||
context.ResponseExpirationTimeSpan = _defaultDuration;
|
||||
|
||||
// Set default vary-by headers.
|
||||
context.CacheVaryByRules.HeaderNames = _defaultVaryByHeaders;
|
||||
|
||||
// Invoke custom vary-by providers (additive, runs after defaults).
|
||||
IEnumerable<IDeliveryApiOutputCacheVaryByProvider> varyByProviders = services.GetServices<IDeliveryApiOutputCacheVaryByProvider>();
|
||||
foreach (IDeliveryApiOutputCacheVaryByProvider varyByProvider in varyByProviders)
|
||||
{
|
||||
varyByProvider.ConfigureVaryBy(context.HttpContext, context.CacheVaryByRules);
|
||||
}
|
||||
|
||||
// Add base tags for bulk eviction.
|
||||
context.Tags.Add(AllItemsTag);
|
||||
context.Tags.Add(Constants.DeliveryApi.OutputCache.AllTag);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (context.HttpContext.Items[ResolvedItemsKey]
|
||||
is not IPublishedContent[] items || items.Length == 0)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
IServiceProvider services = context.HttpContext.RequestServices;
|
||||
ILogger logger = services.GetRequiredService<ILoggerFactory>().CreateLogger(GetType());
|
||||
IDeliveryApiOutputCacheRequestFilter requestFilter = services.GetRequiredService<IDeliveryApiOutputCacheRequestFilter>();
|
||||
IEnumerable<IDeliveryApiOutputCacheTagProvider> tagProviders = services.GetServices<IDeliveryApiOutputCacheTagProvider>();
|
||||
|
||||
foreach (IPublishedContent item in items)
|
||||
{
|
||||
// Check content-aware cacheability.
|
||||
if (requestFilter.IsCacheable(context.HttpContext, item) is false)
|
||||
{
|
||||
context.AllowCacheStorage = false;
|
||||
if (logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
logger.LogDebug("Request filter returned not cacheable for item {ItemKey} — disabling cache storage.", item.Key);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
// Tag with specific item key for targeted eviction.
|
||||
context.Tags.Add(ItemTagPrefix + item.Key);
|
||||
|
||||
// Allow subclasses to add additional per-item tags (e.g. ancestor tags for content).
|
||||
AddItemTags(context, item, services);
|
||||
|
||||
// Invoke custom tag providers.
|
||||
foreach (IDeliveryApiOutputCacheTagProvider tagProvider in tagProviders)
|
||||
{
|
||||
foreach (var tag in tagProvider.GetTags(item))
|
||||
{
|
||||
context.Tags.Add(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
logger.LogDebug(
|
||||
"Caching Delivery API response with {TagCount} tags, duration {Duration}",
|
||||
context.Tags.Count,
|
||||
context.ResponseExpirationTimeSpan);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a Delivery API request is eligible for output caching.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This interface provides two levels of cacheability checks:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="IsCacheable(HttpContext)"/> — called before the controller runs, for
|
||||
/// request-level decisions (e.g. preview mode, access control).</item>
|
||||
/// <item><see cref="IsCacheable(HttpContext, IPublishedContent)"/> — called after the controller
|
||||
/// resolves content, for content-aware decisions (e.g. exclude specific content types).</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public interface IDeliveryApiOutputCacheRequestFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the request is eligible for output caching.
|
||||
/// Called before the controller runs.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context for the current request.</param>
|
||||
/// <returns><c>true</c> if the response may be cached; <c>false</c> to skip caching.</returns>
|
||||
bool IsCacheable(HttpContext context);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the response for the given content or media item is eligible
|
||||
/// for output caching. Called after the controller resolves content.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context for the current request.</param>
|
||||
/// <param name="content">The resolved published content or media item.</param>
|
||||
/// <returns><c>true</c> if the response may be cached; <c>false</c> to skip caching.</returns>
|
||||
bool IsCacheable(HttpContext context, IPublishedContent content);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Configures additional vary-by rules for Delivery API output caching.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Multiple implementations can be registered; the output cache policy invokes all of them
|
||||
/// to configure vary-by rules at cache-write time, after the default vary-by headers have been set.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Providers have direct access to <see cref="CacheVaryByRules"/> and can configure any aspect
|
||||
/// including <see cref="CacheVaryByRules.QueryKeys"/>, <see cref="CacheVaryByRules.HeaderNames"/>,
|
||||
/// and <see cref="CacheVaryByRules.VaryByValues"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IDeliveryApiOutputCacheVaryByProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures vary-by rules for the given request.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context for the current request.</param>
|
||||
/// <param name="rules">The vary-by rules to configure.</param>
|
||||
void ConfigureVaryBy(HttpContext context, CacheVaryByRules rules);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Umbraco.Cms.Web.Common.ApplicationBuilder;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
|
||||
internal sealed class OutputCachePipelineFilter : UmbracoPipelineFilter
|
||||
{
|
||||
public OutputCachePipelineFilter(string name)
|
||||
: base(name)
|
||||
=> PostPipeline = PostPipelineAction;
|
||||
|
||||
private void PostPipelineAction(IApplicationBuilder applicationBuilder)
|
||||
=> applicationBuilder.UseOutputCache();
|
||||
}
|
||||
@@ -41,17 +41,20 @@ public class ByIdContentApiController : ContentApiItemControllerBase
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
IActionResult? deniedAccessResult = await HandleMemberAccessAsync(contentItem, _requestMemberAccessService).ConfigureAwait(false);
|
||||
if (deniedAccessResult is not null)
|
||||
{
|
||||
return deniedAccessResult;
|
||||
}
|
||||
|
||||
IApiContentResponse? apiContentResponse = ApiContentResponseBuilder.Build(contentItem);
|
||||
if (apiContentResponse is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
SetOutputCacheContent(contentItem);
|
||||
return Ok(apiContentResponse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ public class ByIdsContentApiController : ContentApiItemControllerBase
|
||||
.WhereNotNull()
|
||||
.ToArray();
|
||||
|
||||
SetOutputCacheContent(contentItems);
|
||||
return Ok(apiContentItems);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ public class ByRouteContentApiController : ContentApiItemControllerBase
|
||||
return deniedAccessResult;
|
||||
}
|
||||
|
||||
SetOutputCacheContent(contentItem);
|
||||
return Ok(ApiContentResponseBuilder.Build(contentItem));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Umbraco.Cms.Api.Common.Builders;
|
||||
using Umbraco.Cms.Api.Delivery.Caching;
|
||||
using Umbraco.Cms.Api.Delivery.Filters;
|
||||
using Umbraco.Cms.Api.Delivery.Routing;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Controllers.Content;
|
||||
@@ -50,6 +52,13 @@ public abstract class ContentApiControllerBase : DeliveryApiControllerBase
|
||||
.Build()),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Stores the resolved content items in the HTTP context for use by the output cache policy.
|
||||
/// </summary>
|
||||
/// <param name="items">The resolved published content items.</param>
|
||||
protected void SetOutputCacheContent(params IPublishedContent[] items)
|
||||
=> HttpContext.Items[DeliveryApiOutputCacheKeys.ResolvedContentItemsKey] = items;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 403 Forbidden result.
|
||||
/// </summary>
|
||||
|
||||
@@ -62,9 +62,11 @@ public class QueryContentApiController : ContentApiControllerBase
|
||||
}
|
||||
|
||||
PagedModel<Guid> pagedResult = queryAttempt.Result;
|
||||
IEnumerable<IPublishedContent> contentItems = ApiPublishedContentCache.GetByIds(pagedResult.Items);
|
||||
IPublishedContent[] contentItems = ApiPublishedContentCache.GetByIds(pagedResult.Items).ToArray();
|
||||
IApiContentResponse[] apiContentItems = contentItems.Select(ApiContentResponseBuilder.Build).WhereNotNull().ToArray();
|
||||
|
||||
SetOutputCacheContent(contentItems);
|
||||
|
||||
var model = new PagedViewModel<IApiContentResponse>
|
||||
{
|
||||
Total = pagedResult.Total,
|
||||
|
||||
@@ -39,6 +39,7 @@ public class ByIdMediaApiController : MediaApiControllerBase
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
SetOutputCacheMedia(media);
|
||||
return Ok(BuildApiMediaWithCrops(media));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ public class ByIdsMediaApiController : MediaApiControllerBase
|
||||
.Select(BuildApiMediaWithCrops)
|
||||
.ToArray();
|
||||
|
||||
SetOutputCacheMedia(mediaItems);
|
||||
return Ok(apiMediaItems);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ public class ByPathMediaApiController : MediaApiControllerBase
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
SetOutputCacheMedia(media);
|
||||
return Ok(BuildApiMediaWithCrops(media));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Umbraco.Cms.Api.Common.Builders;
|
||||
using Umbraco.Cms.Api.Delivery.Caching;
|
||||
using Umbraco.Cms.Api.Delivery.Filters;
|
||||
using Umbraco.Cms.Api.Delivery.Routing;
|
||||
using Umbraco.Cms.Core;
|
||||
@@ -33,6 +34,13 @@ public abstract class MediaApiControllerBase : DeliveryApiControllerBase
|
||||
protected IApiMediaWithCropsResponse BuildApiMediaWithCrops(IPublishedContent media)
|
||||
=> _apiMediaWithCropsResponseBuilder.Build(media);
|
||||
|
||||
/// <summary>
|
||||
/// Stores the resolved media items in the HTTP context for use by the output cache policy.
|
||||
/// </summary>
|
||||
/// <param name="items">The resolved published media items.</param>
|
||||
protected void SetOutputCacheMedia(params IPublishedContent[] items)
|
||||
=> HttpContext.Items[DeliveryApiOutputCacheKeys.ResolvedMediaItemsKey] = items;
|
||||
|
||||
protected IActionResult ApiMediaQueryOperationStatusResult(ApiMediaQueryOperationStatus status) =>
|
||||
status switch
|
||||
{
|
||||
|
||||
@@ -59,6 +59,8 @@ public class QueryMediaApiController : MediaApiControllerBase
|
||||
PagedModel<Guid> pagedResult = queryAttempt.Result;
|
||||
IPublishedContent[] mediaItems = pagedResult.Items.Select(PublishedMediaCache.GetById).WhereNotNull().ToArray();
|
||||
|
||||
SetOutputCacheMedia(mediaItems);
|
||||
|
||||
var model = new PagedViewModel<IApiMediaWithCropsResponse>
|
||||
{
|
||||
Total = pagedResult.Total,
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Delivery.Accessors;
|
||||
@@ -18,6 +19,7 @@ using Umbraco.Cms.Api.Delivery.Security;
|
||||
using Umbraco.Cms.Api.Delivery.Services;
|
||||
using Umbraco.Cms.Api.Delivery.Services.QueryBuilders;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
@@ -105,6 +107,10 @@ public static class UmbracoBuilderExtensions
|
||||
builder.AddNotificationAsyncHandler<MemberDeletedNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<AssignedMemberRolesNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<RemovedMemberRolesNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<ExternalMemberSavedNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<ExternalMemberDeletedNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<AssignedExternalMemberRolesNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<RemovedExternalMemberRolesNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
|
||||
|
||||
// FIXME: remove this when Delivery API V1 is removed
|
||||
builder.Services.AddSingleton<MatcherPolicy, DeliveryApiItemsEndpointsMatcherPolicy>();
|
||||
@@ -132,7 +138,7 @@ public static class UmbracoBuilderExtensions
|
||||
{
|
||||
options.AddPolicy(
|
||||
Constants.DeliveryApi.OutputCache.ContentCachePolicy,
|
||||
new DeliveryApiOutputCachePolicy(
|
||||
new DeliveryApiOutputCacheContentPolicy(
|
||||
outputCacheSettings.ContentDuration,
|
||||
new StringValues([Constants.DeliveryApi.HeaderNames.AcceptLanguage, Constants.DeliveryApi.HeaderNames.AcceptSegment, Constants.DeliveryApi.HeaderNames.StartItem])));
|
||||
}
|
||||
@@ -141,13 +147,28 @@ public static class UmbracoBuilderExtensions
|
||||
{
|
||||
options.AddPolicy(
|
||||
Constants.DeliveryApi.OutputCache.MediaCachePolicy,
|
||||
new DeliveryApiOutputCachePolicy(
|
||||
new DeliveryApiOutputCacheMediaPolicy(
|
||||
outputCacheSettings.MediaDuration,
|
||||
Constants.DeliveryApi.HeaderNames.StartItem));
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.Configure<UmbracoPipelineOptions>(options => options.AddFilter(new OutputCachePipelineFilter("UmbracoDeliveryApiOutputCache")));
|
||||
// Register eviction handlers.
|
||||
builder.AddNotificationAsyncHandler<ContentCacheRefresherNotification, DeliveryApiDocumentOutputCacheEvictionHandler>();
|
||||
builder.AddNotificationAsyncHandler<MediaCacheRefresherNotification, DeliveryApiMediaOutputCacheEvictionHandler>();
|
||||
builder.AddNotificationAsyncHandler<MemberCacheRefresherNotification, DeliveryApiMemberOutputCacheEvictionHandler>();
|
||||
|
||||
// Register extension point default implementations.
|
||||
builder.Services.AddSingleton<IDeliveryApiOutputCacheTagProvider, DeliveryApiContentTypeOutputCacheTagProvider>();
|
||||
builder.Services.AddUnique<IDeliveryApiOutputCacheRequestFilter, DefaultDeliveryApiOutputCacheRequestFilter>();
|
||||
builder.Services.AddUnique<IDeliveryApiOutputCacheManager, DeliveryApiOutputCacheManager>();
|
||||
|
||||
// Signal that Umbraco has enabled output caching so the application builder registers
|
||||
// the output cache middleware. Gated via a marker rather than IOutputCacheStore so that
|
||||
// applications calling services.AddOutputCache(...) for their own purposes are not
|
||||
// affected by Umbraco's automatic middleware registration.
|
||||
builder.Services.TryAddSingleton<IUmbracoManagedOutputCacheMarker, UmbracoManagedOutputCacheMarker>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
+66
-1
@@ -5,6 +5,7 @@ using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Handlers;
|
||||
@@ -13,7 +14,11 @@ internal sealed class RevokeMemberAuthenticationTokensNotificationHandler
|
||||
: INotificationAsyncHandler<MemberSavedNotification>,
|
||||
INotificationAsyncHandler<MemberDeletedNotification>,
|
||||
INotificationAsyncHandler<AssignedMemberRolesNotification>,
|
||||
INotificationAsyncHandler<RemovedMemberRolesNotification>
|
||||
INotificationAsyncHandler<RemovedMemberRolesNotification>,
|
||||
INotificationAsyncHandler<ExternalMemberSavedNotification>,
|
||||
INotificationAsyncHandler<ExternalMemberDeletedNotification>,
|
||||
INotificationAsyncHandler<AssignedExternalMemberRolesNotification>,
|
||||
INotificationAsyncHandler<RemovedExternalMemberRolesNotification>
|
||||
{
|
||||
private readonly IMemberService _memberService;
|
||||
private readonly IOpenIddictTokenManager _tokenManager;
|
||||
@@ -80,6 +85,38 @@ internal sealed class RevokeMemberAuthenticationTokensNotificationHandler
|
||||
}
|
||||
}
|
||||
|
||||
public async Task HandleAsync(ExternalMemberSavedNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_enabled is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (ExternalMemberIdentity member in notification.SavedEntities.Where(m => m.IsLockedOut || m.IsApproved is false))
|
||||
{
|
||||
await RevokeTokensByKeyAsync(member.Key);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task HandleAsync(ExternalMemberDeletedNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_enabled is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (ExternalMemberIdentity member in notification.DeletedEntities)
|
||||
{
|
||||
await RevokeTokensByKeyAsync(member.Key);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task HandleAsync(AssignedExternalMemberRolesNotification notification, CancellationToken cancellationToken)
|
||||
=> await ExternalMemberRolesChangedAsync(notification);
|
||||
|
||||
public async Task HandleAsync(RemovedExternalMemberRolesNotification notification, CancellationToken cancellationToken)
|
||||
=> await ExternalMemberRolesChangedAsync(notification);
|
||||
|
||||
private async Task MemberRolesChangedAsync(MemberRolesNotification notification)
|
||||
{
|
||||
if (_enabled is false)
|
||||
@@ -99,4 +136,32 @@ internal sealed class RevokeMemberAuthenticationTokensNotificationHandler
|
||||
await RevokeTokensAsync(member);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExternalMemberRolesChangedAsync(ExternalMemberRolesNotification notification)
|
||||
{
|
||||
if (_enabled is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (Guid memberKey in notification.MemberKeys)
|
||||
{
|
||||
await RevokeTokensByKeyAsync(memberKey);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RevokeTokensByKeyAsync(Guid memberKey)
|
||||
{
|
||||
var tokens = await _tokenManager.FindBySubjectAsync(memberKey.ToString()).ToArrayAsync();
|
||||
if (tokens.Any() is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Revoking {count} active tokens for external member with key {key}", tokens.Length, memberKey);
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
await _tokenManager.DeleteAsync(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+65
-2
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -19,6 +20,68 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree;
|
||||
[ApiVersion("1.0")]
|
||||
public class AncestorsDocumentTreeController : DocumentTreeControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AncestorsDocumentTreeController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
|
||||
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
|
||||
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
|
||||
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public AncestorsDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IDocumentStartNodeTreeFilterService treeFilterService,
|
||||
IPublicAccessService publicAccessService,
|
||||
IDocumentPresentationFactory documentPresentationFactory,
|
||||
IDocumentPermissionFilterService documentPermissionFilterService)
|
||||
: base(
|
||||
entityService,
|
||||
flagProviders,
|
||||
treeFilterService,
|
||||
publicAccessService,
|
||||
documentPresentationFactory,
|
||||
documentPermissionFilterService)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AncestorsDocumentTreeController"/> class.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This constructor exists solely to disambiguate DI container constructor resolution between the new
|
||||
/// and the existing obsolete constructors; all parameters except those forwarded to the non-obsolete
|
||||
/// constructor are ignored.
|
||||
/// </remarks>
|
||||
/// <param name="entityService">Service for managing and retrieving entities within Umbraco.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
|
||||
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
|
||||
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
|
||||
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
|
||||
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
|
||||
/// <param name="appCaches">Provides access to application-level caches.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
|
||||
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
|
||||
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public AncestorsDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IUserStartNodeEntitiesService userStartNodeEntitiesService,
|
||||
IDataTypeService dataTypeService,
|
||||
IDocumentStartNodeTreeFilterService treeFilterService,
|
||||
IPublicAccessService publicAccessService,
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
|
||||
IDocumentPresentationFactory documentPresentationFactory,
|
||||
IDocumentPermissionFilterService documentPermissionFilterService)
|
||||
: this(entityService, flagProviders, treeFilterService, publicAccessService, documentPresentationFactory, documentPermissionFilterService)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Umbraco.Cms.Api.Management.Controllers.Document.Tree.AncestorsDocumentTreeController"/> class.
|
||||
/// </summary>
|
||||
@@ -60,7 +123,7 @@ public class AncestorsDocumentTreeController : DocumentTreeControllerBase
|
||||
/// <param name="appCaches">Provides access to application-level caches.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
|
||||
public AncestorsDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
@@ -94,7 +157,7 @@ public class AncestorsDocumentTreeController : DocumentTreeControllerBase
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and authentication.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
|
||||
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
|
||||
public AncestorsDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
|
||||
+65
-2
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -20,6 +21,68 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree;
|
||||
[ApiVersion("1.0")]
|
||||
public class ChildrenDocumentTreeController : DocumentTreeControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChildrenDocumentTreeController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
|
||||
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
|
||||
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
|
||||
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public ChildrenDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IDocumentStartNodeTreeFilterService treeFilterService,
|
||||
IPublicAccessService publicAccessService,
|
||||
IDocumentPresentationFactory documentPresentationFactory,
|
||||
IDocumentPermissionFilterService documentPermissionFilterService)
|
||||
: base(
|
||||
entityService,
|
||||
flagProviders,
|
||||
treeFilterService,
|
||||
publicAccessService,
|
||||
documentPresentationFactory,
|
||||
documentPermissionFilterService)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChildrenDocumentTreeController"/> class.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This constructor exists solely to disambiguate DI container constructor resolution between the new
|
||||
/// and the existing obsolete constructors; all parameters except those forwarded to the non-obsolete
|
||||
/// constructor are ignored.
|
||||
/// </remarks>
|
||||
/// <param name="entityService">Service for managing and retrieving entities within Umbraco.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
|
||||
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
|
||||
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
|
||||
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
|
||||
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
|
||||
/// <param name="appCaches">Provides access to application-level caches.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
|
||||
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
|
||||
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public ChildrenDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IUserStartNodeEntitiesService userStartNodeEntitiesService,
|
||||
IDataTypeService dataTypeService,
|
||||
IDocumentStartNodeTreeFilterService treeFilterService,
|
||||
IPublicAccessService publicAccessService,
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
|
||||
IDocumentPresentationFactory documentPresentationFactory,
|
||||
IDocumentPermissionFilterService documentPermissionFilterService)
|
||||
: this(entityService, flagProviders, treeFilterService, publicAccessService, documentPresentationFactory, documentPermissionFilterService)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChildrenDocumentTreeController"/> class.
|
||||
/// </summary>
|
||||
@@ -61,7 +124,7 @@ public class ChildrenDocumentTreeController : DocumentTreeControllerBase
|
||||
/// <param name="appCaches">Provides application-level caching functionality.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and authentication.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models for the API.</param>
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
|
||||
public ChildrenDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
@@ -95,7 +158,7 @@ public class ChildrenDocumentTreeController : DocumentTreeControllerBase
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
|
||||
/// <param name="documentPermissionFilterService">Service for filtering documents based on permissions.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
|
||||
public ChildrenDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
|
||||
+43
-10
@@ -29,11 +29,14 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree;
|
||||
public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBase<DocumentTreeItemResponseModel>
|
||||
{
|
||||
private readonly IPublicAccessService _publicAccessService;
|
||||
private readonly AppCaches _appCaches;
|
||||
private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor;
|
||||
private readonly IDocumentPresentationFactory _documentPresentationFactory;
|
||||
private readonly IDocumentPermissionFilterService _documentPermissionFilterService;
|
||||
|
||||
// Only populated by the obsolete constructor path; used solely by the obsolete
|
||||
// GetUserStartNodeIds / GetUserStartNodePaths overrides below.
|
||||
private readonly AppCaches? _appCaches;
|
||||
private readonly IBackOfficeSecurityAccessor? _backofficeSecurityAccessor;
|
||||
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
|
||||
protected DocumentTreeControllerBase(
|
||||
IEntityService entityService,
|
||||
@@ -55,7 +58,7 @@ public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBa
|
||||
{
|
||||
}
|
||||
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
|
||||
protected DocumentTreeControllerBase(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
@@ -78,7 +81,7 @@ public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBa
|
||||
{
|
||||
}
|
||||
|
||||
[ActivatorUtilitiesConstructor]
|
||||
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
|
||||
protected DocumentTreeControllerBase(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
@@ -98,6 +101,30 @@ public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBa
|
||||
_documentPermissionFilterService = documentPermissionFilterService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DocumentTreeControllerBase"/> class.
|
||||
/// </summary>
|
||||
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
|
||||
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
|
||||
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
|
||||
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
protected DocumentTreeControllerBase(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IDocumentStartNodeTreeFilterService treeFilterService,
|
||||
IPublicAccessService publicAccessService,
|
||||
IDocumentPresentationFactory documentPresentationFactory,
|
||||
IDocumentPermissionFilterService documentPermissionFilterService)
|
||||
: base(entityService, flagProviders, treeFilterService)
|
||||
{
|
||||
_publicAccessService = publicAccessService;
|
||||
_documentPresentationFactory = documentPresentationFactory;
|
||||
_documentPermissionFilterService = documentPermissionFilterService;
|
||||
}
|
||||
|
||||
protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Document;
|
||||
|
||||
protected override Ordering ItemOrdering => Ordering.By(Infrastructure.Persistence.Dtos.NodeDto.SortOrderColumnName);
|
||||
@@ -122,21 +149,27 @@ public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBa
|
||||
return responseModel;
|
||||
}
|
||||
|
||||
// Only invoked via the CallbackStartNodeTreeFilterService wired up by the obsolete
|
||||
// UserStartNodeTreeControllerBase constructor. The non-obsolete constructor path
|
||||
// routes start node resolution through IDocumentStartNodeTreeFilterService and
|
||||
// never calls these overrides; hence the null-forgiving operator on _appCaches.
|
||||
/// <inheritdoc/>
|
||||
[Obsolete("No longer used. Register a custom IDocumentStartNodeTreeFilterService instead. Scheduled for removal in Umbraco 19.")]
|
||||
protected override int[] GetUserStartNodeIds()
|
||||
=> _backofficeSecurityAccessor
|
||||
=> _backofficeSecurityAccessor?
|
||||
.BackOfficeSecurity?
|
||||
.CurrentUser?
|
||||
.CalculateContentStartNodeIds(EntityService, _appCaches)
|
||||
?? Array.Empty<int>();
|
||||
.CalculateContentStartNodeIds(EntityService, _appCaches!)
|
||||
?? [];
|
||||
|
||||
/// <inheritdoc/>
|
||||
[Obsolete("No longer used. Register a custom IDocumentStartNodeTreeFilterService instead. Scheduled for removal in Umbraco 19.")]
|
||||
protected override string[] GetUserStartNodePaths()
|
||||
=> _backofficeSecurityAccessor
|
||||
=> _backofficeSecurityAccessor?
|
||||
.BackOfficeSecurity?
|
||||
.CurrentUser?
|
||||
.GetContentStartNodePaths(EntityService, _appCaches)
|
||||
?? Array.Empty<string>();
|
||||
.GetContentStartNodePaths(EntityService, _appCaches!)
|
||||
?? [];
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<(IEntitySlim[] Entities, long TotalItems)> FilterTreeEntities(IEntitySlim[] entities, long totalItems)
|
||||
|
||||
+65
-2
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -20,6 +21,68 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree;
|
||||
[ApiVersion("1.0")]
|
||||
public class RootDocumentTreeController : DocumentTreeControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RootDocumentTreeController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
|
||||
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
|
||||
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
|
||||
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public RootDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IDocumentStartNodeTreeFilterService treeFilterService,
|
||||
IPublicAccessService publicAccessService,
|
||||
IDocumentPresentationFactory documentPresentationFactory,
|
||||
IDocumentPermissionFilterService documentPermissionFilterService)
|
||||
: base(
|
||||
entityService,
|
||||
flagProviders,
|
||||
treeFilterService,
|
||||
publicAccessService,
|
||||
documentPresentationFactory,
|
||||
documentPermissionFilterService)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RootDocumentTreeController"/> class.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This constructor exists solely to disambiguate DI container constructor resolution between the new
|
||||
/// and the existing obsolete constructors; all parameters except those forwarded to the non-obsolete
|
||||
/// constructor are ignored.
|
||||
/// </remarks>
|
||||
/// <param name="entityService">Service for managing and retrieving entities within Umbraco.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
|
||||
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
|
||||
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
|
||||
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
|
||||
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
|
||||
/// <param name="appCaches">Provides access to application-level caches.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
|
||||
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
|
||||
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public RootDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IUserStartNodeEntitiesService userStartNodeEntitiesService,
|
||||
IDataTypeService dataTypeService,
|
||||
IDocumentStartNodeTreeFilterService treeFilterService,
|
||||
IPublicAccessService publicAccessService,
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
|
||||
IDocumentPresentationFactory documentPresentationFactory,
|
||||
IDocumentPermissionFilterService documentPermissionFilterService)
|
||||
: this(entityService, flagProviders, treeFilterService, publicAccessService, documentPresentationFactory, documentPermissionFilterService)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RootDocumentTreeController"/> class, which manages the root nodes of the document tree in the Umbraco backoffice.
|
||||
/// </summary>
|
||||
@@ -61,7 +124,7 @@ public class RootDocumentTreeController : DocumentTreeControllerBase
|
||||
/// <param name="appCaches">Provides application-level caching mechanisms.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
|
||||
public RootDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
@@ -95,7 +158,7 @@ public class RootDocumentTreeController : DocumentTreeControllerBase
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
|
||||
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
|
||||
public RootDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
|
||||
+65
-2
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -20,6 +21,68 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree;
|
||||
[ApiVersion("1.0")]
|
||||
public class SiblingsDocumentTreeController : DocumentTreeControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SiblingsDocumentTreeController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
|
||||
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
|
||||
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
|
||||
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public SiblingsDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IDocumentStartNodeTreeFilterService treeFilterService,
|
||||
IPublicAccessService publicAccessService,
|
||||
IDocumentPresentationFactory documentPresentationFactory,
|
||||
IDocumentPermissionFilterService documentPermissionFilterService)
|
||||
: base(
|
||||
entityService,
|
||||
flagProviders,
|
||||
treeFilterService,
|
||||
publicAccessService,
|
||||
documentPresentationFactory,
|
||||
documentPermissionFilterService)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SiblingsDocumentTreeController"/> class.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This constructor exists solely to disambiguate DI container constructor resolution between the new
|
||||
/// and the existing obsolete constructors; all parameters except those forwarded to the non-obsolete
|
||||
/// constructor are ignored.
|
||||
/// </remarks>
|
||||
/// <param name="entityService">Service for managing and retrieving entities within Umbraco.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
|
||||
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
|
||||
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
|
||||
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
|
||||
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
|
||||
/// <param name="appCaches">Provides access to application-level caches.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
|
||||
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
|
||||
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public SiblingsDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IUserStartNodeEntitiesService userStartNodeEntitiesService,
|
||||
IDataTypeService dataTypeService,
|
||||
IDocumentStartNodeTreeFilterService treeFilterService,
|
||||
IPublicAccessService publicAccessService,
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
|
||||
IDocumentPresentationFactory documentPresentationFactory,
|
||||
IDocumentPermissionFilterService documentPermissionFilterService)
|
||||
: this(entityService, flagProviders, treeFilterService, publicAccessService, documentPresentationFactory, documentPermissionFilterService)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SiblingsDocumentTreeController"/> class.
|
||||
/// </summary>
|
||||
@@ -61,7 +124,7 @@ public class SiblingsDocumentTreeController : DocumentTreeControllerBase
|
||||
/// <param name="appCaches">Provides access to application-level caches.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
|
||||
public SiblingsDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
@@ -95,7 +158,7 @@ public class SiblingsDocumentTreeController : DocumentTreeControllerBase
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and user information.</param>
|
||||
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
|
||||
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
|
||||
public SiblingsDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
|
||||
+50
-1
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -18,6 +19,54 @@ namespace Umbraco.Cms.Api.Management.Controllers.Media.Tree;
|
||||
[ApiVersion("1.0")]
|
||||
public class AncestorsMediaTreeController : MediaTreeControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AncestorsMediaTreeController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="entityService">Service for managing and retrieving entities within the system.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
|
||||
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
|
||||
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public AncestorsMediaTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IMediaStartNodeTreeFilterService treeFilterService,
|
||||
IMediaPresentationFactory mediaPresentationFactory)
|
||||
: base(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AncestorsMediaTreeController"/> class.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This constructor exists solely to disambiguate DI container constructor resolution between the new
|
||||
/// and the existing obsolete constructors; all parameters except those forwarded to the non-obsolete
|
||||
/// constructor are ignored.
|
||||
/// </remarks>
|
||||
/// <param name="entityService">Service for accessing and managing entities within the system.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
|
||||
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
|
||||
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
|
||||
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
|
||||
/// <param name="appCaches">Provides access to application-level caches.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
|
||||
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
|
||||
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public AncestorsMediaTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IUserStartNodeEntitiesService userStartNodeEntitiesService,
|
||||
IDataTypeService dataTypeService,
|
||||
IMediaStartNodeTreeFilterService treeFilterService,
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
|
||||
IMediaPresentationFactory mediaPresentationFactory)
|
||||
: this(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AncestorsMediaTreeController"/> class.
|
||||
/// </summary>
|
||||
@@ -49,7 +98,7 @@ public class AncestorsMediaTreeController : MediaTreeControllerBase
|
||||
/// <param name="appCaches">Provides access to application-level caches.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
|
||||
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
[Obsolete("Please use the constructor accepting IMediaStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
|
||||
public AncestorsMediaTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
|
||||
+50
-1
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -19,6 +20,54 @@ namespace Umbraco.Cms.Api.Management.Controllers.Media.Tree;
|
||||
[ApiVersion("1.0")]
|
||||
public class ChildrenMediaTreeController : MediaTreeControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChildrenMediaTreeController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="entityService">Service for managing and retrieving entities within the system.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
|
||||
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
|
||||
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public ChildrenMediaTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IMediaStartNodeTreeFilterService treeFilterService,
|
||||
IMediaPresentationFactory mediaPresentationFactory)
|
||||
: base(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChildrenMediaTreeController"/> class.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This constructor exists solely to disambiguate DI container constructor resolution between the new
|
||||
/// and the existing obsolete constructors; all parameters except those forwarded to the non-obsolete
|
||||
/// constructor are ignored.
|
||||
/// </remarks>
|
||||
/// <param name="entityService">Service for accessing and managing entities within the system.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
|
||||
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
|
||||
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
|
||||
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
|
||||
/// <param name="appCaches">Provides access to application-level caches.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
|
||||
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
|
||||
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public ChildrenMediaTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IUserStartNodeEntitiesService userStartNodeEntitiesService,
|
||||
IDataTypeService dataTypeService,
|
||||
IMediaStartNodeTreeFilterService treeFilterService,
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
|
||||
IMediaPresentationFactory mediaPresentationFactory)
|
||||
: this(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChildrenMediaTreeController"/> class, responsible for handling API requests related to child media items in the media tree.
|
||||
/// </summary>
|
||||
@@ -50,7 +99,7 @@ public class ChildrenMediaTreeController : MediaTreeControllerBase
|
||||
/// <param name="appCaches">Provides access to application-level caches for performance optimization.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context, used for authorization and user information.</param>
|
||||
/// <param name="mediaPresentationFactory">Factory for creating presentation models for media entities.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
[Obsolete("Please use the constructor accepting IMediaStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
|
||||
public ChildrenMediaTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -26,10 +27,13 @@ namespace Umbraco.Cms.Api.Management.Controllers.Media.Tree;
|
||||
[Authorize(Policy = AuthorizationPolicies.SectionAccessForMediaTree)]
|
||||
public class MediaTreeControllerBase : UserStartNodeTreeControllerBase<MediaTreeItemResponseModel>
|
||||
{
|
||||
private readonly AppCaches _appCaches;
|
||||
private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor;
|
||||
private readonly IMediaPresentationFactory _mediaPresentationFactory;
|
||||
|
||||
// Only populated by the obsolete constructor path; used solely by the obsolete
|
||||
// GetUserStartNodeIds / GetUserStartNodePaths overrides below.
|
||||
private readonly AppCaches? _appCaches;
|
||||
private readonly IBackOfficeSecurityAccessor? _backofficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Umbraco.Cms.Api.Management.Controllers.Media.Tree.MediaTreeControllerBase"/> class.
|
||||
/// </summary>
|
||||
@@ -68,7 +72,7 @@ public class MediaTreeControllerBase : UserStartNodeTreeControllerBase<MediaTree
|
||||
/// <param name="appCaches">Provides access to application-level caches.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
|
||||
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
[Obsolete("Please use the constructor accepting IMediaStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
|
||||
public MediaTreeControllerBase(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
@@ -77,11 +81,64 @@ public class MediaTreeControllerBase : UserStartNodeTreeControllerBase<MediaTree
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
|
||||
IMediaPresentationFactory mediaPresentationFactory)
|
||||
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService)
|
||||
: base(
|
||||
entityService,
|
||||
flagProviders,
|
||||
userStartNodeEntitiesService,
|
||||
dataTypeService)
|
||||
{
|
||||
_mediaPresentationFactory = mediaPresentationFactory;
|
||||
_appCaches = appCaches;
|
||||
_backofficeSecurityAccessor = backofficeSecurityAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MediaTreeControllerBase"/> class.
|
||||
/// </summary>
|
||||
/// <param name="entityService">Service for accessing and managing entities within the system.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
|
||||
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
|
||||
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public MediaTreeControllerBase(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IMediaStartNodeTreeFilterService treeFilterService,
|
||||
IMediaPresentationFactory mediaPresentationFactory)
|
||||
: base(entityService, flagProviders, treeFilterService) =>
|
||||
_mediaPresentationFactory = mediaPresentationFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MediaTreeControllerBase"/> class.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This constructor is a parameter superset of the new and existing obsolete constructors. It exists
|
||||
/// solely because <see cref="ActivatorUtilitiesConstructorAttribute"/> is not honoured by the DI
|
||||
/// <c>CallSiteFactory</c> at <c>ServiceProvider</c> <c>ValidateOnBuild</c> time, which requires an
|
||||
/// unambiguous single best-match constructor; all parameters except those forwarded to the non-obsolete
|
||||
/// constructor are ignored.
|
||||
/// </remarks>
|
||||
/// <param name="entityService">Service for accessing and managing entities within the system.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
|
||||
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
|
||||
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
|
||||
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
|
||||
/// <param name="appCaches">Provides access to application-level caches.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
|
||||
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
|
||||
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public MediaTreeControllerBase(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IUserStartNodeEntitiesService userStartNodeEntitiesService,
|
||||
IDataTypeService dataTypeService,
|
||||
IMediaStartNodeTreeFilterService treeFilterService,
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
|
||||
IMediaPresentationFactory mediaPresentationFactory)
|
||||
: this(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
|
||||
{
|
||||
}
|
||||
|
||||
protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Media;
|
||||
@@ -105,17 +162,23 @@ public class MediaTreeControllerBase : UserStartNodeTreeControllerBase<MediaTree
|
||||
return responseModel;
|
||||
}
|
||||
|
||||
// Only invoked via the CallbackStartNodeTreeFilterService wired up by the obsolete
|
||||
// UserStartNodeTreeControllerBase constructor. The non-obsolete constructor path
|
||||
// routes start node resolution through IMediaStartNodeTreeFilterService and
|
||||
// never calls these overrides; hence the null-forgiving operator on _appCaches.
|
||||
[Obsolete("No longer used. Register a custom IMediaStartNodeTreeFilterService instead. Scheduled for removal in Umbraco 19.")]
|
||||
protected override int[] GetUserStartNodeIds()
|
||||
=> _backofficeSecurityAccessor
|
||||
=> _backofficeSecurityAccessor?
|
||||
.BackOfficeSecurity?
|
||||
.CurrentUser?
|
||||
.CalculateMediaStartNodeIds(EntityService, _appCaches)
|
||||
?? Array.Empty<int>();
|
||||
.CalculateMediaStartNodeIds(EntityService, _appCaches!)
|
||||
?? [];
|
||||
|
||||
[Obsolete("No longer used. Register a custom IMediaStartNodeTreeFilterService instead. Scheduled for removal in Umbraco 19.")]
|
||||
protected override string[] GetUserStartNodePaths()
|
||||
=> _backofficeSecurityAccessor
|
||||
=> _backofficeSecurityAccessor?
|
||||
.BackOfficeSecurity?
|
||||
.CurrentUser?
|
||||
.GetMediaStartNodePaths(EntityService, _appCaches)
|
||||
?? Array.Empty<string>();
|
||||
.GetMediaStartNodePaths(EntityService, _appCaches!)
|
||||
?? [];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -19,6 +20,54 @@ namespace Umbraco.Cms.Api.Management.Controllers.Media.Tree;
|
||||
[ApiVersion("1.0")]
|
||||
public class RootMediaTreeController : MediaTreeControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RootMediaTreeController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
|
||||
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
|
||||
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public RootMediaTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IMediaStartNodeTreeFilterService treeFilterService,
|
||||
IMediaPresentationFactory mediaPresentationFactory)
|
||||
: base(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RootMediaTreeController"/> class.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This constructor exists solely to disambiguate DI container constructor resolution between the new
|
||||
/// and the existing obsolete constructors; all parameters except those forwarded to the non-obsolete
|
||||
/// constructor are ignored.
|
||||
/// </remarks>
|
||||
/// <param name="entityService">Service for accessing and managing entities within the system.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
|
||||
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
|
||||
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
|
||||
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
|
||||
/// <param name="appCaches">Provides access to application-level caches.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
|
||||
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
|
||||
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public RootMediaTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IUserStartNodeEntitiesService userStartNodeEntitiesService,
|
||||
IDataTypeService dataTypeService,
|
||||
IMediaStartNodeTreeFilterService treeFilterService,
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
|
||||
IMediaPresentationFactory mediaPresentationFactory)
|
||||
: this(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RootMediaTreeController"/> class, which manages the root of the media tree in the Umbraco backoffice API.
|
||||
/// </summary>
|
||||
@@ -50,7 +99,7 @@ public class RootMediaTreeController : MediaTreeControllerBase
|
||||
/// <param name="appCaches">Provides access to application-level caches.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
|
||||
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
[Obsolete("Please use the constructor accepting IMediaStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
|
||||
public RootMediaTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
|
||||
+50
-1
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -18,6 +19,54 @@ namespace Umbraco.Cms.Api.Management.Controllers.Media.Tree;
|
||||
/// </summary>
|
||||
public class SiblingsMediaTreeController : MediaTreeControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SiblingsMediaTreeController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="entityService">Service for accessing and managing entities within the system.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
|
||||
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
|
||||
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public SiblingsMediaTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IMediaStartNodeTreeFilterService treeFilterService,
|
||||
IMediaPresentationFactory mediaPresentationFactory)
|
||||
: base(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SiblingsMediaTreeController"/> class.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This constructor exists solely to disambiguate DI container constructor resolution between the new
|
||||
/// and the existing obsolete constructors; all parameters except those forwarded to the non-obsolete
|
||||
/// constructor are ignored.
|
||||
/// </remarks>
|
||||
/// <param name="entityService">Service for accessing and managing entities within the system.</param>
|
||||
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
|
||||
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
|
||||
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
|
||||
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
|
||||
/// <param name="appCaches">Provides access to application-level caches.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
|
||||
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
|
||||
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public SiblingsMediaTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IUserStartNodeEntitiesService userStartNodeEntitiesService,
|
||||
IDataTypeService dataTypeService,
|
||||
IMediaStartNodeTreeFilterService treeFilterService,
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
|
||||
IMediaPresentationFactory mediaPresentationFactory)
|
||||
: this(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SiblingsMediaTreeController"/> class, responsible for handling API requests related to sibling media items in the media tree.
|
||||
/// </summary>
|
||||
@@ -49,7 +98,7 @@ public class SiblingsMediaTreeController : MediaTreeControllerBase
|
||||
/// <param name="appCaches">Provides access to application-level caches for performance optimization.</param>
|
||||
/// <param name="backofficeSecurityAccessor">Accessor for back office security context, used for authorization and user information.</param>
|
||||
/// <param name="mediaPresentationFactory">Factory for creating media presentation models for API responses.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
[Obsolete("Please use the constructor accepting IMediaStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
|
||||
public SiblingsMediaTreeController(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.Services;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Member;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
@@ -16,24 +18,43 @@ namespace Umbraco.Cms.Api.Management.Controllers.Member;
|
||||
[ApiVersion("1.0")]
|
||||
public class ByKeyMemberController : MemberControllerBase
|
||||
{
|
||||
private readonly IMemberEditingService _memberEditingService;
|
||||
private readonly IMemberPresentationFactory _memberPresentationFactory;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
private readonly IMemberPresentationService _memberPresentationService;
|
||||
|
||||
// TODO (V19): Remove the unnecessary parameters provided to the constructor.
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ByKeyMemberController"/> class, which handles member management operations by member key.
|
||||
/// Initializes a new instance of the <see cref="ByKeyMemberController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="memberEditingService">Service used to perform editing operations on members.</param>
|
||||
/// <param name="memberPresentationFactory">Factory for creating member presentation models.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
|
||||
/// <param name="memberPresentationService">Service for resolving members across both content and external stores.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public ByKeyMemberController(
|
||||
IMemberEditingService memberEditingService,
|
||||
IMemberPresentationFactory memberPresentationFactory,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IMemberPresentationService memberPresentationService)
|
||||
{
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
_memberPresentationService = memberPresentationService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ByKeyMemberController"/> class.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public ByKeyMemberController(
|
||||
IMemberEditingService memberEditingService,
|
||||
IMemberPresentationFactory memberPresentationFactory,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
: this(
|
||||
memberEditingService,
|
||||
memberPresentationFactory,
|
||||
backOfficeSecurityAccessor,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IMemberPresentationService>())
|
||||
{
|
||||
_memberEditingService = memberEditingService;
|
||||
_memberPresentationFactory = memberPresentationFactory;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -52,13 +73,7 @@ public class ByKeyMemberController : MemberControllerBase
|
||||
[EndpointDescription("Gets a member identified by the provided Id.")]
|
||||
public async Task<IActionResult> ByKey(CancellationToken cancellationToken, Guid id)
|
||||
{
|
||||
IMember? member = await _memberEditingService.GetAsync(id);
|
||||
if (member == null)
|
||||
{
|
||||
return MemberNotFound();
|
||||
}
|
||||
|
||||
MemberResponseModel model = await _memberPresentationFactory.CreateResponseModelAsync(member, CurrentUser(_backOfficeSecurityAccessor));
|
||||
return Ok(model);
|
||||
MemberResponseModel? model = await _memberPresentationService.CreateResponseModelByKeyAsync(id, CurrentUser(_backOfficeSecurityAccessor));
|
||||
return model is not null ? Ok(model) : MemberNotFound();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ public class DeleteMemberController : MemberControllerBase
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeleteMemberController"/> class, which handles member deletion operations.
|
||||
/// Initializes a new instance of the <see cref="DeleteMemberController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="memberEditingService">Service used to perform member editing and deletion operations.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context and authorization.</param>
|
||||
public DeleteMemberController(IMemberEditingService memberEditingService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
public DeleteMemberController(
|
||||
IMemberEditingService memberEditingService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_memberEditingService = memberEditingService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
|
||||
+39
-26
@@ -1,10 +1,15 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Member;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
@@ -19,40 +24,48 @@ namespace Umbraco.Cms.Api.Management.Controllers.Member.Filter;
|
||||
[ApiVersion("1.0")]
|
||||
public class FilterMemberFilterController : MemberFilterControllerBase
|
||||
{
|
||||
private readonly IMemberService _memberService;
|
||||
private readonly IMemberFilterService _memberFilterService;
|
||||
private readonly IMemberPresentationFactory _memberPresentationFactory;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FilterMemberFilterController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="memberService">Service used for member management operations.</param>
|
||||
/// <param name="memberService">Service used for member management operations (unused, retained for DI compatibility).</param>
|
||||
/// <param name="memberPresentationFactory">Factory responsible for creating member presentation models.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context and authentication.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context (unused, retained for DI compatibility).</param>
|
||||
/// <param name="memberFilterService">Service for combined member filtering across content and external stores.</param>
|
||||
// TODO (V19): Remove unused parameters which are only here to avoid ambiguous constructor errors.
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public FilterMemberFilterController(
|
||||
IMemberService memberService,
|
||||
IMemberPresentationFactory memberPresentationFactory,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IMemberFilterService memberFilterService)
|
||||
{
|
||||
_memberFilterService = memberFilterService;
|
||||
_memberPresentationFactory = memberPresentationFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FilterMemberFilterController"/> class.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public FilterMemberFilterController(
|
||||
IMemberService memberService,
|
||||
IMemberPresentationFactory memberPresentationFactory,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
: this(
|
||||
memberService,
|
||||
memberPresentationFactory,
|
||||
backOfficeSecurityAccessor,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IMemberFilterService>())
|
||||
{
|
||||
_memberService = memberService;
|
||||
_memberPresentationFactory = memberPresentationFactory;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a paged, filtered collection of members based on the specified criteria.
|
||||
/// Returns both content-based and external-only members in a unified, correctly paginated result.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <param name="memberTypeId">An optional member type identifier to filter the results.</param>
|
||||
/// <param name="memberGroupName">An optional member group name to filter the results.</param>
|
||||
/// <param name="isApproved">An optional value to filter by member approval status.</param>
|
||||
/// <param name="isLockedOut">An optional value to filter by member lockout status.</param>
|
||||
/// <param name="orderBy">The field by which to order the results. The default is <c>"username"</c>.</param>
|
||||
/// <param name="orderDirection">The direction in which to order the results. The default is <see cref="Direction.Ascending"/>.</param>
|
||||
/// <param name="filter">An optional filter string to search for members.</param>
|
||||
/// <param name="skip">The number of items to skip for pagination. The default is 0.</param>
|
||||
/// <param name="take">The number of items to return for pagination. The default is 100.</param>
|
||||
/// <returns>A task representing the asynchronous operation. The task result contains an <see cref="IActionResult"/> with a <see cref="PagedViewModel{MemberResponseModel}"/> representing the filtered members.</returns>
|
||||
[HttpGet]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(typeof(PagedViewModel<MemberResponseModel>), StatusCodes.Status200OK)]
|
||||
@@ -71,7 +84,7 @@ public class FilterMemberFilterController : MemberFilterControllerBase
|
||||
int skip = 0,
|
||||
int take = 100)
|
||||
{
|
||||
var memberFilter = new MemberFilter()
|
||||
var memberFilter = new MemberFilter
|
||||
{
|
||||
MemberTypeId = memberTypeId,
|
||||
MemberGroupName = memberGroupName,
|
||||
@@ -80,14 +93,14 @@ public class FilterMemberFilterController : MemberFilterControllerBase
|
||||
Filter = filter,
|
||||
};
|
||||
|
||||
PagedModel<IMember> members = await _memberService.FilterAsync(memberFilter, orderBy, orderDirection, skip, take);
|
||||
PagedModel<MemberFilterItem> result = await _memberFilterService.FilterAsync(memberFilter, orderBy, orderDirection, skip, take);
|
||||
|
||||
var pageViewModel = new PagedViewModel<MemberResponseModel>
|
||||
var responseModels = result.Items.Select(_memberPresentationFactory.CreateFilterItemResponseModel).ToList();
|
||||
|
||||
return Ok(new PagedViewModel<MemberResponseModel>
|
||||
{
|
||||
Items = await _memberPresentationFactory.CreateMultipleAsync(members.Items, CurrentUser(_backOfficeSecurityAccessor)),
|
||||
Total = members.Total,
|
||||
};
|
||||
|
||||
return Ok(pageViewModel);
|
||||
Items = responseModels,
|
||||
Total = result.Total,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+32
-17
@@ -1,11 +1,11 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.Services;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Member.Item;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Member.Item;
|
||||
@@ -17,18 +17,37 @@ namespace Umbraco.Cms.Api.Management.Controllers.Member.Item;
|
||||
[ApiVersion("1.0")]
|
||||
public class ItemMemberItemController : MemberItemControllerBase
|
||||
{
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly IMemberPresentationFactory _memberPresentationFactory;
|
||||
private readonly IMemberPresentationService _memberPresentationService;
|
||||
|
||||
// TODO (V19): Remove the unnecessary parameters provided to the constructor.
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ItemMemberItemController"/> class, which manages member item operations in the API.
|
||||
/// Initializes a new instance of the <see cref="ItemMemberItemController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="entityService">Service used for entity operations and retrieval.</param>
|
||||
/// <param name="memberPresentationFactory">Factory responsible for creating member presentation models.</param>
|
||||
public ItemMemberItemController(IEntityService entityService, IMemberPresentationFactory memberPresentationFactory)
|
||||
/// <param name="memberPresentationService">Service for resolving members across both content and external stores.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public ItemMemberItemController(
|
||||
IEntityService entityService,
|
||||
IMemberPresentationFactory memberPresentationFactory,
|
||||
IMemberPresentationService memberPresentationService)
|
||||
{
|
||||
_memberPresentationService = memberPresentationService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ItemMemberItemController"/> class.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public ItemMemberItemController(
|
||||
IEntityService entityService,
|
||||
IMemberPresentationFactory memberPresentationFactory)
|
||||
: this(
|
||||
entityService,
|
||||
memberPresentationFactory,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IMemberPresentationService>())
|
||||
{
|
||||
_entityService = entityService;
|
||||
_memberPresentationFactory = memberPresentationFactory;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -36,20 +55,16 @@ public class ItemMemberItemController : MemberItemControllerBase
|
||||
[ProducesResponseType(typeof(IEnumerable<MemberItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[EndpointSummary("Gets a collection of member items.")]
|
||||
[EndpointDescription("Gets a collection of member items identified by the provided Ids.")]
|
||||
public Task<IActionResult> Item(
|
||||
public async Task<IActionResult> Item(
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery(Name = "id")] HashSet<Guid> ids)
|
||||
{
|
||||
if (ids.Count is 0)
|
||||
{
|
||||
return Task.FromResult<IActionResult>(Ok(Enumerable.Empty<MemberItemResponseModel>()));
|
||||
return Ok(Enumerable.Empty<MemberItemResponseModel>());
|
||||
}
|
||||
|
||||
IEnumerable<IMemberEntitySlim> members = _entityService
|
||||
.GetAll(UmbracoObjectTypes.Member, ids.ToArray())
|
||||
.OfType<IMemberEntitySlim>();
|
||||
|
||||
IEnumerable<MemberItemResponseModel> responseModels = members.Select(_memberPresentationFactory.CreateItemResponseModel);
|
||||
return Task.FromResult<IActionResult>(Ok(responseModels));
|
||||
IEnumerable<MemberItemResponseModel> responseModels = await _memberPresentationService.CreateItemResponseModelsAsync(ids);
|
||||
return Ok(responseModels);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,15 @@ public class MemberControllerBase : ContentControllerBase
|
||||
where TContentModelBase : ContentModelBase<MemberValueModel, MemberVariantRequestModel>
|
||||
=> ContentEditingOperationStatusResult<TContentModelBase, MemberValueModel, MemberVariantRequestModel>(status, requestModel, validationResult);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a 400 Bad Request indicating that external-only members cannot be modified through the Management API.
|
||||
/// </summary>
|
||||
protected IActionResult ExternalMemberCannotBeModified()
|
||||
=> BadRequest(new ProblemDetailsBuilder()
|
||||
.WithTitle("External member cannot be modified")
|
||||
.WithDetail("This member is managed by an external provider. Content operations such as create, update, and property editing are not available for external-only members.")
|
||||
.Build());
|
||||
|
||||
private IActionResult MemberNotFound(ProblemDetailsBuilder problemDetailsBuilder) => NotFound(problemDetailsBuilder
|
||||
.WithTitle("The requested member could not be found")
|
||||
.Build());
|
||||
|
||||
+33
-11
@@ -1,10 +1,13 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.Services;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.TrackedReferences;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
@@ -17,20 +20,39 @@ namespace Umbraco.Cms.Api.Management.Controllers.Member.References;
|
||||
[ApiVersion("1.0")]
|
||||
public class ReferencedByMemberController : MemberControllerBase
|
||||
{
|
||||
private readonly ITrackedReferencesService _trackedReferencesService;
|
||||
private readonly IRelationTypePresentationFactory _relationTypePresentationFactory;
|
||||
private readonly IMemberReferenceService _memberReferenceService;
|
||||
|
||||
// TODO (V19): Remove the unnecessary parameters provided to the constructor.
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ReferencedByMemberController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="trackedReferencesService">An implementation of <see cref="ITrackedReferencesService"/> used to manage tracked references.</param>
|
||||
/// <param name="relationTypePresentationFactory">An implementation of <see cref="IRelationTypePresentationFactory"/> used to create relation type presentations.</param>
|
||||
/// <param name="memberReferenceService">Service for retrieving paged references to a member.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public ReferencedByMemberController(
|
||||
ITrackedReferencesService trackedReferencesService,
|
||||
IRelationTypePresentationFactory relationTypePresentationFactory,
|
||||
IMemberReferenceService memberReferenceService)
|
||||
{
|
||||
_relationTypePresentationFactory = relationTypePresentationFactory;
|
||||
_memberReferenceService = memberReferenceService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ReferencedByMemberController"/> class.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public ReferencedByMemberController(
|
||||
ITrackedReferencesService trackedReferencesService,
|
||||
IRelationTypePresentationFactory relationTypePresentationFactory)
|
||||
: this(
|
||||
trackedReferencesService,
|
||||
relationTypePresentationFactory,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IMemberReferenceService>())
|
||||
{
|
||||
_trackedReferencesService = trackedReferencesService;
|
||||
_relationTypePresentationFactory = relationTypePresentationFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -52,12 +74,12 @@ public class ReferencedByMemberController : MemberControllerBase
|
||||
int skip = 0,
|
||||
int take = 20)
|
||||
{
|
||||
PagedModel<RelationItemModel> relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, true);
|
||||
Attempt<PagedModel<RelationItemModel>, GetReferencesOperationStatus> result = await _memberReferenceService.GetPagedReferencesAsync(id, skip, take);
|
||||
|
||||
var pagedViewModel = new PagedViewModel<IReferenceResponseModel>
|
||||
{
|
||||
Total = relationItems.Total,
|
||||
Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItems.Items),
|
||||
Total = result.Result.Total,
|
||||
Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(result.Result.Items),
|
||||
};
|
||||
|
||||
return pagedViewModel;
|
||||
@@ -87,17 +109,17 @@ public class ReferencedByMemberController : MemberControllerBase
|
||||
int skip = 0,
|
||||
int take = 20)
|
||||
{
|
||||
Attempt<PagedModel<RelationItemModel>, GetReferencesOperationStatus> relationItemsAttempt = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Member, skip, take, true);
|
||||
Attempt<PagedModel<RelationItemModel>, GetReferencesOperationStatus> result = await _memberReferenceService.GetPagedReferencesAsync(id, skip, take);
|
||||
|
||||
if (relationItemsAttempt.Success is false)
|
||||
if (result.Success is false)
|
||||
{
|
||||
return GetReferencesOperationStatusResult(relationItemsAttempt.Status);
|
||||
return GetReferencesOperationStatusResult(result.Status);
|
||||
}
|
||||
|
||||
var pagedViewModel = new PagedViewModel<IReferenceResponseModel>
|
||||
{
|
||||
Total = relationItemsAttempt.Result.Total,
|
||||
Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItemsAttempt.Result.Items),
|
||||
Total = result.Result.Total,
|
||||
Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(result.Result.Items),
|
||||
};
|
||||
|
||||
return Ok(pagedViewModel);
|
||||
|
||||
@@ -22,7 +22,7 @@ public class UpdateMemberController : MemberControllerBase
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UpdateMemberController"/> class, responsible for handling member update operations in the management API.
|
||||
/// Initializes a new instance of the <see cref="UpdateMemberController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="memberEditingService">Service used to perform member editing operations.</param>
|
||||
/// <param name="memberEditingPresentationFactory">Factory for creating presentation models related to member editing.</param>
|
||||
@@ -49,6 +49,13 @@ public class UpdateMemberController : MemberControllerBase
|
||||
Guid id,
|
||||
UpdateMemberRequestModel updateRequestModel)
|
||||
{
|
||||
// External-only members cannot be updated through this endpoint.
|
||||
// Their identity data is managed by the external provider.
|
||||
if (await _memberEditingService.IsExternalMemberAsync(id))
|
||||
{
|
||||
return ExternalMemberCannotBeModified();
|
||||
}
|
||||
|
||||
MemberUpdateModel model = _memberEditingPresentationFactory.MapUpdateModel(updateRequestModel);
|
||||
Attempt<MemberUpdateResult, MemberEditingStatus> result = await _memberEditingService.UpdateAsync(id, model, CurrentUser(_backOfficeSecurityAccessor));
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ public class ValidateUpdateMemberController : MemberControllerBase
|
||||
private readonly IMemberEditingPresentationFactory _memberEditingPresentationFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Umbraco.Cms.Api.Management.Controllers.Member.ValidateUpdateMemberController"/> class.
|
||||
/// Initializes a new instance of the <see cref="ValidateUpdateMemberController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="memberEditingService">The <see cref="IMemberEditingService"/> used for member editing operations.</param>
|
||||
/// <param name="memberEditingPresentationFactory">The <see cref="IMemberEditingPresentationFactory"/> used to create member editing presentations.</param>
|
||||
@@ -44,6 +44,12 @@ public class ValidateUpdateMemberController : MemberControllerBase
|
||||
Guid id,
|
||||
UpdateMemberRequestModel requestModel)
|
||||
{
|
||||
// External-only members cannot be updated through this endpoint.
|
||||
if (await _memberEditingService.IsExternalMemberAsync(id))
|
||||
{
|
||||
return ExternalMemberCannotBeModified();
|
||||
}
|
||||
|
||||
MemberUpdateModel model = _memberEditingPresentationFactory.MapUpdateModel(requestModel);
|
||||
Attempt<ContentValidationResult, ContentEditingOperationStatus> result = await _memberEditingService.ValidateUpdateAsync(id, model);
|
||||
|
||||
|
||||
+103
-95
@@ -5,9 +5,9 @@ using Umbraco.Cms.Api.Management.Services.Flags;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Tree;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Tree;
|
||||
|
||||
@@ -18,11 +18,8 @@ namespace Umbraco.Cms.Api.Management.Controllers.Tree;
|
||||
public abstract class UserStartNodeTreeControllerBase<TItem> : EntityTreeControllerBase<TItem>
|
||||
where TItem : ContentTreeItemResponseModel, new()
|
||||
{
|
||||
private readonly IUserStartNodeEntitiesService _userStartNodeEntitiesService;
|
||||
private readonly IDataTypeService _dataTypeService;
|
||||
private readonly IUserStartNodeTreeFilterService _treeFilterService;
|
||||
|
||||
private int[]? _userStartNodeIds;
|
||||
private string[]? _userStartNodePaths;
|
||||
private Dictionary<Guid, bool> _accessMap = new();
|
||||
private Guid? _dataTypeKey;
|
||||
|
||||
@@ -39,117 +36,87 @@ public abstract class UserStartNodeTreeControllerBase<TItem> : EntityTreeControl
|
||||
{
|
||||
}
|
||||
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
[Obsolete("Please use the constructor accepting IUserStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
|
||||
protected UserStartNodeTreeControllerBase(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IUserStartNodeEntitiesService userStartNodeEntitiesService,
|
||||
IDataTypeService dataTypeService)
|
||||
: base(entityService, flagProviders)
|
||||
{
|
||||
_userStartNodeEntitiesService = userStartNodeEntitiesService;
|
||||
_dataTypeService = dataTypeService;
|
||||
}
|
||||
=> _treeFilterService = new CallbackStartNodeTreeFilterService(
|
||||
userStartNodeEntitiesService,
|
||||
dataTypeService,
|
||||
GetUserStartNodeIds,
|
||||
GetUserStartNodePaths,
|
||||
() => ItemObjectType);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
|
||||
protected abstract int[] GetUserStartNodeIds();
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserStartNodeTreeControllerBase{TItem}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="entityService">The entity service.</param>
|
||||
/// <param name="flagProviders">The flag provider collection.</param>
|
||||
/// <param name="treeFilterService">The user start node tree filter service.</param>
|
||||
protected UserStartNodeTreeControllerBase(
|
||||
IEntityService entityService,
|
||||
FlagProviderCollection flagProviders,
|
||||
IUserStartNodeTreeFilterService treeFilterService)
|
||||
: base(entityService, flagProviders) =>
|
||||
_treeFilterService = treeFilterService;
|
||||
|
||||
protected abstract string[] GetUserStartNodePaths();
|
||||
/// <summary>
|
||||
/// Gets the calculated start node IDs for the current user.
|
||||
/// </summary>
|
||||
/// <returns>An array of start node IDs.</returns>
|
||||
[Obsolete("No longer used. Register a custom IUserStartNodeTreeFilterService instead. Scheduled for removal in Umbraco 19.")]
|
||||
protected virtual int[] GetUserStartNodeIds() => [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the calculated start node paths for the current user.
|
||||
/// </summary>
|
||||
/// <returns>An array of start node paths.</returns>
|
||||
[Obsolete("No longer used. Register a custom IUserStartNodeTreeFilterService instead. Scheduled for removal in Umbraco 19.")]
|
||||
protected virtual string[] GetUserStartNodePaths() => [];
|
||||
|
||||
/// <summary>
|
||||
/// Configures the controller to ignore user start nodes for a specific data type.
|
||||
/// </summary>
|
||||
/// <param name="dataTypeKey">The data type key, or <c>null</c> to disable.</param>
|
||||
protected void IgnoreUserStartNodesForDataType(Guid? dataTypeKey) => _dataTypeKey = dataTypeKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IEntitySlim[] GetPagedRootEntities(int skip, int take, out long totalItems)
|
||||
=> UserHasRootAccess() || IgnoreUserStartNodes()
|
||||
=> ShouldBypassStartNodeFiltering()
|
||||
? base.GetPagedRootEntities(skip, take, out totalItems)
|
||||
: CalculateAccessMap(() => _userStartNodeEntitiesService.RootUserAccessEntities(ItemObjectType, UserStartNodeIds), out totalItems);
|
||||
: MapAccessEntities(_treeFilterService.GetFilteredRootEntities(out totalItems));
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IEntitySlim[] GetPagedChildEntities(Guid parentKey, int skip, int take, out long totalItems)
|
||||
{
|
||||
if (UserHasRootAccess() || IgnoreUserStartNodes())
|
||||
{
|
||||
return base.GetPagedChildEntities(parentKey, skip, take, out totalItems);
|
||||
}
|
||||
|
||||
IEnumerable<UserAccessEntity> userAccessEntities = _userStartNodeEntitiesService.ChildUserAccessEntities(
|
||||
ItemObjectType,
|
||||
UserStartNodePaths,
|
||||
parentKey,
|
||||
skip,
|
||||
take,
|
||||
ItemOrdering,
|
||||
out totalItems);
|
||||
|
||||
return CalculateAccessMap(() => userAccessEntities, out _);
|
||||
}
|
||||
=> ShouldBypassStartNodeFiltering()
|
||||
? base.GetPagedChildEntities(parentKey, skip, take, out totalItems)
|
||||
: MapAccessEntities(_treeFilterService.GetFilteredChildEntities(parentKey, skip, take, ItemOrdering, out totalItems));
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IEntitySlim[] GetSiblingEntities(Guid target, int before, int after, out long totalBefore, out long totalAfter)
|
||||
{
|
||||
if (UserHasRootAccess() || IgnoreUserStartNodes())
|
||||
{
|
||||
return base.GetSiblingEntities(target, before, after, out totalBefore, out totalAfter);
|
||||
}
|
||||
|
||||
IEnumerable<UserAccessEntity> userAccessEntities = _userStartNodeEntitiesService.SiblingUserAccessEntities(
|
||||
ItemObjectType,
|
||||
UserStartNodePaths,
|
||||
target,
|
||||
before,
|
||||
after,
|
||||
ItemOrdering,
|
||||
out totalBefore,
|
||||
out totalAfter);
|
||||
|
||||
return CalculateAccessMap(() => userAccessEntities, out _);
|
||||
}
|
||||
=> ShouldBypassStartNodeFiltering()
|
||||
? base.GetSiblingEntities(target, before, after, out totalBefore, out totalAfter)
|
||||
: MapAccessEntities(_treeFilterService.GetFilteredSiblingEntities(target, before, after, ItemOrdering, out totalBefore, out totalAfter));
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override TItem[] MapTreeItemViewModels(Guid? parentKey, IEntitySlim[] entities)
|
||||
=> ShouldBypassStartNodeFiltering()
|
||||
? base.MapTreeItemViewModels(parentKey, entities)
|
||||
: _treeFilterService.MapWithAccessFiltering(
|
||||
entities,
|
||||
_accessMap,
|
||||
entity => MapTreeItemViewModel(parentKey, entity),
|
||||
entity => MapTreeItemViewModelAsNoAccess(parentKey, entity));
|
||||
|
||||
private IEntitySlim[] MapAccessEntities(UserAccessEntity[] userAccessEntities)
|
||||
{
|
||||
if (UserHasRootAccess() || IgnoreUserStartNodes())
|
||||
{
|
||||
return base.MapTreeItemViewModels(parentKey, entities);
|
||||
}
|
||||
|
||||
// for users with no root access, only add items for the entities contained within the calculated access map.
|
||||
// the access map may contain entities that the user does not have direct access to, but need still to see,
|
||||
// because it has descendants that the user *does* have access to. these entities are added as "no access" items.
|
||||
TItem[] contentTreeItemViewModels = entities.Select(entity =>
|
||||
{
|
||||
if (_accessMap.TryGetValue(entity.Key, out var hasAccess) == false)
|
||||
{
|
||||
// entity is not a part of the calculated access map
|
||||
return null;
|
||||
}
|
||||
|
||||
// direct access => return a regular item
|
||||
// no direct access => return a "no access" item
|
||||
return hasAccess
|
||||
? MapTreeItemViewModel(parentKey, entity)
|
||||
: MapTreeItemViewModelAsNoAccess(parentKey, entity);
|
||||
})
|
||||
.WhereNotNull()
|
||||
.ToArray();
|
||||
|
||||
return contentTreeItemViewModels;
|
||||
}
|
||||
|
||||
private int[] UserStartNodeIds => _userStartNodeIds ??= GetUserStartNodeIds();
|
||||
|
||||
private string[] UserStartNodePaths => _userStartNodePaths ??= GetUserStartNodePaths();
|
||||
|
||||
private bool UserHasRootAccess() => UserStartNodeIds.Contains(Constants.System.Root);
|
||||
|
||||
private bool IgnoreUserStartNodes()
|
||||
=> _dataTypeKey.HasValue
|
||||
&& _dataTypeService.IsDataTypeIgnoringUserStartNodes(_dataTypeKey.Value);
|
||||
|
||||
private IEntitySlim[] CalculateAccessMap(Func<IEnumerable<UserAccessEntity>> getUserAccessEntities, out long totalItems)
|
||||
{
|
||||
UserAccessEntity[] userAccessEntities = getUserAccessEntities().ToArray();
|
||||
|
||||
_accessMap = userAccessEntities.ToDictionary(uae => uae.Entity.Key, uae => uae.HasAccess);
|
||||
|
||||
IEntitySlim[] entities = userAccessEntities.Select(uae => uae.Entity).ToArray();
|
||||
totalItems = entities.Length;
|
||||
|
||||
return entities;
|
||||
return userAccessEntities.Select(uae => uae.Entity).ToArray();
|
||||
}
|
||||
|
||||
private TItem MapTreeItemViewModelAsNoAccess(Guid? parentKey, IEntitySlim entity)
|
||||
@@ -158,4 +125,45 @@ public abstract class UserStartNodeTreeControllerBase<TItem> : EntityTreeControl
|
||||
viewModel.NoAccess = true;
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
private bool ShouldBypassStartNodeFiltering()
|
||||
=> _treeFilterService.ShouldBypassStartNodeFiltering(_dataTypeKey);
|
||||
|
||||
/// <summary>
|
||||
/// A backward-compatible adapter that implements <see cref="UserStartNodeTreeFilterService"/>
|
||||
/// by delegating start node resolution to callback functions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Used by the obsolete constructor to bridge the old abstract-method-based
|
||||
/// start node resolution to the new service-based approach.
|
||||
/// </remarks>
|
||||
[Obsolete("Only used by the obsolete constructor. Scheduled for removal in Umbraco 19.")]
|
||||
private sealed class CallbackStartNodeTreeFilterService : UserStartNodeTreeFilterService
|
||||
{
|
||||
private readonly Func<int[]> _getStartNodeIds;
|
||||
private readonly Func<string[]> _getStartNodePaths;
|
||||
private readonly Func<UmbracoObjectTypes> _getTreeObjectType;
|
||||
|
||||
public CallbackStartNodeTreeFilterService(
|
||||
IUserStartNodeEntitiesService userStartNodeEntitiesService,
|
||||
IDataTypeService dataTypeService,
|
||||
Func<int[]> getStartNodeIds,
|
||||
Func<string[]> getStartNodePaths,
|
||||
Func<UmbracoObjectTypes> getTreeObjectType)
|
||||
: base(userStartNodeEntitiesService, dataTypeService)
|
||||
{
|
||||
_getStartNodeIds = getStartNodeIds;
|
||||
_getStartNodePaths = getStartNodePaths;
|
||||
_getTreeObjectType = getTreeObjectType;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override UmbracoObjectTypes TreeObjectType => _getTreeObjectType();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override int[] CalculateUserStartNodeIds() => _getStartNodeIds();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string[] CalculateUserStartNodePaths() => _getStartNodePaths();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ public class GetCurrentUserController : CurrentUserControllerBase
|
||||
[EndpointDescription("Gets the currently authenticated back office user's information and permissions.")]
|
||||
public async Task<IActionResult> GetCurrentUser(CancellationToken cancellationToken)
|
||||
{
|
||||
var currentUserKey = CurrentUserKey(_backOfficeSecurityAccessor);
|
||||
Guid currentUserKey = CurrentUserKey(_backOfficeSecurityAccessor);
|
||||
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
@@ -78,7 +78,7 @@ public class GetCurrentUserController : CurrentUserControllerBase
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var responseModel = await _userPresentationFactory.CreateCurrentUserResponseModelAsync(user);
|
||||
CurrentUserResponseModel responseModel = await _userPresentationFactory.CreateCurrentUserResponseModelAsync(user);
|
||||
return Ok(responseModel);
|
||||
}
|
||||
}
|
||||
|
||||
+38
-11
@@ -1,10 +1,12 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.User.Current;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
@@ -18,23 +20,46 @@ namespace Umbraco.Cms.Api.Management.Controllers.User.Current;
|
||||
public class GetDocumentPermissionsCurrentUserController : CurrentUserControllerBase
|
||||
{
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IUmbracoMapper _mapper;
|
||||
private readonly IContentPermissionService _contentPermissionService;
|
||||
|
||||
// TODO (V19): Remove the IUserService parameter from the constructor as it is not used in the current implementation.
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GetDocumentPermissionsCurrentUserController"/> class, which handles requests related to retrieving document permissions for the current user.
|
||||
/// </summary>
|
||||
/// <param name="backOfficeSecurityAccessor">Provides access to back office security information for the current user.</param>
|
||||
/// <param name="mapper">The Umbraco object mapper used for mapping between models.</param>
|
||||
/// <param name="contentPermissionService">Service for managing content permissions.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public GetDocumentPermissionsCurrentUserController(
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IUserService userService,
|
||||
IUmbracoMapper mapper,
|
||||
IContentPermissionService contentPermissionService)
|
||||
{
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
_mapper = mapper;
|
||||
_contentPermissionService = contentPermissionService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GetDocumentPermissionsCurrentUserController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="backOfficeSecurityAccessor">Provides access to back office security information for the current user.</param>
|
||||
/// <param name="userService">Service for managing and retrieving user information.</param>
|
||||
/// <param name="mapper">The Umbraco object mapper used for mapping between models.</param>
|
||||
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public GetDocumentPermissionsCurrentUserController(
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IUserService userService,
|
||||
IUmbracoMapper mapper)
|
||||
: this(
|
||||
backOfficeSecurityAccessor,
|
||||
userService,
|
||||
mapper,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IContentPermissionService>())
|
||||
{
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
_userService = userService;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -42,10 +67,10 @@ public class GetDocumentPermissionsCurrentUserController : CurrentUserController
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <param name="ids">A set of document IDs for which to retrieve permissions.</param>
|
||||
/// <returns>An <see cref="IActionResult"/> containing a <see cref="UserPermissionsResponseModel"/> with the permissions for each requested document, or a <see cref="ProblemDetails"/> if not found.</returns>
|
||||
/// <returns>An <see cref="IActionResult"/> containing a <see cref="UserPermissionsResponseModel"/> with the permissions for each requested document.</returns>
|
||||
[MapToApiVersion("1.0")]
|
||||
[HttpGet("permissions/document")]
|
||||
[ProducesResponseType(typeof(IEnumerable<UserPermissionsResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(UserPermissionsResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[EndpointSummary("Gets document permissions for the current user.")]
|
||||
[EndpointDescription("Gets the document permissions for the currently authenticated user.")]
|
||||
@@ -53,14 +78,16 @@ public class GetDocumentPermissionsCurrentUserController : CurrentUserController
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery(Name = "id")] HashSet<Guid> ids)
|
||||
{
|
||||
Attempt<IEnumerable<NodePermissions>, UserOperationStatus> permissionsAttempt = await _userService.GetDocumentPermissionsAsync(CurrentUserKey(_backOfficeSecurityAccessor), ids);
|
||||
IUser currentUser = CurrentUser(_backOfficeSecurityAccessor);
|
||||
NodePermissions[] permissions = (await _contentPermissionService.GetPermissionsAsync(currentUser, ids)).ToArray();
|
||||
|
||||
if (permissionsAttempt.Success is false)
|
||||
// Preserve 404 behavior: if any requested ID was not found, return ContentNodeNotFound.
|
||||
if (ids.Count > 0 && permissions.Length < ids.Count)
|
||||
{
|
||||
return UserOperationStatusResult(permissionsAttempt.Status);
|
||||
return UserOperationStatusResult(UserOperationStatus.ContentNodeNotFound);
|
||||
}
|
||||
|
||||
List<UserPermissionViewModel> viewModels = _mapper.MapEnumerable<NodePermissions, UserPermissionViewModel>(permissionsAttempt.Result);
|
||||
List<UserPermissionViewModel> viewModels = _mapper.MapEnumerable<NodePermissions, UserPermissionViewModel>(permissions);
|
||||
|
||||
return Ok(new UserPermissionsResponseModel { Permissions = viewModels });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.Mapping.Member;
|
||||
using Umbraco.Cms.Api.Management.Services;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
|
||||
@@ -12,6 +13,8 @@ internal static class MemberBuilderExtensions
|
||||
{
|
||||
builder.Services.AddSingleton<IMemberPresentationFactory, MemberPresentationFactory>();
|
||||
builder.Services.AddTransient<IMemberEditingPresentationFactory, MemberEditingPresentationFactory>();
|
||||
builder.Services.AddTransient<IMemberPresentationService, MemberPresentationService>();
|
||||
builder.Services.AddTransient<IMemberReferenceService, MemberReferenceService>();
|
||||
|
||||
builder.WithCollectionBuilder<MapDefinitionCollectionBuilder>().Add<MemberMapDefinition>();
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ internal static class TreeBuilderExtensions
|
||||
internal static IUmbracoBuilder AddTrees(this IUmbracoBuilder builder)
|
||||
{
|
||||
builder.Services.AddTransient<IUserStartNodeEntitiesService, UserStartNodeEntitiesService>();
|
||||
builder.Services.AddTransient<IDocumentStartNodeTreeFilterService, DocumentStartNodeTreeFilterService>();
|
||||
builder.Services.AddTransient<IMediaStartNodeTreeFilterService, MediaStartNodeTreeFilterService>();
|
||||
|
||||
builder.Services.AddUnique<IPartialViewTreeService, PartialViewTreeService>();
|
||||
builder.Services.AddUnique<IScriptTreeService, ScriptTreeService>();
|
||||
|
||||
+2
-1
@@ -54,7 +54,8 @@ public static partial class UmbracoBuilderExtensions
|
||||
factory.GetRequiredService<IUserRepository>(),
|
||||
factory.GetRequiredService<IRuntimeState>(),
|
||||
factory.GetRequiredService<IEventMessagesFactory>(),
|
||||
factory.GetRequiredService<ILogger<BackOfficeUserStore>>()))
|
||||
factory.GetRequiredService<ILogger<BackOfficeUserStore>>(),
|
||||
factory.GetRequiredService<IBackOfficeUserReader>()))
|
||||
.AddUserManager<IBackOfficeUserManager, BackOfficeUserManager>()
|
||||
.AddSignInManager<IBackOfficeSignInManager, BackOfficeSignInManager>()
|
||||
.AddClaimsPrincipalFactory<BackOfficeClaimsPrincipalFactory>()
|
||||
|
||||
@@ -102,7 +102,7 @@ public class DocumentUrlFactory : IDocumentUrlFactory
|
||||
|
||||
if (await _previewService.TryEnterPreviewAsync(currentUser) is false)
|
||||
{
|
||||
_logger.LogError("A server error occured, could not initiate an authenticated preview state for the current user.");
|
||||
_logger.LogError("A server error occurred, could not initiate an authenticated preview state for the current user.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Umbraco.Cms.Api.Management.ViewModels.Member.Item;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Factories;
|
||||
|
||||
@@ -40,4 +41,31 @@ public interface IMemberPresentationFactory
|
||||
/// <param name="entity">The member entity to create the response model from.</param>
|
||||
/// <returns>A MemberItemResponseModel representing the member entity.</returns>
|
||||
MemberItemResponseModel CreateItemResponseModel(IMember entity);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a response model for an external-only member.
|
||||
/// </summary>
|
||||
/// <param name="member">The external member identity to create the response model from.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains the <see cref="MemberResponseModel"/>.</returns>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
Task<MemberResponseModel> CreateExternalMemberResponseModelAsync(ExternalMemberIdentity member)
|
||||
=> Task.FromResult(new MemberResponseModel { Id = member.Key, Kind = MemberKind.ExternalOnly });
|
||||
|
||||
/// <summary>
|
||||
/// Creates an item response model for an external-only member.
|
||||
/// </summary>
|
||||
/// <param name="member">The external member identity to create the item response model from.</param>
|
||||
/// <returns>A <see cref="MemberItemResponseModel"/> representing the external member.</returns>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
MemberItemResponseModel CreateExternalMemberItemResponseModel(ExternalMemberIdentity member)
|
||||
=> new() { Id = member.Key, Kind = MemberKind.ExternalOnly };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a response model from a <see cref="MemberFilterItem"/> returned by the combined filter query.
|
||||
/// </summary>
|
||||
/// <param name="item">The filter item to create the response model from.</param>
|
||||
/// <returns>A <see cref="MemberResponseModel"/> representing the filter item.</returns>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
MemberResponseModel CreateFilterItemResponseModel(MemberFilterItem item)
|
||||
=> new() { Id = item.Key, Kind = item.Kind };
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ public class IndexPresentationFactory : IIndexPresentationFactory
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "An error occured trying to get the searcher name of index {IndexName}", index.Name);
|
||||
_logger.LogError(e, "An error occurred trying to get the searcher name of index {IndexName}", index.Name);
|
||||
name = "Could not determine searcher name because of error.";
|
||||
return false;
|
||||
}
|
||||
@@ -139,7 +139,7 @@ public class IndexPresentationFactory : IIndexPresentationFactory
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "An error occured trying to get the document count of index {IndexName}", index.Name);
|
||||
_logger.LogError(e, "An error occurred trying to get the document count of index {IndexName}", index.Name);
|
||||
documentCount = 0;
|
||||
return false;
|
||||
}
|
||||
@@ -154,7 +154,7 @@ public class IndexPresentationFactory : IIndexPresentationFactory
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "An error occured trying to get the field name count of index {IndexName}", index.Name);
|
||||
_logger.LogError(e, "An error occurred trying to get the field name count of index {IndexName}", index.Name);
|
||||
fieldNameCount = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Factories;
|
||||
|
||||
/// <inheritdoc/>
|
||||
internal sealed class MemberPresentationFactory : IMemberPresentationFactory
|
||||
{
|
||||
private readonly IUmbracoMapper _umbracoMapper;
|
||||
@@ -22,6 +24,7 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory
|
||||
private readonly ITwoFactorLoginService _twoFactorLoginService;
|
||||
private readonly IMemberGroupService _memberGroupService;
|
||||
private readonly DeliveryApiSettings _deliveryApiSettings;
|
||||
private readonly IExternalMemberService _externalMemberService;
|
||||
private IEnumerable<Guid>? _clientCredentialsMemberKeys;
|
||||
|
||||
/// <summary>
|
||||
@@ -33,13 +36,15 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory
|
||||
/// <param name="twoFactorLoginService">Service for handling two-factor authentication for members.</param>
|
||||
/// <param name="memberGroupService">Service for managing member groups.</param>
|
||||
/// <param name="deliveryApiSettings">The configuration options for the Delivery API.</param>
|
||||
/// <param name="externalMemberService">Service for managing external-only members.</param>
|
||||
public MemberPresentationFactory(
|
||||
IUmbracoMapper umbracoMapper,
|
||||
IMemberService memberService,
|
||||
IMemberTypeService memberTypeService,
|
||||
ITwoFactorLoginService twoFactorLoginService,
|
||||
IMemberGroupService memberGroupService,
|
||||
IOptions<DeliveryApiSettings> deliveryApiSettings)
|
||||
IOptions<DeliveryApiSettings> deliveryApiSettings,
|
||||
IExternalMemberService externalMemberService)
|
||||
{
|
||||
_umbracoMapper = umbracoMapper;
|
||||
_memberService = memberService;
|
||||
@@ -47,14 +52,10 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory
|
||||
_twoFactorLoginService = twoFactorLoginService;
|
||||
_memberGroupService = memberGroupService;
|
||||
_deliveryApiSettings = deliveryApiSettings.Value;
|
||||
_externalMemberService = externalMemberService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously creates a <see cref="MemberResponseModel"/> for the specified <see cref="IMember"/>, including or excluding sensitive data based on the current user's permissions.
|
||||
/// </summary>
|
||||
/// <param name="member">The member entity to map to a response model.</param>
|
||||
/// <param name="currentUser">The user requesting the data, used to determine access to sensitive information.</param>
|
||||
/// <returns>A task representing the asynchronous operation, with a <see cref="MemberResponseModel"/> as the result.</returns>
|
||||
/// <inheritdoc/>
|
||||
public async Task<MemberResponseModel> CreateResponseModelAsync(IMember member, IUser currentUser)
|
||||
{
|
||||
MemberResponseModel responseModel = _umbracoMapper.Map<MemberResponseModel>(member)!;
|
||||
@@ -70,6 +71,7 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory
|
||||
: await RemoveSensitiveDataAsync(member, responseModel);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<MemberResponseModel>> CreateMultipleAsync(IEnumerable<IMember> members, IUser currentUser)
|
||||
{
|
||||
var memberResponseModels = new List<MemberResponseModel>();
|
||||
@@ -81,41 +83,101 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory
|
||||
return memberResponseModels;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a response model for a member item from the given entity.
|
||||
/// </summary>
|
||||
/// <param name="entity">The member entity to create the response model from.</param>
|
||||
/// <returns>A <see cref="MemberItemResponseModel"/> representing the member.</returns>
|
||||
/// <inheritdoc/>
|
||||
public MemberItemResponseModel CreateItemResponseModel(IMemberEntitySlim entity)
|
||||
=> CreateItemResponseModel<IMemberEntitySlim>(entity);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a response model for a member item based on the given member entity.
|
||||
/// </summary>
|
||||
/// <param name="entity">The member entity to create the response model from.</param>
|
||||
/// <returns>A <see cref="MemberItemResponseModel"/> representing the member.</returns>
|
||||
/// <inheritdoc/>
|
||||
public MemberItemResponseModel CreateItemResponseModel(IMember entity)
|
||||
=> CreateItemResponseModel<IMember>(entity);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<MemberResponseModel> CreateExternalMemberResponseModelAsync(ExternalMemberIdentity member)
|
||||
{
|
||||
IEnumerable<string> roles = await _externalMemberService.GetRolesAsync(member.Key);
|
||||
IEnumerable<Guid> groupKeys = roles
|
||||
.Select(x => _memberGroupService.GetByName(x))
|
||||
.WhereNotNull()
|
||||
.Select(x => x.Key)
|
||||
.ToArray();
|
||||
|
||||
return new MemberResponseModel
|
||||
{
|
||||
Id = member.Key,
|
||||
Email = member.Email,
|
||||
Username = member.UserName,
|
||||
IsApproved = member.IsApproved,
|
||||
IsLockedOut = member.IsLockedOut,
|
||||
IsTwoFactorEnabled = false,
|
||||
FailedPasswordAttempts = 0,
|
||||
LastLoginDate = member.LastLoginDate.HasValue ? new DateTimeOffset(member.LastLoginDate.Value, TimeSpan.Zero) : null,
|
||||
LastLockoutDate = member.LastLockoutDate.HasValue ? new DateTimeOffset(member.LastLockoutDate.Value, TimeSpan.Zero) : null,
|
||||
LastPasswordChangeDate = null,
|
||||
Kind = MemberKind.ExternalOnly,
|
||||
Variants = [new MemberVariantResponseModel
|
||||
{
|
||||
Name = member.Name ?? string.Empty,
|
||||
CreateDate = new DateTimeOffset(member.CreateDate, TimeSpan.Zero),
|
||||
UpdateDate = new DateTimeOffset(member.UpdateDate, TimeSpan.Zero),
|
||||
}],
|
||||
Values = Enumerable.Empty<MemberValueResponseModel>(),
|
||||
MemberType = new MemberTypeReferenceResponseModel(),
|
||||
Groups = groupKeys,
|
||||
ProfileData = member.ProfileData,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public MemberItemResponseModel CreateExternalMemberItemResponseModel(ExternalMemberIdentity member) =>
|
||||
new()
|
||||
{
|
||||
Id = member.Key,
|
||||
MemberType = new MemberTypeReferenceResponseModel(),
|
||||
Variants = [new VariantItemResponseModel { Name = member.Name ?? string.Empty, Culture = null }],
|
||||
Kind = MemberKind.ExternalOnly,
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
public MemberResponseModel CreateFilterItemResponseModel(MemberFilterItem item) =>
|
||||
new()
|
||||
{
|
||||
Id = item.Key,
|
||||
Email = item.Email,
|
||||
Username = item.UserName,
|
||||
IsApproved = item.IsApproved,
|
||||
IsLockedOut = item.IsLockedOut,
|
||||
LastLoginDate = item.LastLoginDate.HasValue ? new DateTimeOffset(item.LastLoginDate.Value, TimeSpan.Zero) : null,
|
||||
LastLockoutDate = item.LastLockoutDate.HasValue ? new DateTimeOffset(item.LastLockoutDate.Value, TimeSpan.Zero) : null,
|
||||
LastPasswordChangeDate = item.LastPasswordChangeDate.HasValue ? new DateTimeOffset(item.LastPasswordChangeDate.Value, TimeSpan.Zero) : null,
|
||||
Kind = item.Kind,
|
||||
Variants = [new MemberVariantResponseModel { Name = item.Name ?? string.Empty }],
|
||||
Values = [],
|
||||
MemberType = new MemberTypeReferenceResponseModel
|
||||
{
|
||||
Id = item.MemberTypeKey ?? Guid.Empty,
|
||||
Icon = item.MemberTypeIcon ?? string.Empty,
|
||||
},
|
||||
};
|
||||
|
||||
private MemberItemResponseModel CreateItemResponseModel<T>(T entity)
|
||||
where T : ITreeEntity
|
||||
=> new MemberItemResponseModel
|
||||
=> new()
|
||||
{
|
||||
Id = entity.Key,
|
||||
MemberType = _umbracoMapper.Map<MemberTypeReferenceResponseModel>(entity)!,
|
||||
Variants = CreateVariantsItemResponseModels(entity),
|
||||
Kind = GetMemberKind(entity.Key)
|
||||
Kind = GetMemberKind(entity.Key),
|
||||
};
|
||||
|
||||
private static IEnumerable<VariantItemResponseModel> CreateVariantsItemResponseModels(ITreeEntity entity)
|
||||
=> new[]
|
||||
{
|
||||
=>
|
||||
[
|
||||
new VariantItemResponseModel
|
||||
{
|
||||
Name = entity.Name ?? string.Empty,
|
||||
Culture = null
|
||||
Culture = null,
|
||||
}
|
||||
};
|
||||
];
|
||||
|
||||
private async Task<MemberResponseModel> RemoveSensitiveDataAsync(IMember member, MemberResponseModel responseModel)
|
||||
{
|
||||
|
||||
@@ -39,6 +39,7 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
private readonly IBackOfficeExternalLoginProviders _externalLoginProviders;
|
||||
private readonly SecuritySettings _securitySettings;
|
||||
private readonly Dictionary<Type, IPermissionPresentationMapper> _permissionPresentationMappersByType;
|
||||
private readonly IContentPermissionService _contentPermissionService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserPresentationFactory"/> class.
|
||||
@@ -54,6 +55,7 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
/// <param name="securitySettings">Provides access to security-related configuration settings.</param>
|
||||
/// <param name="externalLoginProviders">Manages back office external login providers.</param>
|
||||
/// <param name="permissionPresentationMappers">Collection of mappers for permission presentation models.</param>
|
||||
/// <param name="contentPermissionService">Service for managing content permissions.</param>
|
||||
public UserPresentationFactory(
|
||||
IEntityService entityService,
|
||||
AppCaches appCaches,
|
||||
@@ -65,7 +67,8 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
IPasswordConfigurationPresentationFactory passwordConfigurationPresentationFactory,
|
||||
IOptionsSnapshot<SecuritySettings> securitySettings,
|
||||
IBackOfficeExternalLoginProviders externalLoginProviders,
|
||||
IEnumerable<IPermissionPresentationMapper> permissionPresentationMappers)
|
||||
IEnumerable<IPermissionPresentationMapper> permissionPresentationMappers,
|
||||
IContentPermissionService contentPermissionService)
|
||||
{
|
||||
_entityService = entityService;
|
||||
_appCaches = appCaches;
|
||||
@@ -78,6 +81,50 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
_securitySettings = securitySettings.Value;
|
||||
_absoluteUrlBuilder = absoluteUrlBuilder;
|
||||
_permissionPresentationMappersByType = permissionPresentationMappers.ToDictionary(x => x.PresentationModelToHandle);
|
||||
_contentPermissionService = contentPermissionService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserPresentationFactory"/> class.
|
||||
/// </summary>
|
||||
/// <param name="entityService">Service for accessing and managing entities.</param>
|
||||
/// <param name="appCaches">Provides application-level caching functionality.</param>
|
||||
/// <param name="mediaFileManager">Manages media file storage and retrieval.</param>
|
||||
/// <param name="imageUrlGenerator">Generates URLs for images.</param>
|
||||
/// <param name="userGroupPresentationFactory">Factory for creating user group presentation models.</param>
|
||||
/// <param name="absoluteUrlBuilder">Builds absolute URLs for resources.</param>
|
||||
/// <param name="emailSender">Handles sending emails.</param>
|
||||
/// <param name="passwordConfigurationPresentationFactory">Factory for password configuration presentation models.</param>
|
||||
/// <param name="securitySettings">Provides access to security-related configuration settings.</param>
|
||||
/// <param name="externalLoginProviders">Manages back office external login providers.</param>
|
||||
/// <param name="permissionPresentationMappers">Collection of mappers for permission presentation models.</param>
|
||||
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public UserPresentationFactory(
|
||||
IEntityService entityService,
|
||||
AppCaches appCaches,
|
||||
MediaFileManager mediaFileManager,
|
||||
IImageUrlGenerator imageUrlGenerator,
|
||||
IUserGroupPresentationFactory userGroupPresentationFactory,
|
||||
IAbsoluteUrlBuilder absoluteUrlBuilder,
|
||||
IEmailSender emailSender,
|
||||
IPasswordConfigurationPresentationFactory passwordConfigurationPresentationFactory,
|
||||
IOptionsSnapshot<SecuritySettings> securitySettings,
|
||||
IBackOfficeExternalLoginProviders externalLoginProviders,
|
||||
IEnumerable<IPermissionPresentationMapper> permissionPresentationMappers)
|
||||
: this(
|
||||
entityService,
|
||||
appCaches,
|
||||
mediaFileManager,
|
||||
imageUrlGenerator,
|
||||
userGroupPresentationFactory,
|
||||
absoluteUrlBuilder,
|
||||
emailSender,
|
||||
passwordConfigurationPresentationFactory,
|
||||
securitySettings,
|
||||
externalLoginProviders,
|
||||
permissionPresentationMappers,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IContentPermissionService>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -227,7 +274,9 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
ISet<ReferenceByIdModel> documentStartNodeKeys = GetKeysFromIds(contentStartNodeIds, UmbracoObjectTypes.Document);
|
||||
|
||||
HashSet<IPermissionPresentationModel> permissions = GetAggregatedGranularPermissions(user, presentationGroups);
|
||||
var fallbackPermissions = presentationGroups.SelectMany(x => x.FallbackPermissions).ToHashSet();
|
||||
ISet<string> fallbackPermissions = await _contentPermissionService.FilterFallbackPermissionsAsync(
|
||||
user,
|
||||
presentationGroups.SelectMany(x => x.FallbackPermissions).ToHashSet());
|
||||
|
||||
var hasAccessToAllLanguages = presentationGroups.Any(x => x.HasAccessToAllLanguages);
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ public class MemberMapDefinition : ContentMapDefinition<IMember, MemberValueResp
|
||||
public void DefineMaps(IUmbracoMapper mapper)
|
||||
=> mapper.Define<IMember, MemberResponseModel>((_, _) => new MemberResponseModel(), Map);
|
||||
|
||||
// Umbraco.Code.MapAll -IsTwoFactorEnabled -Groups -Kind -Flags
|
||||
// Umbraco.Code.MapAll -IsTwoFactorEnabled -Groups -Kind -Flags -ProfileData
|
||||
private void Map(IMember source, MemberResponseModel target, MapperContext context)
|
||||
{
|
||||
target.Id = source.Key;
|
||||
|
||||
@@ -19,16 +19,30 @@ namespace Umbraco.Cms.Api.Management.Mapping.Permissions;
|
||||
/// </remarks>
|
||||
public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissionMapper
|
||||
{
|
||||
private readonly Lazy<IEntityService> _entityService;
|
||||
private readonly Lazy<IUserService> _userService;
|
||||
private readonly Lazy<IContentPermissionService> _contentPermissionService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DocumentPermissionMapper"/> class.
|
||||
/// </summary>
|
||||
/// <param name="entityService">The entity service.</param>
|
||||
/// <param name="userService">The user service.</param>
|
||||
/// <param name="contentPermissionService">The content permission service.</param>
|
||||
// TODO (V19): Remove the entityService and userService parameters as they are not used in the current implementation.
|
||||
public DocumentPermissionMapper(
|
||||
Lazy<IEntityService> entityService,
|
||||
Lazy<IUserService> userService,
|
||||
Lazy<IContentPermissionService> contentPermissionService) => _contentPermissionService = contentPermissionService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DocumentPermissionMapper"/> class.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public DocumentPermissionMapper(Lazy<IEntityService> entityService, Lazy<IUserService> userService)
|
||||
: this(
|
||||
entityService,
|
||||
userService,
|
||||
new Lazy<IContentPermissionService>(StaticServiceProvider.Instance.GetRequiredService<IContentPermissionService>))
|
||||
{
|
||||
_entityService = entityService;
|
||||
_userService = userService;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -110,25 +124,18 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
// Batch retrieve all documents by their keys.
|
||||
var documents = _entityService.Value.GetAll<IContent>(documentKeysWithGranularPermissions)
|
||||
.ToDictionary(doc => doc.Key, doc => doc.Path);
|
||||
// Resolve permissions through IContentPermissionService so custom implementations are respected.
|
||||
IEnumerable<NodePermissions> permissions = _contentPermissionService.Value
|
||||
.GetPermissionsAsync(user, documentKeysWithGranularPermissions)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
|
||||
// Iterate through each document key that has granular permissions.
|
||||
foreach (Guid documentKey in documentKeysWithGranularPermissions)
|
||||
foreach (NodePermissions nodePermission in permissions)
|
||||
{
|
||||
// Retrieve the path from the pre-fetched documents.
|
||||
if (!documents.TryGetValue(documentKey, out var path) || string.IsNullOrEmpty(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// With the path we can call the same logic as used server-side for authorizing access to resources.
|
||||
EntityPermissionSet permissionsForPath = _userService.Value.GetPermissionsForPath(user, path);
|
||||
yield return new DocumentPermissionPresentationModel
|
||||
{
|
||||
Document = new ReferenceByIdModel(documentKey),
|
||||
Verbs = permissionsForPath.GetAllPermissions(),
|
||||
Document = new ReferenceByIdModel(nodePermission.NodeKey),
|
||||
Verbs = nodePermission.Permissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+224
-10
@@ -9632,6 +9632,157 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/document/{id}/patch": {
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Document"
|
||||
],
|
||||
"summary": "Make partial updates to a document. For more information, see the documentation at https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-endpoint-guide or https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-endpoint-spec",
|
||||
"operationId": "PatchDocumentByIdPatch",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json-patch+json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PatchDocumentRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Content",
|
||||
"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}/preview-url": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -36708,6 +36859,9 @@
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/NoopSetupTwoFactorModel"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/TwoFactorAuthInfo"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -36802,6 +36956,9 @@
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/NoopSetupTwoFactorModel"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/TwoFactorAuthInfo"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -37195,14 +37352,11 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UserPermissionsResponseModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UserPermissionsResponseModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45917,7 +46071,8 @@
|
||||
"MemberKindModel": {
|
||||
"enum": [
|
||||
"Default",
|
||||
"Api"
|
||||
"Api",
|
||||
"ExternalOnly"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -46058,6 +46213,10 @@
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/components/schemas/MemberKindModel"
|
||||
},
|
||||
"profileData": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -48761,6 +48920,47 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PatchDocumentRequestModel": {
|
||||
"required": [
|
||||
"operations"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operations": {
|
||||
"minItems": 1,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PatchOperationRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PatchOperationRequestModel": {
|
||||
"required": [
|
||||
"op",
|
||||
"path"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"op": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProblemDetails": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -50695,6 +50895,20 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"TwoFactorAuthInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"qrCodeSetupImageUrl": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"secret": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"UnknownTypePermissionPresentationModel": {
|
||||
"required": [
|
||||
"$type",
|
||||
@@ -53245,4 +53459,4 @@
|
||||
"name": "Webhook"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Services.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// User start node tree filter service for document (content) trees.
|
||||
/// Resolves the current user's content start nodes.
|
||||
/// </summary>
|
||||
internal sealed class DocumentStartNodeTreeFilterService : UserStartNodeTreeFilterService, IDocumentStartNodeTreeFilterService
|
||||
{
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly AppCaches _appCaches;
|
||||
|
||||
public DocumentStartNodeTreeFilterService(
|
||||
IUserStartNodeEntitiesService userStartNodeEntitiesService,
|
||||
IDataTypeService dataTypeService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IEntityService entityService,
|
||||
AppCaches appCaches)
|
||||
: base(userStartNodeEntitiesService, dataTypeService)
|
||||
{
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
_entityService = entityService;
|
||||
_appCaches = appCaches;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override UmbracoObjectTypes TreeObjectType => UmbracoObjectTypes.Document;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override int[] CalculateUserStartNodeIds()
|
||||
=> _backOfficeSecurityAccessor
|
||||
.BackOfficeSecurity?
|
||||
.CurrentUser?
|
||||
.CalculateContentStartNodeIds(_entityService, _appCaches)
|
||||
?? [];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string[] CalculateUserStartNodePaths()
|
||||
=> _backOfficeSecurityAccessor
|
||||
.BackOfficeSecurity?
|
||||
.CurrentUser?
|
||||
.GetContentStartNodePaths(_entityService, _appCaches)
|
||||
?? [];
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
namespace Umbraco.Cms.Api.Management.Services.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// User start node tree filter service for document (content) trees.
|
||||
/// </summary>
|
||||
public interface IDocumentStartNodeTreeFilterService : IUserStartNodeTreeFilterService;
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Umbraco.Cms.Api.Management.Services.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// User start node tree filter service for media trees.
|
||||
/// </summary>
|
||||
public interface IMediaStartNodeTreeFilterService : IUserStartNodeTreeFilterService;
|
||||
@@ -0,0 +1,85 @@
|
||||
using Umbraco.Cms.Api.Management.Models.Entities;
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Services.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Provides user start node filtering for tree controllers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations resolve the current user's start node configuration and apply
|
||||
/// access filtering to tree queries, ensuring users only see entities within their
|
||||
/// permitted start nodes.
|
||||
/// </remarks>
|
||||
public interface IUserStartNodeTreeFilterService
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether start node filtering should be bypassed for the current user.
|
||||
/// </summary>
|
||||
/// <param name="dataTypeKey">An optional data type key; if the data type is configured to ignore user start nodes,
|
||||
/// filtering is bypassed.</param>
|
||||
/// <returns><c>true</c> if the user has root access or the data type ignores start nodes; otherwise, <c>false</c>.
|
||||
/// </returns>
|
||||
bool ShouldBypassStartNodeFiltering(Guid? dataTypeKey = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the root entities filtered by user start node access.
|
||||
/// </summary>
|
||||
/// <param name="totalItems">The total number of items returned.</param>
|
||||
/// <returns>An array of user access entities at the root level, each indicating whether the user has direct access
|
||||
/// or if the entity is an ancestor navigation item.</returns>
|
||||
UserAccessEntity[] GetFilteredRootEntities(out long totalItems);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the child entities of a parent filtered by user start node access.
|
||||
/// </summary>
|
||||
/// <param name="parentKey">The key of the parent entity.</param>
|
||||
/// <param name="skip">The number of items to skip.</param>
|
||||
/// <param name="take">The number of items to take.</param>
|
||||
/// <param name="ordering">The ordering to apply.</param>
|
||||
/// <param name="totalItems">The total number of items available.</param>
|
||||
/// <returns>An array of child user access entities filtered by user start node access.</returns>
|
||||
UserAccessEntity[] GetFilteredChildEntities(
|
||||
Guid parentKey,
|
||||
int skip,
|
||||
int take,
|
||||
Ordering ordering,
|
||||
out long totalItems);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sibling entities of a target filtered by user start node access.
|
||||
/// </summary>
|
||||
/// <param name="target">The key of the target entity.</param>
|
||||
/// <param name="before">The number of siblings to retrieve before the target.</param>
|
||||
/// <param name="after">The number of siblings to retrieve after the target.</param>
|
||||
/// <param name="ordering">The ordering to apply.</param>
|
||||
/// <param name="totalBefore">The total number of siblings before the target.</param>
|
||||
/// <param name="totalAfter">The total number of siblings after the target.</param>
|
||||
/// <returns>An array of sibling user access entities filtered by user start node access.</returns>
|
||||
UserAccessEntity[] GetFilteredSiblingEntities(
|
||||
Guid target,
|
||||
int before,
|
||||
int after,
|
||||
Ordering ordering,
|
||||
out long totalBefore,
|
||||
out long totalAfter);
|
||||
|
||||
/// <summary>
|
||||
/// Maps entities to tree item view models, applying access filtering using the provided access map.
|
||||
/// </summary>
|
||||
/// <typeparam name="TItem">The type of tree item view model.</typeparam>
|
||||
/// <param name="entities">The entities to map.</param>
|
||||
/// <param name="accessMap">A dictionary mapping entity keys to their access status, as obtained from a prior call
|
||||
/// to one of the <c>GetFiltered*Entities</c> methods.</param>
|
||||
/// <param name="mapEntity">A function to map an entity the user has access to.</param>
|
||||
/// <param name="mapEntityAsNoAccess">A function to map an entity the user does not have direct access to (ancestor
|
||||
/// navigation items).</param>
|
||||
/// <returns>An array of mapped tree item view models, excluding entities not in the access map.</returns>
|
||||
TItem[] MapWithAccessFiltering<TItem>(
|
||||
IEntitySlim[] entities,
|
||||
Dictionary<Guid, bool> accessMap,
|
||||
Func<IEntitySlim, TItem> mapEntity,
|
||||
Func<IEntitySlim, TItem> mapEntityAsNoAccess)
|
||||
where TItem : class;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Services.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// User start node tree filter service for media trees.
|
||||
/// Resolves the current user's media start nodes.
|
||||
/// </summary>
|
||||
internal sealed class MediaStartNodeTreeFilterService : UserStartNodeTreeFilterService, IMediaStartNodeTreeFilterService
|
||||
{
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly AppCaches _appCaches;
|
||||
|
||||
public MediaStartNodeTreeFilterService(
|
||||
IUserStartNodeEntitiesService userStartNodeEntitiesService,
|
||||
IDataTypeService dataTypeService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IEntityService entityService,
|
||||
AppCaches appCaches)
|
||||
: base(userStartNodeEntitiesService, dataTypeService)
|
||||
{
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
_entityService = entityService;
|
||||
_appCaches = appCaches;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override UmbracoObjectTypes TreeObjectType => UmbracoObjectTypes.Media;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override int[] CalculateUserStartNodeIds()
|
||||
=> _backOfficeSecurityAccessor
|
||||
.BackOfficeSecurity?
|
||||
.CurrentUser?
|
||||
.CalculateMediaStartNodeIds(_entityService, _appCaches)
|
||||
?? [];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string[] CalculateUserStartNodePaths()
|
||||
=> _backOfficeSecurityAccessor
|
||||
.BackOfficeSecurity?
|
||||
.CurrentUser?
|
||||
.GetMediaStartNodePaths(_entityService, _appCaches)
|
||||
?? [];
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using Umbraco.Cms.Api.Management.Models.Entities;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Services.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base class for user start node tree filter services.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Contains the shared filtering logic for tree controllers that support user start node access.
|
||||
/// Concrete implementations provide the start node resolution for their specific domain
|
||||
/// (documents or media).
|
||||
/// </remarks>
|
||||
internal abstract class UserStartNodeTreeFilterService : IUserStartNodeTreeFilterService
|
||||
{
|
||||
private readonly IUserStartNodeEntitiesService _userStartNodeEntitiesService;
|
||||
private readonly IDataTypeService _dataTypeService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserStartNodeTreeFilterService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userStartNodeEntitiesService">The service for retrieving user access entities.</param>
|
||||
/// <param name="dataTypeService">The data type service.</param>
|
||||
protected UserStartNodeTreeFilterService(
|
||||
IUserStartNodeEntitiesService userStartNodeEntitiesService,
|
||||
IDataTypeService dataTypeService)
|
||||
{
|
||||
_userStartNodeEntitiesService = userStartNodeEntitiesService;
|
||||
_dataTypeService = dataTypeService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the object type to include in tree queries.
|
||||
/// </summary>
|
||||
protected abstract UmbracoObjectTypes TreeObjectType { get; }
|
||||
|
||||
private int[] UserStartNodeIds => field ??= CalculateUserStartNodeIds();
|
||||
|
||||
private string[] UserStartNodePaths => field ??= CalculateUserStartNodePaths();
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShouldBypassStartNodeFiltering(Guid? dataTypeKey = null)
|
||||
=> UserHasRootAccess() || IgnoreUserStartNodes(dataTypeKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
public UserAccessEntity[] GetFilteredRootEntities(out long totalItems)
|
||||
{
|
||||
UserAccessEntity[] result = _userStartNodeEntitiesService
|
||||
.RootUserAccessEntities(TreeObjectType, UserStartNodeIds)
|
||||
.ToArray();
|
||||
|
||||
totalItems = result.Length;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public UserAccessEntity[] GetFilteredChildEntities(
|
||||
Guid parentKey,
|
||||
int skip,
|
||||
int take,
|
||||
Ordering ordering,
|
||||
out long totalItems)
|
||||
{
|
||||
UserAccessEntity[] result = _userStartNodeEntitiesService.ChildUserAccessEntities(
|
||||
TreeObjectType,
|
||||
UserStartNodePaths,
|
||||
parentKey,
|
||||
skip,
|
||||
take,
|
||||
ordering,
|
||||
out totalItems)
|
||||
.ToArray();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public UserAccessEntity[] GetFilteredSiblingEntities(
|
||||
Guid target,
|
||||
int before,
|
||||
int after,
|
||||
Ordering ordering,
|
||||
out long totalBefore,
|
||||
out long totalAfter)
|
||||
{
|
||||
UserAccessEntity[] result = _userStartNodeEntitiesService.SiblingUserAccessEntities(
|
||||
TreeObjectType,
|
||||
UserStartNodePaths,
|
||||
target,
|
||||
before,
|
||||
after,
|
||||
ordering,
|
||||
out totalBefore,
|
||||
out totalAfter)
|
||||
.ToArray();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public TItem[] MapWithAccessFiltering<TItem>(
|
||||
IEntitySlim[] entities,
|
||||
Dictionary<Guid, bool> accessMap,
|
||||
Func<IEntitySlim, TItem> mapEntity,
|
||||
Func<IEntitySlim, TItem> mapEntityAsNoAccess)
|
||||
where TItem : class =>
|
||||
entities.Select(entity =>
|
||||
{
|
||||
if (accessMap.TryGetValue(entity.Key, out var hasAccess) is false)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return hasAccess ? mapEntity(entity) : mapEntityAsNoAccess(entity);
|
||||
})
|
||||
.WhereNotNull()
|
||||
.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the start node IDs for the current user.
|
||||
/// </summary>
|
||||
/// <returns>An array of start node IDs.</returns>
|
||||
protected abstract int[] CalculateUserStartNodeIds();
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the start node paths for the current user.
|
||||
/// </summary>
|
||||
/// <returns>An array of start node paths.</returns>
|
||||
protected abstract string[] CalculateUserStartNodePaths();
|
||||
|
||||
private bool UserHasRootAccess() => UserStartNodeIds.Contains(Constants.System.Root);
|
||||
|
||||
private bool IgnoreUserStartNodes(Guid? dataTypeKey)
|
||||
=> dataTypeKey.HasValue
|
||||
&& _dataTypeService.IsDataTypeIgnoringUserStartNodes(dataTypeKey.Value);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Member;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Member.Item;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for resolving members across both content and external stores and creating presentation models.
|
||||
/// </summary>
|
||||
public interface IMemberPresentationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a member by key from either the content or external store and creates a response model.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the member.</param>
|
||||
/// <param name="currentUser">The current backoffice user performing the operation.</param>
|
||||
/// <returns>A <see cref="MemberResponseModel"/> if found; otherwise <c>null</c>.</returns>
|
||||
Task<MemberResponseModel?> CreateResponseModelByKeyAsync(Guid id, IUser currentUser);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves members by keys from both the content and external stores and creates item response models.
|
||||
/// </summary>
|
||||
/// <param name="ids">The unique identifiers of the members to resolve.</param>
|
||||
/// <returns>A collection of <see cref="MemberItemResponseModel"/> for all resolved members.</returns>
|
||||
Task<IEnumerable<MemberItemResponseModel>> CreateItemResponseModelsAsync(HashSet<Guid> ids);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for retrieving paged references to a member, handling the external member fallback
|
||||
/// when the entity-based lookup fails (external members have no <c>umbracoNode</c> entry).
|
||||
/// </summary>
|
||||
public interface IMemberReferenceService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a paged list of items that reference the specified member.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the member.</param>
|
||||
/// <param name="skip">The number of items to skip.</param>
|
||||
/// <param name="take">The maximum number of items to return.</param>
|
||||
/// <returns>An <see cref="Attempt{TResult,TStatus}"/> containing the paged relation items or an operation status on failure.</returns>
|
||||
Task<Attempt<PagedModel<RelationItemModel>, GetReferencesOperationStatus>> GetPagedReferencesAsync(Guid id, int skip, int take);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Member;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Member.Item;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves members across both the content and external member stores and creates presentation models.
|
||||
/// </summary>
|
||||
internal sealed class MemberPresentationService : IMemberPresentationService
|
||||
{
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly IMemberEditingService _memberEditingService;
|
||||
private readonly IMemberPresentationFactory _memberPresentationFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MemberPresentationService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="entityService">Service used for entity operations and retrieval.</param>
|
||||
/// <param name="memberEditingService">Service used for member editing operations.</param>
|
||||
/// <param name="memberPresentationFactory">Factory responsible for creating member presentation models.</param>
|
||||
public MemberPresentationService(
|
||||
IEntityService entityService,
|
||||
IMemberEditingService memberEditingService,
|
||||
IMemberPresentationFactory memberPresentationFactory)
|
||||
{
|
||||
_entityService = entityService;
|
||||
_memberEditingService = memberEditingService;
|
||||
_memberPresentationFactory = memberPresentationFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<MemberResponseModel?> CreateResponseModelByKeyAsync(Guid id, IUser currentUser)
|
||||
{
|
||||
IMember? member = await _memberEditingService.GetAsync(id);
|
||||
if (member is not null)
|
||||
{
|
||||
return await _memberPresentationFactory.CreateResponseModelAsync(member, currentUser);
|
||||
}
|
||||
|
||||
ExternalMemberIdentity? externalMember = await _memberEditingService.GetExternalMemberAsync(id);
|
||||
if (externalMember is not null)
|
||||
{
|
||||
return await _memberPresentationFactory.CreateExternalMemberResponseModelAsync(externalMember);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<MemberItemResponseModel>> CreateItemResponseModelsAsync(HashSet<Guid> ids)
|
||||
{
|
||||
IMemberEntitySlim[] contentMembers = _entityService
|
||||
.GetAll(UmbracoObjectTypes.Member, ids.ToArray())
|
||||
.OfType<IMemberEntitySlim>()
|
||||
.ToArray();
|
||||
|
||||
var responseModels = new List<MemberItemResponseModel>(
|
||||
contentMembers.Select(_memberPresentationFactory.CreateItemResponseModel));
|
||||
|
||||
var resolvedIds = contentMembers.Select(m => m.Key).ToHashSet();
|
||||
|
||||
foreach (Guid unresolvedId in ids.Where(id => resolvedIds.Contains(id) is false))
|
||||
{
|
||||
ExternalMemberIdentity? externalMember = await _memberEditingService.GetExternalMemberAsync(unresolvedId);
|
||||
if (externalMember is not null)
|
||||
{
|
||||
responseModels.Add(_memberPresentationFactory.CreateExternalMemberItemResponseModel(externalMember));
|
||||
}
|
||||
}
|
||||
|
||||
return responseModels;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves paged references to a member, handling the external member fallback
|
||||
/// when the entity-based lookup fails (external members have no <c>umbracoNode</c> entry).
|
||||
/// </summary>
|
||||
internal sealed class MemberReferenceService : IMemberReferenceService
|
||||
{
|
||||
private readonly ITrackedReferencesService _trackedReferencesService;
|
||||
private readonly IMemberEditingService _memberEditingService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MemberReferenceService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="trackedReferencesService">Service used to manage tracked references.</param>
|
||||
/// <param name="memberEditingService">Service used for member editing operations.</param>
|
||||
public MemberReferenceService(
|
||||
ITrackedReferencesService trackedReferencesService,
|
||||
IMemberEditingService memberEditingService)
|
||||
{
|
||||
_trackedReferencesService = trackedReferencesService;
|
||||
_memberEditingService = memberEditingService;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Attempt<PagedModel<RelationItemModel>, GetReferencesOperationStatus>> GetPagedReferencesAsync(Guid id, int skip, int take)
|
||||
{
|
||||
Attempt<PagedModel<RelationItemModel>, GetReferencesOperationStatus> result =
|
||||
await _trackedReferencesService.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Member, skip, take, true);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// The entity-based lookup fails for external-only members (no umbracoNode entry).
|
||||
// Fall back to a key-based relation query if this is an external member.
|
||||
if (result.Status == GetReferencesOperationStatus.ContentNotFound
|
||||
&& await _memberEditingService.IsExternalMemberAsync(id))
|
||||
{
|
||||
#pragma warning disable CS0618 // Type or member is obsolete — using the key-based overload that doesn't require an entity.
|
||||
PagedModel<RelationItemModel> externalRelations = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, true);
|
||||
#pragma warning restore CS0618
|
||||
|
||||
return Attempt.SucceedWithStatus(GetReferencesOperationStatus.Success, externalRelations);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+26
-29
@@ -1,10 +1,9 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Services.PermissionFilter;
|
||||
|
||||
@@ -14,29 +13,25 @@ namespace Umbraco.Cms.Api.Management.Services.PermissionFilter;
|
||||
internal sealed class DocumentPermissionFilterService : IDocumentPermissionFilterService
|
||||
{
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IContentPermissionService _contentPermissionService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DocumentPermissionFilterService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="backOfficeSecurityAccessor">Provides access to the current backoffice user's security context.</param>
|
||||
/// <param name="userService">Service used to retrieve user and document permissions.</param>
|
||||
/// <param name="contentPermissionService">Service used to retrieve content permissions.</param>
|
||||
public DocumentPermissionFilterService(
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IUserService userService)
|
||||
IContentPermissionService contentPermissionService)
|
||||
{
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
_userService = userService;
|
||||
_contentPermissionService = contentPermissionService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IEntitySlim[] Entities, long TotalItems)> FilterAsync(IEntitySlim[] entities, long totalItems)
|
||||
{
|
||||
Dictionary<Guid, NodePermissions>? permissionsByNodeKey = await GetDocumentPermissionsByNodeKeyAsync(entities);
|
||||
if (permissionsByNodeKey is null)
|
||||
{
|
||||
return (entities, totalItems);
|
||||
}
|
||||
Dictionary<Guid, NodePermissions> permissionsByNodeKey = await GetDocumentPermissionsByNodeKeyAsync(entities);
|
||||
|
||||
IEntitySlim[] filteredEntities = FilterEntitiesWithBrowsePermission(entities, permissionsByNodeKey);
|
||||
var removedCount = entities.Length - filteredEntities.Length;
|
||||
@@ -47,11 +42,7 @@ internal sealed class DocumentPermissionFilterService : IDocumentPermissionFilte
|
||||
/// <inheritdoc />
|
||||
public async Task<(IEntitySlim[] Entities, long TotalBefore, long TotalAfter)> FilterAsync(Guid targetKey, IEntitySlim[] entities, long totalBefore, long totalAfter)
|
||||
{
|
||||
Dictionary<Guid, NodePermissions>? permissionsByNodeKey = await GetDocumentPermissionsByNodeKeyAsync(entities);
|
||||
if (permissionsByNodeKey is null)
|
||||
{
|
||||
return (entities, totalBefore, totalAfter);
|
||||
}
|
||||
Dictionary<Guid, NodePermissions> permissionsByNodeKey = await GetDocumentPermissionsByNodeKeyAsync(entities);
|
||||
|
||||
// Find the index of the target entity to determine before/after boundaries
|
||||
var targetIndex = Array.FindIndex(entities, e => e.Key == targetKey);
|
||||
@@ -65,27 +56,33 @@ internal sealed class DocumentPermissionFilterService : IDocumentPermissionFilte
|
||||
return (filteredEntities, totalBefore - removedBefore, totalAfter - removedAfter);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, NodePermissions>?> GetDocumentPermissionsByNodeKeyAsync(IEntitySlim[] entities)
|
||||
private async Task<Dictionary<Guid, NodePermissions>> GetDocumentPermissionsByNodeKeyAsync(IEntitySlim[] entities)
|
||||
{
|
||||
Guid userKey = CurrentUserKey();
|
||||
IUser currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser
|
||||
?? throw new InvalidOperationException("No backoffice user found");
|
||||
|
||||
var entityKeys = entities.Select(e => e.Key).ToHashSet();
|
||||
|
||||
Attempt<IEnumerable<NodePermissions>, UserOperationStatus> permissionsAttempt =
|
||||
await _userService.GetDocumentPermissionsAsync(userKey, entityKeys);
|
||||
IEnumerable<NodePermissions> permissions = await _contentPermissionService.GetPermissionsAsync(currentUser, entityKeys);
|
||||
|
||||
return permissionsAttempt.Success
|
||||
? permissionsAttempt.Result.ToDictionary(p => p.NodeKey)
|
||||
: null;
|
||||
// Build dictionary with an entry for every requested key. Keys missing from the result
|
||||
// default to empty permissions so they are treated as denied (fail-closed).
|
||||
var result = entityKeys.ToDictionary(
|
||||
key => key,
|
||||
key => new NodePermissions { NodeKey = key, Permissions = new HashSet<string>() });
|
||||
|
||||
foreach (NodePermissions nodePermissions in permissions)
|
||||
{
|
||||
result[nodePermissions.NodeKey] = nodePermissions;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private Guid CurrentUserKey()
|
||||
=> _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Key
|
||||
?? throw new InvalidOperationException("No backoffice user found");
|
||||
|
||||
private static IEntitySlim[] FilterEntitiesWithBrowsePermission(IEntitySlim[] entities, Dictionary<Guid, NodePermissions> permissionsByNodeKey)
|
||||
=> entities.Where(e => HasBrowsePermission(e, permissionsByNodeKey)).ToArray();
|
||||
|
||||
private static bool HasBrowsePermission(IEntitySlim entity, Dictionary<Guid, NodePermissions> permissionsByNodeKey)
|
||||
=> permissionsByNodeKey.TryGetValue(entity.Key, out NodePermissions? nodePermissions) is false
|
||||
|| nodePermissions.Permissions.Contains(ActionBrowse.ActionLetter);
|
||||
=> permissionsByNodeKey.TryGetValue(entity.Key, out NodePermissions? nodePermissions)
|
||||
&& nodePermissions.Permissions.Contains(ActionBrowse.ActionLetter);
|
||||
}
|
||||
|
||||
@@ -62,4 +62,15 @@ public class MemberResponseModel : ContentResponseModelBase<MemberValueResponseM
|
||||
/// Gets or sets the classification of the member, indicating the type or category of the member entity.
|
||||
/// </summary>
|
||||
public MemberKind Kind { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the raw JSON profile data for external-only members.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Populated only for members whose <see cref="Kind"/> is <see cref="MemberKind.ExternalOnly"/>,
|
||||
/// from <see cref="Umbraco.Cms.Core.Security.ExternalMemberIdentity.ProfileData"/>. The shape is
|
||||
/// integrator-defined (typically claims serialised by an <c>OnExternalLogin</c> handler), so the
|
||||
/// API returns the raw JSON string and leaves interpretation to the consumer.
|
||||
/// </remarks>
|
||||
public string? ProfileData { get; set; }
|
||||
}
|
||||
|
||||
+1
-1
@@ -391,7 +391,7 @@ internal sealed class CollectibleRuntimeViewCompiler : IViewCompiler
|
||||
|
||||
foreach (var message in messages ?? Enumerable.Empty<string>())
|
||||
{
|
||||
_logger.LogError(compilationException, "Compilation error occured with message: {ErrorMessage}", message);
|
||||
_logger.LogError(compilationException, "Compilation error occurred with message: {ErrorMessage}", message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,11 +23,13 @@ public class UmbracoEFCoreComposer : IComposer
|
||||
builder.AddNotificationAsyncHandler<DatabaseSchemaAndDataCreatedNotification, EFCoreCreateTablesNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<UnattendedInstallNotification, EFCoreCreateTablesNotificationHandler>();
|
||||
|
||||
builder.Services.AddUmbracoDbContext<UmbracoDbContext>((provider, options, connectionString, providerName) =>
|
||||
{
|
||||
// Register the entity sets needed by OpenIddict.
|
||||
options.UseOpenIddict();
|
||||
});
|
||||
builder.Services.AddUmbracoDbContext<UmbracoDbContext>(
|
||||
(provider, options, connectionString, providerName) =>
|
||||
{
|
||||
// Register the entity sets needed by OpenIddict.
|
||||
options.UseOpenIddict();
|
||||
},
|
||||
shareUmbracoConnection: true);
|
||||
|
||||
builder.Services.AddOpenIddict()
|
||||
|
||||
|
||||
+51
-8
@@ -31,22 +31,19 @@ public static class UmbracoEFCoreServiceCollectionExtensions
|
||||
this IServiceCollection services,
|
||||
Action<DbContextOptionsBuilder>? optionsAction = null)
|
||||
where T : DbContext
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
=> AddUmbracoDbContext<T>(services, (sp, optionsBuilder, connectionString, providerName) => optionsAction?.Invoke(optionsBuilder));
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
|
||||
/// <summary>
|
||||
/// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes.
|
||||
/// </summary>
|
||||
[Obsolete("Use the overload accepting shareUmbracoConnection. Scheduled for removal in Umbraco 19.")]
|
||||
public static IServiceCollection AddUmbracoDbContext<T>(
|
||||
this IServiceCollection services,
|
||||
Action<DbContextOptionsBuilder, string?, string?, IServiceProvider?>? optionsAction = null)
|
||||
where T : DbContext
|
||||
{
|
||||
return AddUmbracoDbContext<T>(services, (IServiceProvider provider, DbContextOptionsBuilder optionsBuilder, string? providerName, string? connectionString) =>
|
||||
{
|
||||
ConnectionStrings connectionStrings = GetConnectionStringAndProviderName(provider);
|
||||
optionsAction?.Invoke(optionsBuilder, connectionStrings.ConnectionString, connectionStrings.ProviderName, provider);
|
||||
});
|
||||
}
|
||||
=> AddUmbracoDbContext<T>(services, optionsAction, shareUmbracoConnection: true);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes.
|
||||
@@ -56,22 +53,68 @@ public static class UmbracoEFCoreServiceCollectionExtensions
|
||||
this IServiceCollection services,
|
||||
Action<IServiceProvider, DbContextOptionsBuilder>? optionsAction = null)
|
||||
where T : DbContext
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
=> AddUmbracoDbContext<T>(services, (sp, optionsBuilder, connectionString, providerName) => optionsAction?.Invoke(sp, optionsBuilder));
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
|
||||
/// <summary>
|
||||
/// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes.
|
||||
/// </summary>
|
||||
[Obsolete("Use the overload accepting shareUmbracoConnection. Scheduled for removal in Umbraco 19.")]
|
||||
public static IServiceCollection AddUmbracoDbContext<T>(
|
||||
this IServiceCollection services,
|
||||
Action<IServiceProvider, DbContextOptionsBuilder, string?, string?>? optionsAction = null)
|
||||
where T : DbContext
|
||||
=> AddUmbracoDbContext<T>(services, optionsAction, shareUmbracoConnection: true);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="optionsAction">An optional action to configure the DbContext options.</param>
|
||||
/// <param name="shareUmbracoConnection">
|
||||
/// When <c>true</c> (default), the EF Core scope shares the NPoco (Umbraco main database)
|
||||
/// connection and transaction. Set to <c>false</c> when the DbContext targets a separate
|
||||
/// database with its own connection string.
|
||||
/// </param>
|
||||
public static IServiceCollection AddUmbracoDbContext<T>(
|
||||
this IServiceCollection services,
|
||||
Action<DbContextOptionsBuilder, string?, string?, IServiceProvider?>? optionsAction,
|
||||
bool shareUmbracoConnection)
|
||||
where T : DbContext
|
||||
{
|
||||
return AddUmbracoDbContext<T>(
|
||||
services,
|
||||
(IServiceProvider provider, DbContextOptionsBuilder optionsBuilder, string? connectionString, string? providerName) =>
|
||||
{
|
||||
ConnectionStrings connectionStrings = GetConnectionStringAndProviderName(provider);
|
||||
optionsAction?.Invoke(optionsBuilder, connectionStrings.ConnectionString, connectionStrings.ProviderName, provider);
|
||||
},
|
||||
shareUmbracoConnection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="optionsAction">An optional action to configure the DbContext options.</param>
|
||||
/// <param name="shareUmbracoConnection">
|
||||
/// When <c>true</c> (default), the EF Core scope shares the NPoco (Umbraco main database)
|
||||
/// connection and transaction. Set to <c>false</c> when the DbContext targets a separate
|
||||
/// database with its own connection string.
|
||||
/// </param>
|
||||
public static IServiceCollection AddUmbracoDbContext<T>(
|
||||
this IServiceCollection services,
|
||||
Action<IServiceProvider, DbContextOptionsBuilder, string?, string?>? optionsAction,
|
||||
bool shareUmbracoConnection)
|
||||
where T : DbContext
|
||||
{
|
||||
optionsAction ??= (sp, optionsBuilder, connectionString, providerName) => { };
|
||||
|
||||
|
||||
services.AddPooledDbContextFactory<T>((provider, optionsBuilder) => SetupDbContext(optionsAction, provider, optionsBuilder));
|
||||
services.AddTransient(services => services.GetRequiredService<IDbContextFactory<T>>().CreateDbContext());
|
||||
|
||||
services.AddSingleton(new EFCoreScopeConfiguration<T> { ShareUmbracoConnection = shareUmbracoConnection });
|
||||
services.AddUnique<IAmbientEFCoreScopeStack<T>, AmbientEFCoreScopeStack<T>>();
|
||||
services.AddUnique<IEFCoreScopeAccessor<T>, EFCoreScopeAccessor<T>>();
|
||||
services.AddUnique<IEFCoreScopeProvider<T>, EFCoreScopeProvider<T>>();
|
||||
|
||||
@@ -27,6 +27,7 @@ internal sealed class EFCoreDetachableScope<TDbContext> : EFCoreScope<TDbContext
|
||||
/// <param name="scopeContext">The scope context (must be null for detachable scopes).</param>
|
||||
/// <param name="eventAggregator">The event aggregator.</param>
|
||||
/// <param name="dbContextFactory">The DbContext factory.</param>
|
||||
/// <param name="shareUmbracoConnection">Whether to share the NPoco connection and transaction.</param>
|
||||
/// <param name="repositoryCacheMode">The repository cache mode.</param>
|
||||
/// <param name="scopeFileSystems">Whether to scope file systems.</param>
|
||||
public EFCoreDetachableScope(
|
||||
@@ -38,6 +39,7 @@ internal sealed class EFCoreDetachableScope<TDbContext> : EFCoreScope<TDbContext
|
||||
IScopeContext? scopeContext,
|
||||
IEventAggregator eventAggregator,
|
||||
IDbContextFactory<TDbContext> dbContextFactory,
|
||||
bool shareUmbracoConnection = true,
|
||||
RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified,
|
||||
bool? scopeFileSystems = null)
|
||||
: base(
|
||||
@@ -49,6 +51,7 @@ internal sealed class EFCoreDetachableScope<TDbContext> : EFCoreScope<TDbContext
|
||||
scopeContext,
|
||||
eventAggregator,
|
||||
dbContextFactory,
|
||||
shareUmbracoConnection,
|
||||
repositoryCacheMode,
|
||||
scopeFileSystems)
|
||||
{
|
||||
|
||||
@@ -20,9 +20,10 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
|
||||
private readonly IEFCoreScopeAccessor<TDbContext> _efCoreScopeAccessor;
|
||||
private readonly EFCoreScopeProvider<TDbContext> _efCoreScopeProvider;
|
||||
private readonly IScope? _innerScope;
|
||||
private readonly bool _shareUmbracoConnection;
|
||||
private bool _disposed;
|
||||
private TDbContext? _dbContext;
|
||||
private IDbContextFactory<TDbContext> _dbContextFactory;
|
||||
private readonly IDbContextFactory<TDbContext> _dbContextFactory;
|
||||
private string? _originalConnectionString;
|
||||
|
||||
/// <summary>
|
||||
@@ -36,6 +37,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
|
||||
/// <param name="scopeContext">The scope context.</param>
|
||||
/// <param name="eventAggregator">The event aggregator.</param>
|
||||
/// <param name="dbContextFactory">The DbContext factory.</param>
|
||||
/// <param name="shareUmbracoConnection">Whether to share the NPoco connection and transaction.</param>
|
||||
/// <param name="repositoryCacheMode">The repository cache mode.</param>
|
||||
/// <param name="scopeFileSystems">Whether to scope file systems.</param>
|
||||
protected EFCoreScope(
|
||||
@@ -47,6 +49,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
|
||||
IScopeContext? scopeContext,
|
||||
IEventAggregator eventAggregator,
|
||||
IDbContextFactory<TDbContext> dbContextFactory,
|
||||
bool shareUmbracoConnection = true,
|
||||
RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified,
|
||||
bool? scopeFileSystems = null)
|
||||
: base(distributedLockingMechanismFactory, loggerFactory, scopedFileSystem, eventAggregator, repositoryCacheMode, scopeFileSystems)
|
||||
@@ -55,6 +58,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
|
||||
_efCoreScopeProvider = (EFCoreScopeProvider<TDbContext>)iefCoreScopeProvider;
|
||||
ScopeContext = scopeContext;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_shareUmbracoConnection = shareUmbracoConnection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -69,6 +73,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
|
||||
/// <param name="scopeContext">The scope context.</param>
|
||||
/// <param name="eventAggregator">The event aggregator.</param>
|
||||
/// <param name="dbContextFactory">The DbContext factory.</param>
|
||||
/// <param name="shareUmbracoConnection">Whether to share the NPoco connection and transaction.</param>
|
||||
/// <param name="repositoryCacheMode">The repository cache mode.</param>
|
||||
/// <param name="scopeFileSystems">Whether to scope file systems.</param>
|
||||
public EFCoreScope(
|
||||
@@ -81,6 +86,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
|
||||
IScopeContext? scopeContext,
|
||||
IEventAggregator eventAggregator,
|
||||
IDbContextFactory<TDbContext> dbContextFactory,
|
||||
bool shareUmbracoConnection = true,
|
||||
RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified,
|
||||
bool? scopeFileSystems = null)
|
||||
: base(parentScope, distributedLockingMechanismFactory, loggerFactory, scopedFileSystem, eventAggregator, repositoryCacheMode, scopeFileSystems)
|
||||
@@ -90,6 +96,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
|
||||
ScopeContext = scopeContext;
|
||||
_innerScope = parentScope;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_shareUmbracoConnection = shareUmbracoConnection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -104,6 +111,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
|
||||
/// <param name="scopeContext">The scope context.</param>
|
||||
/// <param name="eventAggregator">The event aggregator.</param>
|
||||
/// <param name="dbContextFactory">The DbContext factory.</param>
|
||||
/// <param name="shareUmbracoConnection">Whether to share the NPoco connection and transaction.</param>
|
||||
/// <param name="repositoryCacheMode">The repository cache mode.</param>
|
||||
/// <param name="scopeFileSystems">Whether to scope file systems.</param>
|
||||
public EFCoreScope(
|
||||
@@ -116,6 +124,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
|
||||
IScopeContext? scopeContext,
|
||||
IEventAggregator eventAggregator,
|
||||
IDbContextFactory<TDbContext> dbContextFactory,
|
||||
bool shareUmbracoConnection = true,
|
||||
RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified,
|
||||
bool? scopeFileSystems = null)
|
||||
: base(parentScope, distributedLockingMechanismFactory, loggerFactory, scopedFileSystem, eventAggregator, repositoryCacheMode, scopeFileSystems)
|
||||
@@ -125,6 +134,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
|
||||
ScopeContext = scopeContext;
|
||||
ParentScope = parentScope;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_shareUmbracoConnection = shareUmbracoConnection;
|
||||
}
|
||||
|
||||
|
||||
@@ -213,27 +223,33 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
|
||||
|
||||
private void InitializeDatabase()
|
||||
{
|
||||
if (_dbContext is null)
|
||||
{
|
||||
_dbContext = FindDbContext();
|
||||
}
|
||||
_dbContext ??= FindDbContext();
|
||||
|
||||
_originalConnectionString ??= _dbContext.Database.GetConnectionString();
|
||||
|
||||
// Check if we are already in a transaction before starting one
|
||||
// Check if we are already in a transaction before starting one.
|
||||
if (_dbContext.Database.CurrentTransaction is null)
|
||||
{
|
||||
DbTransaction? transaction = _innerScope?.Database.Transaction;
|
||||
_dbContext.Database.SetDbConnection(transaction?.Connection);
|
||||
Locks.EnsureLocks(InstanceId);
|
||||
|
||||
if (transaction is null)
|
||||
if (_shareUmbracoConnection)
|
||||
{
|
||||
_dbContext.Database.BeginTransaction();
|
||||
DbTransaction? transaction = _innerScope?.Database.Transaction;
|
||||
_dbContext.Database.SetDbConnection(transaction?.Connection);
|
||||
Locks.EnsureLocks(InstanceId);
|
||||
|
||||
if (transaction is null)
|
||||
{
|
||||
_dbContext.Database.BeginTransaction();
|
||||
}
|
||||
else
|
||||
{
|
||||
_dbContext.Database.UseTransaction(transaction);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_dbContext.Database.UseTransaction(transaction);
|
||||
// Separate database — use the DbContext's own configured connection.
|
||||
Locks.EnsureLocks(InstanceId);
|
||||
_dbContext.Database.BeginTransaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,7 +287,14 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_dbContext is null || _innerScope is not null)
|
||||
if (_dbContext is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// When sharing the Umbraco connection, the NPoco inner scope owns the transaction —
|
||||
// skip commit/rollback here (the inner scope handles it).
|
||||
if (_innerScope is not null && _shareUmbracoConnection)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Umbraco.Cms.Persistence.EFCore.Scoping;
|
||||
|
||||
/// <summary>
|
||||
/// Per-DbContext configuration for EF Core scoping behavior.
|
||||
/// </summary>
|
||||
/// <typeparam name="TDbContext">The type of DbContext.</typeparam>
|
||||
internal class EFCoreScopeConfiguration<TDbContext>
|
||||
where TDbContext : DbContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the EF Core scope should share
|
||||
/// the NPoco (Umbraco main database) connection and transaction.
|
||||
/// When <c>false</c>, the DbContext uses its own configured connection
|
||||
/// and manages its own transaction independently.
|
||||
/// Defaults to <c>true</c>.
|
||||
/// </summary>
|
||||
public bool ShareUmbracoConnection { get; set; } = true;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ internal sealed class EFCoreScopeProvider<TDbContext> : IEFCoreScopeProvider<TDb
|
||||
private readonly FileSystems _fileSystems;
|
||||
private readonly IScopeProvider _scopeProvider;
|
||||
private readonly IDbContextFactory<TDbContext> _dbContextFactory;
|
||||
private readonly bool _shareUmbracoConnection;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EFCoreScopeProvider{TDbContext}"/> class.
|
||||
@@ -43,7 +44,8 @@ internal sealed class EFCoreScopeProvider<TDbContext> : IEFCoreScopeProvider<TDb
|
||||
StaticServiceProvider.Instance.GetRequiredService<IEventAggregator>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<FileSystems>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IScopeProvider>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IDbContextFactory<TDbContext>>())
|
||||
StaticServiceProvider.Instance.GetRequiredService<IDbContextFactory<TDbContext>>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<EFCoreScopeConfiguration<TDbContext>>())
|
||||
{
|
||||
}
|
||||
|
||||
@@ -59,6 +61,7 @@ internal sealed class EFCoreScopeProvider<TDbContext> : IEFCoreScopeProvider<TDb
|
||||
/// <param name="fileSystems">The file systems.</param>
|
||||
/// <param name="scopeProvider">The scope provider.</param>
|
||||
/// <param name="dbContextFactory">The DbContext factory.</param>
|
||||
/// <param name="scopeConfiguration">The per-DbContext scope configuration.</param>
|
||||
internal EFCoreScopeProvider(
|
||||
IAmbientEFCoreScopeStack<TDbContext> ambientEfCoreScopeStack,
|
||||
ILoggerFactory loggerFactory,
|
||||
@@ -68,7 +71,8 @@ internal sealed class EFCoreScopeProvider<TDbContext> : IEFCoreScopeProvider<TDb
|
||||
IEventAggregator eventAggregator,
|
||||
FileSystems fileSystems,
|
||||
IScopeProvider scopeProvider,
|
||||
IDbContextFactory<TDbContext> dbContextFactory)
|
||||
IDbContextFactory<TDbContext> dbContextFactory,
|
||||
EFCoreScopeConfiguration<TDbContext> scopeConfiguration)
|
||||
{
|
||||
_ambientEfCoreScopeStack = ambientEfCoreScopeStack;
|
||||
_loggerFactory = loggerFactory;
|
||||
@@ -79,6 +83,7 @@ internal sealed class EFCoreScopeProvider<TDbContext> : IEFCoreScopeProvider<TDb
|
||||
_fileSystems = fileSystems;
|
||||
_scopeProvider = scopeProvider;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_shareUmbracoConnection = scopeConfiguration.ShareUmbracoConnection;
|
||||
_fileSystems.IsScoped = () => efCoreScopeAccessor.AmbientScope != null && ((EFCoreScope<TDbContext>)efCoreScopeAccessor.AmbientScope).ScopedFileSystems;
|
||||
}
|
||||
|
||||
@@ -95,6 +100,7 @@ internal sealed class EFCoreScopeProvider<TDbContext> : IEFCoreScopeProvider<TDb
|
||||
null,
|
||||
_eventAggregator,
|
||||
_dbContextFactory,
|
||||
_shareUmbracoConnection,
|
||||
repositoryCacheMode,
|
||||
scopeFileSystems);
|
||||
|
||||
@@ -187,6 +193,7 @@ internal sealed class EFCoreScopeProvider<TDbContext> : IEFCoreScopeProvider<TDb
|
||||
newContext,
|
||||
_eventAggregator,
|
||||
_dbContextFactory,
|
||||
_shareUmbracoConnection,
|
||||
repositoryCacheMode,
|
||||
scopeFileSystems);
|
||||
|
||||
@@ -209,6 +216,7 @@ internal sealed class EFCoreScopeProvider<TDbContext> : IEFCoreScopeProvider<TDb
|
||||
null,
|
||||
_eventAggregator,
|
||||
_dbContextFactory,
|
||||
_shareUmbracoConnection,
|
||||
repositoryCacheMode,
|
||||
scopeFileSystems);
|
||||
|
||||
|
||||
@@ -282,7 +282,7 @@ where tbl.[name]=@0 and col.[name]=@1;",
|
||||
|
||||
public override bool DoesPrimaryKeyExist(IDatabase db, string tableName, string primaryKeyName)
|
||||
{
|
||||
IEnumerable<SqlPrimaryKey>? keys = db.Fetch<SqlPrimaryKey>($"select * from sysobjects where xtype='pk' and parent_obj in (select id from sysobjects where name='{tableName}')")
|
||||
IEnumerable<SqlPrimaryKey>? keys = db.Fetch<SqlPrimaryKey>("select * from sysobjects where xtype='pk' and parent_obj in (select id from sysobjects where name=@0)", tableName)
|
||||
.Where(x => x.Name == primaryKeyName);
|
||||
return keys.FirstOrDefault() is not null;
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ public class SqliteSyntaxProvider : SqlSyntaxProviderBase<SqliteSyntaxProvider>
|
||||
/// <inheritdoc />
|
||||
public override bool DoesPrimaryKeyExist(IDatabase db, string tableName, string primaryKeyName)
|
||||
{
|
||||
IEnumerable<string> items = db.Fetch<string>($"select sql from sqlite_master where type = 'table' and name = '{tableName}'")
|
||||
IEnumerable<string> items = db.Fetch<string>("select sql from sqlite_master where type = 'table' and name = @0", tableName)
|
||||
.Where(x => x.Contains($"CONSTRAINT {primaryKeyName} PRIMARY KEY"));
|
||||
|
||||
return items.Any();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html class="no-js" lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
|
||||
<title>Boot Failed</title>
|
||||
|
||||
<!--
|
||||
The {{pathBase}} placeholder is replaced by BootFailedMiddleware
|
||||
with the request's PathBase, so asset URLs resolve correctly when
|
||||
Umbraco is hosted under a virtual directory.
|
||||
-->
|
||||
<link rel="stylesheet" href="{{pathBase}}/umbraco/website/nonodes.css" />
|
||||
<style type="text/css">
|
||||
body {
|
||||
color: initial;
|
||||
}
|
||||
|
||||
section {
|
||||
background: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<section>
|
||||
<article>
|
||||
<div>
|
||||
<h1>Boot Failed</h1>
|
||||
<h2>Umbraco failed to boot</h2>
|
||||
<p>If you are the owner of the website please see the log file for more details.</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.Cache;
|
||||
|
||||
@@ -34,7 +33,7 @@ public abstract class AppPolicedCacheDictionary<TKey> : IDisposable
|
||||
/// Gets or creates a cache.
|
||||
/// </summary>
|
||||
public IAppPolicyCache GetOrCreate(TKey key)
|
||||
=> _caches.GetOrAdd(key, k => _cacheFactory(k));
|
||||
=> _caches.GetOrAdd(key, _cacheFactory);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a cache.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services.Changes;
|
||||
|
||||
namespace Umbraco.Extensions;
|
||||
@@ -308,12 +310,28 @@ public static class DistributedCacheExtensions
|
||||
/// <returns>An enumerable of JSON payloads for the member cache refresher.</returns>
|
||||
/// <remarks>Internal for unit test.</remarks>
|
||||
internal static IEnumerable<MemberCacheRefresher.JsonPayload> GetPayloads(IEnumerable<IMember> members, IDictionary<string, object?> state, bool removed)
|
||||
=> members
|
||||
{
|
||||
bool indexableFieldsChanged = GetMemberIndexableFieldsChanged(state);
|
||||
return members
|
||||
.DistinctBy(x => (x.Id, x.Username))
|
||||
.Select(x => new MemberCacheRefresher.JsonPayload(x.Id, x.Username, removed)
|
||||
.Select(x => new MemberCacheRefresher.JsonPayload(x.Id, x.Username, removed, indexableFieldsChanged)
|
||||
{
|
||||
PreviousUsername = GetPreviousUsername(x, state)
|
||||
});
|
||||
}
|
||||
|
||||
private static bool GetMemberIndexableFieldsChanged(IDictionary<string, object?> state)
|
||||
{
|
||||
// Default to true for backward compatibility — any save that doesn't explicitly signal
|
||||
// "nothing indexable changed" is treated as potentially indexable.
|
||||
if (state.TryGetValue(Constants.Conventions.Member.IndexableFieldsChangedStateKey, out object? value)
|
||||
&& value is bool flag)
|
||||
{
|
||||
return flag;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? GetPreviousUsername(IMember x, IDictionary<string, object?> state)
|
||||
{
|
||||
@@ -334,6 +352,59 @@ public static class DistributedCacheExtensions
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExternalMemberCacheRefresher
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the specified external members in the distributed cache.
|
||||
/// </summary>
|
||||
/// <param name="dc">The distributed cache.</param>
|
||||
/// <param name="externalMembers">The external members to refresh in cache.</param>
|
||||
[Obsolete("Use the overload taking notification state instead. Scheduled for removal in Umbraco 19.")]
|
||||
public static void RefreshExternalMemberCache(this DistributedCache dc, IEnumerable<ExternalMemberIdentity> externalMembers)
|
||||
=> dc.RefreshExternalMemberCache(externalMembers, new Dictionary<string, object?>());
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the specified external members in the distributed cache.
|
||||
/// </summary>
|
||||
/// <param name="dc">The distributed cache.</param>
|
||||
/// <param name="externalMembers">The external members to refresh in cache.</param>
|
||||
/// <param name="state">The notification state dictionary.</param>
|
||||
public static void RefreshExternalMemberCache(this DistributedCache dc, IEnumerable<ExternalMemberIdentity> externalMembers, IDictionary<string, object?> state)
|
||||
=> dc.RefreshByPayload(
|
||||
ExternalMemberCacheRefresher.UniqueId,
|
||||
GetPayloads(externalMembers, state, removed: false));
|
||||
|
||||
/// <summary>
|
||||
/// Removes the specified external members from the distributed cache.
|
||||
/// </summary>
|
||||
/// <param name="dc">The distributed cache.</param>
|
||||
/// <param name="externalMembers">The external members to remove from cache.</param>
|
||||
public static void RemoveExternalMemberCache(this DistributedCache dc, IEnumerable<ExternalMemberIdentity> externalMembers)
|
||||
=> dc.RefreshByPayload(
|
||||
ExternalMemberCacheRefresher.UniqueId,
|
||||
GetPayloads(externalMembers, new Dictionary<string, object?>(), removed: true));
|
||||
|
||||
/// <summary>
|
||||
/// Gets the JSON payloads for external member cache refresh operations.
|
||||
/// </summary>
|
||||
/// <param name="externalMembers">The external members to create payloads for.</param>
|
||||
/// <param name="state">The notification state dictionary.</param>
|
||||
/// <param name="removed">Whether the external members were removed.</param>
|
||||
/// <returns>An enumerable of JSON payloads for the external member cache refresher.</returns>
|
||||
/// <remarks>Internal for unit test.</remarks>
|
||||
internal static IEnumerable<ExternalMemberCacheRefresher.JsonPayload> GetPayloads(
|
||||
IEnumerable<ExternalMemberIdentity> externalMembers,
|
||||
IDictionary<string, object?> state,
|
||||
bool removed)
|
||||
{
|
||||
bool indexableFieldsChanged = GetMemberIndexableFieldsChanged(state);
|
||||
return externalMembers
|
||||
.DistinctBy(x => x.Key)
|
||||
.Select(x => new ExternalMemberCacheRefresher.JsonPayload(x.Id, x.Key, removed, indexableFieldsChanged));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MemberGroupCacheRefresher
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Umbraco.Cms.Core.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Provides additional cache tags to evict from the Delivery API output cache when content changes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Multiple implementations can be registered. The eviction handler iterates all providers
|
||||
/// to collect additional tags to evict beyond the built-in content key tag.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Works as a pair with <see cref="IDeliveryApiOutputCacheTagProvider"/>: the tag provider adds
|
||||
/// custom tags when caching a response, and this provider maps content changes back to those
|
||||
/// tags at eviction time.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IDeliveryApiOutputCacheEvictionProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns additional cache tags to evict when the specified content changes.
|
||||
/// </summary>
|
||||
/// <param name="context">Details of the content change.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>Additional cache tags to evict.</returns>
|
||||
Task<IEnumerable<string>> GetAdditionalEvictionTagsAsync(OutputCacheContentChangedContext context, CancellationToken cancellationToken = default);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user